GitHub

Scoped Views Into a Shared Document

This example builds a book editor where the underlying document contains multiple chapters, but the user doesn't always see or edit the full thing. Two plugins provide narrower views into the same document: a table of contents sidebar that shows just the chapter headings, and a chapter focus mode that shows one chapter at a time.

Both views are editable. Typing in the table of contents renames a chapter. Typing in the focused chapter edits its content. Every edit flows back to the full book document through a custom dispatchTransaction that remaps steps from scoped coordinates to full-document coordinates.

The two plugins are independent and can be toggled on or off with the checkboxes above. When chapter focus is off, the full document is visible and the table of contents tracks whichever chapter contains the cursor.

One Schema With Two Top Nodes

A naive approach to sub-document editing is to define a separate schema for each view. That breaks. NodeType instances are compared by identity, and new Schema() always creates fresh ones. Steps produced against one schema can't be applied to a document from another. You'd have to serialize and deserialize, which defeats the purpose.

This example uses a single bookSchema with two top-level node types. Both views share the same heading type, so steps from the table of contents view can be remapped and applied to the full document directly.

doc:     { content: "chapter+" }
toc_doc: { content: "heading+" }
chapter: { content: "heading block+" }
heading: { content: "text*", isolating: true }

Is this the right way to handle it? Could the schema system support some form of sub-schema or schema projection instead? This works, but it requires knowing upfront which nodes each view needs.

Hijacking dispatchTransaction

Neither scoped view owns its state transitions. Each one has a custom dispatchTransaction that intercepts every transaction, remaps its steps into full-document coordinates, and dispatches the result on the parent book view. The scoped view is a lens. It renders a slice of the document and translates edits back.

This is the same pattern as the footnote example in the ProseMirror docs. The chapter view uses a uniform offset: every position in the scoped doc differs from the full-doc position by chapterStart(doc, i). step.map(StepMap.offset(n)) does the translation.

The table of contents view is more interesting. Each heading lives at a different position in the full document, so the offset is per-heading. The bridge resolves which heading a step falls in, computes that heading's offset, and remaps with it. Same mechanism, but the mapping function is no longer trivial.

Per-Heading Offsets

For the chapter view, one number is enough. For the table of contents, we need to figure out which heading a step belongs to and compute its specific offset. The math:

const fullPos = chapterStart(fullDoc, hIndex) + 1
const tocPos = tocHeadingPos(tocDoc, hIndex)
const offset = fullPos - tocPos

The +1 enters the chapter node to reach its first child (the heading). tocHeadingPos sums up preceding headings' nodeSizes in the flat table of contents document. The difference between these two positions is the offset for StepMap.offset.

This is stable across multi-step transactions because insertions shift both documents equally. But it does require resolving the step's from position against the old table of contents doc to find the heading index. There might be a cleaner way to do this.

Selection Across Rebuilds

When the outer state changes, each scoped view rebuilds its EditorState from scratch. The old Selection can't be reused. Its $anchor and $head are ResolvedPos objects tied to the old document instance. Passing them to a new state throws.

The workaround: stash the raw anchor and head positions from the transaction before dispatching, then recreate the selection with TextSelection.create(newDoc, anchor, head) after the rebuild. This works because the positions themselves are still valid in the new doc (the content at those positions hasn't moved).

This feels like something the framework could help with. A way to create a selection that isn't bound to a specific doc instance, or a way to "transplant" a selection to a new doc with the same structure. Right now it's manual.

Immutability For Skipping Work

ProseMirror nodes are persistent data structures. When you type in a chapter, only that chapter node changes. The heading nodes of other chapters are the same objects. You can check oldHeading === newHeading and skip work when they match.

The table of contents plugin's update() uses this. Before rebuilding its state, it walks the chapters and compares each heading by identity. If none changed (the user typed in a chapter body, not a heading), it skips the rebuild entirely.

This is the same trick React uses with immutable props. For a book with a handful of chapters, the savings are negligible. For a document with hundreds of sections, it could matter.