Lately, I’ve been interested in exploring techniques for rendering a partial ProseMirror doc. My theory is that a good API around this could open up functionality around:
Node-level subsets: “focusing in” on a part of the tree, common in outliners, a one-chapter-at-a-time book editor, presentation
slides
Slice or fragment-level ranges: useful for diff-style views, document search with context, visual clipboards, print previews
Derivative documents: where the parent document has been transformed into a new shape, like a table of contents, a document navigator, aggregated text with comments; maybe even the basis of pagination (this is view-model territory)
What each of these scenarios has in common: we have one or more EditorView instances that need to intercept transactions and map their steps to be applied against a source document. The original document is updated, and then this state is carved up or transformed and passed as a prop to the sub-views. The value in this is that a single document can be persisted and set up for collaboration, rather than a bunch of individual independent pieces of state stitched together out-of-band.
This is all very abstract, so it’s probably best to anchor this conversation with an example. Here is a “book editor”, where a single chapter can be focused at a time, and a table of contents editor selects the chapter and keeps their titles in sync:
This largely works, but I’ve run into some rough edges I’d like to sand down. I’ve tried to keep track of these as github issues so far:
Schema coupling: steps can only be applied across views if the NodeType instances are the same object. In practice this means every view’s structural types have to be defined in a single schema, even when they’re purely a view concern (like the toc_doc top node).
Node type mismatch between source and view: the table of contents reuses heading nodes so steps transfer, but they render as <h1> in both views. Semantically they should be list items.
Selection doesn’t survive document rebuilds: Selection is bound to a specific document instance in ResolvedPos, so scoped views can’t carry a selection across state rebuilds. Both plugins work around this by stashing raw position integers before dispatching and recreating the selection afterward.
Step remapping abstraction: every scoped view has a dispatchTransaction that loops over steps, offsets them, and dispatches on the outer view. The logic is nearly identical between both plugins. If some of this were extracted into a library, what would it’s API look like?
I am definitely not asking for anyone to solve these issues, but if you also find yourself in this design space, I’d love to hear your thoughts (and if you’ve attempted anything similar). I haven’t seen a whole lot of other chatter around this problem in general (this and that come close), but I’m sure the design of all of this could improve by knowing about more use cases.
I am also writing a book editor and have been wrestling almost a year now with prosemirror-model and prosemirror-view which are close, but not quite what I need for queryable and collaborative documents. I feel your pain.
Dervied subdocuments are an antipattern that has landed me in trouble. I think your problems can be solved by having a single document represent your book and then only showing the active chapter via a nodeView linked to state. An example is the folding example.
For your ToC you can limit headings to plaintext and use a <ol><li><input value={heading}> and 2-way bind the inputs. It should be pretty easy to make a transaction to replace the heading’s text from an onchange or oninput event. If your heading schema is more complicated you’ll want <ol><li contenteditable class="ProseMirror"> with a derived schema that contains nodes: { li: { ...bookSchema.nodes.heading, ...yourImpl } }. Your 2-way binding will then require remapping transactions from the ToC editor to the book editor. You could even 2-way bind the selection if you really wanted.
Dervied subdocuments are an antipattern that has landed me in trouble. I think your problems can be solved by having a single document represent your book and then only showing the active chapter via a nodeView linked to state. An example is the folding example.
Curious to know more about where you ran into issues with derived documents. Anything you found troublesome that I didn’t cover?
The folding example is indeed a nice one. It was the example that made me realize that decorations could be used for more than just changing the dom: they are a useful data structure for storing info in the exact same shape as the document. I do something similar today for a “scoped view”, but ran into two downsides:
the original content is still stored in the dom, it’s just hidden with display: none
there’s nothing preventing the cursor from entering the position of a “hidden” node out-of-the-box; you have to override arrow keys or nudge the selection back to the visible area via expensive appendTransaction checks
This is sort of what got me down the path of rendering a subset of a document in the first place: it restricts the coordinate space.
For your ToC you can limit headings to plaintext and use a <ol><li><input value={heading}> and 2-way bind the inputs…
That’s a good suggestion: it’s similar to the way a lot of people integrate another UI framework with ProseMirror, and becomes even easier if you “hoist” your EditorState into a higher position in your app’s view tree.
I picked this example because I’m interested in letting ProseMirror be that projected view, though I admit it’s a little convoluted. A more realistic app would probably restrict renaming chapters only after clicking a little “rename” button in the list, but my underlaying goal is the same: dispatching transactions against the parent document and rendering a subset of it efficiently.
Subschemas, mapping positions, and two-way binding selections. Only simple subdocuments like inline footnotes or a ToC were easy to implement. State management between documents and plugins is painful.
I’m not sure what you mean. The folding example handles selection like I expect. The part of your selection in a collapsing section is removed and you cannot navigate inside a collapsed section with the arrow keys.
The simplest way is:
Render the parent document and hide what you don’t want to see with node views.
Derive subview schemas (can use topNode or simple inputs for attributes).
Two-way bind subviews to the parent document.
These aren’t “efficient” because they require a large parent document be in memory to render potentially small subviews, but they work. Two-way binding is also easier said than done because of position mapping.
The complicated way is:
Create a partially-loadable document model.
Map it to prosemirror-model and map transactions to mutating that model.
I’ve been pursuing the complicated way and am currently experimenting with how to address blocks and marks. I’ve been using ordered IDs, but (version, pos) might lend itself better to merge conflicts and certainly lends itself better to the current prosemirror-model implementation.