Skip to Content
GuidesCustomizing the EditorNode Views

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 @stesura/editor-react): 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 = { panel: PanelNodeView }; // 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. A block type your schema adds that still has no view gets a fallback. See Your own block types.
  5. 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. Behavior pagination adds through wrappers (table cells and rows, the page-break lead of tables, images, the TOC, callouts and your own blocks) survives a custom view that consumes the slots.

Your own block types

A block node your schema adds needs no node view. Rendered from its toDOM alone, it would miss two things pagination charges for it: the margins its spacing grant derives, and the page-break lead when it opens a page. Every page after it would then drift. So a block type that core doesn’t define and that has no view gets a fallback:

  • an extension’s nodeViewFallback, if one answers;
  • otherwise DerivedSpecView, which renders your whole toDOM with the block’s derived values and consumes the slots.

Pagination answers for textblocks. One whose toDOM has a paragraph’s shape, [tag, attrs, 0] or [tag, attrs, [tag, 0]], is laid out like a paragraph, page breaks inside it included. Any other textblock renders whole with its lead, but a page break inside it won’t render; development builds warn once per such type.

Extensions also wrap your block types without naming them (nodeViewWrapperFallback): pagination gives every one that isn’t a textblock its page-break lead, as a margin above the box.

If you write your own view for such a type, it has to do both jobs itself:

  1. Render the derived values. Spread useStesuraNodeView’s props on your root element (or render useDerivedSpec(node)), not attrs you compute from node.attrs.
  2. Consume the slots, as described in Wrappers and slots. Pagination’s lead arrives that way.

Development builds report a miss once per node type: a [pagination] console error when a block’s rendered margin-top differs from the one pagination charges, and a [node-view-slots] warning naming the type when a view drops the slots.

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.
floating_boxFloatingBoxNodeViewText box: a static render of its blocks; a double-click (or Enter on the selected box) edits them in place in a sub-editor, Escape or a click elsewhere leaves. Resize handles while selected.
math_inlineMathInlineNodeViewInline math with LaTeX source editing.
math_displayMathDisplayNodeViewDisplay (block) math.
placeholderPlaceholderNodeViewFill-in prompt: its text read-only in placeholder grey; selected whole on click, edited in a floating editor. See Placeholders.
fileFileViewFile attachment display.
tocTableOfContentNodeViewAuto-generated table of contents.
page_breakPageBreakNodeViewVisual page break.
upload_blockUploadBlockNodeViewDrop-zone upload placeholder.
calloutCalloutNodeViewTitle bar (icon and title, see calloutFrame) above the content.

ImageNodeView, ImageAnchoredNodeView, FloatingBoxNodeView, FileView, MathInlineNodeView, MathDisplayNodeView, PlaceholderNodeView, CalloutNodeView, 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 "@stesura/editor-react"; import { useStesuraNodeView } from "@stesura/editor-react"; const PanelNodeView = 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={`panel panel-${type} ${className ?? ""}`} style={{ ...style, borderColor: "var(--panel-border)" }} > <div className="panel-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 (class → className, 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 "@stesura/editor-react"; 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 { NodeViewSlotsBoundary, useNodeViewSlots } from "@stesura/editor-react"; const slots = useNodeViewSlots(); return ( <td {...domProps} {...slots.attrs} style={{ ...style, ...myStyle, ...slots.style }}> {slots.before} <NodeViewSlotsBoundary>{props.children}</NodeViewSlotsBoundary> {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, and wrap the children in NodeViewSlotsBoundary. The boundary keeps a node’s slots on that node: without it a row’s slots reach its cells, and a cell’s page-break floats reach every cell of a table nested in it.

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, 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.

  • While a report is pending (the 150 ms window), the element carries data-resize-pending. The PDF export and test settle gates wait for it to clear, so they never capture a layout pagination hasn’t caught up with.

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.

Async rendering

A view whose content arrives later than its first render (a lazy-loaded renderer, a fetched embed) follows one convention, so the PDF export and test harnesses know when it is final:

  1. Set data-render-pending on the root from the first render until the result or the error lands. Never leave it set on failure: the export would wait until its timeout.
  2. Attach useReportNodeResize, so the new size reaches pagination. Its own data-resize-pending covers the gap between the render and the re-measure.

The display-math and Mermaid views both do this. An atomic textblock (its text is source code for the rendering) should also declare measureAs: "atomic" on its spec (see Extending the Schema).

Oversized atomic renderings. The failsafe clamp forces display: block on an oversized node. If that breaks your rendering (positioned or inline-block content), set data-oversized-atomic on the root: the clamp then keeps your display and adds position: relative, so a “too tall” marker can anchor inside the clipped box. To shrink rather than crop, mark the element that should scale with data-oversized-fit (an SVG, or an image with object-fit: contain): it gets a max-height of the page, but never less than half its natural height. When even half doesn’t fit, the bottom is cropped and a child you render with data-oversized-badge (hidden with display: none, holding the message) is shown over it. Mermaid diagrams use both. Keep the elements inside the root out of block formatting contexts (overflow other than visible, flex, grid, flow-root): one placed beside a page-break float shrinks to the width left, which can be 0.

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 "@stesura/editor-react"; 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