How to Implement Editable Node with Dynamic HTML?

I’m very new to TipTap and ProseMirror, and I’m trying to implement a custom Node block (Box) whose innerHTML content can be set externally during runtime. I also want it to be editable after the first time its innerHTML content is set. I was able to achieve it in a hacky way, but it’s uneditable because every click on it or every key press will make the cursor jump to the start of the node, making the editable function useless. I have another inline custom Node (Variable) that works as it only renders text and is not editable using the node itself. Both of them are presented as {} in the original content string and are transformed upon rendering the editor. I have included the whole code. I would appreciate any help!

// variableExtension.ts
import { mergeAttributes, Node, Editor as TipTap } from '@tiptap/core'
import Table from '@tiptap/extension-table'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row'
import TextAlign from '@tiptap/extension-text-align'
import Underline from '@tiptap/extension-underline'
import StarterKit from '@tiptap/starter-kit'

// Editor.tsx
import type { Component } from 'solid-js'
import { createEffect, onCleanup, onMount } from 'solid-js'

// Add this before the Editor component
const Box = Node.create({
  name: 'box',
  group: 'block',
  content: 'block*',
  draggable: true,

  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: element => element.getAttribute('data-id'),
      },
      value: {
        default: '',
        parseHTML: element => element.getAttribute('data-value'),
      },
      title: {
        default: '',
        parseHTML: element => element.getAttribute('data-title'),
      },
      isSelected: {
        default: false,
      },
    }
  },

  parseHTML() {
    return [{
      tag: 'div[data-box]',
    }]
  },

  renderHTML({ HTMLAttributes }) {
    const isEmpty = !HTMLAttributes.value
    const isSelected = HTMLAttributes.isSelected

    return [
      'div',
      mergeAttributes(
        {
          'data-box': '',
          'contenteditable': JSON.stringify(!isEmpty),
          'data-id': HTMLAttributes.id,
          'data-value': HTMLAttributes.value,
          'data-title': HTMLAttributes.title,
        },
        {
          class: `print:b-none print:p-0 print:bg-transparent print:text-on-surf b-rd-1 px-4 b-1 transition-all hover:cursor-pointer ${
            isEmpty
              ? (
                  `print:hidden flex flex-row h-32 min-w-96 justify-center items-center ${isSelected ? 'uppercase b-on-tertiary-fixed text-on-tertiary-fixed' : 'uppercase b-tertiary-fixed bg-tertiary-fixed text-on-tertiary-fixed hover:bg-tertiary-fixed-dim/70'}`
                )
              : (
                  isSelected ? 'contents bg-surf-container-highest' : 'contents bg-surf-container-high'
                )}`,
        },
      ),
      ...(isEmpty
        ? [
            ['div', { class: 'i-ri:quill-pen-ai-line size-6 mr-1' }], // Icon container
            ['div', { class: 'title' }, HTMLAttributes.title || 'Untitled'], // Title container
          ]
        : [
            // ['div', { innerHTML: HTMLAttributes.value }],
          ]
      ),
    ]
  },
})

const Variable = Node.create({
  name: 'variable',

  group: 'inline',

  inline: true,

  atom: true,

  selectable: false,

  draggable: true,

  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: element => element.getAttribute('data-id'),
      },
      value: {
        default: '',
        parseHTML: element => element.getAttribute('data-value'),
      },
      title: {
        default: '',
        parseHTML: element => element.getAttribute('data-title'),
      },
      isSelected: {
        default: false,
      },
    }
  },

  parseHTML() {
    return [{
      tag: 'span[data-variable]',
    }]
  },

  renderHTML({ HTMLAttributes }) {
    const isEmpty = !HTMLAttributes.value
    const isSelected = HTMLAttributes.isSelected

    return [
      'span',
      mergeAttributes(
        {
          'data-variable': '',
          'contenteditable': 'false',
          'data-id': HTMLAttributes.id,
          'data-value': HTMLAttributes.value,
          'data-title': HTMLAttributes.title,
        },
        {
          class: `print:b-none print:p-0 print:bg-transparent print:text-on-surf b-rd-1 px-1 b-1 transition-all hover:cursor-pointer ${
            isEmpty
              ? (
                  `print:hidden text-on-tertiary-fixed uppercase ${isSelected ? 'b-on-tertiary-fixed' : 'b-tertiary-fixed bg-tertiary-fixed hover:bg-tertiary-fixed-dim/70'}`
                )
              : (
                  isSelected ? 'b-on-primary-fixed text-on-primary-fixed' : 'b-primary-fixed bg-primary-fixed text-on-primary-fixed hover:bg-primary-fixed-dim/70'
                )}`,
        },
      ),
      HTMLAttributes.value || `${HTMLAttributes.title}`,
    ]
  },
})

interface EditorProps {
  id: string
  content: string
  variables: Record<string, any>
  onVariableSelectChange?: (variableId?: string, value?: boolean) => void
  onEditorReady?: (editor: TipTap) => void // Add this prop
}

const Editor: Component<EditorProps> = (props) => {
  let editor: TipTap | undefined

  const transformInitialContent = (content: string) => {
    const transformedContent = content
      .replace(/\{([^}]+)\}/g, (match, variableId) => {
        const variable = props.variables[variableId]
        if (!variable) {
          return match
        }

        if (variable.type === 'box') {
          const variable = props.variables[variableId]
          if (!variable) {
            return match
          }

          return `<div 
            data-box 
            data-id="${variableId}" 
            data-value="${variable.value || ''}" 
            data-title="${variable.title || ''}"
          ></div>`
        } else {
          return `<span 
            data-variable 
            data-id="${variableId}" 
            data-value="${variable.value || ''}" 
            data-title="${variable.title || ''}"
          ></span>`
        }
      })

    return transformedContent
  }

  const updateVariableNodes = () => {
    // console.log(1)
    if (!editor) {
      return
    }

    const tr = editor.state.tr
    let updated = false

    editor.state.doc.descendants((node, pos) => {
      // console.log(2)
      if (node.type.name === 'variable' || node.type.name === 'box') {
        const variable = props.variables[node.attrs.id]
        if (variable) {
          // console.log(3)
          const newAttrs = {
            ...node.attrs,
            value: variable.value,
            title: variable.title,
            isSelected: variable.isSelected,
          }
          if (JSON.stringify(node.attrs) !== JSON.stringify(newAttrs)) {
            // console.log(4)
            tr.setNodeMarkup(pos, undefined, newAttrs)
            updated = true
          }
        }

        if (node.type.name === 'box' && variable.value) {
          // console.log(5)
          setTimeout(() => {
            const el = editor?.view.dom.querySelector(`[data-id="${node.attrs.id}"]`)
            if (el) {
              // el.innerHTML = variable.value
              const parser = new DOMParser()
              const doc = parser.parseFromString(variable.value, 'text/html')

              // Append the parsed nodes to the parent node
              el.append(...doc.body.children)
            }
          }, 0)
        }
      }
    })

    if (updated) {
      // Prevent adding to history but allow the update
      tr.setMeta('addToHistory', false)
      editor.view.dispatch(tr)
    }
  }

  const handleClick = (event: MouseEvent, type: 'variable' | 'box' | null) => {
    if (!editor) {
      return
    }

    const element = (event.target as HTMLElement).closest('[data-variable], [data-box]')

    const id = element?.getAttribute('data-id') as string | undefined

    if (props.variables[id]?.isSelected && type === 'box') {
      return
    }

    // Update the selection state
    const tr = editor.state.tr
    editor.state.doc.descendants((node, pos) => {
      if ((node.type.name === 'variable' || node.type.name === 'box')) {
        const newAttrs = {
          ...node.attrs,
          isSelected: node.attrs.id === id,
        }
        tr.setNodeMarkup(pos, undefined, newAttrs)
      }
    })
    tr.setMeta('addToHistory', false)

    if (type === 'variable') {
      editor?.view.dom.blur()
    }

    editor.view.dispatch(tr)

    props.onVariableSelectChange?.(id, !!type)
  }

  onMount(() => {
    editor = new TipTap({
      element: document.getElementById(props.id) as HTMLElement,
      extensions: [
        StarterKit,
        Underline,
        TextAlign.configure({
          types: ['heading', 'paragraph'],
        }),
        Variable,
        Box,
        Table.configure({
          resizable: true,
          HTMLAttributes: {
            class: 'tableWrapper',
          },
        }),
        TableRow.configure({
          HTMLAttributes: {
            class: 'tableRow',
          },
        }),
        TableHeader.configure({
          HTMLAttributes: {
            class: 'tableHeader',
          },
        }),
        TableCell.configure({
          HTMLAttributes: {
            class: 'tableCell',
          },
        }),
      ],
      content: transformInitialContent(props.content),
      editable: true,
      editorProps: {
        attributes: {
          class: 'b-rd-2 px-4 py-2 text-sm/loose outline-none transition-all focus:bg-surf-container',
        },
        handleClick: (view, pos, event) => {
          const isVariable = !!(event.target as HTMLElement).closest('[data-variable]')
          const isBox = !!(event.target as HTMLElement).closest('[data-box]')
          handleClick(event, isVariable ? 'variable' : (isBox ? 'box' : null))
          return isVariable
        },
      },
      onCreate: ({ editor }) => {
        props.onEditorReady?.(editor)
      },
    })
  })

  createEffect(() => {
    // Deep watch the variables object
    const variablesSnapshot = JSON.stringify(props.variables)
    if (editor) {
      updateVariableNodes()
    }
  })

  onCleanup(() => {
    editor?.destroy()
  })

  return (
    <div
      id={props.id}
      class="max-w-none prose"
    />
  )
}

export default Editor