Node Views
A node view is a React component that renders a node type in place of the schema’s toDOM. Use one for interactive or stateful UI inside the document.
How node views work
A node view receives NodeViewComponentProps from @handlewithcare/react-prosemirror: the node and its position under nodeProps, the node’s rendered content as children, and editor-injected props (event handlers, decoration style/className) that must reach the DOM.
Register node views with the nodeViews prop, a map of node type name to component:
const nodeViews = { callout: CalloutNodeView }; // module scope: must be stable
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
nodeViews={nodeViews}
uiExtensions={uiExtensions}
/>Keep the object stable (module scope or useMemo). A new identity re-creates every node view.
Views are resolved per node type, last wins:
- Built-in views (
stesuraNodeViews). Always included; you don’t need to pass them. nodeViewsfrom eachuiExtensionsentry, in array order.- Your
nodeViewsprop. An entry that is the built-in component (for examplenodeViews={stesuraNodeViews}) counts as “default” and does not override an extension’s view. - Extension
nodeViewWrappersare then wrapped around the winner. See Wrappers and slots.
A custom view replaces an extension’s view for that type. Pagination, for example, registers its own views for section, section_header, section_footer, paragraph, heading, page_break, image, math_display and file. A custom view for one of these drops pagination’s version. Only table_cell and table_row use wrappers, so only those keep pagination behavior under a custom view.
Built-in node views
stesuraNodeViews from @stesura/editor-react:
| Node | Component | Description |
|---|---|---|
section | Section | Page-level container with margins and header/footer slots. |
table | TableView | Table with cell selection and resize. |
table_row | TableRowView | Table row. |
table_cell | TableCellView | Cell with borders, background, vertical alignment. |
image | ImageNodeView | Image with resize handles, alignment, alt text. |
image_anchored | ImageAnchoredNodeView | Anchored (wrapped) image variant. |
math_inline | MathInlineNodeView | Inline math with LaTeX source editing. |
math_display | MathDisplayNodeView | Display (block) math. |
file | FileView | File attachment display. |
toc | TableOfContentNodeView | Auto-generated table of contents. |
page_break | PageBreakNodeView | Visual page break. |
upload_block | UploadBlockNodeView | Drop-zone upload placeholder. |
ImageNodeView, ImageAnchoredNodeView, FileView, MathInlineNodeView, MathDisplayNodeView, TableCellView and TableRowView are also exported individually. Feature packs add their own views through uiExtensions: footnote and endnote anchors from @stesura/references-react, paginated views from @stesura/pagination-react.
Creating a custom node view
useStesuraNodeView builds the DOM props every node view needs. You still render your own element: the pagination engine measures your DOM, and table elements can’t take wrapper divs.
import { forwardRef } from "react";
import type { NodeViewComponentProps } from "@handlewithcare/react-prosemirror";
import { useStesuraNodeView } from "@stesura/editor-react";
const CalloutNodeView = forwardRef<HTMLDivElement, NodeViewComponentProps>(
(props, ref) => {
const { style, className, ...domProps } = useStesuraNodeView(props, ref);
const type = props.nodeProps.node.attrs.type; // "info" | "warning" | "error"
return (
<div
{...domProps}
className={`callout callout-${type} ${className ?? ""}`}
style={{ ...style, borderColor: "var(--callout-border)" }}
>
<div className="callout-header" contentEditable={false}>
{/* non-editable UI: selects, buttons, labels */}
</div>
{/* ProseMirror renders the node's content here */}
{props.children}
</div>
);
}
);The hook returns:
ref: your forwarded ref merged with ProseMirror’scontentDOMRef.idfromnode.attrs.id. Pagination measures nodes by id.toDOMspec attrs, converted for React (class→className,stylestring → object).- Spacing and track-changes data attrs from
node.attrs.spacingandnode.attrs.trackChanges. - Merged
styleandclassName. react-prosemirror injects decoration attrs asstyle/className. The hook merges them. Spreading raw{...props}after your ownstylewould replace your style whenever a decoration lands on the node.
Merge order: spec attrs, then hook attrs, then editor-injected props. Anything you write after spreading domProps wins, so put view-specific style after the returned one.
Options
useStesuraNodeView(props, ref, options?). ref is required: pass the one forwardRef gives you.
| Option | Default | Effect |
|---|---|---|
contentDOM | true | Merge nodeProps.contentDOMRef into the returned ref. Set false when children isn’t rendered into this element (leaf nodes). |
specAttrs | true | Spread the node’s toDOM spec attrs. Pass { childIndex } to read a nested element of the spec instead of the root. |
spacing | true | Apply spacing style and data-spacing-* attrs from node.attrs.spacing. |
trackChangesAttrs | true | Emit data-track-changes-* attrs from node.attrs.trackChanges. |
hideDeletions | false | display: none on deletion nodes while the track-changes display preference is off. The element stays mounted: unmounting it breaks caret placement. |
chrome | false | Emit data-pm-node-view and position: relative, which the track-change styles key on (author-coloured bar, striped overlay on deletions). |
spacing: false and trackChangesAttrs: false only disable the hook’s own layer. If the toDOM spec emits the same attrs, also pass specAttrs: false.
A view that builds its element without the hook can spread trackChangeChromeProps(node) to get the track-change attrs and the data-pm-node-view marker.
Rules your node view must follow
- Render
props.childrenwhere the node’s content belongs (content-bearing nodes only). - Spread the hook’s props on your root element. Dropping them breaks decorations, selection styling and pagination.
- Set
contentEditable={false}on non-editable UI (toolbars, handles, labels). - Keep height deterministic. The pagination engine measures clones of your DOM, so the height must be right on first render: explicit sizes, never “0px until an effect runs”.
- No
margin-bottomon block nodes. Margin collapse breaks pagination. Usespacing.afteror padding.
Wrappers and slots
A UI extension can add behavior to a node type without replacing its view. It registers a wrapper in nodeViewWrappers. A wrapper renders no DOM: it renders the winning view (built-in or yours) as children and contributes content, style and attrs through the node-view slots context. Wrappers survive your nodeViews overrides.
PaginatedCell (wrapper: slots provider, no DOM)
└─ your cell view, or the built-in TableCellView
└─ renders slots.before, the cell content, slots.afterThe built-in TableCellView, TableRowView and FileView consume slots. The easiest way to customize one of those and keep pagination is to render the built-in inside your view. Your view can add its own slots too:
import type { NodeViewComponentProps } from "@handlewithcare/react-prosemirror";
import { forwardRef, useMemo } from "react";
import { NodeViewSlotsProvider, TableCellView } from "@stesura/editor-react";
const StatusCell = forwardRef<HTMLTableCellElement, NodeViewComponentProps>((props, ref) => {
const status = props.nodeProps.node.attrs.status;
const slots = useMemo(() => ({ attrs: { "data-status": status } }), [status]);
return (
<NodeViewSlotsProvider slots={slots}>
<TableCellView {...props} ref={ref} />
</NodeViewSlotsProvider>
);
});Nested providers stack: the outer before renders first, the outer after last, and inner style/attrs win.
If you build a cell or row from scratch, consume the slots yourself. Otherwise page-break rendering in tables disappears (a dev-mode warning names the provider):
import { useNodeViewSlots } from "@stesura/editor-react";
const slots = useNodeViewSlots();
return (
<td {...domProps} {...slots.attrs} style={{ ...style, ...myStyle, ...slots.style }}>
{slots.before}
{props.children}
{slots.after}
</td>
);The contract: render slots.before first and slots.after last inside the element, merge slots.style after your own style, spread slots.attrs on the element.
Pagination-aware behavior
- Oversized nodes (taller than a page) are clamped by the pagination failsafe with no code needed. For something better than a hard crop (scaling, custom crop), read the clamp height with
useOversizedNode(editorId, nodeId)from@stesura/pagination-reactand setdata-oversized-handledon your root to opt out of the default clamp. - Async size changes (an embed resolving, a chart re-rendering): report them so only your node is re-measured:
import { useMergedDOMRefs } from "@handlewithcare/react-prosemirror";
import { useReportNodeResize } from "@stesura/editor-react";
const reportResize = useReportNodeResize(props.nodeProps.node.attrs.id);
const { style, ...domProps } = useStesuraNodeView(props, useMergedDOMRefs(ref, reportResize));useReportNodeResize(nodeId)
Pagination re-measures nodes when the document changes. A node that changes size without a transaction (an image decoding, a chart re-rendering, a web font swapping in) leaves the page under- or over-filled until something else triggers a re-measure. This hook reports the change.
It returns a ref callback for your root element (merge it with your ref via
useMergedDOMRefs). It attaches a ResizeObserver and, when the height settles,
dispatches a transaction carrying only the PAGINATION_DIRTY_NODES_META meta
with your node’s id. It has no steps, so it never enters the undo history or
reaches collaborators.
-
It needs a block id. Pass
node.attrs.id, which theuniqueIdglobal attribute stamps on block nodes. An inline node (inline math, say) has no id, so pass a function that resolves the enclosing textblock’s id. The hook calls it at report time:const measuredBlockId = useCallback((view: EditorView) => { const id = view.state.doc.resolve(getPos()).parent.attrs.id; return typeof id === "string" ? id : undefined; }, [getPos]); const reportResize = useReportNodeResize(measuredBlockId); -
Reports are debounced (150 ms, trailing), so a burst such as a chart animating to its final height costs one re-measure.
-
It compares
scrollHeight, not the border box. The oversized clamp usesmax-height, which doesn’t changescrollHeight. So applying or removing the clamp can’t cause a report loop, and a clamped node still reports real growth. -
It is inert without pagination. With no pagination plugin it dispatches nothing, so the same view works in paginated and unpaginated editors.
When not to use it. If the size change is already a node attr (a height
the user dragged, say), the attr change is a transaction and pagination
re-measures on its own. A view that clamps itself with an explicit height
must also skip it: the observer would report the clamp, the node would
re-measure, fit, unclamp and grow back in a loop. The built-in image view omits
the hook for this reason.
Print / export mounts
The PDF export captures a live editor mount, so everything your node view renders ends up in the PDF. Screen-only chrome (drag handles, resize handles, hover rings, table outlines, floating menus) must not be rendered at all. Hiding it with @media print doesn’t work: the capture is not a print.
Use usePrintMode():
import type { NodeViewComponentProps } from "@handlewithcare/react-prosemirror";
import { forwardRef } from "react";
import { usePrintMode, useStesuraNodeView } from "@stesura/editor-react";
const MyNodeView = forwardRef<HTMLSpanElement, NodeViewComponentProps>((props, ref) => {
const print = usePrintMode();
const domProps = useStesuraNodeView(props, ref);
return (
<span {...domProps}>
{props.children}
{!print && <MyDragHandle />}
</span>
);
});The value is fixed for the life of the mount: no subscription, no mid-render flips. Outside React (a plugin, a command), use isPrintMount(state) from @stesura/core/helpers.
usePrintMode() reads what StesuraEditor resolved from the printMount flag in configuration.config or the printMode prop. isPrintMount(state) reads printMount from state, and falls back to the data-stesura-print attribute the export sets on <html> when the state lacks the flag (a sub-editor’s, for example). On a screen editor both return false. See PDF export for setting up the export mount.
Performance
All node views render in the editor’s React tree, so context and hooks work normally. Wrap expensive views in React.memo: node views re-render often.
Next Steps
- Extending the Schema — Define the node spec for your custom node
- Customizing the Toolbar — Add toolbar controls for your node view
- Plugins — Create plugins that interact with your node view