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