diff --git a/.gitignore b/.gitignore index 38fef8a7..e2e4b153 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr # graphify (generated knowledge graph — local only) graphify-out/ +.superpowers/ diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 7ca24f0a..a7b0f87c 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -275,6 +275,12 @@ function App() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const editorRef = useRef(null); + // Dev affordance: expose the editor handle for automated smoke tests. + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__ddoc = editorRef; + }, []); + // Poll tab state from Y.Doc for DevBar useEffect(() => { const interval = setInterval(() => { @@ -832,6 +838,12 @@ function App() { initialContent={initialContent} enableIndexeddbSync={true} ddocId={docId} + // Only consulted at doc creation; existing docs follow their marker. + preferredSchemaVersion={ + new URLSearchParams(window.location.search).get('v2') === '1' + ? 2 + : undefined + } tabConfig={tabConfig} onError={(error) => { toast({ diff --git a/demo/src/components/DevBar.tsx b/demo/src/components/DevBar.tsx index 1087fb46..129b4ed5 100644 --- a/demo/src/components/DevBar.tsx +++ b/demo/src/components/DevBar.tsx @@ -39,6 +39,9 @@ export function DevBar({ }: DevBarProps) { const [visible, setVisible] = useState(false); const [contentSize, setContentSize] = useState(0); + const [schemaInfo, setSchemaInfo] = useState('...'); + const [showJson, setShowJson] = useState(false); + const [docJson, setDocJson] = useState(''); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -63,6 +66,54 @@ export function DevBar({ return () => clearInterval(interval); }, [visible, docId]); + // Doc schema: what the marker says vs what the editor actually loaded. + // A disagreement means the extension fork picked the wrong set. + useEffect(() => { + if (!visible) return; + const update = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handle = (window as any).__ddoc?.current; + const editor = handle?.getEditor?.(); + const ydoc = handle?.getYdoc?.(); + if (!editor || !ydoc) { + setSchemaInfo('...'); + return; + } + const marker = ydoc.getMap('ddocMeta').get('schemaVersion'); + const markerVersion = typeof marker === 'number' ? marker : 1; + const loadedVersion = editor.schema.nodes.dBlock ? 1 : 2; + setSchemaInfo( + markerVersion === loadedVersion + ? `v${loadedVersion} ${loadedVersion >= 2 ? '(flat)' : '(dblock)'}` + : `MISMATCH marker=v${markerVersion} loaded=v${loadedVersion}`, + ); + }; + update(); + const interval = setInterval(update, 2000); + return () => clearInterval(interval); + }, [visible]); + + // Live document JSON while the panel is open. + useEffect(() => { + if (!visible || !showJson) return; + const update = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editor = (window as any).__ddoc?.current?.getEditor?.(); + if (!editor) { + setDocJson('editor not ready'); + return; + } + setDocJson(JSON.stringify(editor.getJSON(), null, 2)); + }; + update(); + const interval = setInterval(update, 1000); + return () => clearInterval(interval); + }, [visible, showJson]); + + const handleCopyJson = () => { + navigator.clipboard?.writeText(docJson); + }; + const handleClearData = () => { const confirmed = window.confirm( 'Clear all data for this document? (localStorage + IndexedDB)', @@ -84,11 +135,40 @@ export function DevBar({ ); return ( -
+ <> + {showJson && ( +
+
+ + editor.getJSON() (live) + + +
+
+            {docJson}
+          
+
+ )} +
doc: {docId.slice(0, 8)} + + schema: {schemaInfo} + + tab: {activeTabId} @@ -139,6 +219,13 @@ export function DevBar({
+
+ ); } diff --git a/demo/src/components/DocSwitcher.tsx b/demo/src/components/DocSwitcher.tsx index b4e6d56d..80df7a29 100644 --- a/demo/src/components/DocSwitcher.tsx +++ b/demo/src/components/DocSwitcher.tsx @@ -16,7 +16,7 @@ interface DocSwitcherProps { export function DocSwitcher({ currentDocId, currentTitle }: DocSwitcherProps) { const [docs] = useState(() => docStore.getDocList()); - const handleNewDoc = () => { + const handleNewDoc = (schemaVersion?: 2) => { const newId = generateDocId(); docStore.addDoc({ id: newId, @@ -25,7 +25,9 @@ export function DocSwitcher({ currentDocId, currentTitle }: DocSwitcherProps) { lastModifiedAt: Date.now(), }); docStore.setCurrentDocId(newId); - window.location.href = `${window.location.pathname}?doc=${newId}`; + // v2 only matters at creation; once the doc is stamped, the marker rules. + const v2Param = schemaVersion === 2 ? '&v2=1' : ''; + window.location.href = `${window.location.pathname}?doc=${newId}${v2Param}`; }; const handleSwitchDoc = (docId: string) => { @@ -123,11 +125,19 @@ export function DocSwitcher({ currentDocId, currentTitle }: DocSwitcherProps) { +
} /> diff --git a/docs/DDOCS_NEW_INTEGRATION.md b/docs/DDOCS_NEW_INTEGRATION.md new file mode 100644 index 00000000..7a299252 --- /dev/null +++ b/docs/DDOCS_NEW_INTEGRATION.md @@ -0,0 +1,146 @@ +# ddocs.new integration handoff (M3) + +For the person doing the ddocs.new side of the flat-schema rollout. The +file-level inventory lives in `TEC2515_REMAINING.md` section 3 and the +`FLAT_SCHEMA_V2.md` spec; this document explains how the pieces behave +together, per user journey, and in what order things must happen. Nothing +here supersedes those two, it narrates them. + +## The mental model: one question, asked once + +Every document answers one question: **which schema does it use?** The answer +is decided once, at the moment the document is born, and stamped inside the +document itself (`ydoc.getMap('ddocMeta').get('schemaVersion')`). Every later +open just reads the stamp. + +On editor mount the package checks the Y.Doc: + +- **Stamp present, or doc has content** → that document's schema is settled. + No stamp + content = pre-marker legacy doc = v1. The prop below is ignored + entirely. Nothing the app does can change an existing document's schema. +- **No stamp and the doc is empty** → the document is being born right now. + This is the only moment `preferredSchemaVersion` is consulted. If it says 2, + the package writes the stamp (origin `'self'`, so no spurious save) and the + doc is flat forever. + +"Creation fork" means exactly that: a single if, evaluated once per document, +at birth. The road is one-way. + +Because the stamp travels inside the content blob, everything downstream is +automatic: duplication copies the stamp, sharing sends it, IPFS restore keeps +it, a collaborator's client reads it and builds the matching editor. Dexie +needs no schema change; `IDdoc.version` (crypto/contract) is unrelated. + +## The flag + +``` +flags.ts: flatSchemaEnabled prod: false | staging, preview: true + ↓ + + ↓ consulted only for a brand-new empty doc +stamp written into the Y.Doc + ↓ every open, forever +stamp → extension set (dBlock or flat) +``` + +The flag never decides any existing document's schema; it only decides what +the next newborn is stamped with. Consequences worth internalizing: + +- Flipping prod on: new docs are born v2 from that second; every existing doc, + including one created a minute earlier, is untouched. +- Flipping prod back off (emergency): new docs go back to v1. The v2 docs + created in between keep working, their stamp governs them. Rollback is a + config change, not a deploy, and it never strands data. +- Staging and prod can disagree indefinitely. Mixed v1/v2 libraries are the + normal state, not a transition artifact. +- Package default is 1 so no other consumer gets v2 by accident on a version + bump. The flag is the same protection one layer up, for ddocs.new itself. + +## The app changes (details in TEC2515_REMAINING.md §3) + +1. **Package upgrade** to the release cut from #552. The app pins the exact + version; keep the `yjs` / `y-indexeddb` / `y-protocols` overrides in + lockstep with the package's peers. +2. **The prop**, gated by the flag, at the `` mount. +3. **Title extraction** (`utils/ddoc-title-manager.ts:120-180`) — the app's + own copy of the wrapper-shape assumption. On v2 it fails *silently* and + every doc stays "Untitled" (doc list, export filenames). The package fixed + its identical copy in `package/utils/extract-title-from-content.tsx`; + reuse that shape. **Must land before any v2 doc exists.** +4. **Version-history diff** — verified to need NO code change. The pipeline + is schema-agnostic end to end: `buildVersionDiffSnapshot` decodes blobs + via `yDocToProsemirrorJSON` (no schema involved), the LCS differ compares + whatever block types it is given, and the renderer's dBlock branch at + `utils/diff/node-diff-renderer.ts:272` is a v1-only refinement that flat + content simply never enters. v2 diffs actually align better than v1: + persistent blockId attrs give the LCS block identity, where v1's + attr-less dBlock wrappers are interchangeable. Characterization tests + lock the flat path (`utils/diff/__tests__/node-diff-flat.test.ts` in the + app repo). Cross-schema diff still cannot occur (a doc never changes + schema). +5. **Templates** (`use-create-page.tsx`) — the app converts its template + JSON to a Yjs blob headlessly, before any editor mounts. Pass + `{ schemaVersion: 2 }` to the package's `getYjsConvertor()` when the flag + says v2; the package builds the flat editor, unwraps the dBlock wrappers, + and stamps the marker inside the blob itself. The stamp must be born + there: a headless blob already has content at first real mount, so the + mount-time stamping refuses it, and an unstamped blob is legacy v1 + forever. Never hand-rewrite `template-utils.ts` (144 dBlock nodes); the + v1-shaped JSON stays the source of truth. Note the in-editor template + overlay needs nothing — it already unwraps at insert time against the + live schema. The `.md`/`.docx` import paths (`getYjsContentFromMarkdown` + / `getYjsContentFromDocx`) still build v1 blobs — safe (imported docs + simply stay v1), thread the same option through them when imports should + produce v2 docs. +6. **E2E selectors** (`tests/utils/selectors.ts:13`) couple to v1 node-view + DOM; add v2 variants. + +Everything else is verified opaque: Dexie, IPFS publish, collab transport, +comments, search, AI, key rotation all pass the blob through byte-level. + +## User journeys after the flip + +| Journey | What happens | +|---|---| +| Old user opens a legacy doc | No stamp → v1 extensions → identical behavior, indefinitely. No migration. | +| Same user creates a new doc | Born v2. Their library is mixed v1/v2; every surface picks per-doc by stamp. | +| New user | Only ever sees v2, templates included. | +| Viewer / public page | Blob → stamp → right extension set. v2 read-only collapse/copy-link chrome exists (widget decorations). | +| Collaborator joins | Transport opaque; their client reads the stamp. See the hazard below. | +| Blog user (.md) | Markdown serialization is package-side, parity-verified (20/20 sweep). | +| Split View | Right pane is the real editor; parity-verified. | +| Version history | Per-doc single schema; works once the diff renderer has its flat branch. | +| Duplicate a doc | Stamp travels in the blob; schema preserved for free. | + +## The journey that can destroy data + +A stale browser tab running an old bundle has no concept of schema versions. +If it opens a v2 doc, it parses flat content with dBlock rules and writes that +structure back through Yjs: corruption, synced to everyone, no undo. This +cannot be fixed retroactively for code already running in someone's browser. + +Hence the one hard ordering rule, and the reason it is rigid: + +1. **Ship the guard release** (any release cut from #552; the guard is + dormant) and deploy ddocs.new with it. Let it **soak for weeks** so the + stale-tab population turns over. Clients with the guard refuse + newer-than-supported docs and show "refresh to update". +2. **Test collaboration against v2** — the one unverified area in the package + work. Nothing today can create a v2 collab doc, so this needs deliberate + setup on staging with the flag on. +3. **Only then flip the prod flag.** + +The soak time, not the code, is the schedule's long pole. That is the +argument for merging #552 and cutting the guard release early, while +everything else proceeds in parallel. + +## Sequence checklist + +- [ ] Merge #552, cut a package release (guard now exists in a published version) +- [ ] ddocs.new: upgrade package + land fixes 3-6 above, flag OFF everywhere +- [ ] Deploy to prod (all dormant), start the soak clock +- [ ] Flag ON in staging/preview; run the journey table above, especially collab +- [ ] Title check: create v2 doc with an H1, confirm doc list + export filename +- [ ] Version history on a v2 doc with several versions +- [ ] Soak elapsed + collab verified → flip prod flag +- [ ] Later, once irreversible in practice: delete the flag, hardcode 2 diff --git a/docs/FLAT_SCHEMA_V2.md b/docs/FLAT_SCHEMA_V2.md new file mode 100644 index 00000000..e8204ba1 --- /dev/null +++ b/docs/FLAT_SCHEMA_V2.md @@ -0,0 +1,233 @@ +# Flat Schema v2 Spec + +Owner: Mohit (v2 track), Bhavesh (chrome + point fixes track). +Parent ticket: [TEC-2515](https://linear.app/fileverse/issue/TEC-2515/editor-improvement-dblock-issues). +Last updated: 2026-08-06. + +**Status: M0-M2 built and verified; M3 not started.** Both tracks are combined +in PR #552 (`integration/tec2515-x-v2`), which also carries Bhavesh's chrome +work. Package-side parity is confirmed by a 20-feature v1-vs-v2 sweep +(`scripts/parity-sweep.cjs`, all matching). What is left, and what is +deliberately deferred, is tracked in `TEC2515_REMAINING.md` — read that for +current state; this document describes the design. + +Sections below are marked where the built code diverged from the original +plan. The plan is kept rather than rewritten, because the reasoning still +explains why things are shaped the way they are. + +## Why + +Today every top-level block is wrapped in a `dBlock` node whose node view puts chrome (gutter, buttons, per-block padding) inside the editable document. Root-cause analysis on TEC-2515 traced most open editor bugs to this wrapper: caret rendering at the row edge, cursor jumps from unmapped positions, and 764 lines of custom Enter/Backspace handling in `dblock.ts` that breaks lists and destroys collaborators' cursor positions under Yjs. + +v2 removes the wrapper for new documents. The document becomes flat, like Tiptap's Notion template: + +``` +v1 (today) v2 (flat) +doc doc +├─ dBlock ─ paragraph ├─ paragraph +├─ dBlock ─ heading ├─ heading +└─ dBlock ─ bulletList └─ bulletList +``` + +Old documents are not migrated. They keep the v1 schema and today's code path indefinitely. Migration is explicitly out of scope for this spec. + +## Settled decisions + +| Decision | Outcome | +|---|---| +| Old docs | Stay v1 forever (until a future, separate migration project) | +| New docs after flip | All v2, including template-created docs. No opt-in stage | +| Who writes the schema marker | The package, on the first render of a new doc (`useDocSchemaVersion`) | +| Where the marker lives | `schemaVersion` in the `ddocMeta` Y.Map of the doc itself, following the existing tab-metadata pattern. No marker = v1 | +| Safety check | Ships with the combined PR, and must be live in a real release before the ddocs.new flag is ever flipped (see Ordering constraints) | +| Block IDs | v2 blocks carry a persistent unique ID attribute from day one (cheap at birth, avoids a future migration) | +| Templates | Move to v2 via a runtime unwrap util. The v1-shaped template JSONs in both repos stay untouched as source of truth | +| ddocs.new flag | `NEXT_PUBLIC_*` env var following the existing `utils/feature-flags.ts` pattern | + +## Architecture + +### The marker + +Each doc carries `schemaVersion` in a Yjs map (same pattern as `ddocTabs` / `tabs_state`). Absence of the marker means v1, so every existing doc is v1 by definition without being touched. + +The marker is written in exactly one place: `useDocSchemaVersion` +(`package/hooks/use-doc-schema-version.ts`), in the `useMemo` that calls +`ydoc.transact(...)` with the `'self'` origin. + +> Earlier drafts of this doc placed the write in `applyResolvedTabState` +> (tab seeding). It moved during implementation: the version has to be +> readable on the *first* render, because it decides which extension set the +> editor is built with. `useTabManager` decodes `initialContent` into the ydoc +> synchronously during render, and `useDocSchemaVersion` is called immediately +> after it (`use-ddoc-editor.tsx`) so the marker is already readable when the +> extensions are assembled. Hence the hook-order comment at the top of that +> file — it must stay after `useTabManager`. + +All four conditions must hold before anything is written: + +- the doc has no marker yet, +- content is resolved (IndexedDB can no longer replay a marker), +- the doc is genuinely new (`isNewDdoc`: owner, no collab, no initial content), +- and `preferredSchemaVersion >= 2`. + +That last one is why a v1 doc never gets a marker at all: **with today's +default of 1, the write never executes.** A breakpoint there stays silent +until a consumer explicitly asks for v2. Absence of the marker *is* the v1 +signal — see `getDocSchemaVersion` in `package/utils/schema-version.ts`, which +treats anything non-numeric as 1. + +### Mode selection on open + +``` +open doc + ├─ marker absent or 1 → v1 extension set (today's editor, unchanged) + ├─ marker 2 → v2 extension set (flat) + └─ marker > supported → read-only + "refresh to update" banner (safety check) +``` + +The version is read before extensions are built. Extensions are assembled per tab (`buildExtensionsForTab`), and all tabs share the doc's version. + +### The extension fork + +`defaultExtensions()` in `package/extensions/default-extension.ts` gains a schema-version parameter. Four call paths must respect it; missing any one silently breaks v2 docs in that path: + +1. Main assembly: `default-extension.ts` (`createDBlockExtension`, `Document`, `TrailingNode` are the v1-only entries) +2. Per-tab: `buildExtensionsForTab` in `package/hooks/use-tab-editor.tsx` +3. Headless (exports, import, print): `getHeadlessExtensions` in `package/hooks/use-headless-editor.tsx` +4. The AI path re-fork in `use-tab-editor.tsx` (filters out `dBlock` and re-adds it; must be version-aware) + +As built, the v2 branch also registers what the dBlock extension used to +supply on its own: `FlatDocument`, `FlatHeadingCollapse` (the collapse plugin +plus the read-only preview heading chrome), `FlatMediaConversion` (URL to +media), `BlockId`, and `AiWriterSpaceTrigger`. Anything registered *inside* +`createDBlockExtension` needs an equivalent here — that is the failure mode +this list exists to prevent. + +### The v2 schema + +Two content-spec changes. As built these are *additional* node types rather +than edits, so the v1 specs are untouched and both can coexist in one bundle: + +- `package/extensions/document/document.ts`: `Document` keeps `content: '(dBlock|columns|pageBreak)+'`; `FlatDocument` extends it with `'(block|columns|pageBreak)+'` +- `package/extensions/multi-column/column.ts`: `Column` keeps `content: 'dBlock+'`; `FlatColumn` extends it with `'block+'` + +The M1 wrinkles resolved as follows: the group questions were not a problem in +practice, and removing `priority: 1000` did reshuffle keymap resolution — which +is the point, since v2 wants stock Tiptap handlers. Position arithmetic that +assumed the wrapper was swept separately (`POSITION_AUDIT.md`). + +Housekeeping: `package/extensions/doc.ts` is a dead duplicate top-node (still +unimported, still present). Deleting it is deferred with the rest of the v1 +dead-code cleanup, so nothing is removed while v1 is the shipping schema. + +### New-doc version preference + +`DdocProps` gains `preferredSchemaVersion?: 1 | 2` (default 1). It applies only when the package detects a genuinely new doc (owner, no collab, no initial content — computed in `use-ddoc-editor.tsx` and passed to `useDocSchemaVersion`; `use-tab-manager.ts` derives the same condition separately for tab seeding). Existing docs always follow their marker; the prop is ignored for them. + +The default is 1 on purpose, and it is not a placeholder. The package is a +published library: if 2 were the default, merely bumping the dependency would +silently change the storage format of every new document, before the safety +check has spread and before the consuming app has been taught the new shape. +Off-by-default makes the flip a deliberate, revertible act. It should become 2 +eventually, in a **major** version, once the guard has soaked and ddocs.new is +creating v2 documents for real. + +Consequences that fall out for free: + +- Flipping the flag off stops creating v2 docs but never breaks existing ones +- Duplicating a doc copies the encoded Yjs blob, so the marker travels with it and duplicates keep their source's version automatically + +## Milestones + +### M0: Safety check (ships first, next regular release) + +- Define the `schemaVersion` field (`ddocMeta` Y.Map) and read helpers +- The check: if a doc's version is higher than the package supports, no editor is created at all (no y-sync binding) and the package renders a "refresh to update" banner in both the main and preview editors +- This ships while every doc in existence is v1, so it is dormant. That is the point: by the time v2 launches, even stale browser tabs have the check +- The marker write moved to M1, and then out of tab seeding entirely — see The marker above. `applyResolvedTabState` runs for both new-doc seeding *and* ongoing self-heal of existing docs, so an unconditional write there would have stamped old docs +- **Changed after the fact:** M0 was originally meant to ship on its own, ahead of everything else. Mohit decided (2026-08-06) that nothing ships separately — the guard rides the combined PR. The soak requirement did not go away, it just moved: the constraint is now that a release containing the guard must be live *before* the ddocs.new flag is flipped + +### M1: v2 skeleton that types + +- The two-line schema change + block ID attribute +- `defaultExtensions` fork wired through all four call paths +- Stock keymaps only: paragraphs, headings, lists working with default Tiptap behavior +- Template overlay suppressed on v2 docs (until the unwrap util lands in M2; it is now enabled and unwraps at insert time) +- Demo app toggle: a "new v2 doc" action keyed on docId (demo has multi-doc infra already; `demo/src/App.tsx` has zero dBlock references) +- Exit: a v2 doc can be created, edited, closed, and reopened in the demo, and the four fork paths all produce the right extension set + +M1 is deliberately where surprises are supposed to surface (keymap priority reshuffle, schema group details), while the blast radius is a demo toggle. + +### M2: Parity + +Work items from the package audit (see inventory below). Only one is large on this track: heading collapse. The gutter toolbar / floating handle chrome comes from Bhavesh's track and must be built schema-agnostic. + +Late in M2: the template unwrap util. One exported function, roughly 20 lines: walk template JSON, replace every `dBlock` with its child, recursing into columns. Shared by the package overlay and ddocs.new's create flow. Never hand-rewrite the template JSONs (144 dBlock nodes in ddocs.new, 69 in the package); the transform is the only safe path. Built as `unwrapDBlocksInJSON` in `package/utils/block-schema.ts`. + +Exit criterion: **every template renders and edits correctly in a v2 doc.** Templates contain tables, callouts, media, and columns, so they double as the parity smoke test. + +> Count correction: the package ships **6** templates (meeting-notes, todo-list, +> brainstorm, breathe, pretend-to-work, resume). The "9" in earlier drafts was +> the ddocs.new count. All 6 were verified unwrapping into v2 with block counts +> identical to v1. + +### M3: ddocs.new integration + flip + +- `preferredSchemaVersion` prop passed at the main `` mount (`components/ddoc-editor/ddoc-editor.tsx`), gated by a new `NEXT_PUBLIC_*` flag in `utils/feature-flags.ts` +- App-side fixes (see ddocs.new inventory below) +- Internal testing period with the flag on for the team +- Flip: flag on for everyone. Every new doc is v2, templates included. The create flow needs no v1 fork at all + +## Package work inventory (from the 2026-08-04 audit) + +44 files reference dBlock. Buckets: + +- **Works as-is (9)**: comment-only refs, dead code, the runtime state container. No work +- **v1-only, absent from v2 set (7)**: the `d-block/` folder itself (`dblock.ts`, node view, gutter components). Kept for v1 mode, simply not registered in v2. Note the chrome work has since deleted `dblock-view-registry.ts` and gutted the gutter toolbar, so this bucket is smaller than the audit found +- **Trivial content producers (5)**: stop emitting `type: 'dBlock'` in v2 paths: `sanitize-content.ts`, `resizable-media.ts` Enter-on-media, `multi-column/utils.ts` `buildDBlock`, `multi-column/columns.ts`, plus the package template JSONs (handled by the unwrap util) +- **Point edits (11)**: retarget "find enclosing dBlock" to "top-level block": bubble-menu node-selector (medium, list conversion wraps/unwraps dBlock), code-block Mod-Enter escape, media captions, editor-utils list detection, AI autocomplete gating, markdown paste pageBreak conversion, content-item actions, TOC heading expansion, callout clipboard flatten, plus the headless assembly fork +- **Re-homes (12)**: heading collapse (large, decorations over top-level ranges, `collapsed` attr on the heading node), gutter toolbar + template overlay (large, but mostly Bhavesh's chrome track), media conversion plugin (medium), trailing node (medium, position math assumes the wrapper), the two schema files (trivial), dBlock-specific CSS in `editor.css` / `index.css` / `split-view.css` (small; the `.node-dBlock` rules still look dead — search for them rather than trusting the old line number, the file has moved a lot) + +**What the audit could not see.** Three v2 breaks were found by running the +editor, not by searching it: `getHeadingLinkSlug` (copy-link returned nothing), +the template overlay's second guard (a `[data-type="d-block"]` DOM query), and +`extract-title-from-content.tsx` (every v2 doc untitled). None of those files +mention `dBlock` — they assume the wrapper's *shape*, which no grep finds. +Treat the bucket counts above as a floor, and prefer the parity sweep for +evidence. + +## ddocs.new work inventory (from the 2026-08-04 audit) + +The app treats content as opaque bytes everywhere that matters: all Yjs handling is byte-level, no `getXmlFragment` anywhere, comments/publish/IPFS/search/AI/key-rotation all pass content through. Four structural exceptions: + +1. **Title auto-extraction, must fix**: `utils/ddoc-title-manager.ts:120-180` assumes one wrapper level when finding the H1 title. On v2 docs it fails silently and every doc stays "Untitled". Roughly 5 lines to handle both shapes. Sneaky because nothing throws. The package had its own copy of the same bug, fixed in `package/utils/extract-title-from-content.tsx` — reuse that shape as the fix +2. **Version-history diff**: `utils/diff/node-diff-renderer.ts:272` has an explicit dBlock branch. Add a flat branch; keep the v1 branch (v1 docs live forever). Cross-schema diffs cannot occur because docs never change schema, so that case is deferred until migration exists +3. **App-side templates**: `utils/template-utils.ts` (7k lines, 144 dBlock nodes) feeds the create-page flow. Handled by the shared unwrap util at creation time; the JSON stays untouched +4. **Corrupt-content guard**: compares against a dBlock literal but only runs for legacy JSON content, never Yjs blobs. Inert; no change + +Operational notes: + +- The app pins the package exactly (`"@fileverse-dev/ddoc": "4.3.6"` as audited; the combined branch is now `4.4.0`) and forces single `yjs` / `y-indexeddb` / `y-protocols` instances via `overrides`. v2 package releases must keep these in lockstep or cross-boundary `instanceof` checks break +- One e2e selector (`tests/utils/selectors.ts:13`, `.node-view-content p.select-text`) couples to the v1 node-view DOM and needs a v2 variant +- `IDdoc.version` is the crypto/contract version, unrelated to editor schema. No Dexie migration needed; the marker lives in the doc + +## Ordering constraints + +These are the only hard sequencing rules. Everything else can shuffle. + +1. **Safety check live in a shipped release before any v2 doc exists, with soak time.** The check only protects clients that have it. Stale browser tabs run old bundles; an old bundle opening a v2 doc would write dBlock structure into it and corrupt it for everyone, with no Yjs undo. Since the flip makes all new docs v2 at once, day-one stale-tab exposure is high, and the soak time is what covers it. **This is the one rule still outstanding** — the guard is built and merged with everything else, but the ddocs.new flag must not be flipped until a release carrying it has been out for a while +2. **Chrome (Bhavesh's track) must be schema-agnostic.** The floating drag/plus handle and container padding anchor to top-level blocks, not to `[data-dblock-*]`. Built that way once, v2 inherits it and the large "toolbar re-home" item mostly disappears. *Held up in practice:* `resolveTopLevelBlock` accepts any depth-0 block. The parts that still assumed the wrapper (heading render meta, first-line offset, plus-button insert) were point fixes, not a rebuild +3. **Behavior tests before v2 is judged working.** Tests should be written against editor behavior, not dBlock internals, so the identical suite runs against both extension sets. *As built:* this became `scripts/parity-sweep.cjs`, which drives both schemas through the same actions in a real browser. Unit tests cover the schema-aware utilities; the keymap suite from Bhavesh's track has not been written, since the keymap shrink did not happen +4. **Template unwrap after parity covers what templates contain.** Templates exercise tables, callouts, media, and columns; running them earlier just reports known-missing features + +## Risks and open items + +- ~~M1 unknowns~~ — resolved. Keymap order did change with `priority: 1000` gone, which is the intent in v2; the group handling was a non-issue +- ~~Heading collapse is the single largest v2 work item~~ — done. Generalised in place around a resolver, so every v1 caller kept its signature +- Effort labels in the inventories are informed estimates from the audits, not scoped commitments +- Out of scope, deliberately: migration of v1 docs, cross-schema diffs, retiring the v1 code path +- Still open and **not** part of this design: the ~200ms tab-switch pause (see `TEC2515_REMAINING.md`), and the v1 keymap shrink, which was never done — `dblock.ts` is still ~1090 lines + +## Related tickets + +TEC-2515 (umbrella), TEC-2221 / TEC-2232 (list bugs, fixed by Bhavesh's keymap track), TEC-2539 / TEC-2617 (cursor jumps, fixed by the point-fix track), TEC-2644 (todo UI, rides the chrome work). diff --git a/docs/POSITION_AUDIT.md b/docs/POSITION_AUDIT.md new file mode 100644 index 00000000..6b5c0b57 --- /dev/null +++ b/docs/POSITION_AUDIT.md @@ -0,0 +1,43 @@ +# Position-Mapping Audit + +Audited: 2026-08-05, on `feat/flat-schema-v2`. Scope: every call site using +position arithmetic (`focus(pos + n)`, `insertContentAt(pos + n)`), excluding +the sites already owned by the chrome + fixes track (dblock.ts keymaps, +action-button, dblock-media-plugin, upload-images). + +## The rule + +A ProseMirror position is only valid against the document state it was +computed from. The safe patterns are: + +1. Compute and consume in the same transaction: arithmetic against the + post-mutation `tr.doc` inside one chain is fine and often intentional. +2. Recompute after any await or dispatch, or map through `tr.mapping`. + +The failure pattern is reading a position at chain-BUILD time (plain argument +expressions evaluate before `.run()`), or capturing one before an async gap, +and consuming it after the doc changed. + +## Verdicts + +| Site | Verdict | +|---|---| +| `utils/insert-commands.ts` columns2/columns3 | **STALE, fixed.** `.focus(editor.state.selection.head - 1)` evaluated pre-insert at chain-build time. Removed; `setColumns` now owns the caret. | +| `extensions/multi-column/columns.ts` setColumns | **Fixed (new owner).** Caret placed in the first column cell inside the same transaction, offsets derived from the structure just built (schema-aware: +3 with dBlock, +2 flat), `TextSelection.near` for the final descent. Verified live in both schemas incl. keepContent. | +| `extensions/resizable-media/media-caption.ts` Enter (focus after insert) | Correct. Single chain; arithmetic targets the paragraph inserted earlier in the same transaction; offset is schema-aware. | +| `extensions/resizable-media/media-caption.ts` focus into next block | Correct. Fresh-state read, no mutation before use. v1-only branch. | +| `extensions/resizable-media/resizable-media-menu-util.ts` caption add/migrate | Correct. Single chain; `pos` is fresh at invocation; post-insert arithmetic targets the caption just created, inside the media node (schema-neutral). | +| `extensions/resizable-media/resizable-media-node-view.tsx` migrateLegacyCaption | Correct. Same single-chain caption pattern; `getPos()` fresh at call. | +| `extensions/default-extension.ts` createInputRule | **N/A: dead code.** Zero consumers in the repo. Candidate for deletion when v1 code retires. | + +Sites excluded as already-owned work: `d-block/dblock.ts` keymap offsets +(keymap shrink deletes them), `action-button/action-button-node-view.tsx` (4 +sites, point fix), `d-block/dblock-media-plugin.ts` (point fix), +`utils/upload-images.tsx` IPFS branch (point fix). + +## Outcome + +After the owned fixes land, no call site in the package consumes a position +computed against a stale document state. New code should follow pattern 1 or +2 above; anything reading `editor.state` inside chain arguments is a review +flag. diff --git a/docs/TEC2515_REMAINING.md b/docs/TEC2515_REMAINING.md new file mode 100644 index 00000000..ef1d87d3 --- /dev/null +++ b/docs/TEC2515_REMAINING.md @@ -0,0 +1,142 @@ +# TEC-2515 — what's left + +Status note kept alongside `FLAT_SCHEMA_V2.md`. Last updated 2026-08-06. + +**Where this stands:** every package-side item is done. What remains before +#552 merges is coordination, not code (section 1); everything else is the +ddocs.new integration in section 3, which is deliberately not started. + +**Decision (Mohit, 2026-08-06):** no release pressure. PR #552 +(`integration/tec2515-x-v2` → `main`) carries both tracks and merges as one +unit; #551 does not need to land separately. Everything below is what stands +between here and that merge, plus what follows it. + +## 1. Before #552 merges + +| # | Item | Owner | Notes | +|---|---|---|---| +| 1.1 | ~~Read-only preview collapse + copy-link chrome for the flat schema~~ | us | **Done** (`497c460`). Supplied as a widget decoration from the collapse plugin, sharing v1's classes and icons via `heading-chrome-icons.ts`. Same always-render + CSS `[contenteditable='false']` gate as v1, for the same staleness reason. | +| 1.2 | Bhavesh reviews the integration fixes on his code | Bhavesh | Two of them are **v1 bugs, not v2 bugs**, and matter to #551 on their own: (a) the `.drag-handle` z-index race — DOM insertion order alone decided whether the cluster or the editor surface painted on top, so the cluster was unclickable whenever the handle mounted first; (b) the collapse toggle scrolled to the caret unconditionally, so collapsing a heading part-way down a document threw the reader back to the top. | +| 1.3 | Decide #551's fate | Mohit | Merging #552 absorbs it. His commits keep their attribution, but the PR closes rather than merges — worth telling him rather than letting him find out. | +| 1.4 | Version number for the combined release | Mohit | His branch bumped to `4.4.0`. Confirm that is still the right number for both tracks together. | +| 1.5 | Re-merge + re-verify if `main` moves | us | Two known conflict points: the `editorProps.attributes` literal and `editor.css`. | + +## 2. Package work — done (`497c460`) + +All of the flat-schema parity gaps below are closed and verified with trusted +input in both schemas. + +- ~~**URL-to-media conversion is v1-only.**~~ `getDBlockMediaCandidate` now + understands both shapes (wrapper vs. bare paragraph, with the replaced range + following the same split) and the plugin is registered for v2 through + `FlatMediaConversion`. +- ~~**Template overlay never shows on a blank v2 doc.**~~ Detection no longer + requires a `dBlock` first child or the `[data-type="d-block"]` DOM marker. + Templates stay authored in v1 shape as the single source of truth and are + run through `unwrapDBlocksInJSON` at insert time. +- ~~**Fonts parity nuance.**~~ The trailing paragraph is now identified by + position (last top-level child, still empty) as well as by v1's class + attribute, so v2's stock trailing node is skipped the same way. +- **Found while doing the above:** `getHeadingLinkSlug` resolved the heading + through the wrapper and so returned null for every flat heading, which made + copy-link dead in v2 even where the button rendered. Now shape-agnostic, + matching `getDBlockRenderMeta`. + +Still deferred, deliberately: + +- **Dead code, once v1 retires.** `createInputRule` (zero consumers), the + `isDBlockEmpty` checks in ai-autocomplete, `extensions/doc.ts`, and the + `.node-dBlock` CSS. Removing these while v1 is live buys nothing and risks + the schema we still ship. + +## 2b. Parity sweep — 20 features, all matching + +Run 2026-08-06. Both schemas were driven through the same scripted actions in +a real browser and their results compared. A check only counts if it produced +real evidence — four early versions "passed" against an empty document and had +to be rewritten before they meant anything. + +All 20 match: export markdown, export HTML (text + tag vocabulary), title +extraction, character/word count, table of contents, comments, search and +replace, undo/redo, copy-paste round trip, list indent/outdent, markdown to +slides, read-only render, node commands (heading/list/code/quote/task), block +duplicate and delete, tables (create, add row, add column, delete), page +break, suggestion highlight marks, split-view markdown serialization, media +insert, and columns. + +**One real break found and fixed** (`0128a3b`): `extractTitleFromContent` +walked two levels down, so every flat-schema document came out untitled — +it feeds export filenames from seven call sites. Covered by tests now. + +Worth noting how it was found: it was invisible to a `dBlock` grep, because +the file never mentions dBlock — it just assumed the wrapper's shape. The +same is true of the `getHeadingLinkSlug` and template-overlay breaks found +earlier. Static search cleared all three; only running them exposed the bugs. + +## 3. M3 — ddocs.new (not started, deliberately) + +Nothing here begins until we are confident in the package. All sites are +already audited and located. + +- `utils/ddoc-title-manager.ts:120-180` — `extractTitleFromContent` assumes the + wrapper level and fails silently on v2, leaving documents titled "Untitled". + ~5 lines. **Must fix before any v2 doc exists.** +- `utils/diff/node-diff-renderer.ts:272` — RESOLVED, no code needed: the + diff pipeline is schema-agnostic (schema-less blob decode, LCS differ, + generic renderer; the dBlock branch is v1-only refinement). Locked by + characterization tests in the app repo + (`utils/diff/__tests__/node-diff-flat.test.ts`). +- `components/ddoc-editor/ddoc-editor.tsx:864` — pass `preferredSchemaVersion` + behind a `NEXT_PUBLIC_*` flag (follow the `useTeamWorkspacesEnabled` pattern + in `utils/feature-flags.ts`). **This is the switch that creates the first v2 + document.** +- `use-create-page.tsx` — pass `{ schemaVersion: 2 }` to the package's + `getYjsConvertor()` when creating as v2; the package unwraps the template + JSON and stamps the marker inside the blob (headless support landed + package-side with the M3 prep). +- Keep the `yjs` / `y-indexeddb` / `y-protocols` overrides in lockstep with the + package's peers. +- `tests/utils/selectors.ts:13` — E2E selectors couple to node-view DOM. + +## 4. The one hard ordering constraint + +The unsupported-version guard must be **live in production** before any v2 +document exists anywhere. Stale browser tabs running older code have no +version concept and would write dBlock structure into a flat document through +Yjs, corrupting it with no undo. + +Merging #552 satisfies this, but only once a release actually ships from it. +Confirm that has happened before flipping the ddocs.new flag in 3. + +## 5. Split out as separate work + +- **Tab switching pauses (~200ms).** A warm switch — both editors cached, 700 + blocks each — is one synchronous blocking task, so it is JS and layout in + the click handler rather than progressive rendering. Profile: `setAttribute` + 30ms, ProseMirror `updateStateInner` 29ms, `compareDeep` 12ms, + `nodesBetween` 11ms, the rest unattributed layout. Unconfirmed hypothesis: + inactive panels are `position: absolute` and flip to `relative` on + activation, forcing a full reflow of a large document, plus two React + commits per switch. This is the tabs architecture rather than TEC-2515, and + changing how inactive panels are laid out risks scroll position and + measurement, so it is deliberately not bundled here. + +## 6. Not ours / still open elsewhere + +- **Keymap shrink (Phase 3)** has not happened. `dblock.ts` is still 1090 + lines; #551 touched it by 4 lines. The Enter/Backspace handlers keep their + magic offsets and region rebuilds in v1. v2 does not need them at all, so + this is only worth doing if v1 is going to live a long time. +- **TEC-2617** (style syncing one side only) should be split out — the + evidence points at awareness/sync, not dBlock. +- Three vague ticket items awaiting scoping from Vijay. + +## 7. Accepted, no action + +- The cluster hides below 1024px, while the codebase convention elsewhere is + 1280/1000. Intentional per Bhavesh's spec; flagged only for consistency. +- `setPageBreak` immediately after `setHorizontalRule` consumes the rule. Same + in both schemas, pre-existing command composition, unrelated to the schema. +- The node search in `block-insert.ts` uses a ±2 window and takes the first + same-type match, which can find a preceding sibling of the same type in an + edge case. Caret placement only. diff --git a/docs/superpowers/plans/2026-08-04-editor-chrome.md b/docs/superpowers/plans/2026-08-04-editor-chrome.md new file mode 100644 index 00000000..1e5401d7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-editor-chrome.md @@ -0,0 +1,784 @@ +# Editor Chrome (Floating Block Controls + Container Padding) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Commit convention (user-directed 2026-08-05):** commit at the end of each task with a conventional message (`feat:`/`fix:`/`chore:`). Never add any AI attribution — no `Co-Authored-By`, no "Generated with" lines. + +**Goal:** Move all block chrome (padding, drag/plus/collapse/copy-link controls, template buttons) out of the editable document: container-level padding on `.ProseMirror` and one floating drag-handle cluster, per spec `docs/superpowers/specs/2026-08-04-editor-chrome-design.md`. + +**Architecture:** Keep the dBlock schema untouched. `DBlockNodeView` shrinks to a plain wrapper (no gutter/flex). A single `` (official Tiptap extension) rendered outside the contenteditable replaces per-block gutters; the template overlay portals to the editor panel div instead of inside the document. Padding moves to scoped `.ProseMirror` CSS. + +**Tech Stack:** Tiptap 3.11.0 (pinned), `@tiptap/extension-drag-handle-react@3.11.0`, React 18, vitest + jsdom + @testing-library. + +## Global Constraints + +- **Supply-chain protocol (Shai-Hulud worm, active since 2026-08-04):** only install packages with publish date **before 2026-08-04**; exact pins (no `^`); `npm install --ignore-scripts` for this branch's installs; review every lockfile diff entry; never run `npm update`. +- New dep versions: `@tiptap/extension-drag-handle@3.11.0`, `@tiptap/extension-drag-handle-react@3.11.0`, `@tiptap/extension-node-range@3.11.0` (all published 2025-11-19). +- Padding values (from spec): md+ `72px 80px 20vh`; below md `24px 16px 20vh 36px` (36px left = collapse-chevron room). Padding breakpoint is `md` (768px); plus/grip visibility gates at `lg` (1024px) — intentionally different. +- Doc JSON must remain byte-identical — no schema or content changes. +- Behavior parity: plus (Alt = insert above), grip menu actions, Alt-click grip = delete, collapse chevron visible on all screen sizes, copy-link only in preview mode on headings, no chrome in presentation-preview. +- Run tests with `npx vitest run `; full suite `npx vitest run`. + +--- + +### Task 0: Restore the deleted test harness (pre-existing breakage) + +Commit f20d313 deleted `package/utils/make-editor.ts` but left `package/utils/selection-utils.test.ts` and `package/hooks/use-editor-commands.test.tsx` importing it — both files currently fail to collect. Restore the helper verbatim; `getHeadlessExtensions` still exists (`package/hooks/use-headless-editor.tsx:25`). + +**Files:** +- Create: `package/utils/make-editor.ts` + +**Interfaces:** +- Produces: `makeEditor(content?: string): Editor` — jsdom editor factory used by all tests in this plan. + +- [ ] **Step 1: Recreate the file exactly as deleted (verified against `git show f20d313~1:package/utils/make-editor.ts`)** + +```ts +import { Editor } from '@tiptap/react'; +import { getHeadlessExtensions } from '../hooks/use-headless-editor'; + +/** + * Shared jsdom editor factory for unit tests (use-editor-commands.test.tsx, + * selection-utils.test.ts, ...). Collaboration owns the doc, so content must + * be set via `setContent` *after* construction — passing `content` to the + * `Editor` constructor is silently ignored once Collaboration is configured. + */ +export const makeEditor = (content: string = '

'): Editor => { + const editor = new Editor({ + extensions: getHeadlessExtensions(), + // matches useHeadlessEditor/ddoc-editor; required for dir tracking + textDirection: 'auto', + }); + editor.commands.setContent(content); + return editor; +}; +``` + +- [ ] **Step 2: Verify both broken suites now collect and pass** + +Run: `npx vitest run package/utils/selection-utils.test.ts package/hooks/use-editor-commands.test.tsx` +Expected: PASS (previously "Test Files 1 failed / no tests"). + +--- + +### Task 1: Install drag-handle dependencies under the supply-chain protocol + +**Files:** +- Modify: `package.json` (deps + `overrides`) +- Modify: `package-lock.json` (via npm, then reviewed) + +**Interfaces:** +- Produces: importable `@tiptap/extension-drag-handle-react` (component `DragHandle`), `@tiptap/extension-node-range`. + +- [ ] **Step 1: Verify publish dates of the exact versions (all must be < 2026-08-04)** + +Run: +```bash +npm view @tiptap/extension-drag-handle time --json | python3 -c "import json,sys; print(json.load(sys.stdin)['3.11.0'])" +npm view @tiptap/extension-drag-handle-react time --json | python3 -c "import json,sys; print(json.load(sys.stdin)['3.11.0'])" +npm view @tiptap/extension-node-range time --json | python3 -c "import json,sys; print(json.load(sys.stdin)['3.11.0'])" +``` +Expected: all three print a 2025-11-19 timestamp. If any print a 2026-08 date, STOP and report. + +- [ ] **Step 2: Add worm-guard overrides to `package.json`** (pins the families the attack poisoned to the majors already in our lockfile) + +```json +"overrides": { + "keyv": "4.5.4", + "flat-cache": "3.2.0", + "file-entry-cache": "6.0.1" +} +``` + +- [ ] **Step 3: Install with scripts disabled and exact pins** + +Run: +```bash +npm install --save-exact --ignore-scripts \ + @tiptap/extension-drag-handle@3.11.0 \ + @tiptap/extension-drag-handle-react@3.11.0 \ + @tiptap/extension-node-range@3.11.0 +``` + +- [ ] **Step 4: Review the lockfile diff — every added package** + +Run: `git diff package-lock.json | grep -E '^\+.*"(resolved|version)"' | sort -u | head -50` +Check each newly added name/version: (a) not on the compromised list (keyv 6.0.0, flat-cache 6.1.24, file-entry-cache 11.1.6, cacheable-request 13.0.20, cacheable 2.5.1, @cacheable/* — see spec), (b) publish date pre-2026-08-04 via `npm view time --json`, (c) no new `preinstall`/`postinstall` in its lockfile entry (`git diff package-lock.json | grep -i 'install"'` → expect empty). Expected additions: the three tiptap packages and possibly `@floating-ui/dom` and `@tiptap/y-tiptap` (peer). Anything else unexplained: STOP and report. + +- [ ] **Step 5: Sanity-check the suite still passes** + +Run: `npx vitest run package/utils/selection-utils.test.ts` +Expected: PASS. + +--- + +### Task 2: Floating handle cluster component (coexists with old gutter for now) + +New component rendering the official `DragHandle` with our existing buttons. Mounted by `DBlockToolbarProvider` alongside its current machinery — the old gutter stays until Task 3, so the editor keeps working after this task (dev will briefly show both control sets; acceptable intermediate state). + +**Files:** +- Create: `package/extensions/d-block/dblock-drag-handle.tsx` +- Create: `package/extensions/d-block/dblock-drag-handle.test.tsx` +- Modify: `package/extensions/d-block/dblock-toolbar.tsx` (mount only — render `` next to existing output in `DBlockToolbarProvider`) + +**Interfaces:** +- Consumes: `useContentItemActions(editor, resolveCurrentBlock: () => ResolvedContentItem | null)` from `package/hooks/use-content-item-actions.tsx` where `ResolvedContentItem = { editor: Editor; node: Node; pos: number }`; `getDBlockRenderMeta(node, pos)`, `getHeadingLinkSlug(node, pos)`, `toggleHeadingCollapse(editor, pos)` from `./dblock-collapse`; button/tooltip/menu components from `./components/*`; `DBlockRuntimeState` from `./dblock-runtime`. +- Produces: `DBlockDragHandle({ editor, runtimeState, onCopyHeadingLink }: { editor: Editor; runtimeState: DBlockRuntimeState; onCopyHeadingLink?: (link: string) => void }): JSX.Element | null` — the only floating-chrome entry point after Task 3. + +- [ ] **Step 1: Confirm the DragHandle React API against the installed types** + +Read `node_modules/@tiptap/extension-drag-handle-react/dist/index.d.ts`. Confirm: component name (`DragHandle`), props `editor`, `children`, `onNodeChange({ node, editor, pos })`, and the positioning prop (`computePositionConfig` in 3.11). If prop names differ, adapt Step 3's code to the actual names before writing it. + +- [ ] **Step 2: Write the failing test** + +`package/extensions/d-block/dblock-drag-handle.test.tsx`: + +```tsx +import { describe, it, expect, afterEach, beforeAll } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; +import { DBlockDragHandle } from './dblock-drag-handle'; +import { DEFAULT_DBLOCK_RUNTIME_STATE } from './dblock-runtime'; + +beforeAll(() => { + // floating-ui in jsdom + if (!window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +describe('DBlockDragHandle', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('renders the control cluster outside the editable DOM', () => { + editor = makeEditor('

hello

'); + document.body.appendChild(editor.view.dom); + render( + , + ); + const cluster = screen.getByLabelText('block-controls'); + expect(cluster).toBeTruthy(); + expect(editor.view.dom.contains(cluster)).toBe(false); + }); + + it('renders nothing in presentation preview', () => { + editor = makeEditor('

hello

'); + const { container } = render( + , + ); + expect(container.querySelector('[aria-label="block-controls"]')).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run package/extensions/d-block/dblock-drag-handle.test.tsx` +Expected: FAIL — module `./dblock-drag-handle` not found. + +- [ ] **Step 4: Implement `dblock-drag-handle.tsx`** + +Port the button cluster from `DBlockToolbar` (in `dblock-toolbar.tsx`) onto the floating handle. Key structure (visibility rules copied verbatim from `DBlockToolbar`): + +```tsx +import React, { useCallback, useState } from 'react'; +import { DragHandle } from '@tiptap/extension-drag-handle-react'; +import { Editor } from '@tiptap/react'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { useMediaQuery } from 'usehooks-ts'; +import { cn } from '@fileverse/ui'; +import useContentItemActions, { + ResolvedContentItem, +} from '../../hooks/use-content-item-actions'; +import { + getDBlockRenderMeta, + getHeadingLinkSlug, + toggleHeadingCollapse, +} from './dblock-collapse'; +import type { DBlockRuntimeState } from './dblock-runtime'; +import { DBlockMenu } from './components/menu'; +import { + CollapseButton, + CopyLinkButton, + GripButton, + PlusButton, +} from './components/buttons'; +import { + AddBlockTooltip, + CollapseTooltip, + CopyLinkTooltip, + DragTooltip, +} from './components/tooltips'; + +interface HoveredBlock { + node: ProseMirrorNode; + pos: number; +} + +export const DBlockDragHandle = ({ + editor, + runtimeState, + onCopyHeadingLink, +}: { + editor: Editor; + runtimeState: DBlockRuntimeState; + onCopyHeadingLink?: (link: string) => void; +}) => { + const [hovered, setHovered] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const isBelowLargeScreen = useMediaQuery('(max-width: 1024px)'); + + const resolveBlock = useCallback((): ResolvedContentItem | null => { + if (!hovered) return null; + const node = editor.state.doc.nodeAt(hovered.pos); + if (node?.type.name !== 'dBlock') return null; + return { editor, node, pos: hovered.pos }; + }, [editor, hovered]); + const actions = useContentItemActions(editor, resolveBlock); + + if (runtimeState.isPresentationMode && runtimeState.isPreviewMode) { + return null; + } + + const meta = hovered + ? getDBlockRenderMeta(hovered.node, hovered.pos) + : null; + + const shouldShowEditingControls = + !runtimeState.isPreviewMode && !isBelowLargeScreen; + const shouldShowCollapse = Boolean(meta?.isHeading); + const shouldShowCopyLink = + runtimeState.isPreviewMode && + Boolean(meta?.isHeading) && + !runtimeState.isPreviewEditor && + !isBelowLargeScreen; + + const handleAddBlock = (event: React.MouseEvent) => { + const current = resolveBlock(); + if (!current) return; + const insertPos = event.altKey + ? current.pos + : current.pos + current.node.nodeSize; + current.editor.commands.insertContentAt(insertPos, { + type: 'dBlock', + content: [{ type: 'paragraph' }], + }); + }; + + const handleDragClick = (event: React.MouseEvent) => { + if (event.altKey) { + actions.deleteNode(); + return; + } + setMenuOpen((open) => !open); + }; + + const handleToggleCollapse = () => { + const current = resolveBlock(); + if (current) toggleHeadingCollapse(current.editor, current.pos); + }; + + const handleCopyHeadingLink = () => { + const current = resolveBlock(); + if (!current) return; + const link = getHeadingLinkSlug(current.node, current.pos); + if (link) onCopyHeadingLink?.(link); + }; + + const buttonClassName = cn( + 'd-block-button color-text-default hover:color-bg-default-hover aspect-square h-5 w-5 shrink-0', + ); + + if (!shouldShowEditingControls && !shouldShowCollapse && !shouldShowCopyLink) { + // Still mount DragHandle so hover tracking keeps working; render an + // empty cluster (e.g. mobile hovering a paragraph). + } + + return ( + { + if (node) setHovered({ node, pos }); + }} + > +
+ {shouldShowEditingControls ? ( + <> + + + + + + + } + actions={actions} + /> + + ) : null} + {shouldShowCollapse ? ( + + + + ) : null} + {shouldShowCopyLink ? ( + + + + ) : null} +
+
+ ); +}; +``` + +Notes for the implementer: +- `GripButton` currently sets `draggable data-drag-handle` attributes (`components/buttons.tsx:44-45`) — keep them; inside `DragHandle` they make the grip the drag initiator. +- The menu close-on-node-change behavior from the old toolbar (`useEffect` on `handle.id`) becomes: close the menu when `hovered?.pos` changes — add `useEffect(() => setMenuOpen(false), [hovered?.pos])`. + +- [ ] **Step 5: Mount it in `DBlockToolbarProvider`** (additive only — do not remove existing machinery yet) + +In `dblock-toolbar.tsx`, inside the provider's returned fragment, after `{children}` add: + +```tsx +{editor ? ( + +) : null} +``` + +(The `onCopyHeadingLink` plumbing simplifies in Task 3 when the registry dies: pass the callback into the provider as a prop from `ddoc-editor.tsx` instead. If that wiring is awkward now, hardcode `undefined` here and complete it in Task 3 — copy-link only matters in preview mode.) + +- [ ] **Step 6: Run tests** + +Run: `npx vitest run package/extensions/d-block/dblock-drag-handle.test.tsx` +Expected: PASS. If `DragHandle` throws in jsdom on missing browser APIs, stub them in the test's `beforeAll` (same pattern as ResizeObserver) and note which stub was needed. + +- [ ] **Step 7: Manual check in the demo app** + +Run: `rm -rf demo/node_modules/.vite && npm run dev -- --force` (known linked-UI cache issue). Hover blocks: floating cluster appears in the left margin; drag via grip moves blocks; old gutter still present (expected until Task 3). + +--- + +### Task 3: The layout switch — container padding, plain node view, delete gutter machinery + +**Files:** +- Modify: `package/extensions/d-block/dblock-node-view.ts` (rewrite, ~229 → ~70 lines) +- Modify: `package/extensions/d-block/dblock-toolbar.tsx` (delete `DBlockToolbar`, registry usage, hover/refresh listeners; keep provider shell + `DBlockDragHandle` + template overlay) +- Delete: `package/extensions/d-block/dblock-view-registry.ts` +- Modify: `package/styles/editor.css` (container padding, remove translateY hacks, presentation padding) +- Modify: `package/types.ts:25` (add `main-doc-editor` class to `DdocEditorProps.attributes.class`) +- Modify: `package/hooks/use-content-item-actions.tsx:6` only if imports break (it imports from `dblock-collapse`, unaffected) +- Create: `package/extensions/d-block/dblock-node-view.test.ts` + +**Interfaces:** +- Consumes: `DBlockDragHandle` from Task 2 (now the only chrome). +- Produces: simplified DOM contract — `div[data-type="d-block"] > div[data-node-view-content]`, wrapper classes limited to: `d-block-hidden`, `invalid-content`, `is-table`, presentation/preview classes. Tests and Task 4 rely on this shape. + +- [ ] **Step 1: Write the failing DOM test** + +`package/extensions/d-block/dblock-node-view.test.ts`: + +```ts +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; + +describe('DBlockNodeView (simplified chrome-less wrapper)', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('renders no gutter and no per-block padding classes', () => { + editor = makeEditor('

hello

'); + const block = editor.view.dom.querySelector('[data-type="d-block"]')!; + expect(block).toBeTruthy(); + expect(block.querySelector('[data-dblock-gutter]')).toBeNull(); + expect(block.className).not.toMatch(/px-4|pl-2|pr-8|pr-\[80px\]|pl-\[8px\]/); + // contentDOM is the direct (and only) element child + expect(block.children.length).toBe(1); + expect( + (block.firstElementChild as HTMLElement).dataset.nodeViewContent, + ).toBe('true'); + }); + + it('keeps the is-table marker class for table blocks', () => { + editor = makeEditor( + '

x

', + ); + const block = editor.view.dom.querySelector('[data-type="d-block"]')!; + expect(block.className).toMatch(/is-table/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run package/extensions/d-block/dblock-node-view.test.ts` +Expected: FAIL — gutter present / flex classes present. + +- [ ] **Step 3: Rewrite `dblock-node-view.ts`** + +```ts +import type { Editor } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import type { Decoration, NodeView, ViewMutationRecord } from '@tiptap/pm/view'; +import { + DBLOCK_HIDDEN_CLASS, + getDBlockRenderMeta, +} from './dblock-collapse'; +import type { DBlockRuntimeState } from './dblock-runtime'; +import { getDBlockRuntimeState } from './dblock-runtime'; + +interface DBlockNodeViewOptions { + editor: Editor; + node: ProseMirrorNode; + getPos: () => number; + decorations: readonly Decoration[]; + HTMLAttributes: Record; + getRuntimeState?: () => DBlockRuntimeState; +} + +const joinClasses = (...classes: Array) => + classes.filter(Boolean).join(' '); + +const hasHiddenDecoration = (decorations: readonly Decoration[]) => + decorations.some((decoration) => + String( + (decoration as { type?: { attrs?: { class?: string } } }).type?.attrs + ?.class ?? '', + ) + .split(/\s+/) + .includes(DBLOCK_HIDDEN_CLASS), + ); + +const setAttributes = ( + element: HTMLElement, + attributes: Record, +) => { + Object.entries(attributes).forEach(([key, value]) => { + if (key === 'class' || value === undefined || value === null) return; + element.setAttribute(key, String(value)); + }); +}; + +export class DBlockNodeView implements NodeView { + node: ProseMirrorNode; + editor: Editor; + getPos: () => number; + dom: HTMLDivElement; + contentDOM: HTMLDivElement; + private decorations: readonly Decoration[]; + private getRuntimeState?: () => DBlockRuntimeState; + + constructor({ + editor, + node, + getPos, + decorations, + HTMLAttributes, + getRuntimeState, + }: DBlockNodeViewOptions) { + this.editor = editor; + this.node = node; + this.getPos = getPos; + this.decorations = decorations; + this.getRuntimeState = getRuntimeState; + + this.dom = document.createElement('div'); + this.dom.dataset.type = 'd-block'; + setAttributes(this.dom, HTMLAttributes); + + this.contentDOM = document.createElement('div'); + this.contentDOM.dataset.nodeViewContent = 'true'; + this.dom.appendChild(this.contentDOM); + + this.syncDOM(); + } + + update(node: ProseMirrorNode, decorations: readonly Decoration[]) { + if (node.type !== this.node.type) return false; + this.node = node; + this.decorations = decorations; + this.syncDOM(); + return true; + } + + ignoreMutation(mutation: ViewMutationRecord) { + if (mutation.type === 'selection') return false; + return !this.contentDOM.contains(mutation.target); + } + + private syncDOM() { + const runtime = getDBlockRuntimeState(this.getRuntimeState); + const isPresentationPreview = + runtime.isPresentationMode && runtime.isPreviewMode; + const position = this.safeGetPos(); + const meta = getDBlockRenderMeta(this.node, position ?? 0); + const shouldHide = + !isPresentationPreview && hasHiddenDecoration(this.decorations); + + this.dom.className = joinClasses( + 'd-block w-full relative', + meta.isTable && 'is-table pointer-events-auto', + this.node.attrs?.isCorrupted && 'invalid-content', + runtime.isPreviewMode && 'pointer-events-none', + shouldHide && DBLOCK_HIDDEN_CLASS, + ); + } + + private safeGetPos() { + try { + const position = this.getPos(); + return typeof position === 'number' ? position : null; + } catch { + return null; + } + } +} +``` + +Notes: +- `runtime.isPreviewMode && 'pointer-events-none'` replicates the old presentation-preview content-shell rule; check the old `syncDOM` (`git show HEAD:package/extensions/d-block/dblock-node-view.ts`) while porting — preview (non-presentation) mode must NOT get `pointer-events-none` (old code only applied it under `isPresentationPreview`). Adjust to `isPresentationPreview && 'pointer-events-none'`. +- Heading-alignment flex-reverse classes (`getHeadingAlignmentClass`) are dropped — copy-link lives in the floating cluster now. Remove the now-unused import from `dblock-collapse` if nothing else uses it (check with grep; it's exported for the old toolbar only). +- The `is-table` width cap (`max-w-full lg:max-w-[90%]`, previously on the content shell) moves to CSS in Step 5. + +- [ ] **Step 4: Strip the old machinery from `dblock-toolbar.tsx` and delete the registry** + +- Delete the `DBlockToolbar` component, `resolveCurrentDBlock`, all `dblock-view-registry` imports/usages, the `pointerover/pointerout/focusin/focusout` listeners, `activeHandle`/`refreshKey` state, and `editor.on('transaction'|'selectionUpdate', refreshToolbar)`. +- `DBlockToolbarProvider` keeps: `{children}`, `` (from Task 2 — wire `onCopyHeadingLink` as a new provider prop, passed from `ddoc-editor.tsx` where the old node-view option `onCopyHeadingLink` was configured; grep `onCopyHeadingLink` to find the source), and `` (untouched until Task 4 — it still portals into the content shell; since the shell no longer exists, temporarily portal into `handle.contentElement`'s replacement: `editor.view.dom.querySelector('[data-type="d-block"] > [data-node-view-content]')` — this is a one-task bridge; Task 4 removes it). +- Delete `package/extensions/d-block/dblock-view-registry.ts`; remove the `registerDBlockView` call and `unregister` from the node view (already done in Step 3's rewrite), and remove `uuid` import if now unused. +- In `dblock.ts` `addNodeView()`, drop the `onCopyHeadingLink` option pass-through if the node view no longer accepts it (it doesn't — the provider owns it now); also remove `decorations`/`HTMLAttributes` params only if unused — they ARE used, keep them. + +- [ ] **Step 5: CSS — container padding, hack removal, table cap, presentation inset** + +In `package/types.ts:25` append `main-doc-editor` to the class string in `DdocEditorProps.attributes.class`. Verify both main-doc call sites spread `DdocEditorProps` (`grep -rn "DdocEditorProps" package --include='*.ts*'` → `use-tab-editor.tsx` today; if `preview-ddoc-editor.tsx`/`use-headless-editor` construct separate visible editors with their own props, add the class there too — decide by checking which of them render the full document with dBlock node views). + +In `package/styles/editor.css`, inside the top-level `.ProseMirror { … }` block scope nothing; instead add after it: + +```css +.ProseMirror.main-doc-editor { + padding: 24px 16px 20vh 36px; +} + +@media (min-width: 768px) { + .ProseMirror.main-doc-editor { + padding: 72px 80px 20vh; + } +} + +/* table blocks keep their width cap now that the content shell is gone */ +.ProseMirror [data-type='d-block'].is-table > [data-node-view-content] { + max-width: 100%; +} +@media (min-width: 1024px) { + .ProseMirror [data-type='d-block'].is-table > [data-node-view-content] { + max-width: 90%; + } +} +``` + +Remove the heading transform hacks (`editor.css:66-76`): + +```css +h1 { transform: translateY(-0.5rem); } +h2 { transform: translateY(-0.25rem); } +h3 { transform: translateY(0); } +``` + +Presentation preview inset: find the presentation-mode CSS section (`grep -n "presentation\|slide" package/styles/editor.css | head`) and add the container equivalent of the old per-block `px-4 md:px-[80px]`: + +```css +/* presentation preview: container inset replaces old per-block px-4 md:px-[80px] */ +.presentation-mode .ProseMirror.main-doc-editor { + padding: 16px; +} +@media (min-width: 768px) { + .presentation-mode .ProseMirror.main-doc-editor { + padding: 16px 80px; + } +} +``` + +(Adjust `.presentation-mode` to the actual wrapper class found by the grep — check `package/components/presentation-mode/` for the class it puts on the editor container.) + +Also audit bottom spacers: `grep -rn "pb-40\|padding-bottom" package/styles package/ddoc-editor.tsx | head`. The `max-sm:!pb-40` on preview-mode `EditorContent` (`ddoc-editor.tsx:1282`) predates the 20vh bottom padding — remove it if the 20vh covers the same need in preview mode; keep anything serving a different purpose (note findings in the task summary). + +- [ ] **Step 6: Run the new DOM test + full suite** + +Run: `npx vitest run package/extensions/d-block/dblock-node-view.test.ts && npx vitest run` +Expected: new test PASS; full suite green. Any failing test that asserted gutter DOM: update it to the new DOM contract (wrapper > contentDOM only) and say so in the summary. + +- [ ] **Step 7: Manual demo verification (the payoff checks)** + +In the demo app (`rm -rf demo/node_modules/.vite && npm run dev -- --force`): +1. Insert divider via `/divider` → caret lands in the text column, NOT at the window edge. +2. Upload an image → caret/placeholder stays in the text column. +3. Click below the last block → caret at text-column left edge. +4. Text column position: ~80px inset on desktop, symmetric; mobile viewport (devtools) 36px left / 16px right. +5. Collapse chevron on a heading works at mobile width. +6. Drag a block, drag a block inside a 2-column layout (known tuning spot — if handle positioning is off inside columns, record specifics rather than hacking a fix; it gets its own follow-up). +7. Presentation mode + preview mode render with correct insets and no chrome. + +--- + +### Task 4: Template overlay outside the editable DOM + +**Files:** +- Modify: `package/extensions/d-block/dblock-toolbar.tsx` (`DBlockTemplateOverlay` + `getTemplateTarget`) +- Create: `package/extensions/d-block/dblock-template-overlay.test.tsx` + +**Interfaces:** +- Consumes: simplified DOM contract from Task 3 (`div[data-type="d-block"]` first child of `.ProseMirror`), panel div `[data-ddoc-editor-panel]` with `position: relative` when active (`ddoc-editor.tsx:1231-1266`). +- Produces: `getTemplateTarget(editor, runtimeState)` exported (for tests) returning `{ pos: number; node: Node } | null`; overlay renders into the active panel div, never inside `.ProseMirror`. + +- [ ] **Step 1: Write the failing test** + +```tsx +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; +import { getTemplateTarget } from './dblock-toolbar'; +import { DEFAULT_DBLOCK_RUNTIME_STATE } from './dblock-runtime'; + +describe('getTemplateTarget', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('targets a single empty dBlock', () => { + editor = makeEditor('

'); + editor.commands.setTextSelection(2); + const target = getTemplateTarget(editor, DEFAULT_DBLOCK_RUNTIME_STATE); + expect(target).not.toBeNull(); + expect(target!.pos).toBe(0); + }); + + it('returns null once the doc has content', () => { + editor = makeEditor('

hello

'); + const target = getTemplateTarget(editor, DEFAULT_DBLOCK_RUNTIME_STATE); + expect(target).toBeNull(); + }); + + it('returns null in preview mode', () => { + editor = makeEditor('

'); + const target = getTemplateTarget(editor, { + ...DEFAULT_DBLOCK_RUNTIME_STATE, + isPreviewMode: true, + }); + expect(target).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run package/extensions/d-block/dblock-template-overlay.test.tsx` +Expected: FAIL — `getTemplateTarget` not exported. + +- [ ] **Step 3: Rework `getTemplateTarget` and the overlay** + +- Export `getTemplateTarget`. Simplify its return to `{ pos, node }` — drop the `handle`/`contentElement` fields (registry is gone). The "first dBlock element" lookup becomes `editor.view.dom.querySelector('[data-type="d-block"]')`. +- `DBlockTemplateOverlay` portals into the active panel: `const panel = editor?.view.dom.closest('[data-ddoc-editor-panel]')`. Render via `createPortal` into `panel` an absolutely-positioned wrapper: + +```tsx +if (!target || isFocusMode || !panel) return null; +const firstBlock = editor.view.dom.querySelector('[data-type="d-block"]'); +if (!firstBlock) return null; +const panelRect = panel.getBoundingClientRect(); +const blockRect = firstBlock.getBoundingClientRect(); +return createPortal( +
+ {renderTemplateButtons(/* unchanged args */)} +
, + panel, +); +``` + +- Recompute on doc/selection changes: the overlay component re-renders via a `refreshKey` bumped by `editor.on('transaction')` in the provider (add a minimal `useEffect` subscription in the provider — the old one was deleted in Task 3; re-add scoped to the overlay: `editor.on('transaction', bump)` where `bump` is `setRefreshKey(k => k+1)`), plus a `window.addEventListener('resize', bump)` in the overlay itself. +- Delete the Task 3 bridge portal (into `[data-node-view-content]`). + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run package/extensions/d-block/dblock-template-overlay.test.tsx && npx vitest run` +Expected: PASS; full suite green. + +- [ ] **Step 5: Manual demo verification** + +Empty new doc → template buttons appear under the first line; click one → template inserts (verify the existing `insertContentAt(pos + nodeSize - 4, …)` still lands correctly — it's untouched, but the overlay no longer occupies document flow); place caret in the buttons' area → caret can NOT enter them (they're outside `.ProseMirror`); type text → overlay disappears. + +--- + +### Task 5: Full verification, version bump, PR notes + +**Files:** +- Modify: `package.json` (version `4.3.2` → `4.4.0`) + +- [ ] **Step 1: Full suite + typecheck + lint** + +Run: `npx vitest run && npx tsc --noEmit -p tsconfig.json && npm run lint --if-present` +Expected: all green. + +- [ ] **Step 2: Full manual QA sweep in the demo app** (ticket-mapped) + +The Task 3 Step 7 list, plus: list Enter/Backspace behavior unchanged (out of scope here — just no regressions), split view, focus mode, AI writer trigger (space in empty block), comments on text (decorations render), undo/redo across drag operations. + +- [ ] **Step 3: Bump version and write PR notes** + +`package.json` version → `4.4.0`. PR body must flag for the app team: (a) mobile text-column inset is now symmetric-ish (36px left for chevron / 16px right) — visual change; (b) drag/plus controls now float in the left margin; (c) new deps pinned exact at 3.11.0 with npm `overrides` guarding the Shai-Hulud package families — do not `npm update` while the attack is active. + +--- + +## Self-review notes (kept for the record) + +- Spec coverage: A (Task 3), B (Tasks 2+3), C verification (Tasks 3/5), template overlay (Task 4), dependency safety (Task 1), spec's "flagged risk" fallback (custom dragstart) intentionally not pre-built — YAGNI; Task 2 Step 7 surfaces whether it's needed. +- The old node view's `data-dblock-pos` stamping and `safeGetPos` position dataset are dropped (nothing reads them after the registry dies — verified via grep `dblockPos`). +- Type consistency: `ResolvedContentItem` matches `use-content-item-actions.tsx:9-13`; `DBlockRuntimeState` unchanged. diff --git a/docs/superpowers/specs/2026-08-04-editor-chrome-design.md b/docs/superpowers/specs/2026-08-04-editor-chrome-design.md new file mode 100644 index 00000000..e386f471 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-editor-chrome-design.md @@ -0,0 +1,140 @@ +# Editor Chrome Redesign — container padding + floating block controls + +**Ticket:** TEC-2515 (dblock overhaul, phase 1 "chrome first") +**Date:** 2026-08-04 +**Scope decision:** Chrome only. Position-mapping fixes and list-keymap work are separate follow-up PRs. Flat schema (v2) comes later, per Mohit's sequencing comment on the ticket. + +## Problem + +Every dBlock renders as a flex row that carries its own horizontal padding and a +non-editable gutter `
` (plus/grip/collapse buttons) *inside* the +contenteditable, while `.ProseMirror` has zero horizontal padding. Template +buttons are portaled into the editable DOM. Consequences (root-cause analysis on +TEC-2515): caret renders at the far-left flex edge for any non-text selection +state (divider/image tickets), template buttons can receive the caret, +hand-tuned per-breakpoint asymmetric insets, and translateY/items-center CSS +hacks that desync caret/click/handle geometry. + +Reference architecture (verified live on template.tiptap.dev): flat blocks, +padding on the `.ProseMirror` container, one absolutely-positioned drag handle +outside the editable DOM. + +## Design + +### A. Layout core + +- `.ProseMirror` owns the page inset, applied in `editor.css`: + - md+ (desktop/tablet): `padding: 72px 80px 20vh` + - below md (mobile): `padding: 24px 16px 20vh 36px` — left is 36px to leave + room for the collapse chevron, which remains visible on mobile + - Note the breakpoints are intentionally different axes: *padding* switches + at `md`, while *plus/grip visibility* gates at `lg` (mirrors current + behavior). Tablets (md–lg) get desktop padding with chevron-only chrome. + - Existing bottom-spacer workarounds in scroll containers are audited during + implementation and removed where the 20vh bottom padding makes them + redundant. +- `DBlockNodeView` shrinks to a plain wrapper: `div[data-type=d-block]` → + `contentDOM`. Deleted: gutter section, content shell, flex row, + `items-center`, `justify-center`, all per-block `px-*`/`pl-*`/`pr-*` classes, + `stopEvent` gutter logic, view registry registration. +- Kept on the wrapper (still semantic): `d-block-hidden` (collapse + decorations), `invalid-content` (isCorrupted), `is-table` width cap, + presentation/preview-mode classes. +- CSS compensation hacks removed with the flex row: `h1/h2/h3 { transform: + translateY(...) }`; heading alignment in preview mode re-expressed without + flex-reversal (`flex-row-reverse` + inverted `justify-*` mapping goes away). +- Presentation mode: per-block `px-4 md:px-[80px]` moves to the presentation + container. +- Result: doc JSON byte-identical; only rendered DOM/CSS changes. Caret + geometry becomes standard — gap cursors and node boundaries render inside the + text column. + +### B. Floating chrome + +- New deps: `@tiptap/extension-drag-handle-react` + `@tiptap/extension-node-range` + (official, open source in Tiptap 3; same stack as the Notion-like template). +- `` renders as a **sibling of `EditorContent`**, + positioned by floating-ui in the left margin of the hovered block. + `onNodeChange({ node, pos })` replaces the view-registry hover machinery. +- Button cluster inside the handle (existing components re-homed): + - **Plus** — insert block below (Alt: above); same `insertContentAt` logic. + - **Grip** — opens existing `DBlockMenu` backed by `use-content-item-actions`, + anchored to the floating cluster; Alt-click = delete. + - **Collapse** / **Copy-link** — for heading blocks, driven by + `getDBlockRenderMeta(node, pos)`. +- Visibility rules (carried over from current behavior): + - below `lg`: plus/grip hidden; **collapse chevron still shows** (as today — + `shouldShowCollapse` is not screen-gated); preview mode shows copy-link on + headings. + - presentation-preview: no chrome. +- `DBlockToolbarProvider` slims to rendering the `DragHandle` + menu; registry, + pointerover/focusin listeners, and refresh-key state are deleted. +- Template overlay: same trigger logic (single empty dBlock, selection inside), + but portaled to an absolutely-positioned overlay **sibling of + `EditorContent`**, placed via the first block's `getBoundingClientRect`, + recomputed on transaction + resize. Buttons can no longer receive the caret. +- Drag mechanics come from the extension (node-range slice → `view.dragging`); + dBlock keeps `draggable: true`; `data-drag-handle` GripButton wiring removed. +- **Flagged risk:** dBlock is `selectable: false`; if the drag-handle + extension's node-range selection misbehaves with it, fallback is a thin + custom `dragstart` on the grip that sets `view.dragging = { slice, move: + true }` for the dBlock at `pos` (~20 lines, no design change). + +### C. Deletions, edge surfaces, verification + +**Deleted:** `dblock-view-registry.ts`; gutter/shell/`stopEvent` code in +`dblock-node-view.ts` (~229 → ~60 lines); hover/refresh machinery in +`dblock-toolbar.tsx`; translateY hacks; per-block padding classes. + +**Kept/reused:** buttons, tooltips, `DBlockMenu`, `use-content-item-actions`, +`getDBlockRenderMeta`, collapse plugin. + +**Untouched (follow-ups):** Enter/Backspace keymaps, media-conversion plugin, +upload flow, action buttons, schema, exports, comments. + +**Edge surfaces to verify:** +- Columns (`column.content: 'dBlock+'`): nested blocks currently get their own + gutter; DragHandle positioning inside columns is the likeliest tuning spot. +- Presentation/preview modes (padding + reduced cluster). +- Mobile: chevron-only chrome in the 36px left margin; right inset 16px. +- `is-table` width cap on the simplified wrapper. + +**Verification:** +- Existing vitest suites are command/selection-level and should pass; any test + asserting gutter DOM is updated alongside the change it documents. +- Manual QA keyed to tickets: divider caret, image-upload caret, template + buttons, drag (top-level / columns / tables), collapse, copy-link, split + view, focus mode, mobile layout. +- Live check in demo app (clear `demo/node_modules/.vite` first — known linked + UI cache issue). + +**Rollout:** default behavior (no flag), minor version bump, changelog note to +the app team flagging visible changes: mobile inset change and handle position. + +## Dependency safety (Shai-Hulud npm worm, active as of 2026-08-04) + +Context: self-replicating npm supply-chain attack starting 2026-08-04 (keyv / +flat-cache / file-entry-cache / cacheable* families + 434 and growing +worm-spread packages; payload runs via `preinstall`, published with valid +provenance). Repo audited 2026-08-04: lockfile pins pre-attack majors +(`keyv@4.5.4`, `flat-cache@3.2.0`, `file-entry-cache@6.0.1`), no IoC files, no +`.vscode/tasks.json` / repo `.claude/settings.json` infection. + +Protocol for the new installs in this branch: + +1. **Verify publish dates first**: `npm view time --json` — only accept + versions of `@tiptap/extension-drag-handle-react`, + `@tiptap/extension-drag-handle`, `@tiptap/extension-node-range` (and any new + transitive deps) published **before 2026-08-04**. +2. **Exact-pin** the three packages in `package.json` (no `^`/`~`). +3. **Install with `--ignore-scripts`** (targeted flag for this install, not a + blanket `.npmrc` setting — esbuild/swc binaries need their scripts on fresh + installs). None of the Tiptap packages require install scripts. +4. **Lockfile diff review** after install: every added entry checked against + the compromised list, publish date, and for unexpected + `preinstall`/`postinstall` scripts. +5. **npm `overrides`** pinning `keyv`, `flat-cache`, `file-entry-cache`, and + `cacheable-request` to their current lockfile majors, so no future range + resolution can pull the poisoned versions. +6. **No `npm update` / floating installs** while the attack is active; CI and + teammates use `npm ci` only. diff --git a/package-lock.json b/package-lock.json index fbda8d2d..cb2ef9bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fileverse-dev/ddoc", - "version": "4.3.9", + "version": "4.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fileverse-dev/ddoc", - "version": "4.3.9", + "version": "4.5.0", "dependencies": { "@_ueberdosis/prosemirror-tables": "^1.1.3", "@aarkue/tiptap-math-extension": "^1.4.0", @@ -27,15 +27,18 @@ "@tiptap/extension-blockquote": "^3.11.0", "@tiptap/extension-code-block": "^3.11.0", "@tiptap/extension-code-block-lowlight": "^3.11.0", - "@tiptap/extension-collaboration": "^3.11.0", + "@tiptap/extension-collaboration": "3.11.0", "@tiptap/extension-color": "^3.11.0", "@tiptap/extension-document": "^3.11.0", + "@tiptap/extension-drag-handle": "3.11.0", + "@tiptap/extension-drag-handle-react": "3.11.0", "@tiptap/extension-font-family": "^3.11.0", "@tiptap/extension-heading": "^3.11.0", "@tiptap/extension-highlight": "^3.11.0", "@tiptap/extension-horizontal-rule": "^3.11.0", "@tiptap/extension-list": "^3.11.0", "@tiptap/extension-mathematics": "^3.11.0", + "@tiptap/extension-node-range": "3.11.0", "@tiptap/extension-placeholder": "^3.11.0", "@tiptap/extension-subscript": "^3.11.0", "@tiptap/extension-superscript": "^3.11.0", @@ -130,7 +133,7 @@ "@dnd-kit/utilities": ">=3.2.2", "@fileverse/crypto": ">=0.0.21", "@fileverse/ens": "0.0.4", - "@fileverse/ui": "5.2.2", + "@fileverse/ui": "5.2.3", "framer-motion": ">=11.2.10", "frimousse": ">=0.3.0", "mermaid": "11.14.0", @@ -1445,9 +1448,9 @@ } }, "node_modules/@fileverse/ui": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@fileverse/ui/-/ui-5.2.2.tgz", - "integrity": "sha512-VnLP7VVyAN1ws0KLqLoFO7iTjrC9o9uwe90mqvzLPuTZta+Rpjr16483AP+bJpZx226Ii2xcJVXVN6BiX+r08w==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@fileverse/ui/-/ui-5.2.3.tgz", + "integrity": "sha512-dbURBXzX9nIfz90NWIdbHpvKFxijMlVBTx9Qtu57CJHg9pMEy4EHxG+eG2Uk7FQwaReYmpsLw2jAtwnSIzwkQw==", "peer": true, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", @@ -4305,6 +4308,43 @@ "@tiptap/core": "^3.11.0" } }, + "node_modules/@tiptap/extension-drag-handle": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.11.0.tgz", + "integrity": "sha512-MG6XYhH949FpHZ7uWmZ771ckEsEgw1O8OdoZWM7B5zd8EsUR3ZoAxnnxttVOR4NWohbcNnvX0HA3IvzVOvQIug==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.0", + "@tiptap/extension-collaboration": "^3.11.0", + "@tiptap/extension-node-range": "^3.11.0", + "@tiptap/pm": "^3.11.0", + "@tiptap/y-tiptap": "^3.0.0" + } + }, + "node_modules/@tiptap/extension-drag-handle-react": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle-react/-/extension-drag-handle-react-3.11.0.tgz", + "integrity": "sha512-nX60S0Tq/zRGBonMWeeKJKIF0JRR/1P5kDHaDrCt0oxKXLSJG6xi0NcGUkvgy4C9LrlTjq9rgF/rmdCb6Qf/5Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-drag-handle": "^3.11.0", + "@tiptap/pm": "^3.11.0", + "@tiptap/react": "^3.11.0", + "react": "^16.8 || ^17 || ^18 || ^19", + "react-dom": "^16.8 || ^17 || ^18 || ^19" + } + }, "node_modules/@tiptap/extension-dropcursor": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.11.0.tgz", @@ -4485,6 +4525,20 @@ "katex": "^0.16.4" } }, + "node_modules/@tiptap/extension-node-range": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.11.0.tgz", + "integrity": "sha512-NIly1qdZ+RVMf0Kvm+UUguxzKtsYEBC82j7pMrE9d8dC2ou4IrMfSas7N0O5SNbT+px71VCWza0b5uWJT+tWtw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.0", + "@tiptap/pm": "^3.11.0" + } + }, "node_modules/@tiptap/extension-ordered-list": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.11.0.tgz", diff --git a/package.json b/package.json index 04289ec3..015baf84 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@fileverse-dev/ddoc", "private": false, "description": "DDoc", - "version": "4.3.9", + "version": "4.5.0", "main": "dist/index.es.js", "module": "dist/index.es.js", "exports": { @@ -57,15 +57,18 @@ "@tiptap/extension-blockquote": "^3.11.0", "@tiptap/extension-code-block": "^3.11.0", "@tiptap/extension-code-block-lowlight": "^3.11.0", - "@tiptap/extension-collaboration": "^3.11.0", + "@tiptap/extension-collaboration": "3.11.0", "@tiptap/extension-color": "^3.11.0", "@tiptap/extension-document": "^3.11.0", + "@tiptap/extension-drag-handle": "3.11.0", + "@tiptap/extension-drag-handle-react": "3.11.0", "@tiptap/extension-font-family": "^3.11.0", "@tiptap/extension-heading": "^3.11.0", "@tiptap/extension-highlight": "^3.11.0", "@tiptap/extension-horizontal-rule": "^3.11.0", "@tiptap/extension-list": "^3.11.0", "@tiptap/extension-mathematics": "^3.11.0", + "@tiptap/extension-node-range": "3.11.0", "@tiptap/extension-placeholder": "^3.11.0", "@tiptap/extension-subscript": "^3.11.0", "@tiptap/extension-superscript": "^3.11.0", @@ -129,12 +132,17 @@ "@dnd-kit/utilities": ">=3.2.2", "@fileverse/crypto": ">=0.0.21", "@fileverse/ens": "0.0.4", - "@fileverse/ui": "5.2.2", + "@fileverse/ui": "5.2.3", "framer-motion": ">=11.2.10", "frimousse": ">=0.3.0", "mermaid": "11.14.0", - "yjs": ">=13.6.30 <14", - "viem": ">=2.35.0" + "viem": ">=2.35.0", + "yjs": ">=13.6.30 <14" + }, + "overrides": { + "keyv": "4.5.4", + "flat-cache": "3.2.0", + "file-entry-cache": "6.0.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.0", diff --git a/package/components/editor-bubble-menu/node-selector.tsx b/package/components/editor-bubble-menu/node-selector.tsx index bc08ce3f..f171d6f7 100644 --- a/package/components/editor-bubble-menu/node-selector.tsx +++ b/package/components/editor-bubble-menu/node-selector.tsx @@ -166,9 +166,11 @@ export const convertListToParagraphs = ({ if (!listContent || listPos === -1) return false; + const hasDBlock = Boolean(state.schema.nodes.dBlock); + // Avoid dBlocks inside tables and skip indent attrs inside callouts. const newContent = processListContent(listContent.toJSON(), { - wrapInDBlock: !isInsideCallout && !tableCellRange, + wrapInDBlock: hasDBlock && !isInsideCallout && !tableCellRange, includeIndent: !isInsideCallout, }); @@ -190,6 +192,9 @@ export const convertListToParagraphs = ({ state.schema.nodeFromJSON(json), ); + tr.replaceWith(listPos, listPos + listContent.nodeSize, paragraphNodes); + } else if (!hasDBlock) { + // Flat schema: the list is a top-level node, swap it for the paragraphs. tr.replaceWith(listPos, listPos + listContent.nodeSize, paragraphNodes); } else { // Replace the whole dBlock with paragraphs @@ -382,6 +387,16 @@ export const convertToList = ({ tr.replaceWith(firstDBlockPos, lastDBlockPos, newDblock); } + // ✅ Case 3: FLAT SCHEMA (no dBlock in the doc) — replace the top-level + // blocks in the selection with the list directly. + else if (firstBlockPos !== -1 && lastBlockPos !== -1) { + tr.replaceWith( + firstBlockPos, + lastBlockPos, + state.schema.nodeFromJSON(newListContent), + ); + } + return true; }; diff --git a/package/components/editor-bubble-menu/props.tsx b/package/components/editor-bubble-menu/props.tsx index eb79de15..c92a5a2a 100644 --- a/package/components/editor-bubble-menu/props.tsx +++ b/package/components/editor-bubble-menu/props.tsx @@ -1,4 +1,5 @@ import { Editor } from '@tiptap/core'; +import { isUndoRedoSelection } from '../../extensions/undo-selection'; // Mobile selection handles can blur the editor while the native selection // still belongs to the editor content. Detect that case from the DOM selection. @@ -23,6 +24,13 @@ const shouldShowBubbleMenu = (editor: Editor, ignoreFocus = false) => { return false; } + // Undo/redo re-selects the range it just changed so you can see what moved. + // That is not a selection gesture, so it must not summon the toolbar; the + // next click or keystroke clears the flag. + if (isUndoRedoSelection(editor.state)) { + return false; + } + const selection = window.getSelection(); const commentCards = document.querySelectorAll('.comment-card'); diff --git a/package/components/editor-utils.test.ts b/package/components/editor-utils.test.ts new file mode 100644 index 00000000..6104a7da --- /dev/null +++ b/package/components/editor-utils.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { getHeadlessExtensions } from '../hooks/use-headless-editor'; +import { mergeEditorProps } from './editor-utils'; + +/** + * Regression coverage for the bug found in browser verification of Task 3: + * `editor.setOptions({ editorProps })` replaces the whole `editorProps` + * object instead of merging it (tiptap's `setOptions` only shallow-merges + * top-level keys — see `Editor#setOptions` in `@tiptap/core`). Calling it + * directly with a partial patch (e.g. just `{ handleKeyDown }`, as + * `useEditorToolbar` in `editor-utils.tsx` used to) silently discarded + * `attributes` (including the `main-doc-editor` class added in Task 3), + * `clipboardTextSerializer`, and `handleDOMEvents` set at construction + * time. `mergeEditorProps` fixes this by spreading `editor.options.editorProps` + * before applying the patch; both call sites in `editor-utils.tsx` now go + * through it exclusively. + */ +const makeEditorWithProps = (editorProps: { + attributes?: Record; + clipboardTextSerializer?: () => string; + handleDOMEvents?: Record boolean>; +}) => + new Editor({ + extensions: getHeadlessExtensions(), + textDirection: 'auto', + editorProps, + }); + +describe('mergeEditorProps', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('preserves construction-time attributes/clipboardTextSerializer when patching in a new key', () => { + const clipboardTextSerializer = () => 'serialized'; + editor = makeEditorWithProps({ + attributes: { class: 'main-doc-editor' }, + clipboardTextSerializer, + }); + + mergeEditorProps(editor, { handleKeyDown: () => true }); + + // The exact regression: construction-time attributes must survive a + // later editorProps patch, not get wiped by a wholesale replace. + expect(editor.view.dom.classList.contains('main-doc-editor')).toBe(true); + expect(editor.options.editorProps.clipboardTextSerializer).toBe( + clipboardTextSerializer, + ); + expect(editor.options.editorProps.handleKeyDown).toBeTypeOf('function'); + }); + + it('removing a previously-patched key (handleKeyDown: undefined) does not disturb sibling keys', () => { + const clipboardTextSerializer = () => 'serialized'; + editor = makeEditorWithProps({ + attributes: { class: 'main-doc-editor' }, + clipboardTextSerializer, + }); + + mergeEditorProps(editor, { handleKeyDown: () => true }); + mergeEditorProps(editor, { handleKeyDown: undefined }); + + expect(editor.view.dom.classList.contains('main-doc-editor')).toBe(true); + expect(editor.options.editorProps.clipboardTextSerializer).toBe( + clipboardTextSerializer, + ); + expect(editor.options.editorProps.handleKeyDown).toBeUndefined(); + }); + + it('repeated patch/unpatch cycles (effect re-run simulation) neither duplicate nor lose sibling keys', () => { + const clipboardTextSerializer = () => 'serialized'; + const handleDOMEvents = { click: () => false }; + editor = makeEditorWithProps({ + attributes: { class: 'main-doc-editor' }, + clipboardTextSerializer, + handleDOMEvents, + }); + + const handlerA = () => true; + const handlerB = () => true; + const handlerC = () => true; + + // Simulates the toolbar effect's cleanup -> setup cycle firing several + // times (e.g. on every re-render that changes `buttonRef`). + mergeEditorProps(editor, { handleKeyDown: handlerA }); + mergeEditorProps(editor, { handleKeyDown: undefined }); + mergeEditorProps(editor, { handleKeyDown: handlerB }); + mergeEditorProps(editor, { handleKeyDown: undefined }); + mergeEditorProps(editor, { handleKeyDown: handlerC }); + + expect(editor.view.dom.classList.contains('main-doc-editor')).toBe(true); + expect(editor.options.editorProps.clipboardTextSerializer).toBe( + clipboardTextSerializer, + ); + expect(editor.options.editorProps.handleDOMEvents).toBe(handleDOMEvents); + // Only the latest patch's handler is active — no stacking/duplication. + expect(editor.options.editorProps.handleKeyDown).toBe(handlerC); + }); +}); diff --git a/package/components/editor-utils.tsx b/package/components/editor-utils.tsx index 78455848..302989b8 100644 --- a/package/components/editor-utils.tsx +++ b/package/components/editor-utils.tsx @@ -14,6 +14,7 @@ import { ensureLoaded } from '../utils/font-loader'; import type { FontDescriptor } from '../types'; import { IEditorTool, useEditorToolVisiibility } from '../hooks/use-visibility'; import { Editor, JSONContent } from '@tiptap/react'; +import type { EditorProps } from '@tiptap/pm/view'; import { useEditorCommands } from '../hooks/use-editor-commands'; import { startImageUpload } from '../utils/upload-images'; import cn from 'classnames'; @@ -149,6 +150,30 @@ export const IMG_UPLOAD_SETTINGS = { }, }; +/** + * `Editor#setOptions({ editorProps })` replaces the whole `editorProps` + * object rather than merging it — tiptap merges top-level options but + * swaps nested objects like `editorProps` wholesale. Calling + * `editor.setOptions({ editorProps: { handleKeyDown } })` directly would + * silently discard everything else already configured on `editorProps`: + * `attributes` (including classes set at construction time, e.g. + * `main-doc-editor` from `DdocEditorProps`), `clipboardTextSerializer`, + * and the `handleDOMEvents` wired in `use-tab-editor.tsx`. Always patch + * `editorProps` through this helper instead of calling + * `editor.setOptions({ editorProps: ... })` directly. + */ +export const mergeEditorProps = ( + editor: Editor, + patch: Partial, +) => { + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + ...patch, + }, + }); +}; + export const useEditorToolbar = ({ editor, onError, @@ -207,126 +232,107 @@ export const useEditorToolbar = ({ if (!editor) return; // Add keyboard shortcuts to the editor's keymap - editor.setOptions({ - editorProps: { - handleKeyDown: (_, event) => { - // Strikethrough shortcut (Ctrl + Shift + X for Windows/Linux | Cmd + Shift + X for Mac) - if ( - (event.ctrlKey && event.shiftKey && event.code === 'KeyX') || - (event.metaKey && event.shiftKey && event.code === 'KeyX') - ) { - event.preventDefault(); - editor.chain().focus().toggleStrike().run(); - return true; - } - - // Open link popup (Alt/Option + Enter) - if ((event.altKey || event.metaKey) && event.key === 'Enter') { - event.preventDefault(); - setToolVisibility(IEditorTool.LINK_POPUP); - return true; - } - - // Inline comment shortcut (Shift + Cmd + M for Mac, Ctrl + Alt + m for others) - if ( - (navigator.platform.includes('Mac') - ? event.shiftKey && event.metaKey - : (event.ctrlKey || event.metaKey) && event.altKey) && - event.key.toLowerCase() === 'm' - ) { - event.preventDefault(); - - // First check if there's text selected - const { state } = editor; - const { from, to } = state.selection; - const selectedText = state.doc.textBetween(from, to, ' '); + mergeEditorProps(editor, { + handleKeyDown: (_, event) => { + // Strikethrough shortcut (Ctrl + Shift + X for Windows/Linux | Cmd + Shift + X for Mac) + if ( + (event.ctrlKey && event.shiftKey && event.code === 'KeyX') || + (event.metaKey && event.shiftKey && event.code === 'KeyX') + ) { + event.preventDefault(); + editor.chain().focus().toggleStrike().run(); + return true; + } - if (selectedText) { - editor - .chain() - .setHighlight({ - color: 'var(--color-inline-comment)', - }) - .run(); + // Open link popup (Alt/Option + Enter) + if ((event.altKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + setToolVisibility(IEditorTool.LINK_POPUP); + return true; + } - if (buttonRef.current) { - buttonRef.current.click(); - } + // Inline comment shortcut (Shift + Cmd + M for Mac, Ctrl + Alt + m for others) + if ( + (navigator.platform.includes('Mac') + ? event.shiftKey && event.metaKey + : (event.ctrlKey || event.metaKey) && event.altKey) && + event.key.toLowerCase() === 'm' + ) { + event.preventDefault(); + + // First check if there's text selected + const { state } = editor; + const { from, to } = state.selection; + const selectedText = state.doc.textBetween(from, to, ' '); + + if (selectedText) { + editor + .chain() + .setHighlight({ + color: 'var(--color-inline-comment)', + }) + .run(); + + if (buttonRef.current) { + buttonRef.current.click(); } - return true; } + return true; + } - // Line height increase shortcut (Alt + Shift + ↑) - if (event.altKey && event.shiftKey && event.key === 'ArrowUp') { - event.preventDefault(); - const lineHeights = [ - '120%', - '138%', - '180%', - '240%', - '300%', - '360%', - ]; - - // Get line height from current block node - let currentLineHeight = - editor.getAttributes('paragraph')?.lineHeight; - if (!currentLineHeight && editor.isActive('heading')) { - currentLineHeight = editor.getAttributes('heading')?.lineHeight; - } - if (!currentLineHeight && editor.isActive('listItem')) { - currentLineHeight = editor.getAttributes('listItem')?.lineHeight; - } - currentLineHeight = currentLineHeight || '138%'; + // Line height increase shortcut (Alt + Shift + ↑) + if (event.altKey && event.shiftKey && event.key === 'ArrowUp') { + event.preventDefault(); + const lineHeights = ['120%', '138%', '180%', '240%', '300%', '360%']; - const currentIndex = lineHeights.indexOf(currentLineHeight); - const nextIndex = Math.min( - currentIndex + 1, - lineHeights.length - 1, - ); - editor.chain().setLineHeight(lineHeights[nextIndex]).run(); - return true; + // Get line height from current block node + let currentLineHeight = editor.getAttributes('paragraph')?.lineHeight; + if (!currentLineHeight && editor.isActive('heading')) { + currentLineHeight = editor.getAttributes('heading')?.lineHeight; } + if (!currentLineHeight && editor.isActive('listItem')) { + currentLineHeight = editor.getAttributes('listItem')?.lineHeight; + } + currentLineHeight = currentLineHeight || '138%'; - // Line height decrease shortcut (Alt + Shift + ↓) - if (event.altKey && event.shiftKey && event.key === 'ArrowDown') { - event.preventDefault(); - const lineHeights = [ - '120%', - '138%', - '180%', - '240%', - '300%', - '360%', - ]; - - // Get line height from current block node - let currentLineHeight = - editor.getAttributes('paragraph')?.lineHeight; - if (!currentLineHeight && editor.isActive('heading')) { - currentLineHeight = editor.getAttributes('heading')?.lineHeight; - } - if (!currentLineHeight && editor.isActive('listItem')) { - currentLineHeight = editor.getAttributes('listItem')?.lineHeight; - } - currentLineHeight = currentLineHeight || '138%'; + const currentIndex = lineHeights.indexOf(currentLineHeight); + const nextIndex = Math.min(currentIndex + 1, lineHeights.length - 1); + editor.chain().setLineHeight(lineHeights[nextIndex]).run(); + return true; + } - const currentIndex = lineHeights.indexOf(currentLineHeight); - const prevIndex = Math.max(currentIndex - 1, 0); - editor.chain().setLineHeight(lineHeights[prevIndex]).run(); - return true; + // Line height decrease shortcut (Alt + Shift + ↓) + if (event.altKey && event.shiftKey && event.key === 'ArrowDown') { + event.preventDefault(); + const lineHeights = ['120%', '138%', '180%', '240%', '300%', '360%']; + + // Get line height from current block node + let currentLineHeight = editor.getAttributes('paragraph')?.lineHeight; + if (!currentLineHeight && editor.isActive('heading')) { + currentLineHeight = editor.getAttributes('heading')?.lineHeight; + } + if (!currentLineHeight && editor.isActive('listItem')) { + currentLineHeight = editor.getAttributes('listItem')?.lineHeight; } - return false; - }, + currentLineHeight = currentLineHeight || '138%'; + + const currentIndex = lineHeights.indexOf(currentLineHeight); + const prevIndex = Math.max(currentIndex - 1, 0); + editor.chain().setLineHeight(lineHeights[prevIndex]).run(); + return true; + } + return false; }, }); return () => { - // Clean up by resetting editor props when component unmounts + // Clean up by removing just the handleKeyDown patch added above. + // Merge (not replace) so attributes/clipboardTextSerializer/ + // handleDOMEvents configured elsewhere on editorProps survive — + // setting `handleKeyDown` to undefined is equivalent to omitting it + // (ProseMirror only calls it when defined). if (editor) { - editor.setOptions({ - editorProps: {}, - }); + mergeEditorProps(editor, { handleKeyDown: undefined }); } }; }, [editor, setToolVisibility, buttonRef]); @@ -2543,46 +2549,64 @@ export const checkActiveListsAndDBlocks = (editor: Editor) => { const activeDBlocks: number[] = []; const activeListDBlocks: number[] = []; + const hasDBlock = Boolean(state.schema.nodes.dBlock); - // First pass: collect all dBlocks in selection + // First pass: collect the top-level containers in the selection. + // v1: the dBlock wrappers. Flat v2: the top-level blocks themselves. state.doc.nodesBetween(from, to, (node, pos) => { - if (node.type.name === 'dBlock') { - activeDBlocks.push(pos); - let containsList = false; - node.content.forEach((child) => { - if ( - ['bulletList', 'orderedList', 'taskList'].includes(child.type.name) - ) { - containsList = true; + if (hasDBlock) { + if (node.type.name === 'dBlock') { + activeDBlocks.push(pos); + let containsList = false; + node.content.forEach((child) => { + if ( + ['bulletList', 'orderedList', 'taskList'].includes(child.type.name) + ) { + containsList = true; + } + }); + if (containsList) { + activeListDBlocks.push(pos); } - }); - if (containsList) { + } + return; + } + + if (state.doc.resolve(pos).depth === 0) { + activeDBlocks.push(pos); + if (['bulletList', 'orderedList', 'taskList'].includes(node.type.name)) { activeListDBlocks.push(pos); } } }); - // Check if there are multiple dBlocks with different content types - const hasMultipleContentTypes = - activeDBlocks.length > 1 && - activeDBlocks.some((pos) => { - const node = state.doc.nodeAt(pos); - if (!node) return false; - - // Check if this dBlock has a different content type than others - const hasListContent = node.content.content.some((child) => - ['bulletList', 'orderedList', 'taskList'].includes(child.type.name), - ); - const hasNonListContent = node.content.content.some( - (child) => - !['bulletList', 'orderedList', 'taskList'].includes(child.type.name), - ); - - return ( - (hasListContent && hasNonListContent) || // Mixed content in same dBlock - (hasListContent && activeListDBlocks.length < activeDBlocks.length) - ); // Some dBlocks don't have lists - }); + // Check if there are multiple dBlocks with different content types. + // Flat schema: mixed means the selection spans both list and non-list + // top-level blocks. + const hasMultipleContentTypes = !hasDBlock + ? activeListDBlocks.length > 0 && + activeListDBlocks.length < activeDBlocks.length + : activeDBlocks.length > 1 && + activeDBlocks.some((pos) => { + const node = state.doc.nodeAt(pos); + if (!node) return false; + + // Check if this dBlock has a different content type than others + const hasListContent = node.content.content.some((child) => + ['bulletList', 'orderedList', 'taskList'].includes(child.type.name), + ); + const hasNonListContent = node.content.content.some( + (child) => + !['bulletList', 'orderedList', 'taskList'].includes( + child.type.name, + ), + ); + + return ( + (hasListContent && hasNonListContent) || // Mixed content in same dBlock + (hasListContent && activeListDBlocks.length < activeDBlocks.length) + ); // Some dBlocks don't have lists + }); return { activeListTypes, diff --git a/package/components/split-view/split-view.css b/package/components/split-view/split-view.css index a8f88e19..447bf193 100644 --- a/package/components/split-view/split-view.css +++ b/package/components/split-view/split-view.css @@ -2,20 +2,9 @@ Render the read-only doc cleanly like the Figma: no DBlock drag/insert gutter, no centered max-width rails — just the document at 48px side padding. */ -[data-split-view-preview] [data-dblock-gutter] { - display: none !important; -} - -/* Each dBlock is a flex row [gutter][content-shell] sized to the page width. - In Split View make it a plain full-width block so the content fills the pane. */ -[data-split-view-preview] [data-dblock-node-view] { - display: block !important; - width: 100% !important; - padding-left: 0 !important; - padding-right: 0 !important; -} - -[data-split-view-preview] [data-dblock-content-shell], +/* dBlock no longer renders a drag/insert gutter or flex content-shell (see + dblock-node-view.ts) — each d-block is already a plain full-width block, + so no override is needed to neutralize a gutter layout here anymore. */ [data-split-view-preview] [data-node-view-content], [data-split-view-preview] .ProseMirror > * { width: 100% !important; @@ -69,6 +58,14 @@ margin: 0 !important; } -[data-split-view-preview] .ProseMirror { - padding: 8px 48px 64px 48px; +/* .main-doc-editor pinned explicitly so this beats the base + .ProseMirror.main-doc-editor container-padding rule (editor.css) on + specificity regardless of stylesheet import order — both are two-class + selectors otherwise. */ +[data-split-view-preview] .ProseMirror.main-doc-editor { + --padding-t: 8px; + --padding-r: 48px; + --padding-b: 64px; + --padding-l: var(--padding-r); + padding: var(--padding-t) var(--padding-r) var(--padding-b) var(--padding-l); } diff --git a/package/components/toc/toc.tsx b/package/components/toc/toc.tsx index 65e4ede1..7b4ee6ef 100644 --- a/package/components/toc/toc.tsx +++ b/package/components/toc/toc.tsx @@ -255,6 +255,13 @@ export const ToC = memo( return false; // Stop searching } } + // Flat v2 schema: headings are top-level, match them directly. + // (In v1 the dBlock branch above wins first, since parents are + // visited before children.) + if (node.type.name === 'heading' && node.attrs.id === headingId) { + headingPos = pos; + return false; + } }); if (headingPos !== -1) { diff --git a/package/ddoc-editor.tsx b/package/ddoc-editor.tsx index afb81c2b..798e6611 100644 --- a/package/ddoc-editor.tsx +++ b/package/ddoc-editor.tsx @@ -303,6 +303,7 @@ const DdocEditor = forwardRef( draftAnchorsRef, storeApiRef, dBlockRuntimeState, + isSchemaUnsupported, } = useDdocEditor({ documentStyling, ipfsImageFetchFn, @@ -342,9 +343,9 @@ const DdocEditor = forwardRef( isPresentationMode, metadataProxyUrl, extensions, + onCopyHeadingLink, disableInlineComment, isFocusMode, - onCopyHeadingLink, isConnected, activeModel, maxTokens, @@ -984,11 +985,11 @@ const DdocEditor = forwardRef( !isPreviewMode && !isFocusMode && (isNavbarVisible - ? '-mt-[1.5rem] md:!mt-[0.8rem] pt-0 md:pt-[5rem]' - : 'pt-0 md:pt-[1.5rem]'), + ? '-mt-[1.5rem] md:!mt-[0.8rem]' + : null), !isSplitViewActive && isPreviewMode && - 'md:!mt-[1rem] pt-0 md:!pt-[5rem]', + 'md:!mt-[1rem]', { 'md:!mt-[0.7rem]': !isSplitViewActive && @@ -1002,7 +1003,7 @@ const DdocEditor = forwardRef( !isPreviewMode, }, // Split View: no full-screen top spacing. - isSplitViewActive && 'mt-0 pt-0', + isSplitViewActive && 'mt-0', isFocusMode && 'mt-[48px]', )} style={{ @@ -1097,11 +1098,10 @@ const DdocEditor = forwardRef( /> )} - {!editor || isContentLoading ? fadeInTransition(
{isPreviewMode ? ( @@ -1278,8 +1278,7 @@ const DdocEditor = forwardRef( } className={cn( 'w-full h-auto', - isPreviewMode && - 'preview-mode max-sm:!pb-40', + isPreviewMode && 'preview-mode', activeModel !== undefined && isAIAgentEnabled && 'has-available-models', @@ -1404,6 +1403,29 @@ const DdocEditor = forwardRef( ); }; + // A doc created on a newer schema must never bind editors in this build; + // useDdocEditor already blocks editor creation via the schema guard, this + // branch replaces the editor surface with a refresh prompt. + if (isSchemaUnsupported) { + return ( +
+
+ +
+

+ Update needed to open this document +

+

+ This document was created with a newer version of the app. + Refresh the page to update and open it. +

+
+ +
+
+ ); + } + return ( { + // Select the action button itself so the insert replaces it + // in place (no empty-wrapper intermediate, no magic offsets). + tr.setSelection(NodeSelection.create(tr.doc, pos)); + return true; + }) .setIframe({ src: formattedUrl, width, height }) .run(); } else { @@ -138,12 +142,13 @@ const ActionButtonNodeView = ({ const pos = getPos(); if (pos !== undefined) { - const to = pos + node.nodeSize; filteredTweetId && editor ?.chain() - .focus(pos) - .deleteRange({ from: pos, to }) + .command(({ tr }) => { + tr.setSelection(NodeSelection.create(tr.doc, pos)); + return true; + }) .setTweetEmbed({ tweetId: filteredTweetId }) .run(); } else { @@ -174,13 +179,14 @@ const ActionButtonNodeView = ({ const pos = getPos(); if (pos !== undefined) { - const to = pos + node.nodeSize; sanitizedURL && editor ?.chain() - .focus(pos) - .deleteRange({ from: pos, to }) + .command(({ tr }) => { + tr.setSelection(NodeSelection.create(tr.doc, pos)); + return true; + }) .setIframe({ src: sanitizedURL, width, height }) .run(); } else { @@ -251,9 +257,11 @@ const ActionButtonNodeView = ({ const pos = getPos(); if (pos !== undefined) { - const to = pos + node.nodeSize; if (formattedUrl) { - const chain = editor?.chain().focus(pos).deleteRange({ from: pos, to }); + const chain = editor?.chain().command(({ tr }) => { + tr.setSelection(NodeSelection.create(tr.doc, pos)); + return true; + }); if (mediaType === 'twitter') { chain?.setTweetEmbed({ tweetId: formattedUrl }); } else { @@ -283,13 +291,29 @@ const ActionButtonNodeView = ({ } }; - const debouncedHandleSave = debounce(handleSave, 1000); + // One stable debounce for the component's lifetime. The previous code + // rebuilt the debounce on every render, so every keystroke armed its own + // independent 1s timer — each firing handleSave with a PARTIAL URL + // ("Please enter a valid URL" toast spam), and leaked timers kept firing + // after the node view unmounted. The ref keeps the latest closure without + // resetting the timer identity. + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + const debouncedHandleSave = useMemo( + () => debounce(() => handleSaveRef.current(), 1000), + [], + ); useEffect(() => { if (inputValue) { debouncedHandleSave(); } - }, [inputValue]); + }, [inputValue, debouncedHandleSave]); + + useEffect( + () => () => debouncedHandleSave.cancel(), + [debouncedHandleSave], + ); useEffect(() => { editor?.chain().focus(); diff --git a/package/extensions/action-button/action-button.test.ts b/package/extensions/action-button/action-button.test.ts new file mode 100644 index 00000000..b48f4311 --- /dev/null +++ b/package/extensions/action-button/action-button.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; + +const countActionButtons = (editor: Editor) => { + let count = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === 'actionButton') count += 1; + }); + return count; +}; + +describe('setActionButton', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('leaves the selection in a valid textblock (not at the dBlock boundary)', () => { + editor = makeEditor('

'); + editor.commands.setTextSelection(2); + + editor.commands.setActionButton('iframe-video'); + + expect(countActionButtons(editor)).toBe(1); + // The old behavior parked the selection at pos 1 with the dBlock as + // parent — an invalid resting place that made the cursor render at the + // block edge and broke subsequent inserts. + expect(editor.state.selection.$from.parent.type.name).not.toBe('dBlock'); + // The empty host paragraph is consumed — no blank line left above. + expect(editor.state.doc.firstChild?.firstChild?.type.name).toBe( + 'actionButton', + ); + }); + + it('supports inserting two action buttons back to back (TEC-2539 repro)', () => { + editor = makeEditor('

'); + editor.commands.setTextSelection(2); + + editor.commands.setActionButton('iframe-video'); + editor.commands.setActionButton('iframe-soundcloud'); + + // The JAM flow: insert video input, then soundcloud input — both must + // exist. The old code silently dropped the second insert because the + // selection was stranded at the dBlock boundary. + expect(countActionButtons(editor)).toBe(2); + }); +}); diff --git a/package/extensions/action-button/action-button.ts b/package/extensions/action-button/action-button.ts index b3e547ed..2f888a14 100644 --- a/package/extensions/action-button/action-button.ts +++ b/package/extensions/action-button/action-button.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Node, mergeAttributes } from '@tiptap/core'; +import { replaceSelectionWithBlockNode } from '../../utils/block-insert'; import { getActionButtonView } from './action-button-node-view'; import { ReactNodeViewRenderer } from '@tiptap/react'; @@ -69,14 +70,14 @@ export const actionButton = Node.create({ return { setActionButton: (option) => - ({ commands }) => { - return commands.insertContent({ - type: this.name, - attrs: { - data: option, - }, - content: [], - }); + ({ state, tr, dispatch }) => { + const node = state.schema.nodes[this.name].create({ data: option }); + if (dispatch) { + // Operates on `tr` (not `state`): callers chain this after + // deleteRange (slash menu), and `state` predates the chain. + replaceSelectionWithBlockNode(tr, node); + } + return true; }, }; }, diff --git a/package/extensions/ai-writer/ai-writer-space-trigger.test.ts b/package/extensions/ai-writer/ai-writer-space-trigger.test.ts new file mode 100644 index 00000000..dfa9dfdc --- /dev/null +++ b/package/extensions/ai-writer/ai-writer-space-trigger.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { Editor, Node } from '@tiptap/core'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import { AiWriterSpaceTrigger } from './ai-writer-space-trigger'; + +// A minimal stand-in for the aiWriter node: same name, group, and atom +// shape, no React node view so the editor can run headless. +const AiWriterStub = Node.create({ + name: 'aiWriter', + group: 'block', + atom: true, + addAttributes() { + return { + prompt: { default: '' }, + content: { default: '' }, + tone: { default: 'neutral' }, + }; + }, + renderHTML() { + return ['div', { 'data-type': 'ai-writer-stub' }]; + }, +}); + +const FlatDoc = Document.extend({ content: 'block+' }); + +const buildEditor = (withAiWriter: boolean, content: string) => + new Editor({ + extensions: [ + FlatDoc, + Paragraph, + Text, + ...(withAiWriter ? [AiWriterStub] : []), + AiWriterSpaceTrigger, + ], + content, + }); + +// Drives the plugin the way ProseMirror would on a real keystroke. +const typeSpace = (editor: Editor) => { + const { from, to } = editor.state.selection; + return Boolean( + editor.view.someProp('handleTextInput', (handler) => + handler(editor.view, from, to, ' '), + ), + ); +}; + +const docTypes = (editor: Editor) => + (editor.getJSON().content || []).map((node) => node.type); + +describe('AiWriterSpaceTrigger', () => { + it('replaces an empty top-level paragraph with the aiWriter node', () => { + const editor = buildEditor(true, '

'); + editor.commands.focus('start'); + expect(typeSpace(editor)).toBe(true); + expect(docTypes(editor)).toContain('aiWriter'); + editor.destroy(); + }); + + it('does nothing in a paragraph that has content', () => { + const editor = buildEditor(true, '

hello

'); + editor.commands.focus('end'); + expect(typeSpace(editor)).toBe(false); + expect(docTypes(editor)).not.toContain('aiWriter'); + editor.destroy(); + }); + + it('does not open a second writer while one is active', () => { + const editor = buildEditor(true, '

'); + editor.commands.focus('start'); + expect(typeSpace(editor)).toBe(true); + editor.commands.focus('end'); + expect(typeSpace(editor)).toBe(false); + expect(docTypes(editor).filter((type) => type === 'aiWriter')).toHaveLength( + 1, + ); + editor.destroy(); + }); + + it('self-disables when aiWriter is not in the schema', () => { + const editor = buildEditor(false, '

'); + editor.commands.focus('start'); + expect(typeSpace(editor)).toBe(false); + editor.destroy(); + }); +}); diff --git a/package/extensions/ai-writer/ai-writer-space-trigger.ts b/package/extensions/ai-writer/ai-writer-space-trigger.ts new file mode 100644 index 00000000..1001cbdf --- /dev/null +++ b/package/extensions/ai-writer/ai-writer-space-trigger.ts @@ -0,0 +1,70 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +// Flat-schema (v2) home of "a single space in an empty paragraph opens the +// AI writer". v1 ships the equivalent plugin inside the dBlock extension +// (dblock.ts, gated on hasAvailableModels there too). +export const AiWriterSpaceTrigger = Extension.create({ + name: 'aiWriterSpaceTrigger', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('aiwriter-space-trigger'), + props: { + handleTextInput: (view, from, _to, text) => { + if (text !== ' ') return false; + + const { state, dispatch } = view; + // Self-disables when the AI writer node is not in the schema. + if (!state.schema.nodes.aiWriter) return false; + + const { $from } = state.selection; + const node = $from.node($from.depth); + if (node?.type?.name !== 'paragraph' || node.textContent !== '') { + return false; + } + + // Top-level paragraphs and column cells: the same scope the v1 + // trigger covers via its dBlock-parent check. + const parentName = $from.node($from.depth - 1)?.type?.name; + if (parentName !== 'doc' && parentName !== 'column') { + return false; + } + + const prevChar = state.doc.textBetween(from - 1, from, '\0'); + if (prevChar === ' ') { + return false; + } + + let hasActiveAIWriter = false; + state.doc.descendants((child) => { + if (child.type.name === 'aiWriter') { + hasActiveAIWriter = true; + return false; + } + return true; + }); + if (hasActiveAIWriter) { + return false; + } + + const aiWriterNode = state.schema.nodes.aiWriter.create({ + prompt: '', + content: '', + tone: 'neutral', + }); + dispatch( + state.tr.replaceRangeWith( + $from.before(), + $from.after(), + aiWriterNode, + ), + ); + return true; + }, + }, + }), + ]; + }, +}); diff --git a/package/extensions/block-id/block-id.test.ts b/package/extensions/block-id/block-id.test.ts new file mode 100644 index 00000000..4144bb36 --- /dev/null +++ b/package/extensions/block-id/block-id.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import type { AnyExtension } from '@tiptap/core'; +import * as Y from 'yjs'; +import { getHeadlessExtensions } from '../../hooks/use-headless-editor'; +import { BLOCK_ID_ATTR } from './index'; + +// Undo here is Yjs's UndoManager (StarterKit's undoRedo is off and +// Collaboration is always registered), so these have to run against a real +// Y.Doc-backed editor — a plain schema-only editor would not exercise the bug. +const makeV2Editor = (ydoc: Y.Doc = new Y.Doc()) => { + const editor = new Editor({ + extensions: getHeadlessExtensions({ + ydoc, + schemaVersion: 2, + }) as AnyExtension[], + textDirection: 'auto', + }); + return { editor, ydoc }; +}; + +// The UndoManager merges everything inside its 500ms capture window into one +// stack item; each step needs its own item for undo to be meaningful. +const settle = (ms = 600) => new Promise((resolve) => setTimeout(resolve, ms)); + +const topLevelIds = (editor: Editor) => { + const ids: (string | null)[] = []; + editor.state.doc.forEach((node) => ids.push(node.attrs[BLOCK_ID_ATTR])); + return ids; +}; + +const text = (editor: Editor) => editor.getText().replace(/\s+/g, ' ').trim(); + +describe('blockId (flat v2 schema)', () => { + const editors: Editor[] = []; + const track = (made: T): T => { + editors.push(made.editor); + return made; + }; + + afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()); + }); + + it('gives every top-level block an id', async () => { + const { editor } = track(makeV2Editor()); + editor.commands.setContent('

one

two

three

'); + await settle(); + + const ids = topLevelIds(editor); + expect(ids).toHaveLength(3); + expect(ids.every((id) => typeof id === 'string' && id.length > 0)).toBe( + true, + ); + expect(new Set(ids).size).toBe(3); + }); + + it('re-ids duplicates so a pasted copy never shares an id', async () => { + const { editor } = track(makeV2Editor()); + editor.commands.setContent('

one

'); + await settle(); + const [original] = topLevelIds(editor); + + editor.commands.insertContentAt(editor.state.doc.content.size, { + type: 'paragraph', + attrs: { [BLOCK_ID_ATTR]: original }, + content: [{ type: 'text', text: 'copy' }], + }); + await settle(); + + const ids = topLevelIds(editor); + expect(ids).toHaveLength(2); + expect(ids[0]).toBe(original); + expect(ids[1]).not.toBe(original); + }); + + // The regression: id assignment used to mark its appendTransaction + // `addToHistory: false`, and the y-sync binding stamps the whole batched Yjs + // transaction with the LAST transaction's meta — so the user's edit went + // unrecorded whenever it also created a block that needed an id. + it('keeps a select-all delete undoable', async () => { + const { editor } = track(makeV2Editor()); + editor.commands.setContent('

One

Two

Three

'); + await settle(); + const before = text(editor); + + editor.chain().focus().selectAll().deleteSelection().run(); + await settle(); + expect(text(editor)).toBe(''); + + editor.commands.undo(); + await settle(100); + expect(text(editor)).toBe(before); + }); + + it('keeps a block split undoable without losing the first half', async () => { + const { editor } = track(makeV2Editor()); + editor.commands.setContent('

AlphaBeta

'); + await settle(); + expect(editor.state.doc.childCount).toBe(1); + + // caret between "Alpha" and "Beta" + editor.chain().focus().setTextSelection(6).splitBlock().run(); + await settle(); + expect(editor.state.doc.childCount).toBe(2); + + editor.commands.undo(); + await settle(100); + expect(editor.state.doc.childCount).toBe(1); + expect(text(editor)).toBe('AlphaBeta'); + }); + + it('leaves ids from a remote peer alone', async () => { + const { editor: author, ydoc: authorDoc } = track(makeV2Editor()); + author.commands.setContent('

one

two

'); + await settle(); + const authored = topLevelIds(author); + + const { editor: peer, ydoc: peerDoc } = track(makeV2Editor()); + Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(authorDoc)); + await settle(); + + expect(topLevelIds(peer)).toEqual(authored); + }); +}); diff --git a/package/extensions/block-id/index.ts b/package/extensions/block-id/index.ts new file mode 100644 index 00000000..feb8032c --- /dev/null +++ b/package/extensions/block-id/index.ts @@ -0,0 +1,117 @@ +import { Extension } from '@tiptap/core'; +import { isChangeOrigin } from '@tiptap/extension-collaboration'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { v4 as uuidv4 } from 'uuid'; + +export const BLOCK_ID_ATTR = 'blockId'; + +// Node types that carry a persistent id when they sit at the top level of a +// flat (v2) doc. The attribute is declared globally, so nested occurrences +// (a paragraph inside a callout) have it too, but only top-level blocks get +// ids assigned; nested ones stay null. +const BLOCK_ID_TYPES = [ + 'paragraph', + 'heading', + 'bulletList', + 'orderedList', + 'taskList', + 'blockquote', + 'codeBlock', + 'table', + 'callout', + 'resizableMedia', + 'actionButton', + 'iframe', + 'embeddedTweet', + 'horizontalRule', + 'pageBreak', + 'columns', +]; + +// v2-only: registered in the flat extension set, never in v1. Gives every +// top-level block a stable uuid that survives edits and rides the Yjs doc, +// for deep links, block anchoring, and the floating handle. +export const BlockId = Extension.create({ + name: 'blockId', + + addGlobalAttributes() { + return [ + { + types: BLOCK_ID_TYPES, + attributes: { + [BLOCK_ID_ATTR]: { + default: null, + // The new half of an Enter-split starts without an id and gets a + // fresh one; the original half keeps its identity. + keepOnSplit: false, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-block-id'), + renderHTML: (attributes: Record) => + attributes[BLOCK_ID_ATTR] + ? { 'data-block-id': attributes[BLOCK_ID_ATTR] as string } + : {}, + }, + }, + }, + ]; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('blockIdAssign'), + appendTransaction: (transactions, _oldState, newState) => { + if (!transactions.some((transaction) => transaction.docChanged)) { + return null; + } + + // A remote peer running this same extension already gave its blocks + // ids before the update was sent, so re-deriving them here would + // only race that peer — and would put a write triggered by someone + // else's edit on OUR undo stack. + if (transactions.some(isChangeOrigin)) { + return null; + } + + // Shallow pass over top-level blocks only: assign missing ids and + // re-id duplicates (paste copies ids along with content). + let tr: typeof newState.tr | null = null; + const seenIds = new Set(); + + newState.doc.forEach((node, pos) => { + if (!(BLOCK_ID_ATTR in node.attrs)) { + return; + } + + const id = node.attrs[BLOCK_ID_ATTR] as string | null; + if (id && !seenIds.has(id)) { + seenIds.add(id); + return; + } + + tr = tr ?? newState.tr; + const newId = uuidv4(); + seenIds.add(newId); + tr.setNodeMarkup( + pos, + undefined, + { ...node.attrs, [BLOCK_ID_ATTR]: newId }, + node.marks, + ); + }); + + // Deliberately NOT marked `addToHistory: false`. Undo here is Yjs's + // UndoManager, not prosemirror-history, and the y-sync binding + // writes ONE Yjs transaction per dispatch, stamped with the meta of + // the LAST transaction in the chain — which is this one. Setting the + // flag therefore excludes the user's own edit from the undo stack + // (and calls stopCapturing), so anything that creates a new block — + // Enter, paste, select-all-then-delete — became unrecoverable. The + // ids ride along in the same Yjs transaction as the edit that needed + // them, which is exactly where they belong. + return tr; + }, + }), + ]; + }, +}); diff --git a/package/extensions/code-block/custom-code-block-lowlight.ts b/package/extensions/code-block/custom-code-block-lowlight.ts index 0fba9bc6..26816a6f 100644 --- a/package/extensions/code-block/custom-code-block-lowlight.ts +++ b/package/extensions/code-block/custom-code-block-lowlight.ts @@ -244,31 +244,42 @@ export const CustomCodeBlockLowlight = }) .command(({ tr, state, dispatch }) => { const { $from } = state.selection; - // Find the enclosing dBlock (codeBlock's parent). - let dBlockDepth = -1; + const dBlockType = state.schema.nodes.dBlock; + const paragraphType = state.schema.nodes.paragraph; + if (!paragraphType) return false; + + // Find the block to escape past: the enclosing dBlock in v1, + // the codeBlock itself in the flat v2 schema. The loop walks + // deepest-first, so in v1 the (deeper) codeBlock is skipped + // and the dBlock still wins. + let boundaryDepth = -1; for (let d = $from.depth; d >= 0; d--) { - if ($from.node(d).type.name === 'dBlock') { - dBlockDepth = d; + const name = $from.node(d).type.name; + if ( + name === 'dBlock' || + (!dBlockType && name === 'codeBlock') + ) { + boundaryDepth = d; break; } } - if (dBlockDepth === -1) return false; + if (boundaryDepth === -1) return false; - const dBlockEnd = $from.after(dBlockDepth); - const dBlockType = state.schema.nodes.dBlock; - const paragraphType = state.schema.nodes.paragraph; - if (!dBlockType || !paragraphType) return false; - - const newDBlock = dBlockType.create( - null, - paragraphType.create(), - ); + const boundaryEnd = $from.after(boundaryDepth); + const newBlock = dBlockType + ? dBlockType.create(null, paragraphType.create()) + : paragraphType.create(); if (dispatch) { - tr.insert(dBlockEnd, newDBlock); - // Cursor at start of the new paragraph (dBlockEnd + 2: - // +1 enters dBlock, +1 enters paragraph). - tr.setSelection(TextSelection.create(tr.doc, dBlockEnd + 2)); + tr.insert(boundaryEnd, newBlock); + // Cursor at start of the new paragraph: +1 into the + // paragraph, +1 more when a dBlock wraps it. + tr.setSelection( + TextSelection.create( + tr.doc, + boundaryEnd + (dBlockType ? 2 : 1), + ), + ); } return true; }) diff --git a/package/extensions/d-block/components/buttons.tsx b/package/extensions/d-block/components/buttons.tsx index b0515b60..70ebee14 100644 --- a/package/extensions/d-block/components/buttons.tsx +++ b/package/extensions/d-block/components/buttons.tsx @@ -1,5 +1,5 @@ import React, { forwardRef } from 'react'; -import { Button, LucideIcon, PopoverClose, cn } from '@fileverse/ui'; +import { Button, IconButton, LucideIcon, PopoverClose } from '@fileverse/ui'; // Memoized button components to prevent unnecessary re-renders export const ActionButton = React.memo( @@ -31,13 +31,16 @@ ActionButton.displayName = 'ActionButton'; export const GripButton = React.memo( forwardRef< - HTMLDivElement, + HTMLButtonElement, { - onClick: (event: React.MouseEvent) => void; + onClick: (event: React.MouseEvent) => void; className: string; } >(({ onClick, className, ...props }, ref) => ( -
- -
+ /> )), ); @@ -55,21 +56,21 @@ GripButton.displayName = 'GripButton'; export const PlusButton = React.memo( forwardRef< - HTMLDivElement, + HTMLButtonElement, { - onClick: (event: React.MouseEvent) => void; + onClick: (event: React.MouseEvent) => void; className: string; } >(({ onClick, className, ...props }, ref) => ( -
- -
+ /> )), ); @@ -77,46 +78,24 @@ PlusButton.displayName = 'PlusButton'; export const CollapseButton = React.memo( forwardRef< - HTMLDivElement, + HTMLButtonElement, { isCollapsed: boolean; onToggle: () => void; className: string; } >(({ isCollapsed, onToggle, className, ...props }, ref) => ( -
- -
+ /> )), ); CollapseButton.displayName = 'CollapseButton'; - -export const CopyLinkButton = React.memo( - forwardRef void; className: string }>( - ({ onClick, className, ...props }, ref) => ( -
- -
- ), - ), -); - -CopyLinkButton.displayName = 'CopyLinkButton'; diff --git a/package/extensions/d-block/components/menu.tsx b/package/extensions/d-block/components/menu.tsx index 190c501e..351445f8 100644 --- a/package/extensions/d-block/components/menu.tsx +++ b/package/extensions/d-block/components/menu.tsx @@ -17,7 +17,14 @@ interface MenuProps { } const MenuTrigger = forwardRef( - (props, ref) =>
{props.children}
, + // flex kills the inherited line-box height (24px) that otherwise makes + // this wrapper taller than the 20px grip button and top-aligns it 2px + // above its sibling buttons in the cluster row. + (props, ref) => ( +
+ {props.children} +
+ ), ); MenuTrigger.displayName = 'MenuTrigger'; diff --git a/package/extensions/d-block/components/tooltips.tsx b/package/extensions/d-block/components/tooltips.tsx index d319b7c9..3e80b829 100644 --- a/package/extensions/d-block/components/tooltips.tsx +++ b/package/extensions/d-block/components/tooltips.tsx @@ -65,11 +65,3 @@ export const CollapseTooltip = React.memo( ), ); - -export const CopyLinkTooltip = React.memo( - ({ children }: { children: React.ReactNode }) => ( - - {children} - - ), -); diff --git a/package/extensions/d-block/dblock-collapse.ts b/package/extensions/d-block/dblock-collapse.ts index f631751b..7005471d 100644 --- a/package/extensions/d-block/dblock-collapse.ts +++ b/package/extensions/d-block/dblock-collapse.ts @@ -1,4 +1,4 @@ -import type { Editor } from '@tiptap/core'; +import { Extension, type Editor } from '@tiptap/core'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { EditorState, @@ -9,6 +9,7 @@ import { } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import { headingToSlug } from '../../utils/heading-to-slug'; +import { CHEVRON_SVG, LINK_SVG } from './heading-chrome-icons'; import { HEADING_COLLAPSE_TOGGLE_META } from '../suggestion/suggestion-tracking-extension'; export const DBLOCK_HIDDEN_CLASS = 'd-block-hidden'; @@ -39,46 +40,64 @@ interface DBlockCollapsePluginState { const getFirstChild = (node: ProseMirrorNode) => node.content.firstChild; +// v1 wraps each block in a dBlock whose first child is the real node; the +// flat v2 schema puts the block itself at the top level. These resolvers let +// the same collapse engine serve both shapes, keyed off the doc's own schema. +const docHasDBlock = (doc: ProseMirrorNode) => + Boolean(doc.type.schema.nodes.dBlock); + +// The heading of a top-level block, or null when the block is not a heading. +const getBlockHeading = ( + doc: ProseMirrorNode, + node: ProseMirrorNode | null | undefined, +): ProseMirrorNode | null => { + if (!node) return null; + if (docHasDBlock(doc)) { + const firstChild = node.type.name === 'dBlock' ? getFirstChild(node) : null; + return firstChild?.type.name === 'heading' ? firstChild : null; + } + return node.type.name === 'heading' ? node : null; +}; + +// Position of the heading node given its top-level block position. +const headingPosAt = (doc: ProseMirrorNode, blockPos: number) => + blockPos + (docHasDBlock(doc) ? 1 : 0); + +// Schema-agnostic: in v1 the meaningful node is the dBlock's first child, in +// the flat v2 schema the top-level node IS the block. Resolved from the node's +// own schema so callers (node view, floating drag-handle cluster) need no +// version awareness. export const getDBlockRenderMeta = ( node: ProseMirrorNode, pos: number, ): DBlockRenderMeta => { - const firstChild = getFirstChild(node); - const isHeading = firstChild?.type.name === 'heading'; + const block = + node.type.name === 'dBlock' ? getFirstChild(node) : (node ?? null); + const isHeading = block?.type.name === 'heading'; return { isHeading, - headingId: isHeading ? firstChild?.attrs.id || `heading-${pos}` : null, - isThisHeadingCollapsed: Boolean(isHeading && firstChild?.attrs.isCollapsed), - headingAlignment: isHeading ? firstChild?.attrs.textAlign : undefined, - isTable: firstChild?.type.name === 'table', + headingId: isHeading ? block?.attrs.id || `heading-${pos}` : null, + isThisHeadingCollapsed: Boolean(isHeading && block?.attrs.isCollapsed), + headingAlignment: isHeading ? block?.attrs.textAlign : undefined, + isTable: block?.type.name === 'table', }; }; -export const getHeadingAlignmentClass = (alignment?: string) => { - switch (alignment) { - case 'center': - return 'justify-center'; - case 'left': - return 'justify-end'; - case 'right': - return 'justify-start'; - default: - return 'justify-end'; - } -}; - export const getHeadingLinkSlug = ( node: ProseMirrorNode, pos: number, ): string | null => { - const firstChild = getFirstChild(node); - if (firstChild?.type.name !== 'heading') { + // Same shape-agnostic resolution as getDBlockRenderMeta: v1 passes the + // dBlock wrapper, the flat schema passes the heading itself. + const headingNode = + node.type.name === 'dBlock' ? getFirstChild(node) : (node ?? null); + if (headingNode?.type.name !== 'heading') { return null; } - const id = firstChild.attrs.id || `heading-${pos}`; - const title = firstChild.textContent; + const id = headingNode.attrs.id || `heading-${pos}`; + const title = headingNode.textContent; if (!title) { return null; } @@ -93,12 +112,8 @@ export const buildHeadingMap = (doc: ProseMirrorNode): HeadingLookupMap => { const parentStack: Array<{ id: string; level: number }> = []; doc.forEach((node, position) => { - if (node.type.name !== 'dBlock') { - return; - } - - const headingNode = getFirstChild(node); - if (headingNode?.type.name !== 'heading') { + const headingNode = getBlockHeading(doc, node); + if (!headingNode) { return; } @@ -144,7 +159,7 @@ const isHeadingCollapsed = ( } const node = doc.nodeAt(heading.position); - const headingNode = node ? getFirstChild(node) : null; + const headingNode = getBlockHeading(doc, node); return Boolean(headingNode?.attrs.isCollapsed); }; @@ -154,11 +169,10 @@ export const shouldHideDBlock = ( position: number, headingMap: HeadingLookupMap, ) => { - const firstChild = getFirstChild(node); - const isHeading = firstChild?.type.name === 'heading'; + const blockHeading = getBlockHeading(doc, node); - if (isHeading) { - const headingId = firstChild.attrs.id || `heading-${position}`; + if (blockHeading) { + const headingId = blockHeading.attrs.id || `heading-${position}`; const heading = headingMap.get(headingId); if (!heading || heading.level === 1 || !heading.parent) { @@ -226,7 +240,7 @@ const setHeadingCollapsed = ( dBlockPos: number, isCollapsed: boolean, ) => { - const headingPos = dBlockPos + 1; + const headingPos = headingPosAt(tr.doc, dBlockPos); const headingNode = tr.doc.nodeAt(headingPos); if (headingNode?.type.name !== 'heading') { @@ -259,6 +273,9 @@ const findHeadingAtSelectionEnd = ( return null; } + // Caret max position inside the heading text: block end minus the closing + // heading token, minus one more for the dBlock wrapper when present. + const endOffset = docHasDBlock(doc) ? 2 : 1; let position = 0; while (position < doc.content.size) { const node = doc.nodeAt(position); @@ -266,13 +283,11 @@ const findHeadingAtSelectionEnd = ( break; } - if (node.type.name === 'dBlock') { - const firstChild = getFirstChild(node); - if (firstChild?.type.name === 'heading' && firstChild.attrs.isCollapsed) { - const end = position + node.nodeSize; - if (selection.from >= end - 2 && selection.from <= end) { - return { node, position }; - } + const headingNode = getBlockHeading(doc, node); + if (headingNode?.attrs.isCollapsed) { + const end = position + node.nodeSize; + if (selection.from >= end - endOffset && selection.from <= end) { + return { node, position }; } } @@ -286,15 +301,15 @@ export const findEndOfCollapsedContent = ( doc: ProseMirrorNode, headingPos: number, ) => { - const headingNode = doc.nodeAt(headingPos); - const firstChild = headingNode ? getFirstChild(headingNode) : null; + const blockNode = doc.nodeAt(headingPos); + const headingNode = getBlockHeading(doc, blockNode); - if (!headingNode || firstChild?.type.name !== 'heading') { - return headingPos + (headingNode?.nodeSize ?? 0); + if (!blockNode || !headingNode) { + return headingPos + (blockNode?.nodeSize ?? 0); } - const headingLevel = firstChild.attrs.level || 1; - let position = headingPos + headingNode.nodeSize; + const headingLevel = headingNode.attrs.level || 1; + let position = headingPos + blockNode.nodeSize; while (position < doc.content.size) { const node = doc.nodeAt(position); @@ -302,14 +317,9 @@ export const findEndOfCollapsedContent = ( break; } - if (node.type.name === 'dBlock') { - const nextHeading = getFirstChild(node); - if ( - nextHeading?.type.name === 'heading' && - (nextHeading.attrs.level || 1) <= headingLevel - ) { - break; - } + const nextHeading = getBlockHeading(doc, node); + if (nextHeading && (nextHeading.attrs.level || 1) <= headingLevel) { + break; } position += node.nodeSize; @@ -318,18 +328,25 @@ export const findEndOfCollapsedContent = ( return position; }; -const isEmptyDBlock = (node: ProseMirrorNode | null | undefined) => { - const firstChild = node ? getFirstChild(node) : null; - return ( - node?.type.name === 'dBlock' && - firstChild?.type.name === 'paragraph' && - firstChild.content.size === 0 - ); +const isEmptyBlock = ( + doc: ProseMirrorNode, + node: ProseMirrorNode | null | undefined, +) => { + if (!node) return false; + if (docHasDBlock(doc)) { + const firstChild = getFirstChild(node); + return ( + node.type.name === 'dBlock' && + firstChild?.type.name === 'paragraph' && + firstChild.content.size === 0 + ); + } + return node.type.name === 'paragraph' && node.content.size === 0; }; const getEmptyTrailingDBlockPosition = (doc: ProseMirrorNode) => { const lastChild = doc.lastChild; - if (!isEmptyDBlock(lastChild)) { + if (!isEmptyBlock(doc, lastChild)) { return null; } @@ -341,20 +358,20 @@ export const buildToggleHeadingCollapseTransaction = ( position: number, ) => { const node = state.doc.nodeAt(position); - const firstChild = node ? getFirstChild(node) : null; + const headingNode = getBlockHeading(state.doc, node); - if (node?.type.name !== 'dBlock' || firstChild?.type.name !== 'heading') { + if (!node || !headingNode) { return null; } const headingMap = buildHeadingMap(state.doc); - const headingId = firstChild.attrs.id || `heading-${position}`; + const headingId = headingNode.attrs.id || `heading-${position}`; const heading = headingMap.get(headingId); if (!heading) { return null; } - const wasCollapsed = Boolean(firstChild.attrs.isCollapsed); + const wasCollapsed = Boolean(headingNode.attrs.isCollapsed); const tr = state.tr; setHeadingCollapsed(tr, position, !wasCollapsed); @@ -390,30 +407,40 @@ export const buildToggleHeadingCollapseTransaction = ( return tr; }; +// Toggling collapse must leave the viewport where it is. The caret is +// usually somewhere else entirely (often the document start, from autofocus), +// and scrolling to an untouched selection drags the reader away from the +// heading they just clicked. Only the case where the transaction itself +// relocated the caret — it sat inside the region being hidden — is worth +// scrolling to. +const dispatchCollapseToggle = ( + view: { state: EditorState; dispatch: (tr: Transaction) => void }, + previousSelection: EditorState['selection'], + tr: Transaction, +) => { + view.dispatch(tr.selection.eq(previousSelection) ? tr : tr.scrollIntoView()); +}; + export const toggleHeadingCollapse = (editor: Editor, position: number) => { const tr = buildToggleHeadingCollapseTransaction(editor.state, position); if (!tr) { return false; } - editor.view.dispatch(tr.scrollIntoView()); + dispatchCollapseToggle(editor.view, editor.state.selection, tr); editor.view.focus(); return true; }; export const expandHeadingContent = (editor: Editor, nodePos: number) => { const node = editor.state.doc.nodeAt(nodePos); - const firstChild = node ? getFirstChild(node) : null; + const headingNode = getBlockHeading(editor.state.doc, node); - if ( - node?.type.name !== 'dBlock' || - firstChild?.type.name !== 'heading' || - !firstChild.attrs.isCollapsed - ) { + if (!node || !headingNode || !headingNode.attrs.isCollapsed) { return false; } - const headingLevel = firstChild.attrs.level || 1; + const headingLevel = headingNode.attrs.level || 1; const tr = editor.state.tr; let changed = setHeadingCollapsed(tr, nodePos, false); let position = nodePos + node.nodeSize; @@ -424,11 +451,8 @@ export const expandHeadingContent = (editor: Editor, nodePos: number) => { break; } - const nextHeading = getFirstChild(nextNode); - if ( - nextNode.type.name === 'dBlock' && - nextHeading?.type.name === 'heading' - ) { + const nextHeading = getBlockHeading(editor.state.doc, nextNode); + if (nextHeading) { const nextLevel = nextHeading.attrs.level || 1; if (nextLevel <= headingLevel) { break; @@ -459,8 +483,8 @@ const buildExpandCollapsedHeadingAtSelectionTransaction = ( } const { node, position } = headingAtSelection; - const firstChild = getFirstChild(node); - const headingLevel = firstChild?.attrs.level || 1; + const blockHeading = getBlockHeading(state.doc, node); + const headingLevel = blockHeading?.attrs.level || 1; const insertPos = findEndOfCollapsedContent(state.doc, position); const tr = state.tr; @@ -473,11 +497,8 @@ const buildExpandCollapsedHeadingAtSelectionTransaction = ( break; } - const nextHeading = getFirstChild(nextNode); - if ( - nextNode.type.name === 'dBlock' && - nextHeading?.type.name === 'heading' - ) { + const nextHeading = getBlockHeading(state.doc, nextNode); + if (nextHeading) { const nextLevel = nextHeading.attrs.level || 1; if (nextLevel <= headingLevel) { break; @@ -505,42 +526,133 @@ const buildCollapsedHeadingEnterFollowUpTransaction = ( const tr = state.tr; const nodeAtInsert = state.doc.nodeAt(insertPos); const trailingPos = getEmptyTrailingDBlockPosition(state.doc); + // Caret goes inside the paragraph: +1 into it, +1 more through the dBlock + // wrapper when the schema has one. + const intoParagraph = docHasDBlock(state.doc) ? 2 : 1; const focusPos = - isEmptyDBlock(nodeAtInsert) && + isEmptyBlock(state.doc, nodeAtInsert) && insertPos + nodeAtInsert!.nodeSize >= state.doc.content.size - ? insertPos + 2 + ? insertPos + intoParagraph : trailingPos !== null && insertPos >= state.doc.content.size - ? trailingPos + 2 + ? trailingPos + intoParagraph : null; if (focusPos !== null) { tr.setSelection(TextSelection.create(tr.doc, focusPos)); } else { - const dBlockNode = state.schema.nodes.dBlock.create(null, [ - state.schema.nodes.paragraph.create(), - ]); - tr.insert(insertPos, dBlockNode); - tr.setSelection(TextSelection.create(tr.doc, insertPos + 2)); + const dBlockType = state.schema.nodes.dBlock; + const newBlock = dBlockType + ? dBlockType.create(null, [state.schema.nodes.paragraph.create()]) + : state.schema.nodes.paragraph.create(); + tr.insert(insertPos, newBlock); + tr.setSelection(TextSelection.create(tr.doc, insertPos + intoParagraph)); } return tr.scrollIntoView(); }; -const buildHiddenDecorationSet = (doc: ProseMirrorNode) => { +// Read-only-preview heading chrome for the FLAT schema. v1 renders these +// buttons from the dBlock node view; flat blocks have no node view, so the +// same controls (same classes, same icons) are supplied as a widget +// decoration instead. +// +// Rendered in every mode and gated by CSS on `[contenteditable='false']`, +// exactly like the v1 node view: switching owner -> view-only flips +// editability without dispatching a transaction, so a JS-side gate here +// would keep a stale decision. The widget sits at the heading's inline +// start, is `contenteditable=false`, and is excluded from copied slices. +const buildHeadingPreviewControls = ( + view: { state: EditorState; dispatch: (tr: Transaction) => void }, + getPos: () => number | undefined, + node: ProseMirrorNode, + onCopyHeadingLink?: (link: string) => void, +) => { + const controls = document.createElement('span'); + controls.className = 'd-block-preview-controls d-block-preview-controls-flat'; + controls.contentEditable = 'false'; + controls.dataset.previewControls = 'true'; + + const isCollapsed = Boolean(node.attrs.isCollapsed); + controls.classList.toggle('is-collapsed', isCollapsed); + + // The widget lives at the heading's inline start, so getPos() is one past + // the heading's own position — which is what both helpers below expect. + const resolveHeadingPos = () => { + const widgetPos = getPos(); + if (widgetPos == null) return null; + const $pos = view.state.doc.resolve(widgetPos); + return $pos.depth > 0 ? $pos.before() : widgetPos; + }; + + const makeButton = (extraClass: string, svg: string, label: string) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `d-block-button d-block-preview-button ${extraClass} color-text-default hover:color-bg-default-hover`; + button.setAttribute('aria-label', label); + button.innerHTML = svg; + // Keep the click from moving the selection into the read-only surface. + button.addEventListener('mousedown', (event) => event.preventDefault()); + return button; + }; + + const collapse = makeButton( + '', + CHEVRON_SVG, + isCollapsed ? 'Expand heading' : 'Collapse heading', + ); + collapse.dataset.test = 'preview-collapse-button'; + collapse.classList.toggle('is-collapsed', isCollapsed); + collapse.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const position = resolveHeadingPos(); + if (position == null) return; + const tr = buildToggleHeadingCollapseTransaction(view.state, position); + if (tr) dispatchCollapseToggle(view, view.state.selection, tr); + }); + controls.appendChild(collapse); + + if (onCopyHeadingLink) { + const copyLink = makeButton( + 'd-block-preview-copy-link', + LINK_SVG, + 'Copy heading link', + ); + copyLink.dataset.test = 'preview-copy-link-button'; + copyLink.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const position = resolveHeadingPos(); + if (position == null) return; + const link = getHeadingLinkSlug(node, position); + if (link) onCopyHeadingLink(link); + }); + controls.appendChild(copyLink); + } + + return controls; +}; + +const buildHiddenDecorationSet = ( + doc: ProseMirrorNode, + onCopyHeadingLink?: (link: string) => void, +) => { const decorations: Decoration[] = []; const headingStack: Array<{ level: number; isCollapsed: boolean }> = []; let collapsedHeadingDepth = 0; + const hasDBlock = docHasDBlock(doc); doc.forEach((node, position) => { - if (node.type.name !== 'dBlock') { + // v1 quirk preserved: non-dBlock top nodes (columns, pageBreak) are never + // hidden. In flat v2, every top-level block participates. + if (hasDBlock && node.type.name !== 'dBlock') { return; } - const firstChild = getFirstChild(node); - const isHeading = firstChild?.type.name === 'heading'; + const blockHeading = getBlockHeading(doc, node); - if (isHeading) { - const level = firstChild.attrs.level || 1; + if (blockHeading) { + const level = blockHeading.attrs.level || 1; while ( headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level @@ -560,10 +672,33 @@ const buildHiddenDecorationSet = (doc: ProseMirrorNode) => { ); } - if (isHeading) { - const isCollapsed = Boolean(firstChild.attrs.isCollapsed); + if (blockHeading) { + const isCollapsed = Boolean(blockHeading.attrs.isCollapsed); + + // Flat schema only: v1 gets these controls from its node view. + if (!hasDBlock) { + decorations.push( + Decoration.widget( + position + 1, + (view, getPos) => + buildHeadingPreviewControls( + view, + getPos, + blockHeading, + onCopyHeadingLink, + ), + { + side: -1, + // Re-render only when the rendered state actually changes. + key: `heading-preview-${isCollapsed ? 'collapsed' : 'open'}`, + ignoreSelection: true, + }, + ), + ); + } + headingStack.push({ - level: firstChild.attrs.level || 1, + level: blockHeading.attrs.level || 1, isCollapsed, }); if (isCollapsed) { @@ -577,27 +712,28 @@ const buildHiddenDecorationSet = (doc: ProseMirrorNode) => { const getDBlockCollapseStructureSignature = (doc: ProseMirrorNode) => { const parts: string[] = []; + const hasDBlock = docHasDBlock(doc); doc.forEach((node) => { - if (node.type.name !== 'dBlock') { - parts.push(node.type.name); - return; - } - - const firstChild = getFirstChild(node); - if (firstChild?.type.name === 'heading') { + const blockHeading = getBlockHeading(doc, node); + if (blockHeading) { parts.push( [ 'heading', - firstChild.attrs.id ?? '', - firstChild.attrs.level ?? '', - firstChild.attrs.isCollapsed ? '1' : '0', + blockHeading.attrs.id ?? '', + blockHeading.attrs.level ?? '', + blockHeading.attrs.isCollapsed ? '1' : '0', ].join(':'), ); return; } - parts.push(firstChild?.type.name ?? 'empty'); + if (hasDBlock && node.type.name === 'dBlock') { + parts.push(getFirstChild(node)?.type.name ?? 'empty'); + return; + } + + parts.push(node.type.name); }); return parts.join('|'); @@ -646,8 +782,9 @@ const transactionTouchesTopLevelStructure = (tr: Transaction) => { const buildCollapsePluginState = ( doc: ProseMirrorNode, + onCopyHeadingLink?: (link: string) => void, ): DBlockCollapsePluginState => ({ - decorations: buildHiddenDecorationSet(doc), + decorations: buildHiddenDecorationSet(doc, onCopyHeadingLink), structureSignature: getDBlockCollapseStructureSignature(doc), }); @@ -655,11 +792,33 @@ export const dBlockCollapsePluginKey = new PluginKey( 'dblock-collapse', ); -export const createDBlockCollapsePlugin = () => +// v2 registration point: v1 gets this plugin from createDBlockExtension, the +// flat schema has no dBlock extension, so the same (schema-aware) plugin is +// registered through this wrapper instead. +export interface FlatHeadingCollapseOptions { + // Enables the copy-link button in read-only preview; omitted means the + // host has nowhere to put the link, so the button is not rendered. + onCopyHeadingLink?: (link: string) => void; +} + +export const FlatHeadingCollapse = Extension.create({ + name: 'flatHeadingCollapse', + addOptions() { + return { onCopyHeadingLink: undefined }; + }, + addProseMirrorPlugins() { + return [createDBlockCollapsePlugin(this.options.onCopyHeadingLink)]; + }, +}); + +export const createDBlockCollapsePlugin = ( + onCopyHeadingLink?: (link: string) => void, +) => new Plugin({ key: dBlockCollapsePluginKey, state: { - init: (_config, state) => buildCollapsePluginState(state.doc), + init: (_config, state) => + buildCollapsePluginState(state.doc, onCopyHeadingLink), apply: (tr, previousState) => { if (!tr.docChanged) { return { @@ -676,7 +835,7 @@ export const createDBlockCollapsePlugin = () => transactionTouchesTopLevelStructure(tr) ) { return { - decorations: buildHiddenDecorationSet(tr.doc), + decorations: buildHiddenDecorationSet(tr.doc, onCopyHeadingLink), structureSignature, }; } diff --git a/package/extensions/d-block/dblock-drag-handle.test.tsx b/package/extensions/d-block/dblock-drag-handle.test.tsx new file mode 100644 index 00000000..4fde6eba --- /dev/null +++ b/package/extensions/d-block/dblock-drag-handle.test.tsx @@ -0,0 +1,244 @@ +import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; +import { + DBlockDragHandle, + rescueLeafBlockDragStart, + resolveTopLevelBlock, +} from './dblock-drag-handle'; +import { DEFAULT_DBLOCK_RUNTIME_STATE } from './dblock-runtime'; + +const makeFakeDragEvent = () => + ({ + dataTransfer: { + clearData: () => {}, + setDragImage: () => {}, + }, + }) as unknown as DragEvent; + +beforeAll(() => { + // floating-ui in jsdom + if (!window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +describe('resolveTopLevelBlock', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('resolves a top-level dBlock', () => { + editor = makeEditor('

hello

'); + const resolved = resolveTopLevelBlock(editor, 0); + expect(resolved?.node.type.name).toBe('dBlock'); + expect(resolved?.pos).toBe(0); + }); + + it('resolves non-dBlock top-level nodes so their controls act on the whole block', () => { + // The DragHandle plugin targets the depth-1 node, so inside a columns + // layout (or on a pageBreak) the hovered node is never a dBlock. These + // must resolve — a null here renders live-looking Plus/menu buttons + // whose every action silently no-ops. + editor = makeEditor('

hello

'); + editor.commands.insertContentAt(editor.state.doc.content.size, { + type: 'pageBreak', + }); + let pageBreakPos = -1; + editor.state.doc.forEach((node, pos) => { + if (node.type.name === 'pageBreak') pageBreakPos = pos; + }); + expect(pageBreakPos).toBeGreaterThan(-1); + + const resolved = resolveTopLevelBlock(editor, pageBreakPos); + expect(resolved?.node.type.name).toBe('pageBreak'); + }); + + it('returns null for nested and out-of-range positions', () => { + editor = makeEditor('

hello

'); + // depth > 0: inside the paragraph + expect(resolveTopLevelBlock(editor, 2)).toBeNull(); + // beyond the doc (stale pos after a deletion shrank the doc) + expect( + resolveTopLevelBlock(editor, editor.state.doc.content.size + 5), + ).toBeNull(); + }); +}); + +describe('rescueLeafBlockDragStart', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('declines blocks that have children (the upstream path works for those)', () => { + editor = makeEditor('

hello

'); + const block = resolveTopLevelBlock(editor, 0)!; + expect(block.node.childCount).toBeGreaterThan(0); + + const handled = rescueLeafBlockDragStart( + editor, + block.pos, + block.node, + makeFakeDragEvent(), + ); + + expect(handled).toBe(false); + expect(editor.view.dragging).toBeFalsy(); + }); + + it('arms view.dragging for a childless top-level block', () => { + // pageBreak is the headless schema's childless leaf — the same class as + // captionless media and embeds, where the upstream handler's + // nodeAt(posAtDOM(...)) resolution returns null and the drag dies. + editor = makeEditor('

hello

'); + document.body.appendChild(editor.view.dom); + editor.commands.insertContentAt(editor.state.doc.content.size, { + type: 'pageBreak', + }); + let pageBreakPos = -1; + let pageBreakNode: Editor['state']['doc'] | null = null; + editor.state.doc.forEach((node, pos) => { + if (node.type.name === 'pageBreak') { + pageBreakPos = pos; + pageBreakNode = node as never; + } + }); + expect(pageBreakPos).toBeGreaterThan(-1); + + const handled = rescueLeafBlockDragStart( + editor, + pageBreakPos, + pageBreakNode!, + makeFakeDragEvent(), + ); + + expect(handled).toBe(true); + expect(editor.view.dragging).toBeTruthy(); + expect(editor.view.dragging!.move).toBe(true); + const sliceTypes: string[] = []; + editor.view.dragging!.slice.content.forEach((node) => + sliceTypes.push(node.type.name), + ); + expect(sliceTypes).toEqual(['pageBreak']); + // the selection now covers the dragged block, like the upstream handler + expect(editor.state.selection.from).toBe(pageBreakPos); + }); +}); + +describe('DBlockDragHandle', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('renders the control cluster outside the editable DOM', () => { + editor = makeEditor('

hello

'); + document.body.appendChild(editor.view.dom); + const { unmount } = render( + , + ); + const cluster = screen.getByLabelText('block-controls'); + expect(cluster).toBeTruthy(); + expect(editor.view.dom.contains(cluster)).toBe(false); + + // jsdom limitation (verified to be a real DragHandle behavior, not a + // missing-API gap): @tiptap/extension-drag-handle-react's ProseMirror + // plugin physically relocates its rendered element into a `wrapper` div + // appended next to `editor.view.dom`, outside React's root, without + // informing React. React's own unmount bookkeeping still expects the + // node to be a direct child of the root container, so its cleanup call + // throws `NotFoundError: The node to be removed is not a child of this + // node.`. Testing-library's automatic global `afterEach(cleanup)` does + // not catch that error, which fails the test even though every assertion + // above already passed. Unmounting here ourselves, inside a try/catch, + // moves that same (already-verified-harmless) exception into code that + // handles it, so global cleanup becomes a no-op. + try { + unmount(); + } catch { + // expected — see comment above. + } + }); + + it('does not tear down and re-register the drag handle plugin on re-render', () => { + // Regression guard: `computePositionConfig`/`onNodeChange` must keep a + // stable identity across renders. DragHandle's internal effect depends + // on both by reference and its cleanup calls `editor.unregisterPlugin` + // — a new identity every render would unregister/re-register the + // plugin every render, which resets the handle to hidden each time and + // it can never stay visible. + editor = makeEditor('

hello

'); + document.body.appendChild(editor.view.dom); + const unregisterSpy = vi.spyOn(editor, 'unregisterPlugin'); + const { rerender, unmount } = render( + , + ); + unregisterSpy.mockClear(); + + rerender( + , + ); + + expect(unregisterSpy).not.toHaveBeenCalled(); + + // Same jsdom/React DOM-ownership limitation as the first test — see its + // comment for the full explanation. + try { + unmount(); + } catch { + // expected + } + }); + + it('renders nothing in presentation preview', () => { + editor = makeEditor('

hello

'); + const { container } = render( + , + ); + expect(container.querySelector('[aria-label="block-controls"]')).toBeNull(); + }); + + it('keeps the collapse slot mounted (invisible) so cluster width is constant', () => { + // The DragHandle plugin computes `left` from the cluster's width at + // reposition time. If the chevron mounted only for headings, the cluster + // would widen AFTER positioning and overlap the block's text + // (paragraph → heading hover). The slot must reserve its width always. + editor = makeEditor('

hello

'); + document.body.appendChild(editor.view.dom); + const { unmount } = render( + , + ); + const collapse = document.querySelector('[data-test="collapse-button"]'); + expect(collapse).toBeTruthy(); + expect(collapse!.className).toMatch(/invisible/); + expect(collapse!.className).toMatch(/pointer-events-none/); + + // Same manual unmount as the first test — see its comment about the + // DragHandle plugin relocating the element outside React's root. + try { + unmount(); + } catch { + // expected + } + }); +}); diff --git a/package/extensions/d-block/dblock-drag-handle.tsx b/package/extensions/d-block/dblock-drag-handle.tsx new file mode 100644 index 00000000..f9d5448b --- /dev/null +++ b/package/extensions/d-block/dblock-drag-handle.tsx @@ -0,0 +1,324 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { DragHandle } from '@tiptap/extension-drag-handle-react'; +import { NodeRangeSelection } from '@tiptap/extension-node-range'; +import { Editor } from '@tiptap/react'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { useMediaQuery } from 'usehooks-ts'; +import { cn } from '@fileverse/ui'; +import useContentItemActions, { + ResolvedContentItem, +} from '../../hooks/use-content-item-actions'; +import { getDBlockRenderMeta, toggleHeadingCollapse } from './dblock-collapse'; +import { wrapBlockNode } from '../../utils/block-schema'; +import type { DBlockRuntimeState } from './dblock-runtime'; +import { DBlockMenu } from './components/menu'; +import { CollapseButton, GripButton, PlusButton } from './components/buttons'; +import { + AddBlockTooltip, + CollapseTooltip, + DragTooltip, +} from './components/tooltips'; + +interface HoveredBlock { + node: ProseMirrorNode; + pos: number; +} + +const CLUSTER_HEIGHT = 24; + +const getFirstLineOffset = (editor: Editor, pos: number): number => { + try { + // v1: nodeDOM(pos) → div[data-type=d-block] → div[data-node-view-content] + // → the actual block element (p/hN/ul/...). In the flat schema there is no + // wrapper node view, so nodeDOM(pos) IS that block element. Either way we + // want the element whose computed line-height sets the first line's center. + const domNode = editor.view.nodeDOM(pos) as HTMLElement | null; + const blockEl = + (domNode?.querySelector('[data-node-view-content] > *') ?? + domNode) ?? null; + const lineHeight = blockEl + ? parseFloat(getComputedStyle(blockEl).lineHeight) + : NaN; + return Number.isFinite(lineHeight) && lineHeight > CLUSTER_HEIGHT + ? Math.round((lineHeight - CLUSTER_HEIGHT) / 2) + : 0; + } catch { + return 0; + } +}; + +// The DragHandle plugin always targets the depth-1 (top-level) node, so +// inside a columns layout the hovered node is the `columns` node itself — +// never the nested dBlock. Accept ANY top-level block (dBlock, columns, +// pageBreak): the menu actions and the plus button are NodeSelection-based +// and operate correctly on the whole block, which beats rendering controls +// that silently no-op. Nested/stale positions (depth > 0 after an edit +// shifted the doc under a stored pos) resolve to null. +export const resolveTopLevelBlock = ( + editor: Editor, + pos: number, +): ResolvedContentItem | null => { + const { doc } = editor.state; + if (pos < 0 || pos > doc.content.size) return null; + const $pos = doc.resolve(pos); + if ($pos.depth !== 0) return null; + const node = $pos.nodeAfter; + if (!node) return null; + return { editor, node, pos }; +}; + +// Rescue path for dragging CHILDLESS top-level blocks (captionless media, +// iframe/tweet embeds, page breaks, empty paragraphs). The upstream +// DragHandle re-resolves its drag target from the drag event's coordinates: +// `posAtDOM(blockDom, 0)` lands INSIDE the block, and for a childless node +// `doc.nodeAt(insidePos)` is null, so `getDragHandleRanges` returns [] and +// `dragHandler` bails without ever setting `view.dragging` — the drop then +// silently does nothing ("cannot move the image", TEC-2679). A caption (or +// any child) makes `nodeAt` non-null, which is why captioned images drag +// fine; v1 was immune because the dBlock wrapper always supplied a child. +// We know the hovered block reliably from onNodeChange, so for exactly the +// childless case we build the drag state ourselves (same shape as the +// upstream dragHandler) and stop the event from reaching the broken path. +export const rescueLeafBlockDragStart = ( + editor: Editor, + pos: number, + node: ProseMirrorNode, + event: DragEvent, +): boolean => { + if (node.childCount > 0) return false; + if (!event.dataTransfer) return false; + + const { view } = editor; + const selection = NodeRangeSelection.create( + view.state.doc, + pos, + pos + node.nodeSize, + ); + const slice = selection.content(); + + const dom = view.nodeDOM(pos); + if (dom instanceof HTMLElement) { + const ghost = document.createElement('div'); + ghost.append(dom.cloneNode(true)); + ghost.style.position = 'absolute'; + ghost.style.top = '-10000px'; + document.body.append(ghost); + event.dataTransfer.clearData(); + event.dataTransfer.setDragImage(ghost, 0, 0); + const cleanup = () => ghost.remove(); + document.addEventListener('drop', cleanup, { once: true }); + document.addEventListener('dragend', cleanup, { once: true }); + } + + view.dragging = { slice, move: true }; + view.dispatch(view.state.tr.setSelection(selection)); + + // Mirror the upstream handler: the floating handle sits under the cursor + // at drag start and would swallow the first dragover events. Upstream's + // own dragend listener restores pointer-events (we only stop dragSTART + // propagation, never dragend). + setTimeout(() => { + const handleEl = document.querySelector('.drag-handle'); + if (handleEl) handleEl.style.pointerEvents = 'none'; + }, 0); + + return true; +}; + +// Stable identity: DragHandle's internal useEffect depends on +// `computePositionConfig` (and `onNodeChange`) by reference. A new object +// literal on every render would tear down and re-register the ProseMirror +// plugin on every render, which resets the handle to `visibility: hidden` +// each time (see `hideHandle()` in the plugin's `view()` factory) — the +// handle would never stay visible. +const COMPUTE_POSITION_CONFIG = { placement: 'left-start' as const }; + +export const DBlockDragHandle = ({ + editor, + runtimeState, +}: { + editor: Editor; + runtimeState: DBlockRuntimeState; +}) => { + const [hovered, setHovered] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const clusterRef = useRef(null); + const isBelowLargeScreen = useMediaQuery('(max-width: 1024px)'); + + const resolveBlock = useCallback( + (): ResolvedContentItem | null => + hovered ? resolveTopLevelBlock(editor, hovered.pos) : null, + [editor, hovered], + ); + const actions = useContentItemActions(editor, resolveBlock); + + // Stable identity for the same reason as COMPUTE_POSITION_CONFIG above — + // must not close over `hovered` (that would change identity every time + // `hovered` changes, which is exactly when DragHandle calls it). + // The plugin also calls this with `node: null` on keydown/mouseleave + // (it resets its internal currentNode); we deliberately keep the last + // hovered block in state rather than clearing it, so an open menu + // doesn't lose its target block. + const handleNodeChange = useCallback( + ({ + node, + editor: dragHandleEditor, + pos, + }: { + node: ProseMirrorNode | null; + editor: Editor; + pos: number; + }) => { + if (node) { + // Applied imperatively, NOT via React state/effect: the plugin + // writes the handle's left/top in this same task (its mousemove + // rAF + computePosition microtasks), while a React commit lands in + // a LATER task — the browser can paint between the two, flashing + // the cluster at the block top before the offset corrects it. + // Writing the transform here keeps both style writes inside one + // task, so they always paint together. + const offset = getFirstLineOffset(dragHandleEditor, pos); + if (clusterRef.current) { + clusterRef.current.style.transform = offset + ? `translateY(${offset}px)` + : ''; + } + } + setHovered((prev) => { + if (!node) return prev; + if (prev && prev.pos === pos && prev.node === node) return prev; + return { node, pos }; + }); + }, + [], + ); + + useEffect(() => { + setMenuOpen(false); + }, [hovered?.pos]); + + // Native (not React-synthetic) listener: the DragHandle plugin physically + // relocates our rendered element outside the React root, so synthetic + // event delegation cannot be relied on here. Bubbling from the grip + // reaches the cluster div BEFORE the plugin's own listener on its parent + // element, which lets us take over exactly the childless-block case — + // see rescueLeafBlockDragStart above. + const resolveBlockRef = useRef(resolveBlock); + resolveBlockRef.current = resolveBlock; + useEffect(() => { + const cluster = clusterRef.current; + if (!cluster) return; + + const onDragStart = (event: DragEvent) => { + const current = resolveBlockRef.current(); + if (!current) return; + if ( + rescueLeafBlockDragStart( + current.editor, + current.pos, + current.node, + event, + ) + ) { + event.stopPropagation(); + } + }; + + cluster.addEventListener('dragstart', onDragStart); + return () => cluster.removeEventListener('dragstart', onDragStart); + }, []); + + if (runtimeState.isPresentationMode && runtimeState.isPreviewMode) { + return null; + } + + const meta = hovered ? getDBlockRenderMeta(hovered.node, hovered.pos) : null; + + const shouldShowEditingControls = + !runtimeState.isPreviewMode && !isBelowLargeScreen; + // Heading-only buttons stay MOUNTED (reserving their width) and toggle + // `invisible` instead of unmounting. The DragHandle plugin computes the + // handle's `left` from the cluster's width at reposition time; a cluster + // that widens after positioning (chevron appearing for a heading) grows + // rightward over the block's text. Constant width keeps `left` stable. + const isHeadingHovered = Boolean(meta?.isHeading); + + const handleAddBlock = (event: React.MouseEvent) => { + const current = resolveBlock(); + if (!current) return; + const insertPos = event.altKey + ? current.pos + : current.pos + current.node.nodeSize; + current.editor.commands.insertContentAt( + insertPos, + // v1 needs the dBlock wrapper; the flat schema rejects it (and silently + // inserts nothing), so the shape comes from the live schema. + wrapBlockNode(current.editor.schema, { type: 'paragraph' }), + ); + }; + + const handleDragClick = (event: React.MouseEvent) => { + if (event.altKey) { + actions.deleteNode(); + return; + } + setMenuOpen((open) => !open); + }; + + const handleToggleCollapse = () => { + const current = resolveBlock(); + if (current) toggleHeadingCollapse(current.editor, current.pos); + }; + + const buttonClassName = cn( + 'd-block-button color-text-default hover:color-bg-default-hover aspect-square h-5 w-5 min-w-0 shrink-0', + ); + + return ( + +
+ {shouldShowEditingControls ? ( + <> + + + + + + + } + actions={actions} + /> + + ) : null} + + + +
+
+ ); +}; diff --git a/package/extensions/d-block/dblock-media-plugin.test.ts b/package/extensions/d-block/dblock-media-plugin.test.ts new file mode 100644 index 00000000..9152c138 --- /dev/null +++ b/package/extensions/d-block/dblock-media-plugin.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; + +const YT = 'https://youtu.be/abc12345'; +const LINK_PARAGRAPH = `

${YT}

`; + +const hasIframe = (editor: Editor) => { + let found = false; + editor.state.doc.descendants((node) => { + if (node.type.name === 'iframe') found = true; + }); + return found; +}; + +describe('dblock media conversion plugin', () => { + let editor: Editor; + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + editor?.destroy(); + vi.useRealTimers(); + }); + + it('does NOT convert a link paragraph while the caret is inside it', () => { + editor = makeEditor(LINK_PARAGRAPH); + // caret inside the link text — the user is still working on this line + editor.commands.setTextSelection(5); + + vi.advanceTimersByTime(1500); + + expect(hasIframe(editor)).toBe(false); + // and the caret must not have been yanked + expect(editor.state.selection.from).toBe(5); + }); + + it('converts after the caret leaves, even with no further doc edits', () => { + editor = makeEditor(`${LINK_PARAGRAPH}

elsewhere

`); + // caret inside the link text at the first scan → conversion deferred + editor.commands.setTextSelection(5); + vi.advanceTimersByTime(1000); + expect(hasIframe(editor)).toBe(false); + + // move the caret away WITHOUT changing the doc — selection-only + // transactions never set shouldScan, so only the deferred-candidate + // re-arm can pick this up + editor.commands.setTextSelection(editor.state.doc.content.size - 3); + vi.advanceTimersByTime(1000); + + expect(hasIframe(editor)).toBe(true); + }); + + it('converts once the caret is elsewhere', () => { + editor = makeEditor(LINK_PARAGRAPH); + // add a second block and put the caret there; the insert re-arms the + // conversion timer + editor + .chain() + .insertContentAt(editor.state.doc.content.size, { + type: 'dBlock', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], + }) + .run(); + editor.commands.setTextSelection(editor.state.doc.content.size - 2); + + vi.advanceTimersByTime(1500); + + expect(hasIframe(editor)).toBe(true); + }); +}); diff --git a/package/extensions/d-block/dblock-media-plugin.ts b/package/extensions/d-block/dblock-media-plugin.ts index 8a4bb47b..9f3a3da8 100644 --- a/package/extensions/d-block/dblock-media-plugin.ts +++ b/package/extensions/d-block/dblock-media-plugin.ts @@ -1,3 +1,4 @@ +import { Extension } from '@tiptap/core'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import type { DBlockRuntimeState } from './dblock-runtime'; @@ -48,9 +49,8 @@ const getTextLinkHref = (node: ProseMirrorNode): string | null => { return href; }; -const getSecondTextChild = (node: ProseMirrorNode) => { - const paragraph = node.firstChild; - const secondChild = paragraph?.childCount ? paragraph.maybeChild(1) : null; +const getSecondTextChild = (paragraph: ProseMirrorNode) => { + const secondChild = paragraph.childCount ? paragraph.maybeChild(1) : null; return secondChild?.isText ? (secondChild.text ?? null) : null; }; @@ -58,8 +58,13 @@ export const getDBlockMediaCandidate = ( node: ProseMirrorNode, position: number, ): MediaCandidate | null => { - const firstChild = node.firstChild; - if (node.type.name !== 'dBlock' || firstChild?.type.name !== 'paragraph') { + // v1 hands us the dBlock wrapper whose first child is the paragraph; in the + // flat schema the top-level node IS the paragraph. The replaced range + // follows the same split: inside the wrapper for v1 (so the media lands in + // the existing dBlock), the whole block for v2. + const isWrapper = node.type.name === 'dBlock'; + const paragraph = isWrapper ? node.firstChild : node; + if (paragraph?.type.name !== 'paragraph') { return null; } @@ -68,13 +73,13 @@ export const getDBlockMediaCandidate = ( return null; } - const urlSrc = getTextLinkHref(firstChild); + const urlSrc = getTextLinkHref(paragraph); if (!urlSrc) { return null; } - const from = position + 1; - const to = position + node.nodeSize - 1; + const from = isWrapper ? position + 1 : position; + const to = isWrapper ? position + node.nodeSize - 1 : position + node.nodeSize; if (/\.(jpeg|jpg|gif|png)$/i.test(urlSrc)) { return { @@ -88,7 +93,7 @@ export const getDBlockMediaCandidate = ( if (textContent.includes(' b.from - a.from) .forEach((candidate) => { + // Never convert the block the user is currently in: replacing + // the paragraph under their caret yanks the selection to the + // replacement boundary mid-typing (TEC-2539 cursor jumps). The + // candidate is retried below, once the caret has left. + if ( + selection.from <= candidate.to + 1 && + selection.to >= candidate.from - 1 + ) { + deferred = true; + return; + } const node = candidate.type === 'img' ? view.state.schema.nodes.resizableMedia?.create({ @@ -215,6 +233,14 @@ export const createDBlockMediaConversionPlugin = ( tr.setMeta(DBLOCK_MEDIA_CONVERSION_META, true); view.dispatch(tr); } + + // A deferred candidate has no future trigger of its own: moving the + // caret away is a selection-only transaction, which never sets + // shouldScan. Keep retrying until the caret leaves the candidate + // (or the candidate is gone). + if (deferred) { + timeoutId = window.setTimeout(runConversion, 1000); + } }; return { @@ -232,3 +258,20 @@ export const createDBlockMediaConversionPlugin = ( }; }, }); + +// v2 registration point. v1 gets this plugin from createDBlockExtension; the +// flat schema has no dBlock extension, so the same (schema-aware) plugin is +// registered through this wrapper instead. +export interface FlatMediaConversionOptions { + getRuntimeState?: () => DBlockRuntimeState; +} + +export const FlatMediaConversion = Extension.create({ + name: 'flatMediaConversion', + addOptions() { + return { getRuntimeState: undefined }; + }, + addProseMirrorPlugins() { + return [createDBlockMediaConversionPlugin(this.options.getRuntimeState)]; + }, +}); diff --git a/package/extensions/d-block/dblock-node-view.test.ts b/package/extensions/d-block/dblock-node-view.test.ts new file mode 100644 index 00000000..04b5f320 --- /dev/null +++ b/package/extensions/d-block/dblock-node-view.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Editor } from '@tiptap/react'; +import type { AnyExtension } from '@tiptap/core'; +import { makeEditor } from '../../utils/make-editor'; +import { getHeadlessExtensions } from '../../hooks/use-headless-editor'; +import { createDBlockExtension } from './dblock'; +import { + DEFAULT_DBLOCK_RUNTIME_STATE, + type DBlockRuntimeState, +} from './dblock-runtime'; + +// makeEditor's headless stack builds dBlock with default options; preview +// chrome needs a runtime state + copy-link callback, so swap in a configured +// dBlock extension. +const makePreviewEditor = ( + content: string, + runtime: Partial, + onCopyHeadingLink?: (link: string) => void, +) => { + const state = { ...DEFAULT_DBLOCK_RUNTIME_STATE, ...runtime }; + const extensions = getHeadlessExtensions().map((extension) => + extension.name === 'dBlock' + ? (createDBlockExtension({ + getRuntimeState: () => state, + onCopyHeadingLink, + }) as unknown as AnyExtension) + : extension, + ); + const editor = new Editor({ extensions }); + editor.commands.setContent(content); + return editor; +}; + +describe('DBlockNodeView (simplified chrome-less wrapper)', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('renders no gutter and no per-block padding classes', () => { + editor = makeEditor('

hello

'); + const block = editor.view.dom.querySelector('[data-type="d-block"]')!; + expect(block).toBeTruthy(); + expect(block.querySelector('[data-dblock-gutter]')).toBeNull(); + expect(block.className).not.toMatch( + /px-4|pl-2|pr-8|pr-\[80px\]|pl-\[8px\]/, + ); + // contentDOM is the direct (and only) element child + expect(block.children.length).toBe(1); + expect( + (block.firstElementChild as HTMLElement).dataset.nodeViewContent, + ).toBe('true'); + }); + + it('keeps the is-table marker class for table blocks', () => { + editor = makeEditor( + '

x

', + ); + const block = editor.view.dom.querySelector('[data-type="d-block"]')!; + expect(block.className).toMatch(/is-table/); + }); +}); + +describe('DBlockNodeView preview heading chrome', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + const HEADING_DOC = '

Section

body

'; + const controlsIn = (root: Element | Document) => + root.querySelectorAll('[data-preview-controls]'); + + it('renders collapse + copy-link controls on heading blocks in read-only preview', () => { + editor = makePreviewEditor( + HEADING_DOC, + { isPreviewMode: true }, + () => {}, + ); + editor.setEditable(false); + + const controls = controlsIn(editor.view.dom); + // heading block only — not the paragraph block + expect(controls.length).toBe(1); + const heading = editor.view.dom.querySelector('h2'); + expect(controls[0].closest('[data-type="d-block"]')!.contains(heading)).toBe( + true, + ); + expect( + controls[0].querySelector('[data-test="preview-collapse-button"]'), + ).toBeTruthy(); + expect( + controls[0].querySelector('[data-test="preview-copy-link-button"]'), + ).toBeTruthy(); + }); + + it('keeps the chrome mounted in editable mode (CSS gates visibility, not JS)', () => { + // Mode switches (owner → view-only) flip contenteditable WITHOUT any + // transaction, so vanilla node views never re-run syncDOM. The DOM must + // therefore carry the controls in every mode, with + // `.ProseMirror[contenteditable='false']` scoping when they can show — + // a JS gate on runtime.isPreviewMode froze the initial mode's decision. + editor = makePreviewEditor(HEADING_DOC, {}); + expect(controlsIn(editor.view.dom).length).toBe(1); + }); + + it('renders no chrome in presentation mode or split view (separate editor instances)', () => { + editor = makePreviewEditor(HEADING_DOC, { + isPreviewMode: true, + isPresentationMode: true, + }); + expect(controlsIn(editor.view.dom).length).toBe(0); + editor.destroy(); + + editor = makePreviewEditor(HEADING_DOC, { + isPreviewMode: true, + isSplitView: true, + }); + expect(controlsIn(editor.view.dom).length).toBe(0); + }); + + it('omits the copy-link button when no callback is wired', () => { + editor = makePreviewEditor(HEADING_DOC, { isPreviewMode: true }); + expect( + editor.view.dom.querySelector('[data-test="preview-copy-link-button"]'), + ).toBeNull(); + expect( + editor.view.dom.querySelector('[data-test="preview-collapse-button"]'), + ).toBeTruthy(); + }); + + it('toggles heading collapse from a NON-EDITABLE view and reflects it on the controls', () => { + editor = makePreviewEditor(HEADING_DOC, { isPreviewMode: true }); + editor.setEditable(false); + + const button = editor.view.dom.querySelector( + '[data-test="preview-collapse-button"]', + )!; + button.click(); + + let heading: { attrs: Record } | null = null; + editor.state.doc.descendants((node) => { + if (node.type.name === 'heading') heading = node; + }); + expect(heading!.attrs.isCollapsed).toBe(true); + expect( + editor.view.dom + .querySelector('[data-preview-controls]')! + .className.includes('is-collapsed'), + ).toBe(true); + + // and back + editor.view.dom + .querySelector( + '[data-test="preview-collapse-button"]', + )! + .click(); + heading = null; + editor.state.doc.descendants((node) => { + if (node.type.name === 'heading') heading = node; + }); + expect(heading!.attrs.isCollapsed).toBe(false); + }); + + it('invokes onCopyHeadingLink with the heading slug', () => { + const onCopyHeadingLink = vi.fn(); + editor = makePreviewEditor( + HEADING_DOC, + { isPreviewMode: true }, + onCopyHeadingLink, + ); + editor.setEditable(false); + + editor.view.dom + .querySelector( + '[data-test="preview-copy-link-button"]', + )! + .click(); + + expect(onCopyHeadingLink).toHaveBeenCalledTimes(1); + expect(typeof onCopyHeadingLink.mock.calls[0][0]).toBe('string'); + expect(onCopyHeadingLink.mock.calls[0][0].length).toBeGreaterThan(0); + }); +}); diff --git a/package/extensions/d-block/dblock-node-view.ts b/package/extensions/d-block/dblock-node-view.ts index 1142fa05..abf94c89 100644 --- a/package/extensions/d-block/dblock-node-view.ts +++ b/package/extensions/d-block/dblock-node-view.ts @@ -1,15 +1,16 @@ import type { Editor } from '@tiptap/core'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import type { Decoration, NodeView, ViewMutationRecord } from '@tiptap/pm/view'; -import { v4 as uuidv4 } from 'uuid'; import { DBLOCK_HIDDEN_CLASS, getDBlockRenderMeta, - getHeadingAlignmentClass, + getHeadingLinkSlug, + toggleHeadingCollapse, } from './dblock-collapse'; +import type { DBlockRenderMeta } from './dblock-collapse'; +import { CHEVRON_SVG, LINK_SVG } from './heading-chrome-icons'; import type { DBlockRuntimeState } from './dblock-runtime'; import { getDBlockRuntimeState } from './dblock-runtime'; -import { registerDBlockView } from './dblock-view-registry'; interface DBlockNodeViewOptions { editor: Editor; @@ -39,36 +40,22 @@ const setAttributes = ( attributes: Record, ) => { Object.entries(attributes).forEach(([key, value]) => { - if (key === 'class' || value === undefined || value === null) { - return; - } - + if (key === 'class' || value === undefined || value === null) return; element.setAttribute(key, String(value)); }); }; export class DBlockNodeView implements NodeView { node: ProseMirrorNode; - editor: Editor; - getPos: () => number; - dom: HTMLDivElement; - - gutterElement: HTMLElement; - - contentElement: HTMLDivElement; - contentDOM: HTMLDivElement; - - private id: string; - private decorations: readonly Decoration[]; - private getRuntimeState?: () => DBlockRuntimeState; - - private unregister: () => void; + private onCopyHeadingLink?: (link: string) => void; + private previewControls: HTMLDivElement | null = null; + private collapseButton: HTMLButtonElement | null = null; constructor({ editor, @@ -84,50 +71,21 @@ export class DBlockNodeView implements NodeView { this.getPos = getPos; this.decorations = decorations; this.getRuntimeState = getRuntimeState; - this.id = uuidv4(); + this.onCopyHeadingLink = onCopyHeadingLink; this.dom = document.createElement('div'); this.dom.dataset.type = 'd-block'; - this.dom.dataset.dblockNodeView = 'true'; - this.dom.dataset.nodeViewWrapper = 'true'; - this.dom.setAttribute('data-dblock-id', this.id); setAttributes(this.dom, HTMLAttributes); - this.gutterElement = document.createElement('section'); - this.gutterElement.className = - 'flex h-0 lg:h-6 shrink-0 items-center gap-[2px] min-w-5 lg:min-w-16 justify-end'; - this.gutterElement.setAttribute('aria-label', 'left-menu'); - this.gutterElement.setAttribute('contenteditable', 'false'); - this.gutterElement.dataset.dblockGutter = 'true'; - - this.contentElement = document.createElement('div'); - this.contentElement.dataset.dblockContentShell = 'true'; - this.contentDOM = document.createElement('div'); this.contentDOM.dataset.nodeViewContent = 'true'; - - this.contentElement.appendChild(this.contentDOM); - this.dom.append(this.gutterElement, this.contentElement); - - this.unregister = registerDBlockView({ - id: this.id, - dom: this.dom, - gutterElement: this.gutterElement, - contentElement: this.contentElement, - getPos: this.getPos, - getNode: () => this.node, - refresh: () => this.syncDOM(), - onCopyHeadingLink, - }); + this.dom.appendChild(this.contentDOM); this.syncDOM(); } update(node: ProseMirrorNode, decorations: readonly Decoration[]) { - if (node.type !== this.node.type) { - return false; - } - + if (node.type !== this.node.type) return false; this.node = node; this.decorations = decorations; this.syncDOM(); @@ -135,41 +93,10 @@ export class DBlockNodeView implements NodeView { } ignoreMutation(mutation: ViewMutationRecord) { - if (mutation.type === 'selection') { - return false; - } - + if (mutation.type === 'selection') return false; return !this.contentDOM.contains(mutation.target); } - stopEvent(event: Event) { - const target = event.target; - if (!(target instanceof Element)) { - return false; - } - - const gutterTarget = target.closest('[data-dblock-gutter]'); - if (!gutterTarget) { - return false; - } - - const isDragHandle = Boolean(target.closest('[data-drag-handle]')); - if ( - isDragHandle && - (event.type.startsWith('drag') || - event.type === 'mousedown' || - event.type === 'pointerdown') - ) { - return false; - } - - return true; - } - - destroy() { - this.unregister(); - } - private syncDOM() { const runtime = getDBlockRuntimeState(this.getRuntimeState); const isPresentationPreview = @@ -179,45 +106,116 @@ export class DBlockNodeView implements NodeView { const shouldHide = !isPresentationPreview && hasHiddenDecoration(this.decorations); - this.dom.className = isPresentationPreview - ? joinClasses( - 'flex px-4 md:px-[80px] gap-2 group w-full relative justify-center items-start', - meta.isTable && 'pointer-events-auto', - ) - : joinClasses( - 'flex px-4 pl-2 md:pr-8 lg:pr-[80px] lg:pl-[8px] gap-2 group w-full relative justify-center items-center', - meta.isTable && 'pointer-events-auto', - shouldHide && DBLOCK_HIDDEN_CLASS, - ); - - this.contentElement.className = isPresentationPreview - ? joinClasses( - 'node-view-content w-full relative', - meta.isTable && 'is-table', - this.node.attrs?.isCorrupted && 'invalid-content', - runtime.isPreviewMode && 'pointer-events-none', - ) - : joinClasses( - 'node-view-content w-full relative self-center group/collision', - meta.isTable && 'is-table max-w-full lg:max-w-[90%]', - this.node.attrs?.isCorrupted && 'invalid-content', - meta.isHeading && - runtime.isPreviewMode && - 'flex flex-row-reverse gap-2 items-center', - meta.isHeading && - runtime.isPreviewMode && - getHeadingAlignmentClass(meta.headingAlignment), - ); - - this.gutterElement.hidden = isPresentationPreview; - - if (position !== null) { - this.dom.dataset.dblockPos = String(position); - } else { - delete this.dom.dataset.dblockPos; + this.dom.className = joinClasses( + 'd-block w-full relative', + meta.isTable && 'is-table pointer-events-auto', + this.node.attrs?.isCorrupted && 'invalid-content', + isPresentationPreview && 'pointer-events-none', + shouldHide && DBLOCK_HIDDEN_CLASS, + ); + + this.syncPreviewControls(runtime, meta); + } + + // Read-only preview never shows the floating DragHandle cluster — the + // upstream plugin hard-hides its element whenever `!editor.isEditable` + // (`showHandle()` bails into `hideHandle()`). Heading affordances viewers + // need (expand a collapsed heading, copy its link) therefore live INSIDE + // the node view, shown via CSS :hover — no floating positioning involved. + // + // The controls are rendered for headings in EVERY mode and gated by CSS + // on `.ProseMirror[contenteditable='false']` (the exact condition under + // which the cluster cannot appear). Gating on runtime.isPreviewMode here + // would freeze the initial mode: switching owner → view-only flips + // editable/runtime state but dispatches no transaction, so vanilla node + // views never re-run syncDOM and would keep the stale decision. + // Presentation and split-view are separate editor instances whose runtime + // flags are fixed at construction, so excluding them here is safe. + private shouldShowPreviewControls( + runtime: DBlockRuntimeState, + meta: DBlockRenderMeta, + ) { + return ( + !runtime.isPresentationMode && !runtime.isSplitView && meta.isHeading + ); + } + + private syncPreviewControls( + runtime: DBlockRuntimeState, + meta: DBlockRenderMeta, + ) { + if (!this.shouldShowPreviewControls(runtime, meta)) { + if (this.previewControls) { + this.previewControls.remove(); + this.previewControls = null; + this.collapseButton = null; + } + return; + } + + if (!this.previewControls) { + this.previewControls = this.buildPreviewControls(); + this.dom.appendChild(this.previewControls); + } + + const isCollapsed = Boolean(meta.isThisHeadingCollapsed); + this.previewControls.classList.toggle('is-collapsed', isCollapsed); + if (this.collapseButton) { + this.collapseButton.classList.toggle('is-collapsed', isCollapsed); + this.collapseButton.setAttribute( + 'aria-label', + isCollapsed ? 'Expand heading' : 'Collapse heading', + ); } } + private buildPreviewControls(): HTMLDivElement { + const controls = document.createElement('div'); + controls.className = 'd-block-preview-controls'; + controls.contentEditable = 'false'; + controls.dataset.previewControls = 'true'; + + const collapse = document.createElement('button'); + collapse.type = 'button'; + collapse.className = + 'd-block-button d-block-preview-button color-text-default hover:color-bg-default-hover'; + collapse.dataset.test = 'preview-collapse-button'; + collapse.innerHTML = CHEVRON_SVG; + collapse.addEventListener('mousedown', (event) => event.preventDefault()); + collapse.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const position = this.safeGetPos(); + if (position != null) toggleHeadingCollapse(this.editor, position); + }); + this.collapseButton = collapse; + controls.appendChild(collapse); + + if (this.onCopyHeadingLink) { + const copyLink = document.createElement('button'); + copyLink.type = 'button'; + copyLink.className = + 'd-block-button d-block-preview-button d-block-preview-copy-link color-text-default hover:color-bg-default-hover'; + copyLink.dataset.test = 'preview-copy-link-button'; + copyLink.setAttribute('aria-label', 'Copy heading link'); + copyLink.innerHTML = LINK_SVG; + copyLink.addEventListener('mousedown', (event) => + event.preventDefault(), + ); + copyLink.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const position = this.safeGetPos(); + if (position == null) return; + const link = getHeadingLinkSlug(this.node, position); + if (link) this.onCopyHeadingLink?.(link); + }); + controls.appendChild(copyLink); + } + + return controls; + } + private safeGetPos() { try { const position = this.getPos(); diff --git a/package/extensions/d-block/dblock-paste-normalizer.test.ts b/package/extensions/d-block/dblock-paste-normalizer.test.ts new file mode 100644 index 00000000..81268ede --- /dev/null +++ b/package/extensions/d-block/dblock-paste-normalizer.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, afterEach, beforeAll } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { TextSelection } from '@tiptap/pm/state'; +import { makeEditor } from '../../utils/make-editor'; + +beforeAll(() => { + // jsdom has no ClipboardEvent; prosemirror-view's pasteHTML constructs one + // to thread through the paste pipeline. Handlers may probe clipboardData, + // so give it inert methods rather than null. + if (typeof ClipboardEvent === 'undefined') { + class ClipboardEventShim extends Event { + clipboardData = { + getData: () => '', + types: [] as string[], + files: [] as File[], + items: [] as unknown[], + }; + } + (globalThis as { ClipboardEvent?: unknown }).ClipboardEvent = + ClipboardEventShim; + } +}); + +// Regression for TEC-2679: slices with bare top-level blocks (copied from a +// flat v2 doc, or parsed from any external HTML) silently demoted headings +// to paragraph text when pasted into a v1 doc — prosemirror's fitter found +// no legal closed placement under `(dBlock|columns|pageBreak)+` and fell +// back to merging the heading's inline content into the caret paragraph. + +const topLevelShapes = (editor: Editor): string[] => { + const shapes: string[] = []; + editor.state.doc.forEach((node) => { + shapes.push( + node.type.name === 'dBlock' + ? `dBlock(${node.firstChild?.type.name ?? 'empty'})` + : node.type.name, + ); + }); + return shapes; +}; + +const caretIntoFirstParagraph = (editor: Editor) => { + editor.view.dispatch( + editor.state.tr.setSelection( + TextSelection.near(editor.state.doc.resolve(0), 1), + ), + ); +}; + +describe('dblock paste normalizer (v1 destination)', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('keeps a pasted bare heading a heading, wrapped in a dBlock', () => { + editor = makeEditor('

'); + caretIntoFirstParagraph(editor); + + // Exactly what a v2 copy (or any external site's HTML) puts on the + // clipboard: a bare

, no dBlock wrappers. + editor.view.pasteHTML('

Alpha One

'); + + const headings: string[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === 'heading') headings.push(node.textContent); + }); + expect(headings).toEqual(['Alpha One']); + expect(topLevelShapes(editor)).toContain('dBlock(heading)'); + }); + + it('keeps multi-block flat pastes intact (heading + paragraph)', () => { + editor = makeEditor('

'); + caretIntoFirstParagraph(editor); + + editor.view.pasteHTML('

Beta Two

gamma text

'); + + expect(topLevelShapes(editor)).toEqual([ + 'dBlock(heading)', + 'dBlock(paragraph)', + ]); + }); + + it('still merges plain inline pastes into the caret paragraph', () => { + editor = makeEditor('

hello

'); + // caret at the end of "hello" + const end = editor.state.doc.firstChild!.nodeSize - 1; + editor.view.dispatch( + editor.state.tr.setSelection( + TextSelection.near(editor.state.doc.resolve(end), -1), + ), + ); + + editor.view.pasteHTML('world'); + + // The paste must NOT create a new block — same single dBlock, text + // appended in place. + expect(topLevelShapes(editor)).toEqual(['dBlock(paragraph)']); + expect(editor.state.doc.textContent).toBe('helloworld'); + }); +}); diff --git a/package/extensions/d-block/dblock-paste-normalizer.ts b/package/extensions/d-block/dblock-paste-normalizer.ts new file mode 100644 index 00000000..f9658954 --- /dev/null +++ b/package/extensions/d-block/dblock-paste-normalizer.ts @@ -0,0 +1,76 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Fragment, Slice, Node as PMNode } from '@tiptap/pm/model'; + +/** + * Rewraps pasted top-level bare blocks in dBlocks (v1 schema only). + * + * Slices copied from a flat v2 doc (or parsed from external HTML) carry + * bare blocks at the top level: `` with + * openStart/openEnd 1. The v1 doc only accepts `(dBlock|columns|pageBreak)+` + * and dBlock holds exactly one `(block|columns)`, so prosemirror's + * `replaceRange` finds no legal placement for a closed bare heading: + * `doc.canReplaceWith(..., heading)` fails (no wrapping is considered) and + * `dBlock.canReplaceWith(0, 0, heading)` fails (the wrapper already holds + * the target paragraph and its content expression allows only one child). + * The fitter then falls back to leaving the heading open and merging its + * inline content into the destination paragraph — the heading silently + * demotes to plain text. v1-native copies never hit this because their + * slices carry the dBlock wrappers, which fit at doc level as closed nodes. + * + * The fix: make foreign slices look exactly like v1-native ones before + * fitting — wrap every doc-illegal top-level block in a dBlock and grow the + * open depths across the added wrapper. PM's own defining-node machinery + * (heading has `defining: true`) then preserves headings, while partial + * inline pastes still merge into the caret block exactly as before. + */ +export const createDBlockPasteNormalizerPlugin = () => + new Plugin({ + key: new PluginKey('dblock-paste-normalizer'), + props: { + transformPasted: (slice, view) => { + const schema = view.state.schema; + const dBlock = schema.nodes.dBlock; + if (!dBlock) return slice; + + const docType = view.state.doc.type; + const total = slice.content.childCount; + if (total === 0) return slice; + + // An inline child at the top level means this is an inline paste + // (text fragments); those must keep merging into the caret block. + let hasInline = false; + slice.content.forEach((child) => { + if (child.isInline) hasInline = true; + }); + if (hasInline) return slice; + + let wrappedFirst = false; + let wrappedLast = false; + let changed = false; + const children: PMNode[] = []; + slice.content.forEach((child, _offset, index) => { + // Blocks the doc accepts directly (dBlock, columns, pageBreak) + // are already v1-shaped; leave them alone. + if (docType.contentMatch.matchType(child.type)) { + children.push(child); + return; + } + children.push(dBlock.create(null, child)); + changed = true; + if (index === 0) wrappedFirst = true; + if (index === total - 1) wrappedLast = true; + }); + + if (!changed) return slice; + return new Slice( + Fragment.fromArray(children), + // An open edge that gained a wrapper is now one level deeper; + // closed edges (openStart/openEnd 0) stay closed. + slice.openStart > 0 && wrappedFirst + ? slice.openStart + 1 + : slice.openStart, + slice.openEnd > 0 && wrappedLast ? slice.openEnd + 1 : slice.openEnd, + ); + }, + }, + }); diff --git a/package/extensions/d-block/dblock-template-overlay.test.tsx b/package/extensions/d-block/dblock-template-overlay.test.tsx new file mode 100644 index 00000000..06d12fc7 --- /dev/null +++ b/package/extensions/d-block/dblock-template-overlay.test.tsx @@ -0,0 +1,33 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; +import { getTemplateTarget } from './dblock-toolbar'; +import { DEFAULT_DBLOCK_RUNTIME_STATE } from './dblock-runtime'; + +describe('getTemplateTarget', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('targets a single empty dBlock', () => { + editor = makeEditor('

'); + editor.commands.setTextSelection(2); + const target = getTemplateTarget(editor, DEFAULT_DBLOCK_RUNTIME_STATE); + expect(target).not.toBeNull(); + expect(target!.pos).toBe(0); + }); + + it('returns null once the doc has content', () => { + editor = makeEditor('

hello

'); + const target = getTemplateTarget(editor, DEFAULT_DBLOCK_RUNTIME_STATE); + expect(target).toBeNull(); + }); + + it('returns null in preview mode', () => { + editor = makeEditor('

'); + const target = getTemplateTarget(editor, { + ...DEFAULT_DBLOCK_RUNTIME_STATE, + isPreviewMode: true, + }); + expect(target).toBeNull(); + }); +}); diff --git a/package/extensions/d-block/dblock-toolbar.test.tsx b/package/extensions/d-block/dblock-toolbar.test.tsx new file mode 100644 index 00000000..2dff6b20 --- /dev/null +++ b/package/extensions/d-block/dblock-toolbar.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach, beforeAll } from 'vitest'; +import { render } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from '../../utils/make-editor'; +import { DBlockToolbarProvider } from './dblock-toolbar'; +import { DEFAULT_DBLOCK_RUNTIME_STATE } from './dblock-runtime'; + +beforeAll(() => { + if (!window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +// TEC-2679 blog-preview crash: PreviewDdocEditor is statically read-only, +// but it still mounted the DragHandle chrome. The upstream DragHandle +// plugin relocates its rendered element outside React's tree, so when a +// late-arriving blob flips the schema marker and the editor rebuilds, +// React re-commits EditorContent against the relocated `.drag-handle` +// anchor and insertBefore throws NotFoundError — killing the preview. +// Preview editors must never mount block chrome: the read-only heading +// affordances live inside the node view, and the cluster is hard-hidden +// for non-editable editors anyway. +describe('DBlockToolbarProvider in preview editors', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('mounts no block chrome when isPreviewEditor', () => { + editor = makeEditor('

hello

'); + document.body.appendChild(editor.view.dom); + const { container, unmount } = render( + + + , + ); + + // children still render + expect(container.querySelector('[data-test="preview-child"]')).toBeTruthy(); + // no drag-handle cluster, no template overlay — anywhere in the document + // (the DragHandle plugin relocates its element outside the React root, + // so the query must be document-wide) + expect(document.querySelector('[aria-label="block-controls"]')).toBeNull(); + expect(document.querySelector('[data-template-overlay]')).toBeNull(); + + // With no chrome mounted, unmount must be clean — no try/catch needed. + unmount(); + }); + + it('keeps mounting the chrome for regular editors', () => { + editor = makeEditor('

hello

'); + document.body.appendChild(editor.view.dom); + const { unmount } = render( + + + , + ); + + expect( + document.querySelector('[aria-label="block-controls"]'), + ).toBeTruthy(); + + // Same jsdom/React DOM-ownership limitation as dblock-drag-handle.test: + // the DragHandle plugin relocates its element outside React's root. + try { + unmount(); + } catch { + // expected — see dblock-drag-handle.test.tsx + } + }); +}); diff --git a/package/extensions/d-block/dblock-toolbar.tsx b/package/extensions/d-block/dblock-toolbar.tsx index 9396770d..1e450f5c 100644 --- a/package/extensions/d-block/dblock-toolbar.tsx +++ b/package/extensions/d-block/dblock-toolbar.tsx @@ -1,242 +1,28 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Editor, JSONContent } from '@tiptap/react'; import { Node as ProseMirrorNode } from '@tiptap/pm/model'; -import { useMediaQuery } from 'usehooks-ts'; -import { cn } from '@fileverse/ui'; -import useContentItemActions from '../../hooks/use-content-item-actions'; import { createMoreTemplates, createTemplateButtons, renderTemplateButtons, } from '../../utils/template-utils'; -import { DBlockMenu } from './components/menu'; -import { - CollapseButton, - CopyLinkButton, - GripButton, - PlusButton, -} from './components/buttons'; -import { - AddBlockTooltip, - CollapseTooltip, - CopyLinkTooltip, - DragTooltip, -} from './components/tooltips'; -import { - getDBlockRenderMeta, - getHeadingLinkSlug, - toggleHeadingCollapse, -} from './dblock-collapse'; +import { unwrapDBlocksInJSON } from '../../utils/block-schema'; import { DEFAULT_DBLOCK_RUNTIME_STATE, type DBlockRuntimeState, } from './dblock-runtime'; -import { - getDBlockViewFromElement, - getDBlockViewFromEventTarget, - refreshRegisteredDBlockViews, - type DBlockViewHandle, -} from './dblock-view-registry'; +import { DBlockDragHandle } from './dblock-drag-handle'; -interface ResolvedDBlock { - editor: Editor; - handle: DBlockViewHandle; +interface DBlockTemplateTarget { node: ProseMirrorNode; pos: number; } -const resolveCurrentDBlock = ( - editor: Editor | null, - handle: DBlockViewHandle | null, -): ResolvedDBlock | null => { - if (!editor || !handle?.dom.isConnected) { - return null; - } - - try { - const pos = handle.getPos(); - const node = editor.state.doc.nodeAt(pos); - - if (typeof pos !== 'number' || node?.type.name !== 'dBlock') { - return null; - } - - return { - editor, - handle, - node, - pos, - }; - } catch { - return null; - } -}; - -const DBlockToolbar = React.memo( - ({ - editor, - handle, - runtimeState, - refreshKey, - }: { - editor: Editor; - handle: DBlockViewHandle; - runtimeState: DBlockRuntimeState; - refreshKey: number; - }) => { - const isBelowLargeScreen = useMediaQuery('(max-width: 1024px)'); - const [menuOpen, setMenuOpen] = useState(false); - const resolved = useMemo(() => { - void refreshKey; - return resolveCurrentDBlock(editor, handle); - }, [editor, handle, refreshKey]); - const resolveBlock = useCallback( - () => resolveCurrentDBlock(editor, handle), - [editor, handle], - ); - const actions = useContentItemActions(editor, resolveBlock); - - useEffect(() => { - setMenuOpen(false); - }, [handle.id]); - - if (!resolved) { - return null; - } - - const meta = getDBlockRenderMeta(resolved.node, resolved.pos); - - const handleAddBlock = (event: React.MouseEvent) => { - const current = resolveBlock(); - if (!current) { - return; - } - - const insertPos = event.altKey - ? current.pos - : current.pos + current.node.nodeSize; - - current.editor.commands.insertContentAt(insertPos, { - type: 'dBlock', - content: [{ type: 'paragraph' }], - }); - }; - - const handleDragClick = (event: React.MouseEvent) => { - if (event.altKey) { - actions.deleteNode(); - return; - } - - setMenuOpen((open) => !open); - }; - - const handleToggleCollapse = () => { - const current = resolveBlock(); - if (current) { - toggleHeadingCollapse(current.editor, current.pos); - } - }; - - const handleCopyHeadingLink = () => { - const current = resolveBlock(); - if (!current) { - return; - } - - const headingLink = getHeadingLinkSlug(current.node, current.pos); - if (headingLink) { - current.handle.onCopyHeadingLink?.(headingLink); - } - }; - - const buttonClassName = cn( - 'd-block-button color-text-default hover:color-bg-default-hover aspect-square h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity', - ); - - const shouldShowEditingControls = - !runtimeState.isPreviewMode && !isBelowLargeScreen; - const shouldShowCollapse = meta.isHeading; - const shouldShowCopyLink = - runtimeState.isPreviewMode && - meta.isHeading && - !runtimeState.isPreviewEditor && - !isBelowLargeScreen; - - if ( - (runtimeState.isPresentationMode && runtimeState.isPreviewMode) || - !resolved.handle.gutterElement.isConnected || - (!shouldShowEditingControls && !shouldShowCollapse && !shouldShowCopyLink) - ) { - return null; - } - - return createPortal( -
- {shouldShowEditingControls ? ( - <> - - - - - - - - } - actions={actions} - /> - - ) : null} - - {shouldShowCollapse ? ( - - - - ) : null} - - {shouldShowCopyLink ? ( - - - - ) : null} -
, - resolved.handle.gutterElement, - ); - }, -); - -DBlockToolbar.displayName = 'DBlockToolbar'; - -const getTemplateTarget = ( +export const getTemplateTarget = ( editor: Editor | null, runtimeState: DBlockRuntimeState, -) => { +): DBlockTemplateTarget | null => { if ( !editor || editor.isDestroyed || @@ -251,12 +37,12 @@ const getTemplateTarget = ( const node = editor.state.doc.firstChild; const pos = 0; - const paragraphNode = node?.content.firstChild; + // v1 wraps the paragraph in a dBlock; in the flat schema the only block IS + // the paragraph. + const paragraphNode = + node?.type.name === 'dBlock' ? node.content.firstChild : node; - if ( - node?.type.name !== 'dBlock' || - paragraphNode?.type.name !== 'paragraph' - ) { + if (!node || paragraphNode?.type.name !== 'paragraph') { return null; } @@ -280,17 +66,7 @@ const getTemplateTarget = ( return null; } - const firstDBlockElement = editor.view.dom.querySelector( - '[data-dblock-node-view]', - ); - const handle = getDBlockViewFromElement(firstDBlockElement); - - if (!handle?.contentElement.isConnected) { - return null; - } - return { - handle, node, pos, }; @@ -299,15 +75,30 @@ const getTemplateTarget = ( const DBlockTemplateOverlay = ({ editor, runtimeState, - refreshKey, }: { editor: Editor | null; runtimeState: DBlockRuntimeState; - refreshKey: number; }) => { const [isExpanded, setIsExpanded] = useState(false); const [visibleTemplateCount, setVisibleTemplateCount] = useState(2); + const [refreshKey, setRefreshKey] = useState(0); const isFocusMode = runtimeState.isFocusMode; + + useEffect(() => { + if (!editor) return; + + const refresh = () => setRefreshKey((key) => key + 1); + editor.on('transaction', refresh); + editor.on('selectionUpdate', refresh); + window.addEventListener('resize', refresh); + + return () => { + editor.off('transaction', refresh); + editor.off('selectionUpdate', refresh); + window.removeEventListener('resize', refresh); + }; + }, [editor]); + const target = useMemo(() => { void refreshKey; return getTemplateTarget(editor, runtimeState); @@ -320,9 +111,17 @@ const DBlockTemplateOverlay = ({ return; } + // The template JSON is authored in v1 shape (dBlock wrappers) and stays + // the single source of truth; the flat schema gets it unwrapped at + // insert time. Insert position: v1's arithmetic lands on the empty + // wrapper's own position, which is what the flat schema uses directly. + const hasDBlock = Boolean(editor?.schema.nodes.dBlock); + editor?.commands.insertContentAt( - currentTarget.pos + currentTarget.node.nodeSize - 4, - template, + hasDBlock + ? currentTarget.pos + currentTarget.node.nodeSize - 4 + : currentTarget.pos, + hasDBlock ? template : unwrapDBlocksInJSON(template), ); }, [editor, runtimeState], @@ -344,22 +143,44 @@ const DBlockTemplateOverlay = ({ }); }, [moreTemplates.length]); - if (!target || isFocusMode) { + // Same hazard #553 fixed on the old toolbar: the tab-editor cache destroys + // and recreates editors inside pre-paint layout effects, so this can render + // with an already-destroyed editor, whose `view` access throws on tiptap v3. + const panel = + editor && !editor.isDestroyed + ? editor.view.dom.closest('[data-ddoc-editor-panel]') + : null; + + if (!target || isFocusMode || !panel) { + return null; + } + + // The editor's first block must be rendered before the overlay is worth + // portaling. Matched by position rather than by the v1-only + // `[data-type="d-block"]` marker, which flat blocks do not carry. + const firstBlock = editor?.view.dom.firstElementChild; + if (!firstBlock) { return null; } return createPortal( - renderTemplateButtons( - templateButtons, - moreTemplates, - visibleTemplateCount, - toggleAllTemplates, - isExpanded, - runtimeState.isCollaboratorsDoc, - runtimeState.isPreviewMode, - isFocusMode, - ), - target.handle.contentElement, +
+ {renderTemplateButtons( + templateButtons, + moreTemplates, + visibleTemplateCount, + toggleAllTemplates, + isExpanded, + runtimeState.isCollaboratorsDoc, + runtimeState.isPreviewMode, + isFocusMode, + )} +
, + panel, ); }; @@ -367,112 +188,34 @@ export const DBlockToolbarProvider = ({ children, editor, runtimeState = DEFAULT_DBLOCK_RUNTIME_STATE, + isPreviewEditor = false, }: { children: React.ReactNode; editor: Editor | null; runtimeState?: DBlockRuntimeState; + // Statically read-only surfaces (PreviewDdocEditor: blog preview, version + // history) must never mount block chrome. The read-only heading + // affordances live inside the node view, and the upstream DragHandle + // plugin relocates its DOM element outside React's tree — so when a + // late-arriving blob flips the schema marker and the editor rebuilds, + // React re-commits EditorContent against that relocated `.drag-handle` + // anchor and insertBefore throws NotFoundError, killing the preview + // (TEC-2679 blog publish modal on v2 docs). Never mounted = never a + // stale anchor, and no relocated element leaked per version switch. + isPreviewEditor?: boolean; }) => { - const [activeHandle, setActiveHandle] = useState( - null, - ); - const [refreshKey, setRefreshKey] = useState(0); - const activeHandleRef = useRef(null); - - const setActiveDBlock = useCallback((handle: DBlockViewHandle | null) => { - activeHandleRef.current = handle; - setActiveHandle(handle); - }, []); - - const refreshToolbar = useCallback(() => { - if (!editor || editor.isDestroyed) { - setActiveDBlock(null); - return; - } - - const currentHandle = activeHandleRef.current; - currentHandle?.refresh(); - - if (currentHandle && !resolveCurrentDBlock(editor, currentHandle)) { - setActiveDBlock(null); - return; - } - - setRefreshKey((key) => key + 1); - }, [editor, setActiveDBlock]); - - useEffect(() => { - // The tab-editor cache destroys/recreates editors in pre-paint layout - // effects, so this effect can fire with an already-destroyed editor — - // whose `view` access throws on tiptap v3. - if (!editor || editor.isDestroyed) { - setActiveDBlock(null); - return; - } - - const editorDom = editor.view.dom; - - const activateFromTarget = (target: EventTarget | null) => { - const handle = getDBlockViewFromEventTarget(target); - if (!handle || !editorDom.contains(handle.dom)) { - return; - } - - setActiveDBlock(handle); - setRefreshKey((key) => key + 1); - }; - - const handlePointerOver = (event: PointerEvent) => { - activateFromTarget(event.target); - }; - const handleFocusIn = (event: FocusEvent) => { - activateFromTarget(event.target); - }; - const handlePointerOut = () => { - refreshToolbar(); - }; - const handleFocusOut = () => { - refreshToolbar(); - }; - - editorDom.addEventListener('pointerover', handlePointerOver); - editorDom.addEventListener('pointerout', handlePointerOut); - editorDom.addEventListener('focusin', handleFocusIn); - editorDom.addEventListener('focusout', handleFocusOut); - editor.on('transaction', refreshToolbar); - editor.on('selectionUpdate', refreshToolbar); - - return () => { - editorDom.removeEventListener('pointerover', handlePointerOver); - editorDom.removeEventListener('pointerout', handlePointerOut); - editorDom.removeEventListener('focusin', handleFocusIn); - editorDom.removeEventListener('focusout', handleFocusOut); - editor.off('transaction', refreshToolbar); - editor.off('selectionUpdate', refreshToolbar); - }; - }, [editor, refreshToolbar, setActiveDBlock]); - - useEffect(() => { - refreshRegisteredDBlockViews(); - setActiveDBlock(null); - setRefreshKey((key) => key + 1); - }, [runtimeState, setActiveDBlock]); - + if (isPreviewEditor) { + return <>{children}; + } return ( <> {children} - {activeHandle && editor ? ( - + {/* The drag-handle plugin reads editor.view, so a destroyed editor + would throw here too — see the note in DBlockTemplateOverlay. */} + {editor && !editor.isDestroyed ? ( + ) : null} - + ); }; diff --git a/package/extensions/d-block/dblock-view-registry.ts b/package/extensions/d-block/dblock-view-registry.ts deleted file mode 100644 index df320bf6..00000000 --- a/package/extensions/d-block/dblock-view-registry.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; - -export interface DBlockViewHandle { - id: string; - dom: HTMLElement; - gutterElement: HTMLElement; - contentElement: HTMLElement; - getPos: () => number; - getNode: () => ProseMirrorNode; - refresh: () => void; - onCopyHeadingLink?: (link: string) => void; -} - -const handleByElement = new WeakMap(); -const registeredHandles = new Set(); - -const isElement = (value: unknown): value is Element => - typeof Element !== 'undefined' && value instanceof Element; - -const getElementFromTarget = (target: EventTarget | null): Element | null => { - if (!target) { - return null; - } - - if (isElement(target)) { - return target; - } - - if ( - typeof Node !== 'undefined' && - target instanceof Node && - target.parentElement - ) { - return target.parentElement; - } - - return null; -}; - -export const registerDBlockView = (handle: DBlockViewHandle) => { - registeredHandles.add(handle); - handleByElement.set(handle.dom, handle); - handleByElement.set(handle.gutterElement, handle); - handleByElement.set(handle.contentElement, handle); - - return () => { - registeredHandles.delete(handle); - handleByElement.delete(handle.dom); - handleByElement.delete(handle.gutterElement); - handleByElement.delete(handle.contentElement); - }; -}; - -export const getDBlockViewFromElement = ( - element: Element | null, -): DBlockViewHandle | null => { - if (!element) { - return null; - } - - const registeredElement = element.closest( - '[data-dblock-node-view], [data-dblock-gutter], [data-dblock-content-shell]', - ); - - return registeredElement - ? (handleByElement.get(registeredElement) ?? null) - : null; -}; - -export const getDBlockViewFromEventTarget = ( - target: EventTarget | null, -): DBlockViewHandle | null => - getDBlockViewFromElement(getElementFromTarget(target)); - -export const refreshRegisteredDBlockViews = () => { - registeredHandles.forEach((handle) => { - if (handle.dom.isConnected) { - handle.refresh(); - } - }); -}; diff --git a/package/extensions/d-block/dblock.ts b/package/extensions/d-block/dblock.ts index 3d0f33dc..c62774ee 100644 --- a/package/extensions/d-block/dblock.ts +++ b/package/extensions/d-block/dblock.ts @@ -7,13 +7,16 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import type { DBlockRuntimeState } from './dblock-runtime'; import { createDBlockCollapsePlugin } from './dblock-collapse'; import { createDBlockMediaConversionPlugin } from './dblock-media-plugin'; +import { createDBlockPasteNormalizerPlugin } from './dblock-paste-normalizer'; export interface DBlockOptions { HTMLAttributes: Record; ipfsImageUploadFn?: (file: File) => Promise; - onCopyHeadingLink?: (link: string) => void; hasAvailableModels: boolean; getRuntimeState?: () => DBlockRuntimeState; + // Consumed by the node view's read-only-preview heading chrome; the + // editing-mode equivalent lives in the floating drag-handle cluster. + onCopyHeadingLink?: (link: string) => void; } declare module '@tiptap/core' { @@ -58,9 +61,9 @@ export const DBlock = Node.create({ addOptions() { return { HTMLAttributes: {}, - onCopyHeadingLink: undefined, hasAvailableModels: false, getRuntimeState: undefined, + onCopyHeadingLink: undefined, }; }, @@ -1014,6 +1017,7 @@ export const DBlock = Node.create({ const plugins = [ createDBlockCollapsePlugin(), createDBlockMediaConversionPlugin(this.options.getRuntimeState), + createDBlockPasteNormalizerPlugin(), ]; if (!this.options.hasAvailableModels) { diff --git a/package/extensions/d-block/heading-chrome-icons.ts b/package/extensions/d-block/heading-chrome-icons.ts new file mode 100644 index 00000000..91eaabb4 --- /dev/null +++ b/package/extensions/d-block/heading-chrome-icons.ts @@ -0,0 +1,11 @@ +// Icons for the read-only-preview heading chrome. Shared so the v1 dBlock +// node view and the flat-schema decoration widget cannot drift apart. +const SVG_ATTRS = + 'xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; + +// lucide chevron-down; rotated -90deg via CSS when collapsed (= ChevronRight, +// matching the editing cluster's CollapseButton semantics) +export const CHEVRON_SVG = ``; + +// lucide link +export const LINK_SVG = ``; diff --git a/package/extensions/default-extension.test.ts b/package/extensions/default-extension.test.ts new file mode 100644 index 00000000..b5b9a027 --- /dev/null +++ b/package/extensions/default-extension.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { defaultExtensions } from './default-extension'; + +const extensionNames = (schemaVersion: number) => + defaultExtensions({ onError: () => null, schemaVersion }).map( + (extension) => (extension as { name: string }).name, + ); + +describe('defaultExtensions schema fork', () => { + it('drops the gap cursor from the flat v2 set', () => { + // The flat schema exposes gap positions at every boundary between two + // non-textblock blocks (image→table, table→table, before embeds), where + // the gap cursor renders as a stray dash glued to the next block's + // border while being unreachable by deliberate input (TEC-2679). + // Inserting between blocks is the plus button's job, Notion-style. + expect(extensionNames(2)).not.toContain('gapCursor'); + }); + + it('keeps the gap cursor in the v1 set (legacy behavior unchanged)', () => { + expect(extensionNames(1)).toContain('gapCursor'); + }); +}); diff --git a/package/extensions/default-extension.ts b/package/extensions/default-extension.ts index 6d24a229..8e05f73b 100644 --- a/package/extensions/default-extension.ts +++ b/package/extensions/default-extension.ts @@ -45,14 +45,21 @@ const ExtendedTextStyle = TextStyle.extend({ }); import HorizontalRule from './horizontal-rule'; import ColumnExtension from './multi-column'; +import { FlatColumn } from './multi-column/column'; import CustomKeymap from './custom-keymap'; import { CollapsibleHeading } from './collapsible-heading'; import { Color } from '@tiptap/extension-color'; import { Iframe } from './iframe'; import { EmbeddedTweet } from './twitter-embed'; import { createDBlockExtension } from './d-block'; +import { FlatHeadingCollapse } from './d-block/dblock-collapse'; +import { FlatMediaConversion } from './d-block/dblock-media-plugin'; +import { BlockId } from './block-id'; +import { ListNormalization } from './list-normalization'; +import { UndoSelection } from './undo-selection'; +import { AiWriterSpaceTrigger } from './ai-writer/ai-writer-space-trigger'; import { SuperchargedTableExtensions } from './supercharged-table'; -import { Document } from './document'; +import { Document, FlatDocument } from './document'; import { TrailingNode } from './trailing-node'; import { type NodeType } from '@tiptap/pm/model'; import { Plugin } from '@tiptap/pm/state'; @@ -261,6 +268,7 @@ export const defaultExtensions = ({ onTocUpdate, dBlockRuntimeStateRef, hasAvailableModels = false, + schemaVersion = 1, }: { ipfsImageFetchFn?: ( _data: IpfsImageFetchPayload, @@ -273,6 +281,7 @@ export const defaultExtensions = ({ onTocUpdate?: (data: ToCItemType[], isCreate?: boolean) => void; dBlockRuntimeStateRef?: DBlockRuntimeStateRef; hasAvailableModels?: boolean; + schemaVersion?: number; }) => [ FontFamily, FontFamilyPersistence, @@ -332,7 +341,9 @@ export const defaultExtensions = ({ bulletList: false, listItem: false, codeBlock: false, - trailingNode: false, + // v1 uses the custom dBlock-aware TrailingNode registered below; the + // flat v2 schema uses StarterKit's stock one (schema-agnostic). + trailingNode: schemaVersion >= 2 ? undefined : false, }), CollapsibleHeading.configure({ HTMLAttributes: { @@ -424,17 +435,57 @@ export const defaultExtensions = ({ ipfsImageFetchFn, fetchV1ImageFn, }), - Gapcursor, - createDBlockExtension({ - ipfsImageUploadFn, - onCopyHeadingLink, - hasAvailableModels, - getRuntimeState: dBlockRuntimeStateRef - ? () => dBlockRuntimeStateRef.current - : undefined, - }), - TrailingNode, - Document, + // v1 keeps the gap cursor it has always shipped with. v2 drops it + // entirely (TEC-2679): the flat schema exposes gap positions at every + // image→table / table→table / before-embed boundary, where the cursor + // renders as a stray dash glued to the following block's border — while + // mouse clicks in the gap don't even reach it (browser caret-from-point + // snaps into the nearest table cell) and vertical arrows hop table→table + // past it. Tiptap's Notion-like template ships the extension too, but the + // state is equally unreachable through normal input there (verified live: + // mid-gap clicks land in a cell, arrows skip the boundary) — the real + // insert-between-blocks affordance is the block handle's plus button, + // which we match. Removing the state beats restyling an artifact users + // can neither aim for nor understand. + ...(schemaVersion >= 2 ? [] : [Gapcursor]), + // Both schemas: Yjs restores a stale selection after undo/redo. + UndoSelection, + // Schema fork. v1: every block wrapped in a dBlock (TrailingNode's position + // math assumes the wrapper, so it is v1-only until re-homed in M2). + // v2: flat top node, stock Tiptap structure. + ...(schemaVersion >= 2 + ? [ + FlatDocument, + // Supplies the read-only-preview heading chrome that v1 renders from + // its node view (flat blocks have none). + FlatHeadingCollapse.configure({ onCopyHeadingLink }), + // Pasted image/video URLs convert to media in v1 through a plugin + // registered inside the dBlock extension; re-registered here for v2. + FlatMediaConversion.configure({ + getRuntimeState: dBlockRuntimeStateRef + ? () => dBlockRuntimeStateRef.current + : undefined, + }), + BlockId, + // Notion-template parity: Backspace on an empty block between two + // same-type lists joins them (TEC-2679). v1's lists live inside + // dBlock wrappers where this is the custom keymap's territory. + ListNormalization, + // Same hasAvailableModels gate as v1's in-dBlock space trigger. + ...(hasAvailableModels ? [AiWriterSpaceTrigger] : []), + ] + : [ + createDBlockExtension({ + ipfsImageUploadFn, + onCopyHeadingLink, + hasAvailableModels, + getRuntimeState: dBlockRuntimeStateRef + ? () => dBlockRuntimeStateRef.current + : undefined, + }), + TrailingNode, + Document, + ]), ...SuperchargedTableExtensions, CustomKeymap, Iframe.configure({ ipfsImageFetchFn, fetchV1ImageFn }), @@ -442,7 +493,10 @@ export const defaultExtensions = ({ actionButton.configure({ onError, }), - ColumnExtension, + // v2 swaps the column node for one whose content is bare blocks. + ...(schemaVersion >= 2 + ? [ColumnExtension.configure({ column: false }), FlatColumn] + : [ColumnExtension]), DocxFileHandler.configure({ ipfsImageUploadFn, onError, diff --git a/package/extensions/document/document.ts b/package/extensions/document/document.ts index dc1e9ad0..da4cf7eb 100644 --- a/package/extensions/document/document.ts +++ b/package/extensions/document/document.ts @@ -4,4 +4,11 @@ export const Document = TiptapDocument.extend({ content: '(dBlock|columns|pageBreak)+', }); +// Schema v2: blocks sit directly under doc, no dBlock wrapper. +// pageBreak and columns are listed explicitly because their groups are +// 'pageBreak' and 'columns', not 'block'. +export const FlatDocument = TiptapDocument.extend({ + content: '(block|columns|pageBreak)+', +}); + export default Document; diff --git a/package/extensions/iframe/iframe.ts b/package/extensions/iframe/iframe.ts index 6d1082c2..4b531062 100644 --- a/package/extensions/iframe/iframe.ts +++ b/package/extensions/iframe/iframe.ts @@ -4,6 +4,7 @@ import { ReactNodeViewRenderer } from '@tiptap/react'; import { getResizableMediaNodeView } from '../resizable-media/resizable-media-node-view'; import { IpfsImageFetchPayload } from '../../types'; import { isAllowedEmbedSrc } from '../../utils/is-allowed-embed-src'; +import { replaceSelectionWithBlockNode } from '../../utils/block-insert'; export interface IframeOptions { allowFullscreen: boolean; @@ -128,11 +129,13 @@ export const Iframe = Node.create({ (options: { src: string; width?: number; height?: number }) => ({ tr, dispatch }) => { if (!isAllowedEmbedSrc(options.src)) return false; - const { selection } = tr; const node = this.type.create(options); if (dispatch) { - tr.replaceRangeWith(selection.from - 1, selection.to, node); + // Replaces the old `selection.from - 1` magic offset, which + // assumed the action-button flow's exact post-delete selection + // and stranded the caret at the dBlock boundary afterwards. + replaceSelectionWithBlockNode(tr, node); } return true; diff --git a/package/extensions/list-normalization.test.ts b/package/extensions/list-normalization.test.ts new file mode 100644 index 00000000..0a476ebf --- /dev/null +++ b/package/extensions/list-normalization.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { defaultExtensions } from './default-extension'; +import { PageBreak } from './page-break'; +import { joinListsAroundEmptyBlock } from './list-normalization'; + +const li = (text: string) => ({ + type: 'listItem', + content: [ + { type: 'paragraph', content: [{ type: 'text', text }] }, + ], +}); + +const makeV2Editor = (content: object[]) => { + const editor = new Editor({ + // PageBreak is registered by the callers (use-tab-editor / headless), + // and FlatDocument's content expression requires it in the schema. + extensions: [ + ...defaultExtensions({ onError: () => null, schemaVersion: 2 }), + PageBreak, + ] as never, + }); + editor.commands.setContent({ type: 'doc', content }); + return editor; +}; + +// caret into the empty top-level paragraph between the two lists +const putCaretInEmptyParagraph = (editor: Editor) => { + let pos: number | null = null; + editor.state.doc.forEach((node, offset) => { + if (pos === null && node.type.name === 'paragraph' && node.childCount === 0) + pos = offset; + }); + editor.commands.setTextSelection(pos! + 1); + return pos!; +}; + +describe('joinListsAroundEmptyBlock (Notion-template list normalization)', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('joins two bullet lists when the empty block between them is backspaced away', () => { + editor = makeV2Editor([ + { type: 'bulletList', content: [li('one'), li('two'), li('three')] }, + { type: 'paragraph' }, + { + type: 'bulletList', + content: [li('four'), li('five'), li('six'), li('seven')], + }, + ]); + putCaretInEmptyParagraph(editor); + + expect(joinListsAroundEmptyBlock(editor)).toBe(true); + + const lists: number[] = []; + editor.state.doc.forEach((node) => { + if (node.type.name === 'bulletList') lists.push(node.childCount); + }); + expect(lists).toEqual([7]); + expect(editor.state.doc.firstChild!.textContent).toBe( + 'onetwothreefourfivesixseven', + ); + // caret parked at the end of "three" — typing continues in the old last item + const { $from } = editor.state.selection; + expect($from.parent.textContent).toBe('three'); + expect($from.parentOffset).toBe(5); + }); + + it('declines when the lists are of different types', () => { + editor = makeV2Editor([ + { type: 'bulletList', content: [li('a')] }, + { type: 'paragraph' }, + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: false }, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'b' }] }, + ], + }, + ], + }, + ]); + putCaretInEmptyParagraph(editor); + + const docBefore = editor.state.doc.toJSON(); + expect(joinListsAroundEmptyBlock(editor)).toBe(false); + expect(editor.state.doc.toJSON()).toEqual(docBefore); + }); + + it('declines when the block between the lists has content', () => { + editor = makeV2Editor([ + { type: 'bulletList', content: [li('a')] }, + { type: 'paragraph', content: [{ type: 'text', text: 'random text' }] }, + { type: 'bulletList', content: [li('b')] }, + ]); + // caret at the START of the non-empty paragraph + let pos: number | null = null; + editor.state.doc.forEach((node, offset) => { + if (pos === null && node.type.name === 'paragraph' && node.childCount > 0) + pos = offset; + }); + editor.commands.setTextSelection(pos! + 1); + + expect(joinListsAroundEmptyBlock(editor)).toBe(false); + }); + + it('declines at the document edges (no list on one side)', () => { + editor = makeV2Editor([ + { type: 'paragraph' }, + { type: 'bulletList', content: [li('a')] }, + ]); + putCaretInEmptyParagraph(editor); + + expect(joinListsAroundEmptyBlock(editor)).toBe(false); + }); + + it('is registered in the v2 extension set only', () => { + const names = (schemaVersion: number) => + defaultExtensions({ onError: () => null, schemaVersion }).map( + (extension) => (extension as { name: string }).name, + ); + expect(names(2)).toContain('listNormalization'); + expect(names(1)).not.toContain('listNormalization'); + }); +}); diff --git a/package/extensions/list-normalization.ts b/package/extensions/list-normalization.ts new file mode 100644 index 00000000..47206b74 --- /dev/null +++ b/package/extensions/list-normalization.ts @@ -0,0 +1,67 @@ +import { Extension, type Editor } from '@tiptap/core'; +import { TextSelection } from '@tiptap/pm/state'; +import { canJoin } from '@tiptap/pm/transform'; + +const JOINABLE_LISTS = ['bulletList', 'orderedList', 'taskList']; + +// Parity with Tiptap's Notion-like template, which ships a template-private +// `listNormalization` extension doing exactly this (verified live against +// their running editor): pressing Backspace on an EMPTY top-level block that +// sits between two lists of the same type removes the block and joins the +// lists into one. `[one, two, three] ␣ [four, five]` → Backspace → +// `[one, two, three, four, five]`, caret at the end of "three". +// +// Deliberately a KEYMAP, not an appendTransaction invariant: the template +// behaves identically (a programmatic delete of the middle block leaves two +// adjacent lists in ITS doc too — their Simple Editor keeps them separate +// forever), and an invariant would also fight collab-applied edits and undo +// grouping. Everything that doesn't match the exact pattern falls through to +// stock Backspace behavior. +export const joinListsAroundEmptyBlock = (editor: Editor): boolean => { + const { state, view } = editor; + const { selection } = state; + if (!selection.empty) return false; + + const { $from } = selection; + if ( + $from.depth !== 1 || + !$from.parent.isTextblock || + $from.parent.content.size > 0 + ) { + return false; + } + + const { doc } = state; + const index = $from.index(0); + if (index === 0 || index >= doc.childCount - 1) return false; + + const before = doc.child(index - 1); + const after = doc.child(index + 1); + if ( + before.type !== after.type || + !JOINABLE_LISTS.includes(before.type.name) + ) { + return false; + } + + const from = $from.before(1); + const to = $from.after(1); + const tr = state.tr.delete(from, to); + // After the deletion the two lists meet exactly at `from`. + if (!canJoin(tr.doc, from)) return false; + tr.join(from); + tr.setSelection(TextSelection.near(tr.doc.resolve(from), -1)); + tr.scrollIntoView(); + view.dispatch(tr); + return true; +}; + +export const ListNormalization = Extension.create({ + name: 'listNormalization', + + addKeyboardShortcuts() { + return { + Backspace: () => joinListsAroundEmptyBlock(this.editor), + }; + }, +}); diff --git a/package/extensions/mardown-paste-handler/index.ts b/package/extensions/mardown-paste-handler/index.ts index c7dc146d..a2345606 100644 --- a/package/extensions/mardown-paste-handler/index.ts +++ b/package/extensions/mardown-paste-handler/index.ts @@ -7,6 +7,7 @@ import { Fragment, DOMParser as ProseMirrorDOMParser, Node as PMNode, + Slice, } from 'prosemirror-model'; import markdownItFootnote from 'markdown-it-footnote'; import TurndownService from 'turndown'; @@ -625,6 +626,46 @@ const MarkdownPasteHandler = ( return [ new Plugin({ props: { + // Non-markdown clipboard HTML goes through ProseMirror's native + // paste, which faithfully keeps the stacks of empty

that + // sites like Wikipedia put between sections. Collapse interior + // runs of empty paragraphs to one; the first and last slice + // children are never touched (they can be open-ended and merge + // into surrounding blocks). + transformPasted: (slice) => { + const isEmptyParagraphChild = (node: PMNode) => { + if (node.type.name === 'paragraph') + return node.childCount === 0; + return ( + node.type.name === 'dBlock' && + node.childCount === 1 && + node.firstChild!.type.name === 'paragraph' && + node.firstChild!.childCount === 0 + ); + }; + + const total = slice.content.childCount; + if (total < 3) return slice; + + const kept: PMNode[] = []; + let previousWasEmpty = false; + slice.content.forEach((child, _offset, index) => { + const isEmpty = isEmptyParagraphChild(child); + const isEdge = index === 0 || index === total - 1; + if (!isEdge && isEmpty && previousWasEmpty) { + return; + } + previousWasEmpty = isEmpty; + kept.push(child); + }); + + if (kept.length === total) return slice; + return new Slice( + Fragment.fromArray(kept), + slice.openStart, + slice.openEnd, + ); + }, handlePaste: (view, event) => { const clipboardData = event.clipboardData; if (!clipboardData) return false; @@ -1006,12 +1047,18 @@ const MarkdownPasteHandler = ( find: /===\s*$/m, handler: ({ state, range }) => { const { tr } = state; - const start = range.from - 2; + // v1: replace the dBlock around the paragraph (2 levels up). + // Flat v2: replace the paragraph itself (1 level up). + const hasDBlock = Boolean(state.schema.nodes.dBlock); + const start = range.from - (hasDBlock ? 2 : 1); const end = range.to; - const isDBlock = state.doc.nodeAt(start)?.type.name === 'dBlock'; + const containerName = state.doc.nodeAt(start)?.type.name; + const isReplaceableContainer = hasDBlock + ? containerName === 'dBlock' + : containerName === 'paragraph'; // Create a page break node - if (isDBlock) { + if (isReplaceableContainer) { tr.replaceWith( start, end, @@ -1367,9 +1414,33 @@ export async function handleMarkdownContent( view.state.schema, ).parse(domContent); - // Post-process: replace dBlock nodes containing a "===" paragraph with pageBreak nodes + // Post-process: replace "===" paragraphs with pageBreak nodes (v1 wraps + // them in a dBlock; flat v2 has them top-level), and collapse runs of + // empty paragraphs — pasted HTML (Wikipedia and friends) carries stacks of + // empty

that render as huge gaps. A single empty paragraph is kept as + // possible intent; consecutive ones are junk. + const isEmptyParagraphBlock = (child: PMNode) => { + if (child.type.name === 'paragraph') return child.childCount === 0; + return ( + child.type.name === 'dBlock' && + child.childCount === 1 && + child.firstChild!.type.name === 'paragraph' && + child.firstChild!.childCount === 0 + ); + }; + const newChildren: PMNode[] = []; + let previousWasEmptyParagraph = false; proseMirrorNodes.forEach((child: PMNode) => { + if (isEmptyParagraphBlock(child)) { + if (!previousWasEmptyParagraph) { + newChildren.push(child); + } + previousWasEmptyParagraph = true; + return; + } + previousWasEmptyParagraph = false; + if (child.type.name === 'dBlock' && child.childCount === 1) { const inner = child.firstChild!; if ( @@ -1380,6 +1451,10 @@ export async function handleMarkdownContent( return; } } + if (child.type.name === 'paragraph' && child.textContent.trim() === '===') { + newChildren.push(view.state.schema.nodes.pageBreak.create()); + return; + } newChildren.push(child); }); diff --git a/package/extensions/multi-column/column.ts b/package/extensions/multi-column/column.ts index fccd66f1..5a066986 100644 --- a/package/extensions/multi-column/column.ts +++ b/package/extensions/multi-column/column.ts @@ -34,4 +34,9 @@ export const Column = Node.create({ }, }); +// Schema v2 variant: columns hold bare blocks, no dBlock wrapper. +export const FlatColumn = Column.extend({ + content: 'block+', +}); + export default Column; diff --git a/package/extensions/multi-column/columns.ts b/package/extensions/multi-column/columns.ts index ae4ce276..f333e6b4 100644 --- a/package/extensions/multi-column/columns.ts +++ b/package/extensions/multi-column/columns.ts @@ -5,7 +5,7 @@ import { Predicate, findParentNodeClosestToPos, } from '@tiptap/core'; -import { NodeSelection } from 'prosemirror-state'; +import { NodeSelection, TextSelection } from 'prosemirror-state'; import type { Node as ProseMirrorNode, NodeType } from 'prosemirror-model'; import { ColumnSelection } from './column-selection'; import { @@ -117,20 +117,21 @@ export const Columns = Node.create({ } // create columns and put old content in the first column + const hasDBlock = Boolean(doc.type.schema.nodes.dBlock); let columnBlock; if (keepContent) { const content = sel.content().toJSON(); const firstColumn = buildColumn(content); - const otherColumns = buildNColumns(n - 1); + const otherColumns = buildNColumns(n - 1, hasDBlock); columnBlock = buildColumnBlock({ content: [firstColumn, ...otherColumns], }); } else { - const columns = buildNColumns(n); + const columns = buildNColumns(n, hasDBlock); columnBlock = buildColumnBlock({ content: columns }); } const newNode = doc.type.schema.nodeFromJSON( - buildDBlock({ content: [columnBlock] }), + hasDBlock ? buildDBlock({ content: [columnBlock] }) : columnBlock, ); if (newNode === null) { return; @@ -154,7 +155,19 @@ export const Columns = Node.create({ } tr = tr.setSelection(sel); + const columnsStart = tr.selection.from; tr = tr.replaceSelectionWith(newNode, false); + // Caret into the first cell, computed against THIS transaction: + // open tokens for columns + column (+ dBlock in v1), then + // TextSelection.near finds the first text position inside. + const intoFirstCell = hasDBlock ? 3 : 2; + tr = tr.setSelection( + TextSelection.near( + tr.doc.resolve( + Math.min(columnsStart + intoFirstCell, tr.doc.content.size), + ), + ), + ); return dispatch(tr); } catch (error) { console.error(error); diff --git a/package/extensions/multi-column/utils.ts b/package/extensions/multi-column/utils.ts index 0694d1b5..e423c1d6 100644 --- a/package/extensions/multi-column/utils.ts +++ b/package/extensions/multi-column/utils.ts @@ -19,12 +19,10 @@ export const buildColumn = ({ content }: Partial) => export const buildColumnBlock = ({ content }: Partial) => buildNode({ type: 'columns', content }); -export const buildNColumns = (n: number) => { - const content = [ - buildDBlock({ - content: [buildParagraph({})], - }), - ]; +export const buildNColumns = (n: number, wrapInDBlock = true) => { + const content = wrapInDBlock + ? [buildDBlock({ content: [buildParagraph({})] })] + : [buildParagraph({})]; const fn = () => buildColumn({ content }); return times(n, fn); }; diff --git a/package/extensions/resizable-media/media-caption.ts b/package/extensions/resizable-media/media-caption.ts index 5efde158..d301e04b 100644 --- a/package/extensions/resizable-media/media-caption.ts +++ b/package/extensions/resizable-media/media-caption.ts @@ -1,5 +1,6 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { TextSelection } from '@tiptap/pm/state'; +import { schemaHasDBlock, wrapBlockNode } from '../../utils/block-schema'; export const MediaCaption = Node.create({ name: 'mediaCaption', @@ -69,14 +70,17 @@ export const MediaCaption = Node.create({ })(); if (parentDBlockDepth === -1) { - // resizableMedia is not inside a dBlock — insert a dBlock after it + // No dBlock ancestor: the flat v2 schema, or v1 edge cases. + // Caret lands inside the new paragraph: +1 into it, +1 more when a + // dBlock wrapper is added around it. + const caretOffset = schemaHasDBlock(editor.schema) ? 2 : 1; return editor .chain() - .insertContentAt(afterMedia, { - type: 'dBlock', - content: [{ type: 'paragraph' }], - }) - .focus(afterMedia + 2) + .insertContentAt( + afterMedia, + wrapBlockNode(editor.schema, { type: 'paragraph' }), + ) + .focus(afterMedia + caretOffset) .run(); } diff --git a/package/extensions/resizable-media/resizable-media.ts b/package/extensions/resizable-media/resizable-media.ts index 91a0f6b2..ead5232d 100644 --- a/package/extensions/resizable-media/resizable-media.ts +++ b/package/extensions/resizable-media/resizable-media.ts @@ -6,6 +6,7 @@ import { getMediaPasteDropPlugin } from './media-paste-drop-plugin'; import UploadImagesPlugin from '../../utils/upload-images'; import { InlineLoaderPlugin } from '../../utils/inline-loader'; import { IpfsImageFetchPayload, IpfsImageUploadResponse } from '../../types'; +import { wrapBlockNode } from '../../utils/block-schema'; // Background color of a media element, from an inline style or the // data-background-color round-trip attribute (whichever is present). @@ -312,14 +313,10 @@ export const ResizableMedia = Node.create({ const pos = selection.$to.pos; - return editor.commands.insertContentAt(pos, { - type: 'dBlock', - content: [ - { - type: 'paragraph', - }, - ], - }); + return editor.commands.insertContentAt( + pos, + wrapBlockNode(editor.schema, { type: 'paragraph' }), + ); }, }; }, diff --git a/package/extensions/typography-persistence.ts b/package/extensions/typography-persistence.ts index 26538cd1..9ae98e82 100644 --- a/package/extensions/typography-persistence.ts +++ b/package/extensions/typography-persistence.ts @@ -61,8 +61,18 @@ export const TypographyPersistence = Extension.create({ newState.doc.nodesBetween(safeStart, safeEnd, (node, pos) => { if (node.type.name !== 'paragraph') return; - // Skip trailing node — user should be able to clear it + // Skip trailing node — user should be able to clear it. + // v1's custom TrailingNode tags it with a class; v2 uses + // Tiptap's stock one, which adds no attrs, so the trailing + // paragraph is identified by position instead: the last + // top-level child, still empty. if (node.attrs.class === 'trailing-node') return; + if ( + node.content.size === 0 && + newState.doc.lastChild === node + ) { + return; + } // Already has both — nothing to inherit if (node.attrs.fontFamily && node.attrs.fontSize) return; diff --git a/package/extensions/undo-selection.test.ts b/package/extensions/undo-selection.test.ts new file mode 100644 index 00000000..357066bd --- /dev/null +++ b/package/extensions/undo-selection.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import type { AnyExtension } from '@tiptap/core'; +import * as Y from 'yjs'; +import { getHeadlessExtensions } from '../hooks/use-headless-editor'; +import { isUndoRedoSelection } from './undo-selection'; + +const makeEditor = (schemaVersion: number) => + new Editor({ + extensions: getHeadlessExtensions({ + ydoc: new Y.Doc(), + schemaVersion, + }) as AnyExtension[], + textDirection: 'auto', + }); + +// The UndoManager merges everything inside a 500ms window into one stack item. +const settle = (ms = 600) => new Promise((resolve) => setTimeout(resolve, ms)); + +const selectWord = (editor: Editor, word: string) => { + let at: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (at === null && node.isText && node.text?.includes(word)) { + at = pos + (node.text?.indexOf(word) ?? 0); + } + }); + if (at === null) throw new Error(`"${word}" not found`); + editor.chain().focus().setTextSelection({ from: at, to: at + word.length }).run(); +}; + +const selectedText = (editor: Editor) => { + const { from, to } = editor.state.selection; + return editor.state.doc.textBetween(from, to, ' '); +}; + +describe.each([ + ['v1 (dBlock)', 1], + ['v2 (flat)', 2], +])('undo selection — %s', (_label, schemaVersion) => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + // Yjs restores the selection captured before the change it is undoing, which + // runs one step behind: undoing the colour change used to leave the + // previously-formatted word highlighted, and the bubble menu opened over it. + it('selects the range that each undo actually changed', async () => { + editor = makeEditor(schemaVersion); + editor.commands.setContent('

alpha bravo charlie delta echo

'); + await settle(); + + selectWord(editor, 'alpha'); + editor.chain().focus().toggleBold().run(); + await settle(); + + selectWord(editor, 'charlie'); + editor.chain().focus().toggleItalic().run(); + await settle(); + + selectWord(editor, 'echo'); + editor.chain().focus().toggleUnderline().run(); + await settle(); + + editor.commands.undo(); + await settle(150); + expect(selectedText(editor)).toBe('echo'); + + editor.commands.undo(); + await settle(150); + expect(selectedText(editor)).toBe('charlie'); + + editor.commands.undo(); + await settle(150); + expect(selectedText(editor)).toBe('alpha'); + + expect(editor.isActive('bold')).toBe(false); + expect(editor.isActive('italic')).toBe(false); + expect(editor.isActive('underline')).toBe(false); + }); + + it('flags the selection as machine-made, and clears on the next gesture', async () => { + editor = makeEditor(schemaVersion); + editor.commands.setContent('

alpha bravo charlie

'); + await settle(); + + selectWord(editor, 'alpha'); + editor.chain().focus().toggleBold().run(); + await settle(); + expect(isUndoRedoSelection(editor.state)).toBe(false); + + editor.commands.undo(); + await settle(150); + expect(isUndoRedoSelection(editor.state)).toBe(true); + + selectWord(editor, 'charlie'); + expect(isUndoRedoSelection(editor.state)).toBe(false); + }); + + // An undo can empty the Yjs fragment outright (undoing an edit that + // replaced the initial paragraph — a heading insert does). ProseMirror + // still renders its mandatory empty paragraph, and the next dispatch made + // the y-sync binding write it back as a fresh TRACKED change, which the + // UndoManager captured — wiping the redo stack. Redo was dead for any + // heading-bearing insert, in both schemas. + it('keeps redo alive when undo empties the document', async () => { + editor = makeEditor(schemaVersion); + editor + .chain() + .focus() + .insertContent([ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Only heading' }], + }, + ]) + .run(); + await settle(); + const inserted = editor.getText().trim(); + expect(inserted).toBe('Only heading'); + + editor.commands.undo(); + await settle(); + expect(editor.getText().trim()).toBe(''); + + // In the app SOMETHING always dispatches between undo and redo (the TOC + // debounce, a caret move); that dispatch is what made the binding write + // the phantom paragraph back as a tracked change. Reproduce it. + editor.view.dispatch(editor.state.tr); + await settle(150); + + editor.commands.redo(); + await settle(); + expect(editor.getText().trim()).toBe(inserted); + }); + + it('leaves a plain edit untouched', async () => { + editor = makeEditor(schemaVersion); + editor.commands.setContent('

alpha

'); + await settle(); + + editor.chain().focus('end').insertContent(' bravo').run(); + await settle(); + expect(isUndoRedoSelection(editor.state)).toBe(false); + expect(editor.state.selection.empty).toBe(true); + }); +}); diff --git a/package/extensions/undo-selection.ts b/package/extensions/undo-selection.ts new file mode 100644 index 00000000..a4b7008e --- /dev/null +++ b/package/extensions/undo-selection.ts @@ -0,0 +1,151 @@ +import { Extension } from '@tiptap/core'; +import { isChangeOrigin } from '@tiptap/extension-collaboration'; +import { + Plugin, + PluginKey, + TextSelection, + type EditorState, + type Transaction, +} from '@tiptap/pm/state'; + +/** + * Undo/redo here is Yjs's UndoManager, and its selection restore lags one step + * behind the change it just applied: bold a word, colour a second, italicise a + * third, then undo three times and you get the *previous* word highlighted + * each time — the bubble menu opens over text that did not change. + * + * y-prosemirror rebuilds the whole document on undo (`tr.replace(0, size, …)`), + * so the transaction's own step maps span everything and say nothing about + * what moved. Diff the two documents instead and put the selection on the + * range that genuinely differs. + */ + +// The y-sync meta carries `isUndoRedoOperation`, but reading it by plugin key +// would mean importing y-tiptap directly — a transitive dependency whose +// PluginKey instance is not guaranteed to be the one Collaboration used, and a +// mismatched key silently reads back `undefined`. Match on the shape instead. +const isUndoRedo = (transaction: Transaction): boolean => { + if (!isChangeOrigin(transaction)) { + return false; + } + const meta = (transaction as unknown as { meta?: Record }) + .meta; + return Object.values(meta ?? {}).some( + (value) => + typeof value === 'object' && + value !== null && + (value as { isUndoRedoOperation?: unknown }).isUndoRedoOperation === true, + ); +}; + +export const undoSelectionKey = new PluginKey('undoSelection'); + +/** + * True while the current selection is one this plugin placed. The bubble menu + * reads it so a toolbar never appears over a selection the user did not make + * with their own gesture; the next click, drag or keystroke clears it. + */ +export const isUndoRedoSelection = (state: EditorState): boolean => + undoSelectionKey.getState(state) === true; + +// The y-sync plugin's state, read by the key NAME rather than the key +// instance for the same reason as `isUndoRedo` above. `type` is the +// Y.XmlFragment this editor binds to. +const getYSyncFragment = (state: EditorState): { length: number } | null => { + const ystate = (state as unknown as Record)['y-sync$'] as + | { type?: { length: number } } + | undefined; + return ystate?.type ?? null; +}; + +export const UndoSelection = Extension.create({ + name: 'undoSelection', + + addProseMirrorPlugins() { + const { editor } = this; + + // Redo dies whenever an undo empties the Yjs fragment outright (which + // happens when the undone edit had replaced the initial paragraph — a + // heading insert does). ProseMirror cannot render an empty document, so + // the view shows its mandatory empty paragraph, and on the NEXT dispatch + // (the TOC debounce, a caret move, anything) the y-sync binding writes + // that paragraph back to Yjs as a fresh tracked change — which the + // UndoManager captures, wiping the redo stack. Force that reconciliation + // to happen right now instead, under `addToHistory: false`, so it is + // never captured and redo survives. + const reconcileEmptiedFragment = (state: EditorState) => { + const fragment = getYSyncFragment(state); + if (!fragment || fragment.length !== 0) { + return; + } + queueMicrotask(() => { + if (editor.isDestroyed) { + return; + } + const current = getYSyncFragment(editor.state); + if (!current || current.length !== 0) { + return; + } + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)); + }); + }; + + return [ + new Plugin({ + key: undoSelectionKey, + state: { + init: () => false, + apply: (transaction, value) => { + if (transaction.getMeta(undoSelectionKey) === true) { + return true; + } + return transaction.selectionSet || transaction.docChanged + ? false + : value; + }, + }, + appendTransaction: (transactions, oldState, newState) => { + const undoRedo = transactions.some( + (transaction) => transaction.docChanged && isUndoRedo(transaction), + ); + if (!undoRedo) { + return null; + } + + reconcileEmptiedFragment(newState); + + const from = oldState.doc.content.findDiffStart(newState.doc.content); + if (from === null || from === undefined) { + return null; + } + const ends = oldState.doc.content.findDiffEnd(newState.doc.content); + if (!ends) { + return null; + } + + // `ends.b` is the end of the differing range in the NEW document. + // An undo that only removed content leaves it before `from`. + const to = Math.min( + Math.max(from, ends.b), + newState.doc.content.size, + ); + + const tr = newState.tr + .setSelection( + TextSelection.between( + newState.doc.resolve(Math.min(from, newState.doc.content.size)), + newState.doc.resolve(to), + ), + ) + .setMeta(undoSelectionKey, true); + + // Deliberately NOT `addToHistory: false` — see the note in + // extensions/block-id. This changes no content, so it records + // nothing either way, and the flag would leak onto the y-sync write + // for this whole dispatch. + return tr; + }, + }), + ]; + }, +}); diff --git a/package/hooks/use-doc-schema-version.ts b/package/hooks/use-doc-schema-version.ts new file mode 100644 index 00000000..71d23325 --- /dev/null +++ b/package/hooks/use-doc-schema-version.ts @@ -0,0 +1,66 @@ +import { useEffect, useMemo, useReducer } from 'react'; +import * as Y from 'yjs'; +import { + DDOC_META_ROOT_KEY, + SCHEMA_VERSION_META_KEY, + SUPPORTED_SCHEMA_VERSION, +} from '../utils/schema-version'; + +interface UseDocSchemaVersionArgs { + ydoc: Y.Doc; + // Mirrors useTabManager's isNewDdoc: owner, no collab, no initial content. + isNewDdoc: boolean; + // False while IndexedDB may still replay content for this doc; a doc is + // only "genuinely new" once that possibility is exhausted. + isContentResolved: boolean; + preferredSchemaVersion?: number; +} + +// Resolves which schema a doc uses and stamps brand-new docs with the +// preferred version. Must be called AFTER useTabManager in the hook order: +// useTabManager decodes initialContent into the ydoc synchronously during +// render, and reading the marker here at render time (not in state) means +// the very first editor build already sees the right version. +export const useDocSchemaVersion = ({ + ydoc, + isNewDdoc, + isContentResolved, + preferredSchemaVersion = 1, +}: UseDocSchemaVersionArgs) => { + const [, bumpOnMetaChange] = useReducer((count: number) => count + 1, 0); + + useEffect(() => { + const metaMap = ydoc.getMap(DDOC_META_ROOT_KEY); + metaMap.observe(bumpOnMetaChange); + return () => metaMap.unobserve(bumpOnMetaChange); + }, [ydoc]); + + const rawMarker = ydoc.getMap(DDOC_META_ROOT_KEY).get(SCHEMA_VERSION_META_KEY); + const markerVersion = typeof rawMarker === 'number' ? rawMarker : null; + const shouldStampNewDoc = + markerVersion === null && + isContentResolved && + isNewDdoc && + preferredSchemaVersion >= 2; + + // The package owns the marker: a doc is v2 iff its marker says so. + // Render-phase write follows the deriveTabsFromEncodedState precedent + // (tab seeding also writes to the ydoc from a useMemo). 'self' origin: + // bootstrapping metadata is not a user edit, same as tab seeding. + useMemo(() => { + if (!shouldStampNewDoc) return; + ydoc.transact(() => { + ydoc + .getMap(DDOC_META_ROOT_KEY) + .set(SCHEMA_VERSION_META_KEY, preferredSchemaVersion); + }, 'self'); + }, [shouldStampNewDoc, preferredSchemaVersion, ydoc]); + + const docSchemaVersion = + markerVersion ?? (shouldStampNewDoc ? preferredSchemaVersion : 1); + + return { + docSchemaVersion, + isSchemaUnsupported: docSchemaVersion > SUPPORTED_SCHEMA_VERSION, + }; +}; diff --git a/package/hooks/use-headless-editor.test.tsx b/package/hooks/use-headless-editor.test.tsx new file mode 100644 index 00000000..b3f11537 --- /dev/null +++ b/package/hooks/use-headless-editor.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import * as Y from 'yjs'; +import { toUint8Array } from 'js-base64'; +import { useHeadlessEditor } from './use-headless-editor'; +import { getDocSchemaVersion } from '../utils/schema-version'; + +// v1-shaped template JSON — the app's template-utils source-of-truth shape. +const v1TemplateJSON = { + type: 'doc', + content: [ + { + type: 'dBlock', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Pretend to work' }], + }, + ], + }, + { + type: 'dBlock', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'step one' }], + }, + ], + }, + ], +}; + +// What a stored blob looks like to the rest of the system: apply it to a +// fresh Y.Doc (exactly what the real editor mount does) and inspect. +const decodeBlob = (base64: string) => { + const doc = new Y.Doc(); + Y.applyUpdate(doc, toUint8Array(base64)); + const fragment = doc.getXmlFragment('default'); + const topLevelTypes: string[] = []; + for (let i = 0; i < fragment.length; i++) { + topLevelTypes.push((fragment.get(i) as Y.XmlElement).nodeName); + } + return { doc, topLevelTypes, schemaVersion: getDocSchemaVersion(doc) }; +}; + +const convertWith = ( + options: { schemaVersion?: number } | undefined, + json: object, +) => { + const { result } = renderHook(() => useHeadlessEditor()); + const convertor = result.current.getYjsConvertor(options); + try { + return convertor.convertJSONContentToYjsEncodedString(json); + } finally { + convertor.cleanup(); + } +}; + +describe('useHeadlessEditor schema-version support (M3 template creation)', () => { + it('stamps the marker and stores flat content when converting as v2', () => { + const blob = convertWith({ schemaVersion: 2 }, v1TemplateJSON); + const { topLevelTypes, schemaVersion } = decodeBlob(blob); + + // The stamp must be inside the blob: at first real mount the doc + // already has content, so useDocSchemaVersion will never stamp it. + expect(schemaVersion).toBe(2); + // dBlock wrappers hoisted away, real blocks at the top level. + expect(topLevelTypes[0]).toBe('heading'); + expect(topLevelTypes).toContain('paragraph'); + expect(topLevelTypes).not.toContain('dBlock'); + }); + + it('preserves the template text through the v2 conversion', () => { + const blob = convertWith({ schemaVersion: 2 }, v1TemplateJSON); + const { doc } = decodeBlob(blob); + const heading = doc.getXmlFragment('default').get(0) as Y.XmlElement; + // The heading itself must be the top-level node (not buried in a + // wrapper) and still carry its text after the unwrap. + expect(heading.nodeName).toBe('heading'); + expect(heading.toString()).toContain('Pretend to work'); + }); + + it('keeps the default conversion byte-compatible with v1: wrappers kept, no stamp', () => { + const blob = convertWith(undefined, v1TemplateJSON); + const { topLevelTypes, doc } = decodeBlob(blob); + + expect(topLevelTypes[0]).toBe('dBlock'); + // Absence of the marker (not a `1`) is the v1 contract. + expect(doc.getMap('ddocMeta').get('schemaVersion')).toBeUndefined(); + }); + + it('accepts already-flat JSON through the v2 convertor unchanged', () => { + const flatJSON = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'already flat' }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'body' }] }, + ], + }; + const blob = convertWith({ schemaVersion: 2 }, flatJSON); + const { topLevelTypes, schemaVersion } = decodeBlob(blob); + + expect(schemaVersion).toBe(2); + expect(topLevelTypes[0]).toBe('heading'); + expect(topLevelTypes).not.toContain('dBlock'); + }); +}); diff --git a/package/hooks/use-headless-editor.tsx b/package/hooks/use-headless-editor.tsx index f15a1bb3..0422e3f4 100644 --- a/package/hooks/use-headless-editor.tsx +++ b/package/hooks/use-headless-editor.tsx @@ -8,6 +8,11 @@ import * as Y from 'yjs'; import { isJSONString } from '../utils/isJsonString'; import { fromUint8Array, toUint8Array } from 'js-base64'; import { sanitizeContent } from '../utils/sanitize-content'; +import { unwrapDBlocksInJSON } from '../utils/block-schema'; +import { + DDOC_META_ROOT_KEY, + SCHEMA_VERSION_META_KEY, +} from '../utils/schema-version'; import { handleMarkdownContent } from '../extensions/mardown-paste-handler'; import { IpfsImageUploadResponse } from '../types'; import mammoth from 'mammoth'; @@ -25,6 +30,7 @@ export interface UseHeadlessEditorProps { export const getHeadlessExtensions = (options?: { ydoc?: Y.Doc; optionalExtensions?: string[]; + schemaVersion?: number; }): AnyExtension[] => { const ydoc = options?.ydoc ?? new Y.Doc(); @@ -42,7 +48,10 @@ export const getHeadlessExtensions = (options?: { }; return [ - ...defaultExtensions({ onError: () => null }).filter( + ...defaultExtensions({ + onError: () => null, + schemaVersion: options?.schemaVersion, + }).filter( (extension) => extension.name !== 'characterCount', ), customTextInputRules, @@ -53,12 +62,26 @@ export const getHeadlessExtensions = (options?: { }; export const useHeadlessEditor = (props?: UseHeadlessEditorProps) => { - const getEditor = () => { + const getEditor = (options?: { schemaVersion?: number }) => { const ydoc = new Y.Doc(); + // Blobs produced headlessly (templates, imports) already have content + // when the real editor first mounts them, so useDocSchemaVersion will + // never stamp them — the stamp must be born inside the blob here, or + // the doc is treated as legacy v1 forever. 'self' origin, same as the + // mount-time stamping: bootstrapping metadata is not a user edit. + if ((options?.schemaVersion ?? 1) >= 2) { + ydoc.transact(() => { + ydoc + .getMap(DDOC_META_ROOT_KEY) + .set(SCHEMA_VERSION_META_KEY, options?.schemaVersion); + }, 'self'); + } + const extensions = getHeadlessExtensions({ ydoc, optionalExtensions: props?.optionalExtensions, + schemaVersion: options?.schemaVersion, }); const editor = new Editor({ @@ -100,14 +123,22 @@ export const useHeadlessEditor = (props?: UseHeadlessEditorProps) => { Y.applyUpdate(ydoc, toUint8Array(initialContent as string)); } } else { + const hasDBlock = Boolean(editor.schema.nodes.dBlock); editor.commands.setContent( - sanitizeContent({ data: initialContent as JSONContent }), + sanitizeContent({ + // v1-shaped JSON (templates, legacy exports) cannot load into the + // flat schema; hoist the real blocks out of their dBlock wrappers. + data: hasDBlock + ? (initialContent as JSONContent) + : unwrapDBlocksInJSON(initialContent as JSONContent), + wrapInDBlock: hasDBlock, + }), ); } }; - const getYjsConvertor = () => { - const { editor, ydoc } = getEditor(); + const getYjsConvertor = (options?: { schemaVersion?: number }) => { + const { editor, ydoc } = getEditor(options); return { convertJSONContentToYjsEncodedString: (content: JSONContent) => { setContent(content, editor, ydoc); diff --git a/package/hooks/use-tab-editor-cache.ts b/package/hooks/use-tab-editor-cache.ts index 67f1c64c..6124a823 100644 --- a/package/hooks/use-tab-editor-cache.ts +++ b/package/hooks/use-tab-editor-cache.ts @@ -30,7 +30,13 @@ interface UseTabEditorCacheArgs { destroyEditor: (editor: Editor) => void; } -const PREVIOUS_TAB_CACHE_LIMIT = 2; +// Editors for the N most recently visited tabs stay warm; anything beyond is +// destroyed and rebuilt on revisit. At 2, a user rotating through 3+ tabs got +// a full editor teardown+rebuild on EVERY switch — on 10k-word tabs that is +// the dominant source of transient garbage (measured: ~43k detached DOM +// nodes per 4-tab rotation, vs ~23k with all tabs warm, while 4 warm +// 10k-word editors cost only ~1MB heap / ~3k live nodes over 2). +const PREVIOUS_TAB_CACHE_LIMIT = 4; const setInactiveEditorDOMState = (editor: Editor, isInactive: boolean) => { const dom = editor.view?.dom; diff --git a/package/hooks/use-tab-editor.test.ts b/package/hooks/use-tab-editor.test.ts new file mode 100644 index 00000000..54f19d3c --- /dev/null +++ b/package/hooks/use-tab-editor.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { getHeadlessExtensions } from './use-headless-editor'; +import { TAB_EDITOR_ATTRIBUTES } from './use-tab-editor'; + +/** + * Regression coverage for a second round of the Task 3 browser-verification + * bug: `new Editor({ editorProps: { ...DdocEditorProps, ..., attributes: { + * spellCheck: 'true' } } })` in `createEditorForTab` (use-tab-editor.tsx) + * placed a local `attributes:` key AFTER `...DdocEditorProps` in the same + * object literal. In a plain JS object literal, a later duplicate key wins + * outright — it does not merge with the earlier one — so this silently + * replaced DdocEditorProps' whole `attributes` record (the + * `main-doc-editor`/prose classes, `spellcheck`, and + * `suppressContentEditableWarning`) with just `{ spellCheck: 'true' }`, + * confirmed live via `editor.options.editorProps.attributes`. This predates + * Task 3 (it silently dropped the prose classes too) but Task 3's container + * padding is keyed on `main-doc-editor`, which made it Critical. + * + * `TAB_EDITOR_ATTRIBUTES` is the extracted, single source of truth for + * those attributes, used at the `new Editor(...)` call site instead of a + * second inline `attributes:` key — this locks in that there's nowhere left + * for that collision to reappear. + */ +describe('TAB_EDITOR_ATTRIBUTES', () => { + it('carries DdocEditorProps.attributes forward unclobbered', () => { + expect(TAB_EDITOR_ATTRIBUTES.class).toMatch(/\bmain-doc-editor\b/); + expect(TAB_EDITOR_ATTRIBUTES.class).toMatch(/\bprose\b/); + expect(TAB_EDITOR_ATTRIBUTES.spellcheck).toBe('true'); + expect(TAB_EDITOR_ATTRIBUTES.suppressContentEditableWarning).toBe('true'); + // The construction-site literal used to additionally (and redundantly) + // set a camelCase duplicate — confirm it's gone now that the merge + // point is this single constant rather than a second inline key. + expect(TAB_EDITOR_ATTRIBUTES.spellCheck).toBeUndefined(); + }); + + it('smoke test: an Editor constructed with these attributes renders main-doc-editor on its DOM root', () => { + const editor = new Editor({ + extensions: getHeadlessExtensions(), + editorProps: { attributes: TAB_EDITOR_ATTRIBUTES }, + textDirection: 'auto', + }); + try { + expect(editor.view.dom.className).toMatch(/\bmain-doc-editor\b/); + } finally { + editor.destroy(); + } + }); +}); diff --git a/package/hooks/use-tab-editor.tsx b/package/hooks/use-tab-editor.tsx index bbaaeaed..f949d7fe 100644 --- a/package/hooks/use-tab-editor.tsx +++ b/package/hooks/use-tab-editor.tsx @@ -75,6 +75,20 @@ import { import { destroyEditorWithYSyncCleanup } from '../utils/y-prosemirror-cleanup'; import { clearTableOfContentsStorage } from '../extensions/table-of-contents'; import { useTabEditorCache } from './use-tab-editor-cache'; + +// The single source of truth for the tab editor's construction-time +// `editorProps.attributes` (the `main-doc-editor`/prose classes, +// `spellcheck`, `suppressContentEditableWarning`). Extracted to a named, +// independently-testable constant so a future local `attributes:` key +// added inline at the `new Editor({ editorProps: { ... } })` call site +// below can't silently collide with — and replace — the spread of +// `DdocEditorProps` the way the pre-existing bug did (a later `attributes:` +// key in the same object literal overwrites the earlier one from +// `...DdocEditorProps`, rather than merging). +export const TAB_EDITOR_ATTRIBUTES: Record = { + ...(DdocEditorProps.attributes as Record), +}; + const usercolors = [ '#30bced', '#6eeb83', @@ -266,6 +280,7 @@ interface UseTabEditorArgs { editorRef?: MutableRefObject; initialCommentAnchors?: SerializedCommentAnchor[]; dBlockRuntimeStateRef?: DBlockRuntimeStateRef; + docSchemaVersion?: number; } export const useTabEditor = ({ @@ -316,6 +331,7 @@ export const useTabEditor = ({ editorRef, initialCommentAnchors, dBlockRuntimeStateRef, + docSchemaVersion = 1, }: UseTabEditorArgs) => { const collabEnabled = collaboration?.enabled === true; const connection = collabEnabled ? collaboration.connection : null; @@ -382,6 +398,7 @@ export const useTabEditor = ({ initialCommentAnchors, isSuggestionMode, dBlockRuntimeStateRef: resolvedDBlockRuntimeStateRef, + docSchemaVersion, }); const { handleCommentInteraction, handleCommentClick } = @@ -590,8 +607,16 @@ export const useTabEditor = ({ return false; }, + // `...DdocEditorProps` above already sets `attributes` as ONE key + // inside this same object literal — a later `attributes:` key + // replaces the earlier one wholesale rather than merging, so this + // must build on the same merged value (TAB_EDITOR_ATTRIBUTES, see + // above) instead of introducing a colliding fresh literal. + // data-schema-version lets CSS target one schema (v1 keeps the + // dBlock-era compensation rules, v2 gets its own rhythm). attributes: { - spellCheck: 'true', + ...TAB_EDITOR_ATTRIBUTES, + 'data-schema-version': String(docSchemaVersion), }, }, textDirection: 'auto', @@ -606,6 +631,7 @@ export const useTabEditor = ({ focusSubmittedSuggestionFromEditorEvent, handleCommentClick, handleCommentInteraction, + docSchemaVersion, ], ); @@ -861,6 +887,7 @@ export const useTabEditor = ({ data: initialContent as JSONContent, ignoreCorruptedData, onInvalidContentError, + wrapInDBlock: Boolean(editor.schema.nodes.dBlock), }), ); } @@ -1537,6 +1564,7 @@ interface UseExtensionStackArgs { initialCommentAnchors?: SerializedCommentAnchor[]; isSuggestionMode?: boolean; dBlockRuntimeStateRef: DBlockRuntimeStateRef; + docSchemaVersion?: number; } const useEditorExtension = ({ @@ -1560,6 +1588,7 @@ const useEditorExtension = ({ initialCommentAnchors, isSuggestionMode = false, dBlockRuntimeStateRef, + docSchemaVersion = 1, }: UseExtensionStackArgs) => { const onErrorRef = useRef(onError); onErrorRef.current = onError; @@ -1650,6 +1679,7 @@ const useEditorExtension = ({ onTocUpdateForTab(tabId, data, isCreate), hasAvailableModels, dBlockRuntimeStateRef, + schemaVersion: docSchemaVersion, }), createSlashCommand(), customTextInputRules, @@ -1722,12 +1752,17 @@ const useEditorExtension = ({ tone: 'neutral', }), AIWriter, - createDBlockExtension({ - hasAvailableModels, - ipfsImageUploadFn, - onCopyHeadingLink: handleCopyHeadingLink, - getRuntimeState: () => dBlockRuntimeStateRef.current, - }), + // v2 has no dBlock: the filter above removed nothing, re-add nothing. + ...(docSchemaVersion >= 2 + ? [] + : [ + createDBlockExtension({ + hasAvailableModels, + ipfsImageUploadFn, + onCopyHeadingLink: handleCopyHeadingLink, + getRuntimeState: () => dBlockRuntimeStateRef.current, + }), + ]), createSlashCommand(), ] as AnyExtension[]; }, @@ -1747,6 +1782,7 @@ const useEditorExtension = ({ activeModel, maxTokens, onCommentActivated, + docSchemaVersion, ], ); diff --git a/package/preview-ddoc-editor.tsx b/package/preview-ddoc-editor.tsx index 84a99024..40b111b1 100644 --- a/package/preview-ddoc-editor.tsx +++ b/package/preview-ddoc-editor.tsx @@ -163,6 +163,23 @@ const PreviewDdocEditorContent = forwardRef( const isMobile = useMediaQuery('(max-width: 768px)'); + // Newer-schema docs never bind an editor in this build (schema guard in + // useDdocEditor); show a refresh prompt instead of the preview surface. + if (rest.isSchemaUnsupported) { + return ( +
+

+ Update needed to open this document +

+

+ This document was created with a newer version of the app. Refresh + the page to update and open it. +

+ +
+ ); + } + return ( <> {editor && rest.tabs.length > 0 && ( @@ -191,7 +208,7 @@ const PreviewDdocEditorContent = forwardRef( {!editor || isContentLoading || isLoading ? fadeInTransition( -
+
@@ -271,6 +288,7 @@ const PreviewDdocEditorContent = forwardRef( .is-empty:first-child::before { content: 'Type / to browse options'; font-family: system-ui; } - > * + * p.is-empty::before { - content: none; - } - /* Callout block placeholder (non-focused fallback) */ aside[data-type='callout'] p.is-empty:first-of-type::before { content: 'Type to make writing stand out'; @@ -63,16 +59,28 @@ } } - h1 { - transform: translateY(-0.5rem); + .katex-display > .katex > .katex-html { + white-space: normal; } - h2 { - transform: translateY(-0.25rem); + ul + ul { + margin-top: 1.5rem; } - h3 { - transform: translateY(0); + /* Flat v2 rhythm: match v1's effective uniform 24px block gap. prose-lg + gives headings 48px margins; v1 never shows them (margins collapse + through the wrapper rows), so v2 pins headings to the same 1.5rem as + paragraphs. */ + &[data-schema-version='2'] { + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 1.5rem; + margin-bottom: 1.5rem; + } } & > p { @@ -333,12 +341,10 @@ ul { padding: 0; - margin: 0; } ol { padding: 0; - margin: 0; } li { @@ -469,12 +475,21 @@ .task-item { grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; - align-items: center; + /* Top-align, not center: on multi-line items the checkbox must sit on + the FIRST line (TEC-2644), not float at the item's vertical middle. */ + align-items: start; &:has(> div:has(ul)) { row-gap: 0.5rem; } > label { grid-area: 1/1/2/2; + /* flex kills the inline line-box slack that pushed the checkbox a few + px below the label top (block label + inline-grid input). */ + display: flex; + /* Optically center the 1.25rem checkbox within the first text line + (1.5 line-height): (1.5em - 1.25rem) / 2, in em so it tracks the + document font size. */ + margin-top: calc(0.75em - 0.625rem); } > div { p { @@ -489,6 +504,143 @@ } } +.ProseMirror.main-doc-editor { + --padding-t: 24px; + --padding-r: 16px; + --padding-b: 20vh; + --padding-l: 36px; + padding: var(--padding-t) var(--padding-r) var(--padding-b) var(--padding-l); +} + +/* The floating handle sits in the container's left padding, which is part of + .ProseMirror's own box. Both are positioned siblings in one stacking + context, so without an explicit z-index the winner is decided by DOM + insertion order — and the plugin's element lands before .ProseMirror in + some mounts and after it in others. When it lands first the editor surface + paints over the cluster: it stays visible but every click hits the editor + instead of the buttons. Pin it above the surface so the order stops + mattering. */ +.drag-handle { + z-index: 5; +} + +@media (min-width: 768px) { + .ProseMirror.main-doc-editor { + --padding-t: 72px; + --padding-r: 80px; + --padding-b: 20vh; + --padding-l: var(--padding-r); + padding: var(--padding-t) var(--padding-r) var(--padding-b) var(--padding-l); + } +} + +/* Read-only-preview heading chrome (collapse chevron + copy link), rendered + by the dBlock node view INSIDE the editor DOM — the floating drag-handle + cluster can never appear in preview because the upstream plugin hides its + element whenever the editor is not editable. The node view renders the + controls for headings in every mode; `[contenteditable='false']` is what + scopes them to read-only editors (mode switches flip that attribute + without dispatching a transaction, so a JS-side gate would go stale — + see DBlockNodeView.shouldShowPreviewControls). Sits in the container's + left padding (36px mobile / 80px desktop); hover-revealed, but kept + visible while the heading is collapsed so hidden content stays + discoverable. */ +.ProseMirror [data-type='d-block'] > .d-block-preview-controls { + display: none; +} + +.ProseMirror[contenteditable='false'] + [data-type='d-block'] + > .d-block-preview-controls { + position: absolute; + right: 100%; + top: 2px; + display: flex; + align-items: center; + gap: 2px; + padding-right: 4px; + visibility: hidden; +} + +.ProseMirror[contenteditable='false'] + [data-type='d-block']:hover + > .d-block-preview-controls, +.ProseMirror[contenteditable='false'] + [data-type='d-block'] + > .d-block-preview-controls.is-collapsed { + visibility: visible; +} + +/* Flat-schema equivalent of the block above. There is no wrapper to anchor + to, so the controls are a widget decoration at the heading's inline start + and the heading itself becomes the positioning context. Height is set in + em so the buttons stay centred on the first line at every heading size. */ +.ProseMirror[contenteditable='false'] + :is(h1, h2, h3, h4, h5, h6):has(> .d-block-preview-controls-flat) { + position: relative; +} + +.ProseMirror .d-block-preview-controls-flat { + display: none; +} + +.ProseMirror[contenteditable='false'] .d-block-preview-controls-flat { + position: absolute; + right: 100%; + top: 0; + height: 1.2em; + display: flex; + align-items: center; + gap: 2px; + padding-right: 4px; + visibility: hidden; + user-select: none; +} + +.ProseMirror[contenteditable='false'] + :is(h1, h2, h3, h4, h5, h6):hover + > .d-block-preview-controls-flat, +.ProseMirror[contenteditable='false'] + .d-block-preview-controls-flat.is-collapsed { + visibility: visible; +} + +.ProseMirror .d-block-preview-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + cursor: pointer; +} + +.ProseMirror .d-block-preview-button svg { + transition: transform 150ms ease; +} + +.ProseMirror .d-block-preview-button.is-collapsed svg { + transform: rotate(-90deg); +} + +/* Below the large breakpoint there is no hover to reveal it and the 36px + gutter fits only one button — match the cluster's lg gate and keep just + the collapse chevron (which self-reveals when collapsed). */ +@media (max-width: 1023px) { + .ProseMirror .d-block-preview-copy-link { + display: none; + } +} + +/* table blocks keep their width cap now that the content shell is gone */ +.ProseMirror [data-type='d-block'].is-table > [data-node-view-content] { + max-width: 100%; + margin-inline: auto; +} +@media (min-width: 1024px) { + .ProseMirror [data-type='d-block'].is-table > [data-node-view-content] { + max-width: 90%; + } +} + [data-mode='focus'] { :is(.ProseMirror, .ProseMirror-focused) { p.is-empty::before { @@ -513,10 +665,6 @@ width: 100%; } -[data-ddoc-editor-root='true'] { - padding-bottom: 4rem; -} - .node-dBlock:first-child > div > .is-table { padding-top: 0.5rem; } @@ -941,6 +1089,15 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span { } } + /* presentation preview: container inset replaces old per-block px-4 md:px-[80px] */ + .ProseMirror { + padding: 16px; + + @media (min-width: 768px) { + padding: 16px 80px; + } + } + &.preview-slide { transform: scale(0.25); transform-origin: top left; diff --git a/package/styles/index.css b/package/styles/index.css index 5d2e805b..4afe415f 100644 --- a/package/styles/index.css +++ b/package/styles/index.css @@ -355,12 +355,43 @@ li > ol > li > ol > li:before { z-index: 999; } -.node-view-content > div { +/* On the editor root, so it inherits into every block in both schemas: the + v1 dBlock contentDOM's children and the flat schema's top-level blocks + alike. (Scoping this to `[data-node-view-content]` covers v1 only, since + flat-schema blocks have no node view; scoping it to `.node-view-content + > div` — the original — worked only while the contentDOM was a div child + of the class-bearing shell.) */ +.ProseMirror { word-break: break-word; } +/* Collapse-hidden blocks stay IN layout at zero height rather than + display:none. The floating drag-handle clamps every hover into the span + between the first and last DOM children's rects; a display:none last child + has a [0,0] rect, which clamped every hover off-screen — so ending the + document with a collapsed section killed the block chrome for the whole + document. Zero-height keeps the rect at the right y. overflow:hidden + contains child margins; the margin/padding zeroing kills the block-rhythm + gap the elements would otherwise leave behind. */ .d-block-hidden { - display: none !important; + visibility: hidden !important; + height: 0 !important; + min-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + border: none !important; + overflow: hidden !important; +} + +/* The zero-height boxes above block margin-collapsing between the blocks + around them. In v2 the block after a hidden run is always a heading (a + collapse region ends at the next same-or-higher heading), and v2 headings + carry margins on both sides — so the pair that normally collapses to one + 24px gap stacked to 48px. Drop the follower's top margin; the preceding + heading's bottom margin supplies the rhythm. v1 wraps blocks differently + and measures correctly without this. */ +[data-schema-version='2'] .d-block-hidden + * { + margin-top: 0 !important; } .invalid-content { diff --git a/package/types.ts b/package/types.ts index bd26eb40..60b9195d 100644 --- a/package/types.ts +++ b/package/types.ts @@ -22,7 +22,7 @@ export type { export const DdocEditorProps: EditorProps = { attributes: { - class: `prose-lg prose-headings:font-display prose prose-p:my-2 prose-h1:my-2 prose-h2:my-2 prose-h3:my-2 prose-ul:my-2 prose-ol:my-2 max-w-none focus:outline-none w-full`, + class: `prose-lg prose-headings:font-display prose prose-p:my-2 prose-h1:my-2 prose-h2:my-2 prose-h3:my-2 prose-ul:my-2 prose-ol:my-2 max-w-none focus:outline-none w-full main-doc-editor`, spellcheck: 'true', suppressContentEditableWarning: 'true', }, @@ -240,6 +240,12 @@ export interface DdocProps extends CommentAccountProps { enableIndexeddbSync?: boolean; ddocId?: string; initialContent?: JSONContent | string | string[] | null; + /** + * Schema for newly created docs only: 1 = dBlock (default), 2 = flat. + * Ignored for existing docs, which always follow the schemaVersion marker + * stored inside the doc itself. + */ + preferredSchemaVersion?: 1 | 2; walletAddress?: string | null; username?: string | null; setUsername?: React.Dispatch>; diff --git a/package/use-ddoc-editor.tsx b/package/use-ddoc-editor.tsx index 8d5e0ac5..90dcf808 100644 --- a/package/use-ddoc-editor.tsx +++ b/package/use-ddoc-editor.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { DdocProps } from './types'; +import { useDocSchemaVersion } from './hooks/use-doc-schema-version'; import { useTabEditor } from './hooks/use-tab-editor'; import { useTabManager } from './hooks/use-tab-manager'; import { useYjsSetup } from './hooks/use-yjs-setup'; @@ -45,6 +46,7 @@ export const useDdocEditor = ({ initialCommentAnchors, isPreviewEditor = false, fonts, + preferredSchemaVersion, ...rest }: Partial & { isFocusMode?: boolean; @@ -102,6 +104,10 @@ export const useDdocEditor = ({ !ddocContent, ); + const shouldSyncActiveTab = Boolean( + !isVersionMode && !isPreviewMode && !collabEnabled && rest.isDDocOwner, + ); + const tabManager = useTabManager({ ydoc: yjsSetup.ydoc, initialContent: ddocContent, @@ -118,9 +124,7 @@ export const useDdocEditor = ({ yjsSetup.isIndexeddbSynced), ), defaultTabId: rest.tabConfig?.defaultTabId, - shouldSyncActiveTab: Boolean( - !isVersionMode && !isPreviewMode && !collabEnabled && rest.isDDocOwner, - ), + shouldSyncActiveTab, // Viewers (non-owners) should land on the first tab, not whatever the // owner last selected, since active-tab is persisted in the shared Yjs doc. preferFirstTabOnInit: !rest.isDDocOwner, @@ -133,6 +137,18 @@ export const useDdocEditor = ({ [tabManager.tabs], ); + // Called after useTabManager on purpose: initialContent is decoded into the + // ydoc synchronously by useTabManager's hydration memo, so the schema marker + // is already readable here on the very first render. + const { docSchemaVersion, isSchemaUnsupported } = useDocSchemaVersion({ + ydoc: yjsSetup.ydoc, + isNewDdoc: Boolean(rest.isDDocOwner && !collabEnabled && !ddocContent), + isContentResolved: + !shouldWaitForIndexeddbBeforeCreatingDefaultTab || + yjsSetup.isIndexeddbSynced, + preferredSchemaVersion, + }); + const tabEditor = useTabEditor({ ydoc: yjsSetup.ydoc, isPreviewMode, @@ -173,8 +189,15 @@ export const useDdocEditor = ({ hasCollabContentInitialised: yjsSetup.hasCollabContentInitialised, initialiseYjsIndexedDbProvider: yjsSetup.initialiseYjsIndexedDbProvider, externalExtensions, - activeTabId: tabManager.activeTabId, + // An empty activeTabId makes useTabEditorCache destroy every editor and + // create none, so this build never binds y-sync to a newer-schema doc. + // Do NOT also gate on schema resolution: IndexedDB sync only starts from + // the editor's content effect, so holding the editor until sync deadlocks. + // A doc whose marker arrives late rebuilds its editors via the + // docSchemaVersion dependency of buildExtensionsForTab instead. + activeTabId: isSchemaUnsupported ? '' : tabManager.activeTabId, tabIds, + docSchemaVersion, hasTabState: tabManager.hasTabState, isVersionMode, theme, @@ -183,11 +206,29 @@ export const useDdocEditor = ({ dBlockRuntimeStateRef, }); + // A tab switch is not a content edit, so it is written to Yjs with the + // 'self' origin and never reaches the consumer's onChange. The blob the + // consumer hands back as initialContent therefore carries a STALE + // activeTabId, while IndexedDB (which records every update regardless of + // origin) carries the true one. The stale tab is applied synchronously and + // painted, then corrected once IndexedDB syncs — visibly flashing another + // tab's content for ~100ms. Keep the loading state up over that window so + // the first thing rendered is the tab the user actually left on. + // + // Only the editor CONTENT is withheld; the editor instance is still created + // (it is what triggers IndexedDB initialisation), so this cannot deadlock. + const isActiveTabUnsettled = Boolean( + enableIndexeddbSync && + !yjsSetup.isIndexeddbSynced && + shouldSyncActiveTab && + tabManager.tabs.length > 1, + ); + const isOwner = collabEnabled ? collaboration.connection.isOwner : true; const aggregatedContentLoading = - collabEnabled && !isOwner + (collabEnabled && !isOwner ? tabEditor.isContentLoading || isCollabContentLoading - : tabEditor.isContentLoading; + : tabEditor.isContentLoading) || isActiveTabUnsettled; return { ...tabEditor, @@ -197,6 +238,8 @@ export const useDdocEditor = ({ refreshYjsIndexedDbProvider: yjsSetup.refreshYjsIndexedDbProvider, terminateSession: yjsSetup.terminateSession, isContentLoading: Boolean(aggregatedContentLoading), + isSchemaUnsupported, + docSchemaVersion, tabs: tabManager.tabs, hasTabState: tabManager.hasTabState, dBlockRuntimeState, diff --git a/package/utils/block-insert.ts b/package/utils/block-insert.ts new file mode 100644 index 00000000..a516b37b --- /dev/null +++ b/package/utils/block-insert.ts @@ -0,0 +1,66 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { Selection, type Transaction } from '@tiptap/pm/state'; + +/** + * Replace the current (tr) selection with a block node, expanding over an + * empty parent (the just-emptied host paragraph, or the empty dBlock a + * caller left behind after deleting its child), then park the caret in the + * first textblock AFTER the inserted node — creating a trailing + * dBlock+paragraph when the node became the last block. + * + * Without the explicit placement, atom block inserts strand the selection + * at the dBlock boundary: the caret renders at the block edge and the next + * insert command silently fails (TEC-2539). + */ +export function replaceSelectionWithBlockNode( + tr: Transaction, + node: ProseMirrorNode, +): void { + const schema = node.type.schema; + let { from, to } = tr.selection; + const { $from } = tr.selection; + + if (tr.selection.empty && $from.depth > 0 && $from.parent.content.size === 0) { + from = $from.before(); + to = $from.after(); + } + + tr.replaceRangeWith(from, to, node); + + // replaceRangeWith may have expanded the range while fitting; locate the + // inserted node around the mapped position. + const mapped = tr.mapping.map(from, -1); + let nodePos: number | null = null; + tr.doc.nodesBetween( + Math.max(0, mapped - 2), + Math.min(tr.doc.content.size, mapped + node.nodeSize + 2), + (child, childPos) => { + if (nodePos == null && child.type === node.type) nodePos = childPos; + }, + ); + if (nodePos == null) return; + + const afterPos = Math.min(nodePos + node.nodeSize, tr.doc.content.size); + let selection = Selection.findFrom(tr.doc.resolve(afterPos), 1, true); + + if (!selection) { + // The node landed last, so there is no textblock after it to hold the + // caret — append one. v1 needs it inside a dBlock wrapper; the flat + // schema takes the paragraph directly. + const dBlockType = schema.nodes.dBlock; + const paragraphType = schema.nodes.paragraph; + if (paragraphType) { + const end = tr.doc.content.size; + tr.insert( + end, + dBlockType + ? dBlockType.create(null, paragraphType.create()) + : paragraphType.create(), + ); + selection = Selection.findFrom(tr.doc.resolve(end), 1, true); + } + } + + if (selection) tr.setSelection(selection); + tr.scrollIntoView(); +} diff --git a/package/utils/block-schema.test.ts b/package/utils/block-schema.test.ts new file mode 100644 index 00000000..6b6ea0ab --- /dev/null +++ b/package/utils/block-schema.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import type { JSONContent } from '@tiptap/core'; +import { unwrapDBlocksInJSON } from './block-schema'; +import { getTemplateContent } from './getTemplateContent'; + +const TEMPLATE_NAMES = [ + 'meeting-notes', + 'todo-list', + 'brainstorm', + 'breathe', + 'pretend-to-work', + 'resume', +]; + +const containsDBlock = (node: JSONContent): boolean => { + if (node.type === 'dBlock') return true; + return (node.content || []).some(containsDBlock); +}; + +const collectText = (node: JSONContent): string => { + const own = node.type === 'text' ? (node.text ?? '') : ''; + return own + (node.content || []).map(collectText).join(''); +}; + +describe('unwrapDBlocksInJSON', () => { + it('hoists the single child out of a dBlock', () => { + const result = unwrapDBlocksInJSON({ + type: 'doc', + content: [ + { type: 'dBlock', content: [{ type: 'heading', attrs: { level: 1 } }] }, + ], + }); + expect(result.content).toEqual([{ type: 'heading', attrs: { level: 1 } }]); + }); + + it('recurses into columns whose cells hold dBlocks', () => { + const result = unwrapDBlocksInJSON({ + type: 'doc', + content: [ + { + type: 'dBlock', + content: [ + { + type: 'columns', + content: [ + { + type: 'column', + content: [ + { type: 'dBlock', content: [{ type: 'paragraph' }] }, + ], + }, + ], + }, + ], + }, + ], + }); + expect(containsDBlock(result)).toBe(false); + expect(result.content?.[0].content?.[0].content).toEqual([ + { type: 'paragraph' }, + ]); + }); + + it('degrades an empty dBlock to an empty paragraph', () => { + const result = unwrapDBlocksInJSON({ + type: 'doc', + content: [{ type: 'dBlock' }], + }); + expect(result.content).toEqual([{ type: 'paragraph' }]); + }); + + it('leaves flat JSON untouched', () => { + const flat = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }], + }; + expect(unwrapDBlocksInJSON(flat)).toEqual(flat); + }); + + describe('every package template survives the unwrap (M2 exit check)', () => { + TEMPLATE_NAMES.forEach((name) => { + it(`${name}: no dBlock remains, text and block count preserved`, () => { + const raw = getTemplateContent(name); + expect(raw).not.toBeNull(); + + const unwrapped = unwrapDBlocksInJSON(raw!); + expect(containsDBlock(unwrapped)).toBe(false); + expect(collectText(unwrapped)).toBe(collectText(raw!)); + expect(unwrapped.content?.length).toBe(raw!.content?.length); + }); + }); + }); +}); diff --git a/package/utils/block-schema.ts b/package/utils/block-schema.ts new file mode 100644 index 00000000..ec33882d --- /dev/null +++ b/package/utils/block-schema.ts @@ -0,0 +1,32 @@ +import type { JSONContent } from '@tiptap/core'; +import type { Schema } from '@tiptap/pm/model'; + +// v1 wraps every top-level block in a dBlock; the flat v2 schema does not. +// Detecting from the live schema keeps call sites version-agnostic. +export const schemaHasDBlock = (schema: Schema): boolean => + Boolean(schema.nodes.dBlock); + +// Returns content shaped for insertion at the top level of the doc: +// dBlock-wrapped under v1, the bare block under v2. +export const wrapBlockNode = ( + schema: Schema, + content: JSONContent, +): JSONContent => + schemaHasDBlock(schema) ? { type: 'dBlock', content: [content] } : content; + +// Recursively removes dBlock wrappers from v1-shaped JSON (templates, +// persisted legacy JSON) so it can load into a flat v2 doc. The v1 JSON +// sources stay untouched as the single source of truth; this transform is +// the only supported path, never hand-rewritten copies. +export const unwrapDBlocksInJSON = (node: JSONContent): JSONContent => { + if (node.type === 'dBlock') { + // dBlock wraps exactly one (block|columns); hoist it. An empty wrapper + // degrades to an empty paragraph. + const child = node.content?.[0]; + return child ? unwrapDBlocksInJSON(child) : { type: 'paragraph' }; + } + if (!node.content) { + return node; + } + return { ...node, content: node.content.map(unwrapDBlocksInJSON) }; +}; diff --git a/package/utils/extract-title-from-content.test.ts b/package/utils/extract-title-from-content.test.ts new file mode 100644 index 00000000..8f6e7bad --- /dev/null +++ b/package/utils/extract-title-from-content.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { extractTitleFromContent } from './extract-title-from-content'; + +// The same document in both shapes: v1 wraps each block in a dBlock, the flat +// v2 schema does not. Titles must come out identical. +const wrap = (node: unknown) => ({ type: 'dBlock', content: [node] }); + +const heading = (text: string, level = 1) => ({ + type: 'heading', + attrs: { level, textAlign: 'left' }, + content: [{ type: 'text', text }], +}); + +const paragraph = (text: string) => ({ + type: 'paragraph', + content: [{ type: 'text', text }], +}); + +describe('extractTitleFromContent', () => { + it('reads an H1 in both schemas', () => { + const v1 = { content: [wrap(heading('Quarterly Report')), wrap(paragraph('body'))] }; + const v2 = { content: [heading('Quarterly Report'), paragraph('body')] }; + + expect(extractTitleFromContent(v1 as never)).toBe('Quarterly Report'); + expect(extractTitleFromContent(v2 as never)).toBe('Quarterly Report'); + }); + + it('prefers an H1 over earlier text in both schemas', () => { + const blocks = [paragraph('intro line'), heading('The Real Title')]; + const v1 = { content: blocks.map(wrap) }; + const v2 = { content: blocks }; + + expect(extractTitleFromContent(v1 as never)).toBe('The Real Title'); + expect(extractTitleFromContent(v2 as never)).toBe('The Real Title'); + }); + + it('falls back to the first text when there is no H1, in both schemas', () => { + const blocks = [paragraph(''), paragraph('first real line'), paragraph('second')]; + const v1 = { content: blocks.map(wrap) }; + const v2 = { content: blocks }; + + expect(extractTitleFromContent(v1 as never)).toBe('first real line'); + expect(extractTitleFromContent(v2 as never)).toBe('first real line'); + }); + + it('joins split text nodes of a marked heading in both schemas', () => { + const split = { + type: 'heading', + attrs: { level: 1 }, + content: [ + { type: 'text', text: 'Bold' }, + { type: 'text', text: 'ed Title', marks: [{ type: 'bold' }] }, + ], + }; + + expect(extractTitleFromContent({ content: [wrap(split)] } as never)).toBe('Bolded Title'); + expect(extractTitleFromContent({ content: [split] } as never)).toBe('Bolded Title'); + }); + + it('ignores a non-H1 heading for the H1 pass but still finds its text', () => { + const blocks = [heading('Section', 2)]; + expect(extractTitleFromContent({ content: blocks.map(wrap) } as never)).toBe('Section'); + expect(extractTitleFromContent({ content: blocks } as never)).toBe('Section'); + }); + + it('truncates to 50 characters in both schemas', () => { + const long = 'x'.repeat(80); + expect(extractTitleFromContent({ content: [wrap(heading(long))] } as never)).toHaveLength(50); + expect(extractTitleFromContent({ content: [heading(long)] } as never)).toHaveLength(50); + }); + + it('returns null for an empty document in both schemas', () => { + expect(extractTitleFromContent({ content: [wrap(paragraph(''))] } as never)).toBeNull(); + expect(extractTitleFromContent({ content: [paragraph('')] } as never)).toBeNull(); + }); +}); diff --git a/package/utils/extract-title-from-content.tsx b/package/utils/extract-title-from-content.tsx index ae966fc9..dc509003 100644 --- a/package/utils/extract-title-from-content.tsx +++ b/package/utils/extract-title-from-content.tsx @@ -6,6 +6,26 @@ import { JSONContent } from '@tiptap/core'; * Extract title from document content (JSON structure from editor) * Looks for H1 headings first, then any text content */ +// v1 wraps every top-level block in a dBlock whose single child is the real +// node; the flat v2 schema puts that node at the top level. Yield the real +// node(s) either way, so the same walk serves both shapes. +const realBlocks = (block: JSONContent): JSONContent[] => + block?.type === 'dBlock' && Array.isArray(block.content) + ? block.content + : [block]; + +const joinTextPieces = (node: JSONContent | undefined): string => { + if (!node?.content || !Array.isArray(node.content)) { + return ''; + } + return node.content + .filter((piece: any) => piece.type === 'text') + .map((piece: any) => piece.text) + .filter(Boolean) + .join('') + .trim(); +}; + export const extractTitleFromContent = (changes: { content: JSONContent; }): string | null => { @@ -18,43 +38,22 @@ export const extractTitleFromContent = (changes: { // First try to find H1 headings for (const block of changes.content) { - if ( - block.content && - Array.isArray(block.content) && - block.content[0]?.type === 'heading' && - block.content[0]?.attrs?.level === 1 - ) { - // Handle heading content that may contain multiple text nodes with marks - if ( - block.content[0]?.content && - Array.isArray(block.content[0].content) - ) { - const textPieces = block.content[0].content - .filter((piece: any) => piece.type === 'text') - .map((piece: any) => piece.text) - .filter(Boolean); - - firstNonEmptyLine = textPieces.join('').trim(); + for (const node of realBlocks(block)) { + if (node?.type === 'heading' && node?.attrs?.level === 1) { + // Headings can hold several text nodes when marks split them. + firstNonEmptyLine = joinTextPieces(node); if (firstNonEmptyLine) break; } } + if (firstNonEmptyLine) break; } // If no H1 found, look for any text content if (!firstNonEmptyLine) { for (const block of changes.content) { - if (block.content && Array.isArray(block.content)) { - for (const item of block.content) { - if (item?.content && Array.isArray(item.content)) { - const textPieces = item.content - .filter((piece: any) => piece.type === 'text') - .map((piece: any) => piece.text) - .filter(Boolean); - - firstNonEmptyLine = textPieces.join('').trim(); - if (firstNonEmptyLine) break; - } - } + for (const node of realBlocks(block)) { + firstNonEmptyLine = joinTextPieces(node); + if (firstNonEmptyLine) break; } if (firstNonEmptyLine) break; } diff --git a/package/utils/insert-commands.ts b/package/utils/insert-commands.ts index 97cce66c..a2a854a6 100644 --- a/package/utils/insert-commands.ts +++ b/package/utils/insert-commands.ts @@ -118,21 +118,14 @@ export const insertCommands: Record = { .run(); }, // The column commands never consumed the slash range (existing behavior). + // setColumns places the caret in the first cell within its own + // transaction; a post-hoc focus(head - 1) here read the PRE-insert + // selection at chain-build time and aimed at a stale position. columns2: (editor) => { - editor - .chain() - .focus() - .setColumns(2) - .focus(editor.state.selection.head - 1) - .run(); + editor.chain().focus().setColumns(2).run(); }, columns3: (editor) => { - editor - .chain() - .focus() - .setColumns(3) - .focus(editor.state.selection.head - 1) - .run(); + editor.chain().focus().setColumns(3).run(); }, bulletList: (editor, range) => { begin(editor, range).toggleBulletList().run(); diff --git a/package/utils/make-editor.ts b/package/utils/make-editor.ts new file mode 100644 index 00000000..d71f4d6a --- /dev/null +++ b/package/utils/make-editor.ts @@ -0,0 +1,18 @@ +import { Editor } from '@tiptap/react'; +import { getHeadlessExtensions } from '../hooks/use-headless-editor'; + +/** + * Shared jsdom editor factory for unit tests (use-editor-commands.test.tsx, + * selection-utils.test.ts, ...). Collaboration owns the doc, so content must + * be set via `setContent` *after* construction — passing `content` to the + * `Editor` constructor is silently ignored once Collaboration is configured. + */ +export const makeEditor = (content: string = '

'): Editor => { + const editor = new Editor({ + extensions: getHeadlessExtensions(), + // matches useHeadlessEditor/ddoc-editor; required for dir tracking + textDirection: 'auto', + }); + editor.commands.setContent(content); + return editor; +}; diff --git a/package/utils/sanitize-content.ts b/package/utils/sanitize-content.ts index 9f711fc7..4da4bebd 100644 --- a/package/utils/sanitize-content.ts +++ b/package/utils/sanitize-content.ts @@ -1,22 +1,29 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { JSONContent } from '@tiptap/core'; -const createInvalidContentBlock = (node: any): JSONContent => ({ - type: 'dBlock', - attrs: { isCorrupted: true }, - content: [ - { - type: 'paragraph', - attrs: { textAlign: 'left' }, - content: [ - { - type: 'text', - text: `Invalid content: "${node}", please delete this node`, - }, - ], - }, - ], -}); +const createInvalidContentBlock = ( + node: any, + wrapInDBlock: boolean, +): JSONContent => { + const paragraph: JSONContent = { + type: 'paragraph', + attrs: { textAlign: 'left' }, + content: [ + { + type: 'text', + text: `Invalid content: "${node}", please delete this node`, + }, + ], + }; + + if (!wrapInDBlock) return paragraph; + + return { + type: 'dBlock', + attrs: { isCorrupted: true }, + content: [paragraph], + }; +}; const isCorruptedData = (node: JSONContent): boolean => { return ( @@ -32,12 +39,15 @@ type SanitizeContentProps = { data: JSONContent; ignoreCorruptedData?: boolean; onInvalidContentError?: (e: unknown) => void; + // v1 docs wrap the fallback block in a dBlock; the flat v2 schema does not. + wrapInDBlock?: boolean; }; export const sanitizeContent = ({ data, ignoreCorruptedData = true, onInvalidContentError, + wrapInDBlock = true, }: SanitizeContentProps): JSONContent => { if (!data) return { type: 'paragraph', content: [] }; const sanitizedContent = { ...data }; @@ -47,7 +57,9 @@ export const sanitizeContent = ({ if (isCorruptedData(node)) { console.error('corrupted data:', node); onInvalidContentError?.('Invalid content: ' + typeof node); - return ignoreCorruptedData ? null : createInvalidContentBlock(node); + return ignoreCorruptedData + ? null + : createInvalidContentBlock(node, wrapInDBlock); } return node; }) diff --git a/package/utils/schema-version.test.ts b/package/utils/schema-version.test.ts new file mode 100644 index 00000000..b9b98d78 --- /dev/null +++ b/package/utils/schema-version.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { + DDOC_META_ROOT_KEY, + SCHEMA_VERSION_META_KEY, + SUPPORTED_SCHEMA_VERSION, + getDocSchemaVersion, + isDocSchemaSupported, +} from './schema-version'; + +const NEWER_THAN_SUPPORTED = SUPPORTED_SCHEMA_VERSION + 1; + +describe('schema-version', () => { + it('treats docs without a marker as v1 (every pre-marker doc)', () => { + const doc = new Y.Doc(); + expect(getDocSchemaVersion(doc)).toBe(1); + expect(isDocSchemaSupported(doc)).toBe(true); + }); + + it('accepts docs at the supported version', () => { + const doc = new Y.Doc(); + doc + .getMap(DDOC_META_ROOT_KEY) + .set(SCHEMA_VERSION_META_KEY, SUPPORTED_SCHEMA_VERSION); + expect(isDocSchemaSupported(doc)).toBe(true); + }); + + it('rejects docs from a newer schema', () => { + const doc = new Y.Doc(); + doc + .getMap(DDOC_META_ROOT_KEY) + .set(SCHEMA_VERSION_META_KEY, NEWER_THAN_SUPPORTED); + expect(getDocSchemaVersion(doc)).toBe(NEWER_THAN_SUPPORTED); + expect(isDocSchemaSupported(doc)).toBe(false); + }); + + it('treats a malformed marker as v1 instead of locking the doc', () => { + const doc = new Y.Doc(); + doc.getMap(DDOC_META_ROOT_KEY).set(SCHEMA_VERSION_META_KEY, 'two'); + expect(getDocSchemaVersion(doc)).toBe(1); + expect(isDocSchemaSupported(doc)).toBe(true); + }); + + it('sees a marker applied via a remote update', () => { + const source = new Y.Doc(); + source + .getMap(DDOC_META_ROOT_KEY) + .set(SCHEMA_VERSION_META_KEY, NEWER_THAN_SUPPORTED); + + const receiver = new Y.Doc(); + expect(isDocSchemaSupported(receiver)).toBe(true); + Y.applyUpdate(receiver, Y.encodeStateAsUpdate(source)); + expect(isDocSchemaSupported(receiver)).toBe(false); + }); +}); diff --git a/package/utils/schema-version.ts b/package/utils/schema-version.ts new file mode 100644 index 00000000..374578e0 --- /dev/null +++ b/package/utils/schema-version.ts @@ -0,0 +1,17 @@ +import * as Y from 'yjs'; + +export const DDOC_META_ROOT_KEY = 'ddocMeta'; +export const SCHEMA_VERSION_META_KEY = 'schemaVersion'; + +// The highest doc schema this build can safely open for editing. +// 1 = dBlock wrapper, 2 = flat (wrapper-less) blocks. +export const SUPPORTED_SCHEMA_VERSION = 2; + +// Docs created before the marker existed have no ddocMeta entry: treat as v1. +export const getDocSchemaVersion = (doc: Y.Doc): number => { + const version = doc.getMap(DDOC_META_ROOT_KEY).get(SCHEMA_VERSION_META_KEY); + return typeof version === 'number' ? version : 1; +}; + +export const isDocSchemaSupported = (doc: Y.Doc): boolean => + getDocSchemaVersion(doc) <= SUPPORTED_SCHEMA_VERSION; diff --git a/package/utils/template-utils.tsx b/package/utils/template-utils.tsx index ccacbbe7..54cb92ac 100644 --- a/package/utils/template-utils.tsx +++ b/package/utils/template-utils.tsx @@ -119,7 +119,7 @@ const renderTemplateButtons = ( return null; } return ( - + {templateButtons.map((button, index) => (