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);
        },
      };
    },
  });
}