deferred-auto-convert.ts2.8 KBView on GitHub import { Plugin, PluginKey } from '@tiptap/pm/state';
import type { EditorState, Transaction } from '@tiptap/pm/state';
/**
* Shared driver for the auto-convert plugins (fileLink, playbookStage,
* playbookTrigger). These walk the document looking for raw markdown tokens
* (`[[doc:…]]`, `## Stage:`, `[on:…]`) that arrive via Y.js sync — server-seeded
* docs, remote edits, agent writes — and wrap them into nodes. Local typing /
* pasting is handled by the nodes' own input & paste rules.
*
* Why a DEFERRED dispatch instead of `appendTransaction`:
*
* When content hydrates from Y.js, y-prosemirror's ySyncPlugin dispatches the
* sync transaction. An `appendTransaction` conversion runs bundled with that
* sync transaction — and y-prosemirror deliberately does NOT write changes that
* are bundled with its own sync transaction back into the Y.Doc (its loop
* guard). The converted nodes would therefore render in the editor but never
* reach the Y.Doc: the editor and the persisted document diverge, and the next
* sync (e.g. the server's nodeId backstop echo) collapses the ghost nodes away —
* the user watches the playbook render correctly and then lose every special
* component. See `yjs/__tests__/playbookNodeSurvival.test.ts`.
*
* Running the conversion as its own transaction on the next tick makes it a
* normal local edit that y-prosemirror commits to the Y.Doc, so it persists and
* survives reconciliation.
*
* `scan(state)` returns a transaction that makes progress (converts one or more
* regions) or `null` when nothing is left to convert. It is re-run after every
* doc change until it returns `null`, so multi-pass conversions converge and new
* tokens arriving from later syncs are still picked up.
*/
export function createDeferredAutoConvertPlugin(
name: string,
scan: (state: EditorState) => Transaction | null,
): Plugin {
const key = new PluginKey(name);
return new Plugin({
key,
view(view) {
let timer: ReturnType<typeof setTimeout> | null = null;
const run = () => {
timer = null;
if (view.isDestroyed) return;
const tr = scan(view.state);
if (tr && tr.docChanged) view.dispatch(tr);
};
const schedule = () => {
if (timer !== null) return;
timer = setTimeout(run, 0);
};
// Warm load: the doc may already be populated when the editor binds.
schedule();
return {
update(updatedView, prevState) {
// Re-scan only on real doc changes — new tokens from a sync, an edit,
// or our own conversion making partial progress. Selection-only
// transactions are ignored so we don't spin.
if (!prevState.doc.eq(updatedView.state.doc)) schedule();
},
destroy() {
if (timer !== null) clearTimeout(timer);
},
};
},
});
}