Skip to Content

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:

  1. Built-in views (stesuraNodeViews). Always included; you don’t need to pass them.
  2. nodeViews from each uiExtensions entry, in array order.
  3. Your nodeViews prop. An entry that is the built-in component (for example nodeViews={stesuraNodeViews}) counts as “default” and does not override an extension’s view.
  4. Extension nodeViewWrappers are 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:

NodeComponentDescription
sectionSectionPage-level container with margins and header/footer slots.
tableTableViewTable with cell selection and resize.
table_rowTableRowViewTable row.
table_cellTableCellViewCell with borders, background, vertical alignment.
imageImageNodeViewImage with resize handles, alignment, alt text.
image_anchoredImageAnchoredNodeViewAnchored (wrapped) image variant.
math_inlineMathInlineNodeViewInline math with LaTeX source editing.
math_displayMathDisplayNodeViewDisplay (block) math.
fileFileViewFile attachment display.
tocTableOfContentNodeViewAuto-generated table of contents.
page_breakPageBreakNodeViewVisual page break.
upload_blockUploadBlockNodeViewDrop-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’s contentDOMRef.
  • id from node.attrs.id. Pagination measures nodes by id.
  • toDOM spec attrs, converted for React (classclassName, style string → object).
  • Spacing and track-changes data attrs from node.attrs.spacing and node.attrs.trackChanges.
  • Merged style and className. react-prosemirror injects decoration attrs as style/className. The hook merges them. Spreading raw {...props} after your own style would 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.

OptionDefaultEffect
contentDOMtrueMerge nodeProps.contentDOMRef into the returned ref. Set false when children isn’t rendered into this element (leaf nodes).
specAttrstrueSpread the node’s toDOM spec attrs. Pass { childIndex } to read a nested element of the spec instead of the root.
spacingtrueApply spacing style and data-spacing-* attrs from node.attrs.spacing.
trackChangesAttrstrueEmit data-track-changes-* attrs from node.attrs.trackChanges.
hideDeletionsfalsedisplay: none on deletion nodes while the track-changes display preference is off. The element stays mounted: unmounting it breaks caret placement.
chromefalseEmit 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

  1. Render props.children where the node’s content belongs (content-bearing nodes only).
  2. Spread the hook’s props on your root element. Dropping them breaks decorations, selection styling and pagination.
  3. Set contentEditable={false} on non-editable UI (toolbars, handles, labels).
  4. 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”.
  5. No margin-bottom on block nodes. Margin collapse breaks pagination. Use spacing.after or 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.after

The 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-react and set data-oversized-handled on 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 the uniqueId global 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 uses max-height, which doesn’t change scrollHeight. 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.

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

Last updated on