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