Create Rich Text Editor Custom Node
This guide helps create a new custom TipTap node for the Rich Text Editor following the project's architecture conventions. The node will work across all three rendering contexts: edit mode (interactive), on-screen preview, and PDF download.
Architecture Overview
renderHTML() in .schema.ts <-- single source of truth for preview + PDF
|
+-- On-screen preview: editor.getHTML() --> dangerouslySetInnerHTML
+-- PDF download: editor.getHTML() --> printHtmlContent (hidden iframe)
NodeView (edit mode only) <-- interactive UI, no preview logic
All files live under src/components/designSystem/RichTextEditor/.
Key Rules
renderHTMLis the single source of truth for preview/PDF appearance. Never duplicate rendering logic in NodeViews.- NodeViews are for edit mode only. No
if (isPreview)branches. - Split schema from view:
.schema.tshas zero React imports (headless-safe)..tsextends it withaddNodeView(). - Resolution data flows via
addOptions()+.configure(). The schema declares options, consumers pass data at configuration time. - Styles go in
richTextEditor.cssscoped to.ProseMirror. They apply to both the editor and PDF automatically.
Reference Files
Read these before starting:
- Base extensions:
extensions/baseExtensions.ts - Schema with resolution data:
extensions/PlanBlock.schema.ts - Schema extending TipTap built-in:
extensions/Mention.schema.ts - Editor extension wrapping schema:
extensions/PlanBlock.ts - Simple node (no NodeView):
extensions/LinkCard.ts - NodeView (edit mode only):
PlanBlock/PlanBlockView.tsx - Editor component:
RichTextEditor.tsx - PDF download:
downloadMarkdownPdf.ts - Schema test:
__tests__/PlanBlock.test.ts
Extension Pattern Decision
| Pattern | When to use | Files to create |
|---|---|---|
| Schema only (like LinkCard) | No interactive edit UI, no resolution data | extensions/YourNode.ts only |
| Schema + NodeView (like PlanBlock) | Needs interactive edit UI and/or resolution data | extensions/YourNode.schema.ts + extensions/YourNode.ts + YourNode/YourNodeView.tsx |
Step 1: Create the Schema
Create extensions/{NodeName}.schema.ts with zero React imports:
import { mergeAttributes, Node } from '@tiptap/core'
export interface YourNodeAttributes {
yourId: string
}
export const YourNodeSchema = Node.create({
name: 'yourNode',
group: 'block',
atom: true,
// Only needed if renderHTML uses external data for resolution
addOptions() {
return {
items: {} as Record<string, { name: string }>,
}
},
addAttributes() {
return {
yourId: {
default: '',
parseHTML: (element) => element.dataset.yourId ?? '',
},
}
},
// Markdown serialization
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
node: { attrs: YourNodeAttributes },
) {
state.write(`<!-- entity:yourtype:${node.attrs.yourId} -->`)
state.closeBlock(node)
},
parse: {
updateDOM(element: HTMLElement) {
element.innerHTML = element.innerHTML.replaceAll(
/<!--\s*entity:yourtype:(\S*?)\s*-->/g,
(_match: string, yourId: string) =>
`<div data-type="your-node" data-your-id="${yourId}"></div>`,
)
},
},
},
}
},
parseHTML() {
return [{ tag: 'div[data-type="your-node"]' }]
},
// SINGLE SOURCE OF TRUTH for preview and PDF
renderHTML({ HTMLAttributes }) {
const yourId = String(HTMLAttributes.yourId ?? '')
const item = this.options.items?.[yourId]
const wrapperAttrs = mergeAttributes(HTMLAttributes, {
'data-type': 'your-node',
'data-your-id': yourId,
class: 'your-node',
})
if (item) {
return ['div', wrapperAttrs, ['span', { class: 'your-node__name' }, item.name]]
}
return ['div', wrapperAttrs, ['span', { class: 'your-node__placeholder' }, yourId || 'Select...']]
},
})
Step 2: Create the Editor Extension
Create extensions/{NodeName}.ts:
import { ReactNodeViewRenderer } from '@tiptap/react'
import { YourNodeView } from '../YourNode/YourNodeView'
import { YourNodeSchema } from './YourNode.schema'
export type { YourNodeAttributes } from './YourNode.schema'
export const YourNode = YourNodeSchema.extend({
addNodeView() {
return ReactNodeViewRenderer(YourNodeView)
},
})
Step 3: Create the NodeView (Edit Mode Only)
Create {NodeName}/{NodeName}View.tsx:
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'
export const YourNodeView = ({ node, updateAttributes, selected }: NodeViewProps) => {
const yourId = String(node.attrs.yourId ?? '')
const isEmpty = !yourId
// Edit mode UI only. No preview branches.
if (isEmpty) {
return (
<NodeViewWrapper>
<button className={`your-node your-node--empty ${selected ? 'your-node--selected' : ''}`}>
<span className="your-node__placeholder">Select...</span>
</button>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper>
<button className={`your-node ${selected ? 'your-node--selected' : ''}`}>
<span>Item: {yourId}</span>
</button>
</NodeViewWrapper>
)
}
Step 4: Add CSS Styles
In richTextEditor.css inside .ProseMirror:
.your-node {
@apply my-2 rounded-lg border border-grey-300 p-3;
}
.your-node__name {
@apply text-base font-medium text-grey-700;
}
Step 5: Register the Extension
If the node needs resolution data:
In RichTextEditor.tsx extensions array:
YourNode.configure({ items: itemsFromProps }),
In downloadMarkdownPdf.ts extensions array:
YourNodeSchema.configure({ items }),
Also add the new data to DownloadMarkdownPdfOptions and to RichTextEditorProps.
If the node does NOT need resolution data:
Add it to baseExtensions.ts:
export const getBaseExtensions = (): Extensions => [
// ...existing...
YourNode,
]
Step 6: Add Tests
Schema test (__tests__/{NodeName}.test.ts)
Test renderHTML via a real Editor instance:
const getHtml = (attrs: Record<string, unknown>, options?: Record<string, unknown>) => {
const editor = new Editor({
extensions: [StarterKit, YourNodeSchema.configure(options)],
content: { type: 'doc', content: [{ type: 'yourNode', attrs }] },
})
const html = editor.getHTML()
editor.destroy()
return html
}
Test: renderHTML with/without resolution data, markdown serialize/parse, parseHTML.
NodeView test (__tests__/{NodeName}View.test.tsx)
Test edit mode interactions only. No preview tests.
Verification Checklist
- Schema file has zero React imports
-
renderHTMLhandles both resolved and fallback cases - NodeView has no
if (isPreview)branches - Markdown round-trips correctly (serialize -> parse -> same node)
- CSS is scoped inside
.ProseMirror - Extension registered in
RichTextEditor.tsxanddownloadMarkdownPdf.ts(orbaseExtensions.ts) -
npx tsc --noEmitpasses -
npx jest --no-coverage src/components/designSystem/RichTextEditor/__tests__/passes