From 9ca7695446b1a081b61b7564078aac6dcf94a20b Mon Sep 17 00:00:00 2001 From: mbj36 Date: Tue, 4 Aug 2026 17:28:58 +0530 Subject: [PATCH 01/77] docs: add flat schema v2 spec --- docs/FLAT_SCHEMA_V2.md | 160 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/FLAT_SCHEMA_V2.md diff --git a/docs/FLAT_SCHEMA_V2.md b/docs/FLAT_SCHEMA_V2.md new file mode 100644 index 00000000..ca90049d --- /dev/null +++ b/docs/FLAT_SCHEMA_V2.md @@ -0,0 +1,160 @@ +# Flat Schema v2 Spec + +Status: planned. 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-04. + +## 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, at new-doc seeding time | +| Where the marker lives | A Yjs map field in the doc itself (`schemaVersion`), following the existing tab-metadata pattern. No marker = v1 | +| Safety check | Ships in the next regular release, months before v2 exists (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. + +New docs get the marker written during their first seeding transaction, in `applyResolvedTabState` (`package/components/tabs/utils/tab-utils.ts`, the existing `doc.transact` that seeds the default tab and registries). + +### 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) + +### The v2 schema + +Two content-spec changes: + +- `package/extensions/document/document.ts`: `content: '(dBlock|columns|pageBreak)+'` becomes `'(block|columns|pageBreak)+'` +- `package/extensions/multi-column/column.ts`: `content: 'dBlock+'` becomes `'block+'` + +Known wrinkles to resolve in M1: `columns` is `group: 'columns'` (not `block`), `pageBreak` needs a group check, and `dBlock` has `priority: 1000`, so removing it changes which handler answers Enter/Tab/Backspace globally in v2. The behavior test suite is the guard for this. + +Housekeeping: `package/extensions/doc.ts` is a dead duplicate top-node (nothing imports it). Delete it. + +### New-doc version preference + +`DdocProps` gains `preferredSchemaVersion?: 1 | 2` (default 1). It applies only when the package detects a genuinely new doc (`isNewDdoc` in `use-tab-manager.ts`: owner, no collab, no initial content). Existing docs always follow their marker; the prop is ignored for them. 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: Marker + safety check (ships first, next regular release) + +- Define the `schemaVersion` field and read/write helpers +- Write the marker for new docs in `applyResolvedTabState` +- The check: if a doc's version is higher than the package supports, open read-only with a "refresh to update" banner. Package renders the banner itself +- 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 + +### 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) +- 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. + +Exit criterion: **all 9 templates render and edit correctly in a v2 doc.** Templates contain tables, callouts, media, and columns, so they double as the parity smoke test. + +### 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, view registry, gutter components). Kept for v1 mode, simply not registered in v2 +- **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; note `.node-dBlock` rules at `editor.css:510` appear already dead, verify before porting) + +## 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 +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"`) 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 before any v2 doc exists, with months of soak.** 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 +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 +3. **Behavior tests before v2 is judged working.** The ~15 list/caret tests from Bhavesh's keymap track should be written against editor behavior, not dBlock internals, so the identical suite runs against both extension sets +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 (accepted, that is what M1 is for): keymap resolution order after removing `priority: 1000`, `columns` / `pageBreak` group handling in the new content expression +- Heading collapse is the single largest v2 work item on this track +- 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 + +## 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). From 5b6a379da1b2d4cfe4ff27ead51c51c7228ec2c0 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Tue, 4 Aug 2026 17:36:23 +0530 Subject: [PATCH 02/77] feat: schema version guard for forward compatibility (flat schema M0) Docs now carry an optional schemaVersion in a ddocMeta Y.Map (absent = v1). When a doc declares a version newer than this build supports, no editor is created at all (no y-sync binding) and a refresh prompt replaces the editor surface in both DdocEditor and PreviewDdocEditor. Dormant until v2 docs exist; shipping early so stale tabs are protected by launch. --- docs/FLAT_SCHEMA_V2.md | 8 ++-- package/ddoc-editor.tsx | 24 ++++++++++++ package/hooks/use-schema-version-guard.ts | 29 ++++++++++++++ package/preview-ddoc-editor.tsx | 17 +++++++++ package/use-ddoc-editor.tsx | 7 +++- package/utils/schema-version.test.ts | 46 +++++++++++++++++++++++ package/utils/schema-version.ts | 17 +++++++++ 7 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 package/hooks/use-schema-version-guard.ts create mode 100644 package/utils/schema-version.test.ts create mode 100644 package/utils/schema-version.ts diff --git a/docs/FLAT_SCHEMA_V2.md b/docs/FLAT_SCHEMA_V2.md index ca90049d..c33a3be5 100644 --- a/docs/FLAT_SCHEMA_V2.md +++ b/docs/FLAT_SCHEMA_V2.md @@ -81,12 +81,12 @@ Housekeeping: `package/extensions/doc.ts` is a dead duplicate top-node (nothing ## Milestones -### M0: Marker + safety check (ships first, next regular release) +### M0: Safety check (ships first, next regular release) -- Define the `schemaVersion` field and read/write helpers -- Write the marker for new docs in `applyResolvedTabState` -- The check: if a doc's version is higher than the package supports, open read-only with a "refresh to update" banner. Package renders the banner itself +- 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: `applyResolvedTabState` runs for both new-doc seeding and ongoing self-heal of existing docs, so an unconditional write there would stamp old docs. The scoped new-doc write ships with the v2 creation path, where it is needed ### M1: v2 skeleton that types diff --git a/package/ddoc-editor.tsx b/package/ddoc-editor.tsx index afb81c2b..9014bc57 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, @@ -1404,6 +1405,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 ( { + const [isSchemaUnsupported, setIsSchemaUnsupported] = useState( + () => !isDocSchemaSupported(ydoc), + ); + + useEffect(() => { + const metaMap = ydoc.getMap(DDOC_META_ROOT_KEY); + const evaluate = () => setIsSchemaUnsupported(!isDocSchemaSupported(ydoc)); + // Re-check on effect attach: the marker may have arrived between the + // initial state computation and the observer being registered. + evaluate(); + metaMap.observe(evaluate); + return () => metaMap.unobserve(evaluate); + }, [ydoc]); + + return isSchemaUnsupported; +}; diff --git a/package/preview-ddoc-editor.tsx b/package/preview-ddoc-editor.tsx index 84a99024..9599cfc2 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 && ( diff --git a/package/use-ddoc-editor.tsx b/package/use-ddoc-editor.tsx index 8d5e0ac5..17308c07 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 { useSchemaVersionGuard } from './hooks/use-schema-version-guard'; import { useTabEditor } from './hooks/use-tab-editor'; import { useTabManager } from './hooks/use-tab-manager'; import { useYjsSetup } from './hooks/use-yjs-setup'; @@ -93,6 +94,7 @@ export const useDdocEditor = ({ onCollaboratorChange, onIndexedDbError, }); + const isSchemaUnsupported = useSchemaVersionGuard(yjsSetup.ydoc); const shouldWaitForIndexeddbBeforeCreatingDefaultTab = Boolean( enableIndexeddbSync && !collabEnabled && @@ -173,7 +175,9 @@ 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. + activeTabId: isSchemaUnsupported ? '' : tabManager.activeTabId, tabIds, hasTabState: tabManager.hasTabState, isVersionMode, @@ -197,6 +201,7 @@ export const useDdocEditor = ({ refreshYjsIndexedDbProvider: yjsSetup.refreshYjsIndexedDbProvider, terminateSession: yjsSetup.terminateSession, isContentLoading: Boolean(aggregatedContentLoading), + isSchemaUnsupported, tabs: tabManager.tabs, hasTabState: tabManager.hasTabState, dBlockRuntimeState, diff --git a/package/utils/schema-version.test.ts b/package/utils/schema-version.test.ts new file mode 100644 index 00000000..c4f9da53 --- /dev/null +++ b/package/utils/schema-version.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { + DDOC_META_ROOT_KEY, + SCHEMA_VERSION_META_KEY, + getDocSchemaVersion, + isDocSchemaSupported, +} from './schema-version'; + +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, 1); + 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, 2); + expect(getDocSchemaVersion(doc)).toBe(2); + 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, 2); + + 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..c142b259 --- /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. +// The flat (wrapper-less) block schema will bump this to 2. +export const SUPPORTED_SCHEMA_VERSION = 1; + +// 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; From 285d46631a8c1aaf24027bad7acfa14e0f12305e Mon Sep 17 00:00:00 2001 From: mbj36 Date: Tue, 4 Aug 2026 18:11:51 +0530 Subject: [PATCH 03/77] feat: flat schema v2 skeleton behind per-doc marker (M1) defaultExtensions gains a schemaVersion param: v2 registers FlatDocument (content 'block|columns|pageBreak') and FlatColumn instead of dBlock, TrailingNode and the wrapped Document. All four assembly paths respect it (main, per-tab, headless, AI re-fork). useDocSchemaVersion resolves a doc's version from its ddocMeta marker and stamps genuinely new docs with preferredSchemaVersion (new DdocProps field, creation-time only). Demo gets a 'New Document (flat schema v2)' action driven by a v2 URL param. Verified in the running demo via headless Chrome: v2 doc creates flat, types with stock keymaps, survives reload without the URL param (marker rules), v1 docs unchanged, template overlay auto-suppressed on v2. --- demo/src/App.tsx | 6 +++ demo/src/components/DocSwitcher.tsx | 16 ++++-- package/extensions/default-extension.ts | 37 ++++++++----- package/extensions/document/document.ts | 7 +++ package/extensions/multi-column/column.ts | 5 ++ package/hooks/use-doc-schema-version.ts | 66 +++++++++++++++++++++++ package/hooks/use-headless-editor.tsx | 6 ++- package/hooks/use-schema-version-guard.ts | 29 ---------- package/hooks/use-tab-editor.tsx | 24 ++++++--- package/types.ts | 6 +++ package/use-ddoc-editor.tsx | 22 +++++++- package/utils/schema-version.test.ts | 17 ++++-- package/utils/schema-version.ts | 4 +- 13 files changed, 186 insertions(+), 59 deletions(-) create mode 100644 package/hooks/use-doc-schema-version.ts delete mode 100644 package/hooks/use-schema-version-guard.ts diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 7ca24f0a..a3be63c8 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -832,6 +832,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/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/package/extensions/default-extension.ts b/package/extensions/default-extension.ts index 6d24a229..82be5935 100644 --- a/package/extensions/default-extension.ts +++ b/package/extensions/default-extension.ts @@ -45,6 +45,7 @@ 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'; @@ -52,7 +53,7 @@ import { Iframe } from './iframe'; import { EmbeddedTweet } from './twitter-embed'; import { createDBlockExtension } from './d-block'; 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 +262,7 @@ export const defaultExtensions = ({ onTocUpdate, dBlockRuntimeStateRef, hasAvailableModels = false, + schemaVersion = 1, }: { ipfsImageFetchFn?: ( _data: IpfsImageFetchPayload, @@ -273,6 +275,7 @@ export const defaultExtensions = ({ onTocUpdate?: (data: ToCItemType[], isCreate?: boolean) => void; dBlockRuntimeStateRef?: DBlockRuntimeStateRef; hasAvailableModels?: boolean; + schemaVersion?: number; }) => [ FontFamily, FontFamilyPersistence, @@ -425,16 +428,23 @@ export const defaultExtensions = ({ fetchV1ImageFn, }), Gapcursor, - createDBlockExtension({ - ipfsImageUploadFn, - onCopyHeadingLink, - hasAvailableModels, - getRuntimeState: dBlockRuntimeStateRef - ? () => dBlockRuntimeStateRef.current - : undefined, - }), - TrailingNode, - Document, + // 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] + : [ + createDBlockExtension({ + ipfsImageUploadFn, + onCopyHeadingLink, + hasAvailableModels, + getRuntimeState: dBlockRuntimeStateRef + ? () => dBlockRuntimeStateRef.current + : undefined, + }), + TrailingNode, + Document, + ]), ...SuperchargedTableExtensions, CustomKeymap, Iframe.configure({ ipfsImageFetchFn, fetchV1ImageFn }), @@ -442,7 +452,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/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/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.tsx b/package/hooks/use-headless-editor.tsx index f15a1bb3..5d9d940b 100644 --- a/package/hooks/use-headless-editor.tsx +++ b/package/hooks/use-headless-editor.tsx @@ -25,6 +25,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 +43,10 @@ export const getHeadlessExtensions = (options?: { }; return [ - ...defaultExtensions({ onError: () => null }).filter( + ...defaultExtensions({ + onError: () => null, + schemaVersion: options?.schemaVersion, + }).filter( (extension) => extension.name !== 'characterCount', ), customTextInputRules, diff --git a/package/hooks/use-schema-version-guard.ts b/package/hooks/use-schema-version-guard.ts deleted file mode 100644 index bafb8b4a..00000000 --- a/package/hooks/use-schema-version-guard.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useState } from 'react'; -import * as Y from 'yjs'; -import { - DDOC_META_ROOT_KEY, - isDocSchemaSupported, -} from '../utils/schema-version'; - -// True when the doc declares a schema newer than this build supports. -// Editors must never bind to such a doc: a stale client editing a -// newer-schema doc writes structure newer clients cannot reconcile, and -// Yjs has no undo for that. The meta map is observed because the marker -// can arrive after mount via IndexedDB replay or a collab sync. -export const useSchemaVersionGuard = (ydoc: Y.Doc) => { - const [isSchemaUnsupported, setIsSchemaUnsupported] = useState( - () => !isDocSchemaSupported(ydoc), - ); - - useEffect(() => { - const metaMap = ydoc.getMap(DDOC_META_ROOT_KEY); - const evaluate = () => setIsSchemaUnsupported(!isDocSchemaSupported(ydoc)); - // Re-check on effect attach: the marker may have arrived between the - // initial state computation and the observer being registered. - evaluate(); - metaMap.observe(evaluate); - return () => metaMap.unobserve(evaluate); - }, [ydoc]); - - return isSchemaUnsupported; -}; diff --git a/package/hooks/use-tab-editor.tsx b/package/hooks/use-tab-editor.tsx index bbaaeaed..0677cdbf 100644 --- a/package/hooks/use-tab-editor.tsx +++ b/package/hooks/use-tab-editor.tsx @@ -266,6 +266,7 @@ interface UseTabEditorArgs { editorRef?: MutableRefObject; initialCommentAnchors?: SerializedCommentAnchor[]; dBlockRuntimeStateRef?: DBlockRuntimeStateRef; + docSchemaVersion?: number; } export const useTabEditor = ({ @@ -316,6 +317,7 @@ export const useTabEditor = ({ editorRef, initialCommentAnchors, dBlockRuntimeStateRef, + docSchemaVersion = 1, }: UseTabEditorArgs) => { const collabEnabled = collaboration?.enabled === true; const connection = collabEnabled ? collaboration.connection : null; @@ -382,6 +384,7 @@ export const useTabEditor = ({ initialCommentAnchors, isSuggestionMode, dBlockRuntimeStateRef: resolvedDBlockRuntimeStateRef, + docSchemaVersion, }); const { handleCommentInteraction, handleCommentClick } = @@ -1537,6 +1540,7 @@ interface UseExtensionStackArgs { initialCommentAnchors?: SerializedCommentAnchor[]; isSuggestionMode?: boolean; dBlockRuntimeStateRef: DBlockRuntimeStateRef; + docSchemaVersion?: number; } const useEditorExtension = ({ @@ -1560,6 +1564,7 @@ const useEditorExtension = ({ initialCommentAnchors, isSuggestionMode = false, dBlockRuntimeStateRef, + docSchemaVersion = 1, }: UseExtensionStackArgs) => { const onErrorRef = useRef(onError); onErrorRef.current = onError; @@ -1650,6 +1655,7 @@ const useEditorExtension = ({ onTocUpdateForTab(tabId, data, isCreate), hasAvailableModels, dBlockRuntimeStateRef, + schemaVersion: docSchemaVersion, }), createSlashCommand(), customTextInputRules, @@ -1722,12 +1728,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 +1758,7 @@ const useEditorExtension = ({ activeModel, maxTokens, onCommentActivated, + docSchemaVersion, ], ); diff --git a/package/types.ts b/package/types.ts index bd26eb40..a7aaeacd 100644 --- a/package/types.ts +++ b/package/types.ts @@ -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 17308c07..3b42028d 100644 --- a/package/use-ddoc-editor.tsx +++ b/package/use-ddoc-editor.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { DdocProps } from './types'; -import { useSchemaVersionGuard } from './hooks/use-schema-version-guard'; +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'; @@ -46,6 +46,7 @@ export const useDdocEditor = ({ initialCommentAnchors, isPreviewEditor = false, fonts, + preferredSchemaVersion, ...rest }: Partial & { isFocusMode?: boolean; @@ -94,7 +95,6 @@ export const useDdocEditor = ({ onCollaboratorChange, onIndexedDbError, }); - const isSchemaUnsupported = useSchemaVersionGuard(yjsSetup.ydoc); const shouldWaitForIndexeddbBeforeCreatingDefaultTab = Boolean( enableIndexeddbSync && !collabEnabled && @@ -135,6 +135,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, @@ -177,8 +189,13 @@ export const useDdocEditor = ({ externalExtensions, // 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, @@ -202,6 +219,7 @@ export const useDdocEditor = ({ terminateSession: yjsSetup.terminateSession, isContentLoading: Boolean(aggregatedContentLoading), isSchemaUnsupported, + docSchemaVersion, tabs: tabManager.tabs, hasTabState: tabManager.hasTabState, dBlockRuntimeState, diff --git a/package/utils/schema-version.test.ts b/package/utils/schema-version.test.ts index c4f9da53..b9b98d78 100644 --- a/package/utils/schema-version.test.ts +++ b/package/utils/schema-version.test.ts @@ -3,10 +3,13 @@ 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(); @@ -16,14 +19,18 @@ describe('schema-version', () => { it('accepts docs at the supported version', () => { const doc = new Y.Doc(); - doc.getMap(DDOC_META_ROOT_KEY).set(SCHEMA_VERSION_META_KEY, 1); + 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, 2); - expect(getDocSchemaVersion(doc)).toBe(2); + 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); }); @@ -36,7 +43,9 @@ describe('schema-version', () => { 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, 2); + source + .getMap(DDOC_META_ROOT_KEY) + .set(SCHEMA_VERSION_META_KEY, NEWER_THAN_SUPPORTED); const receiver = new Y.Doc(); expect(isDocSchemaSupported(receiver)).toBe(true); diff --git a/package/utils/schema-version.ts b/package/utils/schema-version.ts index c142b259..374578e0 100644 --- a/package/utils/schema-version.ts +++ b/package/utils/schema-version.ts @@ -4,8 +4,8 @@ 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. -// The flat (wrapper-less) block schema will bump this to 2. -export const SUPPORTED_SCHEMA_VERSION = 1; +// 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 => { From aeeaed55b7306942e87e4c09d4d40ab10ea7a7d8 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 00:59:26 +0530 Subject: [PATCH 04/77] docs: add editor chrome design spec and implementation plan (TEC-2515) --- .gitignore | 1 + .../plans/2026-08-04-editor-chrome.md | 784 ++++++++++++++++++ .../specs/2026-08-04-editor-chrome-design.md | 140 ++++ 3 files changed, 925 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-editor-chrome.md create mode 100644 docs/superpowers/specs/2026-08-04-editor-chrome-design.md 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/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. From eb2b9f75f4fe345c857d5af77c2d537702738534 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 01:28:56 +0530 Subject: [PATCH 05/77] test: restore make-editor helper deleted in f20d313 --- package/utils/make-editor.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 package/utils/make-editor.ts 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; +}; From 0ef517ead51bc30f969161d688c8717d036d853f Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 01:57:01 +0530 Subject: [PATCH 06/77] chore: add drag-handle deps pinned at 3.11.0 with supply-chain guards --- package-lock.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 16 ++++++++++---- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index cfb66bb6..7220117e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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 4c853ff3..d7cfef58 100644 --- a/package.json +++ b/package.json @@ -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", @@ -133,8 +136,13 @@ "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", @@ -167,4 +175,4 @@ "vitest": "^2.1.0", "yjs": "13.6.30" } -} \ No newline at end of file +} From 348a0d910e858abe34c99ff30f51e71cc789bf3b Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 02:12:43 +0530 Subject: [PATCH 07/77] feat: add floating DBlockDragHandle cluster (coexists with gutter) --- .../d-block/dblock-drag-handle.test.tsx | 69 +++++++ .../extensions/d-block/dblock-drag-handle.tsx | 168 ++++++++++++++++++ package/extensions/d-block/dblock-toolbar.tsx | 12 ++ 3 files changed, 249 insertions(+) create mode 100644 package/extensions/d-block/dblock-drag-handle.test.tsx create mode 100644 package/extensions/d-block/dblock-drag-handle.tsx 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..9c4f414f --- /dev/null +++ b/package/extensions/d-block/dblock-drag-handle.test.tsx @@ -0,0 +1,69 @@ +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); + 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('renders nothing in presentation preview', () => { + editor = makeEditor('

hello

'); + const { container } = render( + , + ); + expect(container.querySelector('[aria-label="block-controls"]')).toBeNull(); + }); +}); 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..7904c997 --- /dev/null +++ b/package/extensions/d-block/dblock-drag-handle.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useEffect, 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); + + useEffect(() => { + setMenuOpen(false); + }, [hovered?.pos]); + + 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', + ); + + return ( + { + if (node) setHovered({ node, pos }); + }} + > +
+ {shouldShowEditingControls ? ( + <> + + + + + + + } + actions={actions} + /> + + ) : null} + {shouldShowCollapse ? ( + + + + ) : null} + {shouldShowCopyLink ? ( + + + + ) : null} +
+
+ ); +}; diff --git a/package/extensions/d-block/dblock-toolbar.tsx b/package/extensions/d-block/dblock-toolbar.tsx index e4d72dad..d474e87b 100644 --- a/package/extensions/d-block/dblock-toolbar.tsx +++ b/package/extensions/d-block/dblock-toolbar.tsx @@ -44,6 +44,7 @@ import { refreshRegisteredDBlockViews, type DBlockViewHandle, } from './dblock-view-registry'; +import { DBlockDragHandle } from './dblock-drag-handle'; interface ResolvedDBlock { editor: Editor; @@ -451,6 +452,17 @@ export const DBlockToolbarProvider = ({ return ( <> {children} + {editor ? ( + + ) : null} {activeHandle && editor ? ( Date: Wed, 5 Aug 2026 02:27:19 +0530 Subject: [PATCH 08/77] fix: stabilize DragHandle plugin registration in DBlockDragHandle --- .../d-block/dblock-drag-handle.test.tsx | 38 +++++++++++++++++- .../extensions/d-block/dblock-drag-handle.tsx | 39 +++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/package/extensions/d-block/dblock-drag-handle.test.tsx b/package/extensions/d-block/dblock-drag-handle.test.tsx index 9c4f414f..0f9e9066 100644 --- a/package/extensions/d-block/dblock-drag-handle.test.tsx +++ b/package/extensions/d-block/dblock-drag-handle.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, beforeAll } from 'vitest'; +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'; @@ -52,6 +52,42 @@ describe('DBlockDragHandle', () => { } }); + 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( diff --git a/package/extensions/d-block/dblock-drag-handle.tsx b/package/extensions/d-block/dblock-drag-handle.tsx index 7904c997..237963c1 100644 --- a/package/extensions/d-block/dblock-drag-handle.tsx +++ b/package/extensions/d-block/dblock-drag-handle.tsx @@ -32,6 +32,14 @@ interface HoveredBlock { pos: number; } +// 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, @@ -53,6 +61,31 @@ export const DBlockDragHandle = ({ }, [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, + pos, + }: { + node: ProseMirrorNode | null; + editor: Editor; + pos: number; + }) => { + setHovered((prev) => { + if (!node) return prev; + if (prev && prev.pos === pos && prev.node === node) return prev; + return { node, pos }; + }); + }, + [], + ); + useEffect(() => { setMenuOpen(false); }, [hovered?.pos]); @@ -111,10 +144,8 @@ export const DBlockDragHandle = ({ return ( { - if (node) setHovered({ node, pos }); - }} + computePositionConfig={COMPUTE_POSITION_CONFIG} + onNodeChange={handleNodeChange} >
Date: Wed, 5 Aug 2026 02:44:53 +0530 Subject: [PATCH 09/77] feat: move editor chrome to container padding + floating controls Switches the dBlock node view from the old per-block gutter/content-shell flex layout to container-level padding on .ProseMirror.main-doc-editor, with DBlockDragHandle (the Task 2 floating control cluster) as the sole block chrome. Deletes DBlockToolbar, the dblock-view-registry portal resolution machinery, and the associated hover/focus listeners. --- package/components/split-view/split-view.css | 23 +- package/ddoc-editor.tsx | 4 +- .../d-block/dblock-node-view.test.ts | 31 ++ .../extensions/d-block/dblock-node-view.ts | 140 +------ package/extensions/d-block/dblock-toolbar.tsx | 354 ++---------------- .../d-block/dblock-view-registry.ts | 81 ---- package/extensions/d-block/dblock.ts | 1 - package/styles/editor.css | 41 +- package/styles/index.css | 2 +- package/types.ts | 2 +- 10 files changed, 116 insertions(+), 563 deletions(-) create mode 100644 package/extensions/d-block/dblock-node-view.test.ts delete mode 100644 package/extensions/d-block/dblock-view-registry.ts diff --git a/package/components/split-view/split-view.css b/package/components/split-view/split-view.css index a8f88e19..991fb862 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,10 @@ margin: 0 !important; } -[data-split-view-preview] .ProseMirror { +/* .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: 8px 48px 64px 48px; } diff --git a/package/ddoc-editor.tsx b/package/ddoc-editor.tsx index afb81c2b..d82fca4b 100644 --- a/package/ddoc-editor.tsx +++ b/package/ddoc-editor.tsx @@ -1214,6 +1214,7 @@ const DdocEditor = forwardRef( runtimeState={ splitAwareDBlockRuntimeState } + onCopyHeadingLink={onCopyHeadingLink} >
{(cachedEditorEntries?.length @@ -1278,8 +1279,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', 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..50481ade --- /dev/null +++ b/package/extensions/d-block/dblock-node-view.test.ts @@ -0,0 +1,31 @@ +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/); + }); +}); diff --git a/package/extensions/d-block/dblock-node-view.ts b/package/extensions/d-block/dblock-node-view.ts index 1142fa05..29ff10f9 100644 --- a/package/extensions/d-block/dblock-node-view.ts +++ b/package/extensions/d-block/dblock-node-view.ts @@ -1,15 +1,9 @@ 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, -} from './dblock-collapse'; +import { DBLOCK_HIDDEN_CLASS, getDBlockRenderMeta } from './dblock-collapse'; import type { DBlockRuntimeState } from './dblock-runtime'; import { getDBlockRuntimeState } from './dblock-runtime'; -import { registerDBlockView } from './dblock-view-registry'; interface DBlockNodeViewOptions { editor: Editor; @@ -18,7 +12,6 @@ interface DBlockNodeViewOptions { decorations: readonly Decoration[]; HTMLAttributes: Record; getRuntimeState?: () => DBlockRuntimeState; - onCopyHeadingLink?: (link: string) => void; } const joinClasses = (...classes: Array) => @@ -39,37 +32,20 @@ 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; - constructor({ editor, node, @@ -77,57 +53,26 @@ export class DBlockNodeView implements NodeView { decorations, HTMLAttributes, getRuntimeState, - onCopyHeadingLink, }: DBlockNodeViewOptions) { this.editor = editor; this.node = node; this.getPos = getPos; this.decorations = decorations; this.getRuntimeState = getRuntimeState; - this.id = uuidv4(); 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 +80,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,43 +93,13 @@ 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, + ); } private safeGetPos() { diff --git a/package/extensions/d-block/dblock-toolbar.tsx b/package/extensions/d-block/dblock-toolbar.tsx index d474e87b..ee84dba3 100644 --- a/package/extensions/d-block/dblock-toolbar.tsx +++ b/package/extensions/d-block/dblock-toolbar.tsx @@ -1,243 +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 { 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 { + contentElement: Element; 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 = ( editor: Editor | null, runtimeState: DBlockRuntimeState, -) => { +): DBlockTemplateTarget | null => { if ( !editor || runtimeState.isPreviewMode || @@ -280,17 +65,20 @@ const getTemplateTarget = ( return null; } - const firstDBlockElement = editor.view.dom.querySelector( - '[data-dblock-node-view]', + // TODO(Task 4): the registry used to resolve the target node view's + // content element directly. With the gutter/content-shell DOM gone, the + // first (and only, per the childCount check above) d-block's content + // element is queried directly off the editor DOM as a one-task bridge. + const contentElement = editor.view.dom.querySelector( + '[data-type="d-block"] > [data-node-view-content]', ); - const handle = getDBlockViewFromElement(firstDBlockElement); - if (!handle?.contentElement.isConnected) { + if (!contentElement || !contentElement.isConnected) { return null; } return { - handle, + contentElement, node, pos, }; @@ -299,15 +87,28 @@ 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); + + return () => { + editor.off('transaction', refresh); + editor.off('selectionUpdate', refresh); + }; + }, [editor]); + const target = useMemo(() => { void refreshKey; return getTemplateTarget(editor, runtimeState); @@ -359,7 +160,7 @@ const DBlockTemplateOverlay = ({ runtimeState.isPreviewMode, isFocusMode, ), - target.handle.contentElement, + target.contentElement, ); }; @@ -367,88 +168,13 @@ export const DBlockToolbarProvider = ({ children, editor, runtimeState = DEFAULT_DBLOCK_RUNTIME_STATE, + onCopyHeadingLink, }: { children: React.ReactNode; editor: Editor | null; runtimeState?: DBlockRuntimeState; + onCopyHeadingLink?: (link: string) => void; }) => { - 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(() => { - const currentHandle = activeHandleRef.current; - currentHandle?.refresh(); - - if (currentHandle && !resolveCurrentDBlock(editor, currentHandle)) { - setActiveDBlock(null); - return; - } - - setRefreshKey((key) => key + 1); - }, [editor, setActiveDBlock]); - - useEffect(() => { - if (!editor) { - 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]); - return ( <> {children} @@ -456,26 +182,10 @@ export const DBlockToolbarProvider = ({ - ) : null} - {activeHandle && editor ? ( - ) : 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 927ae80e..19bd26f3 100644 --- a/package/extensions/d-block/dblock.ts +++ b/package/extensions/d-block/dblock.ts @@ -990,7 +990,6 @@ export const DBlock = Node.create({ decorations, HTMLAttributes, getRuntimeState: this.options.getRuntimeState, - onCopyHeadingLink: this.options.onCopyHeadingLink, }); }, diff --git a/package/styles/editor.css b/package/styles/editor.css index f83a6c1e..2894b262 100644 --- a/package/styles/editor.css +++ b/package/styles/editor.css @@ -63,18 +63,6 @@ } } - h1 { - transform: translateY(-0.5rem); - } - - h2 { - transform: translateY(-0.25rem); - } - - h3 { - transform: translateY(0); - } - & > p { margin-top: 1.5rem; margin-bottom: 1.5rem; @@ -489,6 +477,26 @@ } } +.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%; + } +} + [data-mode='focus'] { :is(.ProseMirror, .ProseMirror-focused) { p.is-empty::before { @@ -941,6 +949,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.main-doc-editor { + 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..7ad204e7 100644 --- a/package/styles/index.css +++ b/package/styles/index.css @@ -355,7 +355,7 @@ li > ol > li > ol > li:before { z-index: 999; } -.node-view-content > div { +[data-node-view-content] > div { word-break: break-word; } diff --git a/package/types.ts b/package/types.ts index bd26eb40..d5a1d458 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', }, From 8e5983e666117d5618042722b3548a24b4ae0464 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 02:57:34 +0530 Subject: [PATCH 10/77] fix: merge editorProps in setOptions instead of replacing them Editor#setOptions only shallow-merges top-level options, so passing a partial editorProps object (as the toolbar's keyboard-shortcut effect did) silently replaced the whole thing -- wiping construction-time attributes (including the main-doc-editor class), clipboardTextSerializer, and handleDOMEvents. Adds a mergeEditorProps helper that spreads the current editorProps before patching, used at both call sites in editor-utils.tsx. --- package/components/editor-utils.test.ts | 98 +++++++++++ package/components/editor-utils.tsx | 216 ++++++++++++------------ 2 files changed, 209 insertions(+), 105 deletions(-) create mode 100644 package/components/editor-utils.test.ts 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..6f8b1c23 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; - } + 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; + } - // 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(); + // 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]); From cb7c1365e03bc7061b090fca57b22ad2c0525b7a Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 03:03:42 +0530 Subject: [PATCH 11/77] fix: stop construction-time attributes literal from clobbering DdocEditorProps createEditorForTab's new Editor({ editorProps: { ...DdocEditorProps, ..., attributes: { spellCheck: 'true' } } }) placed a duplicate attributes: key after the DdocEditorProps spread in the same object literal. A later duplicate key in a JS object literal replaces the earlier one outright rather than merging, so this silently dropped DdocEditorProps' whole attributes record (main-doc-editor/prose classes, spellcheck, suppressContentEditableWarning) down to just { spellCheck: 'true' } -- predates this branch, but the new container padding is keyed on main-doc-editor so it's now load-bearing. Extracts the merged attributes into an exported TAB_EDITOR_ATTRIBUTES constant used at the single call site instead of a second inline attributes key, and drops the redundant camelCase spellCheck duplicate (DdocEditorProps' lowercase spellcheck already covers it -- setAttribute normalizes case on HTML elements anyway). --- package/hooks/use-tab-editor.test.ts | 49 ++++++++++++++++++++++++++++ package/hooks/use-tab-editor.tsx | 27 +++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 package/hooks/use-tab-editor.test.ts 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..8c376c22 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', @@ -590,9 +604,16 @@ export const useTabEditor = ({ return false; }, - attributes: { - spellCheck: 'true', - }, + // `...DdocEditorProps` above already sets `attributes` as ONE key + // inside this same object literal — a later `attributes:` key in + // a JS object literal replaces the earlier one wholesale rather + // than merging, so this must reuse the same merged value instead + // of introducing a second, colliding `attributes:` key. See + // TAB_EDITOR_ATTRIBUTES above. (The camelCase `spellCheck` this + // used to additionally set here was redundant with — and + // normalized by the DOM to the same attribute as — + // DdocEditorProps' lowercase `spellcheck`, so it's dropped.) + attributes: TAB_EDITOR_ATTRIBUTES, }, textDirection: 'auto', autofocus: shouldAutofocus ? 'start' : false, From 22070200f079aae2a4c38ea74976b11c0100f473 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 10:07:56 +0530 Subject: [PATCH 12/77] feat: portal template overlay outside the editable DOM --- .../d-block/dblock-template-overlay.test.tsx | 33 ++++++++++ package/extensions/d-block/dblock-toolbar.tsx | 63 +++++++++++-------- 2 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 package/extensions/d-block/dblock-template-overlay.test.tsx 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.tsx b/package/extensions/d-block/dblock-toolbar.tsx index ee84dba3..3c407536 100644 --- a/package/extensions/d-block/dblock-toolbar.tsx +++ b/package/extensions/d-block/dblock-toolbar.tsx @@ -14,12 +14,11 @@ import { import { DBlockDragHandle } from './dblock-drag-handle'; interface DBlockTemplateTarget { - contentElement: Element; node: ProseMirrorNode; pos: number; } -const getTemplateTarget = ( +export const getTemplateTarget = ( editor: Editor | null, runtimeState: DBlockRuntimeState, ): DBlockTemplateTarget | null => { @@ -65,20 +64,7 @@ const getTemplateTarget = ( return null; } - // TODO(Task 4): the registry used to resolve the target node view's - // content element directly. With the gutter/content-shell DOM gone, the - // first (and only, per the childCount check above) d-block's content - // element is queried directly off the editor DOM as a one-task bridge. - const contentElement = editor.view.dom.querySelector( - '[data-type="d-block"] > [data-node-view-content]', - ); - - if (!contentElement || !contentElement.isConnected) { - return null; - } - return { - contentElement, node, pos, }; @@ -102,10 +88,12 @@ const DBlockTemplateOverlay = ({ 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]); @@ -145,22 +133,43 @@ const DBlockTemplateOverlay = ({ }); }, [moreTemplates.length]); - if (!target || isFocusMode) { + const panel = editor?.view.dom.closest('[data-ddoc-editor-panel]'); + + 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( - templateButtons, - moreTemplates, - visibleTemplateCount, - toggleAllTemplates, - isExpanded, - runtimeState.isCollaboratorsDoc, - runtimeState.isPreviewMode, - isFocusMode, - ), - target.contentElement, +
+ {renderTemplateButtons( + templateButtons, + moreTemplates, + visibleTemplateCount, + toggleAllTemplates, + isExpanded, + runtimeState.isCollaboratorsDoc, + runtimeState.isPreviewMode, + isFocusMode, + )} +
, + panel, ); }; From 44eaf925d2f93961662372b88aa194ffadab9b5b Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 10:15:16 +0530 Subject: [PATCH 13/77] chore: bump version to 4.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7cfef58..faf06756 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@fileverse-dev/ddoc", "private": false, "description": "DDoc", - "version": "4.3.6", + "version": "4.4.0", "main": "dist/index.es.js", "module": "dist/index.es.js", "exports": { From f574671d66bd5570b94ab496800dcffd5d04505f Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 10:42:27 +0530 Subject: [PATCH 14/77] fix: presentation padding scope, table centering, dead chrome code --- package/ddoc-editor.tsx | 1 - package/extensions/d-block/dblock-collapse.ts | 13 ------------- package/extensions/d-block/dblock.ts | 2 -- package/extensions/default-extension.ts | 3 --- package/hooks/use-tab-editor.tsx | 13 ------------- package/styles/editor.css | 3 ++- package/use-ddoc-editor.tsx | 2 -- 7 files changed, 2 insertions(+), 35 deletions(-) diff --git a/package/ddoc-editor.tsx b/package/ddoc-editor.tsx index d82fca4b..1221a59c 100644 --- a/package/ddoc-editor.tsx +++ b/package/ddoc-editor.tsx @@ -344,7 +344,6 @@ const DdocEditor = forwardRef( extensions, disableInlineComment, isFocusMode, - onCopyHeadingLink, isConnected, activeModel, maxTokens, diff --git a/package/extensions/d-block/dblock-collapse.ts b/package/extensions/d-block/dblock-collapse.ts index f631751b..3162ea3d 100644 --- a/package/extensions/d-block/dblock-collapse.ts +++ b/package/extensions/d-block/dblock-collapse.ts @@ -55,19 +55,6 @@ export const getDBlockRenderMeta = ( }; }; -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, diff --git a/package/extensions/d-block/dblock.ts b/package/extensions/d-block/dblock.ts index 19bd26f3..b936ba41 100644 --- a/package/extensions/d-block/dblock.ts +++ b/package/extensions/d-block/dblock.ts @@ -11,7 +11,6 @@ import { createDBlockMediaConversionPlugin } from './dblock-media-plugin'; export interface DBlockOptions { HTMLAttributes: Record; ipfsImageUploadFn?: (file: File) => Promise; - onCopyHeadingLink?: (link: string) => void; hasAvailableModels: boolean; getRuntimeState?: () => DBlockRuntimeState; } @@ -58,7 +57,6 @@ export const DBlock = Node.create({ addOptions() { return { HTMLAttributes: {}, - onCopyHeadingLink: undefined, hasAvailableModels: false, getRuntimeState: undefined, }; diff --git a/package/extensions/default-extension.ts b/package/extensions/default-extension.ts index 6d24a229..35ca1332 100644 --- a/package/extensions/default-extension.ts +++ b/package/extensions/default-extension.ts @@ -255,7 +255,6 @@ export const defaultExtensions = ({ ipfsImageFetchFn, onError, metadataProxyUrl, - onCopyHeadingLink, ipfsImageUploadFn, fetchV1ImageFn, onTocUpdate, @@ -268,7 +267,6 @@ export const defaultExtensions = ({ onError: (error: string) => void; ipfsImageUploadFn?: (file: File) => Promise; metadataProxyUrl?: string; - onCopyHeadingLink?: (link: string) => void; fetchV1ImageFn?: (url: string) => Promise; onTocUpdate?: (data: ToCItemType[], isCreate?: boolean) => void; dBlockRuntimeStateRef?: DBlockRuntimeStateRef; @@ -427,7 +425,6 @@ export const defaultExtensions = ({ Gapcursor, createDBlockExtension({ ipfsImageUploadFn, - onCopyHeadingLink, hasAvailableModels, getRuntimeState: dBlockRuntimeStateRef ? () => dBlockRuntimeStateRef.current diff --git a/package/hooks/use-tab-editor.tsx b/package/hooks/use-tab-editor.tsx index 8c376c22..18ab5dc8 100644 --- a/package/hooks/use-tab-editor.tsx +++ b/package/hooks/use-tab-editor.tsx @@ -249,7 +249,6 @@ interface UseTabEditorArgs { onError?: DdocProps['onError']; ipfsImageUploadFn?: DdocProps['ipfsImageUploadFn']; metadataProxyUrl?: string; - onCopyHeadingLink?: DdocProps['onCopyHeadingLink']; ipfsImageFetchFn?: DdocProps['ipfsImageFetchFn']; fetchV1ImageFn?: DdocProps['fetchV1ImageFn']; isConnected?: boolean; @@ -299,7 +298,6 @@ export const useTabEditor = ({ onError, ipfsImageUploadFn, metadataProxyUrl, - onCopyHeadingLink, ipfsImageFetchFn, fetchV1ImageFn, isConnected, @@ -379,7 +377,6 @@ export const useTabEditor = ({ onError, ipfsImageUploadFn, metadataProxyUrl, - onCopyHeadingLink, ipfsImageFetchFn, fetchV1ImageFn, enableCollaboration: collabEnabled, @@ -1537,7 +1534,6 @@ interface UseExtensionStackArgs { onError?: (error: string) => void; ipfsImageUploadFn?: DdocProps['ipfsImageUploadFn']; metadataProxyUrl?: string; - onCopyHeadingLink?: DdocProps['onCopyHeadingLink']; ipfsImageFetchFn?: DdocProps['ipfsImageFetchFn']; fetchV1ImageFn?: DdocProps['fetchV1ImageFn']; enableCollaboration?: boolean; @@ -1565,7 +1561,6 @@ const useEditorExtension = ({ onError, ipfsImageUploadFn, metadataProxyUrl, - onCopyHeadingLink, ipfsImageFetchFn, fetchV1ImageFn, enableCollaboration, @@ -1584,14 +1579,9 @@ const useEditorExtension = ({ }: UseExtensionStackArgs) => { const onErrorRef = useRef(onError); onErrorRef.current = onError; - const onCopyHeadingLinkRef = useRef(onCopyHeadingLink); - onCopyHeadingLinkRef.current = onCopyHeadingLink; const handleExtensionError = useCallback((error: string) => { onErrorRef.current?.(error); }, []); - const handleCopyHeadingLink = useCallback((link: string) => { - onCopyHeadingLinkRef.current?.(link); - }, []); const slashCommandConfigRef = useRef({ isConnected, enableCollaboration, @@ -1664,7 +1654,6 @@ const useEditorExtension = ({ onError: handleExtensionError, ipfsImageUploadFn, metadataProxyUrl, - onCopyHeadingLink: handleCopyHeadingLink, ipfsImageFetchFn, fetchV1ImageFn, onTocUpdate: (data, isCreate) => @@ -1746,7 +1735,6 @@ const useEditorExtension = ({ createDBlockExtension({ hasAvailableModels, ipfsImageUploadFn, - onCopyHeadingLink: handleCopyHeadingLink, getRuntimeState: () => dBlockRuntimeStateRef.current, }), createSlashCommand(), @@ -1756,7 +1744,6 @@ const useEditorExtension = ({ ipfsImageUploadFn, metadataProxyUrl, handleExtensionError, - handleCopyHeadingLink, ipfsImageFetchFn, fetchV1ImageFn, onTocUpdateForTab, diff --git a/package/styles/editor.css b/package/styles/editor.css index 2894b262..715d29fc 100644 --- a/package/styles/editor.css +++ b/package/styles/editor.css @@ -490,6 +490,7 @@ /* 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] { @@ -950,7 +951,7 @@ 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.main-doc-editor { + .ProseMirror { padding: 16px; @media (min-width: 768px) { diff --git a/package/use-ddoc-editor.tsx b/package/use-ddoc-editor.tsx index 8d5e0ac5..daf33bf3 100644 --- a/package/use-ddoc-editor.tsx +++ b/package/use-ddoc-editor.tsx @@ -33,7 +33,6 @@ export const useDdocEditor = ({ isPresentationMode, metadataProxyUrl, extensions: externalExtensions, - onCopyHeadingLink, ipfsImageFetchFn, fetchV1ImageFn, isConnected, @@ -148,7 +147,6 @@ export const useDdocEditor = ({ onError, ipfsImageUploadFn, metadataProxyUrl, - onCopyHeadingLink, ipfsImageFetchFn, fetchV1ImageFn, isConnected, From 63ca1a6e1ca7ea39f4daefd991a11d867e826a46 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 10:52:34 +0530 Subject: [PATCH 15/77] feat: schema-aware block insertion across v1/v2 (M2 sweep) Every code path that emitted dBlock-shaped content or walked to a dBlock ancestor now detects the live schema (schema.nodes.dBlock) and produces flat content under v2: resizable-media and media-caption Enter, setColumns and buildNColumns, sanitize-content's corrupt-block fallback, list <-> text conversion in the bubble menu (incl. the mixed-selection guard), code-block Mod-Enter escape, the === page-break input rule and paste post-process, and TOC heading expansion. New shared util: utils/block-schema.ts (schemaHasDBlock / wrapBlockNode). Demo exposes window.__ddoc for smoke tests. Verified by driving real editor commands in the demo on both schemas: v2 doc holds paragraph/hr/pageBreak/codeBlock/table/callout/columns as flat top-level nodes with a dblock-free Yjs fragment, survives reopen; v1 control produces identical dBlock-wrapped structure; zero page exceptions. --- demo/src/App.tsx | 6 ++ .../editor-bubble-menu/node-selector.tsx | 17 +++- package/components/editor-utils.tsx | 82 +++++++++++-------- package/components/toc/toc.tsx | 7 ++ .../code-block/custom-code-block-lowlight.ts | 47 +++++++---- .../extensions/mardown-paste-handler/index.ts | 19 ++++- package/extensions/multi-column/columns.ts | 7 +- package/extensions/multi-column/utils.ts | 10 +-- .../resizable-media/media-caption.ts | 16 ++-- .../resizable-media/resizable-media.ts | 13 ++- package/hooks/use-headless-editor.tsx | 5 +- package/hooks/use-tab-editor.tsx | 1 + package/utils/block-schema.ts | 15 ++++ package/utils/sanitize-content.ts | 46 +++++++---- 14 files changed, 195 insertions(+), 96 deletions(-) create mode 100644 package/utils/block-schema.ts diff --git a/demo/src/App.tsx b/demo/src/App.tsx index a3be63c8..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(() => { 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-utils.tsx b/package/components/editor-utils.tsx index 78455848..5a3bb34f 100644 --- a/package/components/editor-utils.tsx +++ b/package/components/editor-utils.tsx @@ -2543,46 +2543,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/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/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/mardown-paste-handler/index.ts b/package/extensions/mardown-paste-handler/index.ts index c7dc146d..d48c1d58 100644 --- a/package/extensions/mardown-paste-handler/index.ts +++ b/package/extensions/mardown-paste-handler/index.ts @@ -1006,12 +1006,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,7 +1373,8 @@ 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. In v1 they + // arrive wrapped in a dBlock; in the flat v2 schema they are top-level. const newChildren: PMNode[] = []; proseMirrorNodes.forEach((child: PMNode) => { if (child.type.name === 'dBlock' && child.childCount === 1) { @@ -1380,6 +1387,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/columns.ts b/package/extensions/multi-column/columns.ts index ae4ce276..d8fce18a 100644 --- a/package/extensions/multi-column/columns.ts +++ b/package/extensions/multi-column/columns.ts @@ -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; 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/hooks/use-headless-editor.tsx b/package/hooks/use-headless-editor.tsx index 5d9d940b..d66a9ef7 100644 --- a/package/hooks/use-headless-editor.tsx +++ b/package/hooks/use-headless-editor.tsx @@ -105,7 +105,10 @@ export const useHeadlessEditor = (props?: UseHeadlessEditorProps) => { } } else { editor.commands.setContent( - sanitizeContent({ data: initialContent as JSONContent }), + sanitizeContent({ + data: initialContent as JSONContent, + wrapInDBlock: Boolean(editor.schema.nodes.dBlock), + }), ); } }; diff --git a/package/hooks/use-tab-editor.tsx b/package/hooks/use-tab-editor.tsx index 0677cdbf..16893724 100644 --- a/package/hooks/use-tab-editor.tsx +++ b/package/hooks/use-tab-editor.tsx @@ -864,6 +864,7 @@ export const useTabEditor = ({ data: initialContent as JSONContent, ignoreCorruptedData, onInvalidContentError, + wrapInDBlock: Boolean(editor.schema.nodes.dBlock), }), ); } diff --git a/package/utils/block-schema.ts b/package/utils/block-schema.ts new file mode 100644 index 00000000..88db3007 --- /dev/null +++ b/package/utils/block-schema.ts @@ -0,0 +1,15 @@ +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; 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; }) From a4789c71da0769daff9e3fc6bd607076cce5c153 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:02:09 +0530 Subject: [PATCH 16/77] feat: heading collapse works on both schemas (M2) The collapse engine in dblock-collapse.ts is generalized around one resolver: getBlockHeading(doc, node) returns the heading of a top-level block (the dBlock's first child in v1, the node itself in flat v2), with heading positions and caret offsets derived from schema presence. All exports keep their signatures, so v1 callers (dblock extension, node view, toolbar, content-item actions) are untouched. v2 registers the same plugin via a new FlatHeadingCollapse extension since it has no dBlock extension to host it. Preserved v1 quirk: non-dBlock top nodes (columns, pageBreak) are never hidden; flat v2 hides every block in a collapsed region. Verified in the demo on both schemas: collapsing an H1 hides exactly its section (paragraph, child H2, its paragraph) while the sibling H1 stays visible, state survives reopen via the Yjs attr, Enter at the collapsed heading end expands and re-anchors the caret, v1 behavior unchanged, zero page exceptions. --- package/extensions/d-block/dblock-collapse.ts | 212 ++++++++++-------- package/extensions/default-extension.ts | 3 +- 2 files changed, 122 insertions(+), 93 deletions(-) diff --git a/package/extensions/d-block/dblock-collapse.ts b/package/extensions/d-block/dblock-collapse.ts index f631751b..82fdb1ff 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, @@ -39,6 +39,29 @@ 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); + export const getDBlockRenderMeta = ( node: ProseMirrorNode, pos: number, @@ -93,12 +116,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 +163,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 +173,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 +244,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 +277,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 +287,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 +305,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 +321,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 +332,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 +362,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); @@ -403,17 +424,13 @@ export const toggleHeadingCollapse = (editor: Editor, position: number) => { 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 +441,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 +473,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 +487,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,22 +516,26 @@ 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(); @@ -530,17 +545,19 @@ const buildHiddenDecorationSet = (doc: ProseMirrorNode) => { 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 +577,10 @@ const buildHiddenDecorationSet = (doc: ProseMirrorNode) => { ); } - if (isHeading) { - const isCollapsed = Boolean(firstChild.attrs.isCollapsed); + if (blockHeading) { + const isCollapsed = Boolean(blockHeading.attrs.isCollapsed); headingStack.push({ - level: firstChild.attrs.level || 1, + level: blockHeading.attrs.level || 1, isCollapsed, }); if (isCollapsed) { @@ -577,27 +594,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('|'); @@ -655,6 +673,16 @@ export const dBlockCollapsePluginKey = new PluginKey( 'dblock-collapse', ); +// 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 const FlatHeadingCollapse = Extension.create({ + name: 'flatHeadingCollapse', + addProseMirrorPlugins() { + return [createDBlockCollapsePlugin()]; + }, +}); + export const createDBlockCollapsePlugin = () => new Plugin({ key: dBlockCollapsePluginKey, diff --git a/package/extensions/default-extension.ts b/package/extensions/default-extension.ts index 82be5935..02f6657f 100644 --- a/package/extensions/default-extension.ts +++ b/package/extensions/default-extension.ts @@ -52,6 +52,7 @@ 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 { SuperchargedTableExtensions } from './supercharged-table'; import { Document, FlatDocument } from './document'; import { TrailingNode } from './trailing-node'; @@ -432,7 +433,7 @@ export const defaultExtensions = ({ // math assumes the wrapper, so it is v1-only until re-homed in M2). // v2: flat top node, stock Tiptap structure. ...(schemaVersion >= 2 - ? [FlatDocument] + ? [FlatDocument, FlatHeadingCollapse] : [ createDBlockExtension({ ipfsImageUploadFn, From 38e74626a6544ff10a96ac9ba7bcd296334ae987 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:06:13 +0530 Subject: [PATCH 17/77] feat: schema badge in demo DevBar Cmd+Shift+D now shows which schema a doc uses: the ddocMeta marker vs the extension set the editor actually loaded, with a red MISMATCH state when the fork picked the wrong set. Reads through window.__ddoc, no prop threading. --- demo/src/components/DevBar.tsx | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/demo/src/components/DevBar.tsx b/demo/src/components/DevBar.tsx index 1087fb46..81cbaae0 100644 --- a/demo/src/components/DevBar.tsx +++ b/demo/src/components/DevBar.tsx @@ -39,6 +39,7 @@ export function DevBar({ }: DevBarProps) { const [visible, setVisible] = useState(false); const [contentSize, setContentSize] = useState(0); + const [schemaInfo, setSchemaInfo] = useState('...'); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -63,6 +64,33 @@ 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]); + const handleClearData = () => { const confirmed = window.confirm( 'Clear all data for this document? (localStorage + IndexedDB)', @@ -89,6 +117,15 @@ export function DevBar({ doc: {docId.slice(0, 8)} + + schema: {schemaInfo} + + tab: {activeTabId} From bea3fac91579cf4f4fb20922dd216904f4495d14 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:11:00 +0530 Subject: [PATCH 18/77] feat: live doc JSON panel in demo DevBar The JSON button in the DevBar opens a panel showing editor.getJSON() pretty-printed and refreshed every second while open, with a copy button. For eyeballing v1 vs v2 document shapes while testing. --- demo/src/components/DevBar.tsx | 53 +++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/demo/src/components/DevBar.tsx b/demo/src/components/DevBar.tsx index 81cbaae0..129b4ed5 100644 --- a/demo/src/components/DevBar.tsx +++ b/demo/src/components/DevBar.tsx @@ -40,6 +40,8 @@ export function DevBar({ 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) => { @@ -91,6 +93,27 @@ export function DevBar({ 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)', @@ -112,7 +135,27 @@ export function DevBar({ ); return ( -
+ <> + {showJson && ( +
+
+ + editor.getJSON() (live) + + +
+
+            {docJson}
+          
+
+ )} +
doc: {docId.slice(0, 8)} @@ -176,6 +219,13 @@ export function DevBar({
+
+ ); } From fa7f6f3fc7bb5d0804c1ff8b3e5ec629ab50e139 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:16:41 +0530 Subject: [PATCH 19/77] feat: persistent block ids in the flat v2 schema (M2) New BlockId extension, v2-only: every top-level block carries a blockId uuid as a global attribute (data-block-id in DOM), assigned by an appendTransaction plugin that does a shallow top-level pass on doc changes. keepOnSplit false so the original half of a split keeps its identity; the same pass re-ids duplicates that arrive via paste. Not undoable on its own (addToHistory false). Nested blocks declare the attr but stay null. Verified in the demo: fresh blocks get distinct ids, split keeps the original id and mints one for the new half, inserting a block with a stolen id gets re-identified, ids persist across reopen, and v1 docs contain no blockId anywhere. --- package/extensions/block-id/index.ts | 104 ++++++++++++++++++++++++ package/extensions/default-extension.ts | 3 +- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 package/extensions/block-id/index.ts diff --git a/package/extensions/block-id/index.ts b/package/extensions/block-id/index.ts new file mode 100644 index 00000000..e90f7927 --- /dev/null +++ b/package/extensions/block-id/index.ts @@ -0,0 +1,104 @@ +import { Extension } from '@tiptap/core'; +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; + } + + // 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, + ); + }); + + // Id bookkeeping is not a user edit and must not be undoable on + // its own. + if (tr) { + (tr as typeof newState.tr).setMeta('addToHistory', false); + } + return tr; + }, + }), + ]; + }, +}); diff --git a/package/extensions/default-extension.ts b/package/extensions/default-extension.ts index 02f6657f..0080e2d0 100644 --- a/package/extensions/default-extension.ts +++ b/package/extensions/default-extension.ts @@ -53,6 +53,7 @@ import { Iframe } from './iframe'; import { EmbeddedTweet } from './twitter-embed'; import { createDBlockExtension } from './d-block'; import { FlatHeadingCollapse } from './d-block/dblock-collapse'; +import { BlockId } from './block-id'; import { SuperchargedTableExtensions } from './supercharged-table'; import { Document, FlatDocument } from './document'; import { TrailingNode } from './trailing-node'; @@ -433,7 +434,7 @@ export const defaultExtensions = ({ // math assumes the wrapper, so it is v1-only until re-homed in M2). // v2: flat top node, stock Tiptap structure. ...(schemaVersion >= 2 - ? [FlatDocument, FlatHeadingCollapse] + ? [FlatDocument, FlatHeadingCollapse, BlockId] : [ createDBlockExtension({ ipfsImageUploadFn, From 509de748a025491e26156ac3cc965e1391eb1016 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:25:19 +0530 Subject: [PATCH 20/77] feat: stock trailing node for the flat v2 schema (M2) v1 keeps the custom dBlock-aware TrailingNode (its detection reads the wrapper's inner child and would loop on a flat doc). v2 enables StarterKit's stock trailing node instead: schema-agnostic, appends a paragraph whenever the doc ends with a non-paragraph block. Known nuance for the fonts follow-up: stock trailing paragraphs carry no trailing-node class, so typography-persistence does not skip them in v2. Verified in the demo: v2 docs ending with a table or code block get a trailing paragraph, typing does not multiply paragraphs, v1 trailing dBlock with its class is unchanged. --- package/extensions/default-extension.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package/extensions/default-extension.ts b/package/extensions/default-extension.ts index 0080e2d0..b214e651 100644 --- a/package/extensions/default-extension.ts +++ b/package/extensions/default-extension.ts @@ -337,7 +337,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: { From e509e0522fcb06233aa4cf2127b28318240a2254 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:39:31 +0530 Subject: [PATCH 21/77] feat: AI writer space trigger for the flat v2 schema (M2) New AiWriterSpaceTrigger extension: a single space in an empty top-level paragraph (or column cell) replaces it with the aiWriter node, matching the v1 behavior that lives inside the dBlock extension. Registered in the v2 set behind the same hasAvailableModels gate as v1, and self-disables when aiWriter is absent from the schema. The two dBlock checks inside ai-autocomplete needed no change: both are shadowed by schema-neutral guards in either schema. Verified by unit tests driving the plugin headlessly: trigger fires in an empty paragraph, skips non-empty ones, refuses a second writer while one is active, and no-ops without the aiWriter node type. (The demo has no AI models configured, so end-to-end needs ddocs.new, same as v1 today.) --- .../ai-writer/ai-writer-space-trigger.test.ts | 89 +++++++++++++++++++ .../ai-writer/ai-writer-space-trigger.ts | 70 +++++++++++++++ package/extensions/default-extension.ts | 9 +- 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 package/extensions/ai-writer/ai-writer-space-trigger.test.ts create mode 100644 package/extensions/ai-writer/ai-writer-space-trigger.ts 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/default-extension.ts b/package/extensions/default-extension.ts index b214e651..09ffc122 100644 --- a/package/extensions/default-extension.ts +++ b/package/extensions/default-extension.ts @@ -54,6 +54,7 @@ import { EmbeddedTweet } from './twitter-embed'; import { createDBlockExtension } from './d-block'; import { FlatHeadingCollapse } from './d-block/dblock-collapse'; import { BlockId } from './block-id'; +import { AiWriterSpaceTrigger } from './ai-writer/ai-writer-space-trigger'; import { SuperchargedTableExtensions } from './supercharged-table'; import { Document, FlatDocument } from './document'; import { TrailingNode } from './trailing-node'; @@ -436,7 +437,13 @@ export const defaultExtensions = ({ // math assumes the wrapper, so it is v1-only until re-homed in M2). // v2: flat top node, stock Tiptap structure. ...(schemaVersion >= 2 - ? [FlatDocument, FlatHeadingCollapse, BlockId] + ? [ + FlatDocument, + FlatHeadingCollapse, + BlockId, + // Same hasAvailableModels gate as v1's in-dBlock space trigger. + ...(hasAvailableModels ? [AiWriterSpaceTrigger] : []), + ] : [ createDBlockExtension({ ipfsImageUploadFn, From cdf8467335aaff8603611b9f3fb3b6c7f1d251f6 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:44:46 +0530 Subject: [PATCH 22/77] feat: dBlock unwrap transform for v1-shaped JSON (M2 exit) unwrapDBlocksInJSON recursively hoists dBlock children (recursing into columns; empty wrappers degrade to empty paragraphs) so templates and any legacy JSON load into flat v2 docs. The v1 template JSONs stay untouched as the single source of truth. M2 parity exit test passed: all 6 package templates loaded into a live v2 editor with zero dBlock nodes, text fully preserved, and top-level block counts exactly matching the v1 baseline (including identical trailing-node behavior). Unit tests cover the transform invariants per template plus nesting and edge cases. --- package/utils/block-schema.test.ts | 94 ++++++++++++++++++++++++++++++ package/utils/block-schema.ts | 17 ++++++ 2 files changed, 111 insertions(+) create mode 100644 package/utils/block-schema.test.ts 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 index 88db3007..ec33882d 100644 --- a/package/utils/block-schema.ts +++ b/package/utils/block-schema.ts @@ -13,3 +13,20 @@ export const wrapBlockNode = ( 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) }; +}; From a4af33caab95e11adb1fe130a5cc4f948e5dd857 Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 11:55:22 +0530 Subject: [PATCH 23/77] fix: columns caret set in setColumns' own transaction (position audit) The slash-menu columns commands chained .focus(selection.head - 1), but that argument evaluated at chain-build time, before setColumns ran, so the caret aimed at a pre-insert position. setColumns now places the caret in the first column cell within the same transaction, with schema-aware offsets and TextSelection.near for the final descent. docs/POSITION_AUDIT.md records the verdict for every audited call site: these two were the only stale ones in scope; the caption-flow arithmetic is correct-by-construction (single chains against the post-mutation doc); createInputRule is dead code with zero consumers. Verified live in both schemas: caret lands in column index 0 after setColumns, typing goes into the first cell, keepContent variant included. --- docs/POSITION_AUDIT.md | 43 ++++++++++++++++++++++ package/extensions/multi-column/columns.ts | 14 ++++++- package/utils/insert-commands.ts | 17 +++------ 3 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 docs/POSITION_AUDIT.md 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/package/extensions/multi-column/columns.ts b/package/extensions/multi-column/columns.ts index d8fce18a..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 { @@ -155,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/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(); From 618c0506d6d66759654fd0b9f41e1df30593df38 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 17:46:23 +0530 Subject: [PATCH 24/77] refactor: use IconButton for block control buttons --- .../extensions/d-block/components/buttons.tsx | 51 ++++++++++--------- .../extensions/d-block/dblock-drag-handle.tsx | 8 +-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/package/extensions/d-block/components/buttons.tsx b/package/extensions/d-block/components/buttons.tsx index b0515b60..678f8bf3 100644 --- a/package/extensions/d-block/components/buttons.tsx +++ b/package/extensions/d-block/components/buttons.tsx @@ -1,5 +1,11 @@ import React, { forwardRef } from 'react'; -import { Button, LucideIcon, PopoverClose, cn } from '@fileverse/ui'; +import { + Button, + IconButton, + LucideIcon, + PopoverClose, + cn, +} from '@fileverse/ui'; // Memoized button components to prevent unnecessary re-renders export const ActionButton = React.memo( @@ -31,13 +37,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 +62,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,26 +84,23 @@ PlusButton.displayName = 'PlusButton'; export const CollapseButton = React.memo( forwardRef< - HTMLDivElement, + HTMLButtonElement, { isCollapsed: boolean; onToggle: () => void; className: string; } >(({ isCollapsed, onToggle, className, ...props }, ref) => ( -
- -
+ /> )), ); @@ -108,7 +112,6 @@ export const CopyLinkButton = React.memo(
) => { + const handleAddBlock = (event: React.MouseEvent) => { const current = resolveBlock(); if (!current) return; const insertPos = event.altKey @@ -117,7 +117,7 @@ export const DBlockDragHandle = ({ }); }; - const handleDragClick = (event: React.MouseEvent) => { + const handleDragClick = (event: React.MouseEvent) => { if (event.altKey) { actions.deleteNode(); return; @@ -138,7 +138,7 @@ export const DBlockDragHandle = ({ }; const buttonClassName = cn( - 'd-block-button color-text-default hover:color-bg-default-hover aspect-square h-5 w-5 shrink-0', + 'd-block-button color-text-default hover:color-bg-default-hover aspect-square h-5 w-5 min-w-0 shrink-0', ); return ( @@ -149,7 +149,7 @@ export const DBlockDragHandle = ({ >
{shouldShowEditingControls ? ( <> From 72521d88dbdb3116af0ac5525081504dcf943408 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 17:46:24 +0530 Subject: [PATCH 25/77] fix: drop legacy editor spacing superseded by container padding --- package/ddoc-editor.tsx | 8 ++++---- package/styles/editor.css | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/package/ddoc-editor.tsx b/package/ddoc-editor.tsx index 1221a59c..3035d786 100644 --- a/package/ddoc-editor.tsx +++ b/package/ddoc-editor.tsx @@ -983,11 +983,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 && @@ -1001,7 +1001,7 @@ const DdocEditor = forwardRef( !isPreviewMode, }, // Split View: no full-screen top spacing. - isSplitViewActive && 'mt-0 pt-0', + isSplitViewActive && 'mt-0', isFocusMode && 'mt-[48px]', )} style={{ diff --git a/package/styles/editor.css b/package/styles/editor.css index 715d29fc..8a73c7c1 100644 --- a/package/styles/editor.css +++ b/package/styles/editor.css @@ -522,10 +522,6 @@ width: 100%; } -[data-ddoc-editor-root='true'] { - padding-bottom: 4rem; -} - .node-dBlock:first-child > div > .is-table { padding-top: 0.5rem; } From 58e315d92f9f232cfe1ad119046d338af779f101 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 17:46:59 +0530 Subject: [PATCH 26/77] chore: sync package-lock version to 4.4.0 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7220117e..ac9096b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fileverse-dev/ddoc", - "version": "4.3.6", + "version": "4.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fileverse-dev/ddoc", - "version": "4.3.6", + "version": "4.4.0", "dependencies": { "@_ueberdosis/prosemirror-tables": "^1.1.3", "@aarkue/tiptap-math-extension": "^1.4.0", From 34059351ef74ac583b96346b7ed3e02ef48f032a Mon Sep 17 00:00:00 2001 From: mbj36 Date: Wed, 5 Aug 2026 17:51:59 +0530 Subject: [PATCH 27/77] fix: paste empty-paragraph collapse + v2 spacing parity Three measured spacing problems from the Wikipedia-paste report: 1. Pasted HTML kept stacks of empty

(ProseMirror's native paste path, not the markdown handler). transformPasted now collapses interior runs of empty paragraphs to one, both schemas; slice edges never touched. The markdown-file path got the same collapse in its post-process. 2. The v1 heading translateY compensation leaked into v2, shifting headings off their caret line (40/56px asymmetric gaps). Scoped to :not([data-schema-version='2']) via a new data-schema-version attribute on the editor root. 3. v2 headings inherited prose-lg's 48px margins that v1 never shows (margins collapse through its wrapper rows). v2 pins headings to the same 1.5rem as paragraphs: uniform 24px rhythm, identical to v1. Documented while here: the editorProps attributes literal shadows DdocEditorProps.attributes, so the prose-p:my-2 class list in types.ts never reaches the main editor; today's real rhythm is 24px everywhere. Changing that is a product-visible design call, left for the layout track. Verified by DOM measurement in the demo, before and after, both schemas: v1 pixel-identical, v2 uniform 24px gaps, empty run collapsed 8 to 7 blocks. --- .../extensions/mardown-paste-handler/index.ts | 68 ++++++++++++++++++- package/hooks/use-tab-editor.tsx | 7 ++ package/styles/editor.css | 35 ++++++++-- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/package/extensions/mardown-paste-handler/index.ts b/package/extensions/mardown-paste-handler/index.ts index d48c1d58..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; @@ -1373,10 +1414,33 @@ export async function handleMarkdownContent( view.state.schema, ).parse(domContent); - // Post-process: replace "===" paragraphs with pageBreak nodes. In v1 they - // arrive wrapped in a dBlock; in the flat v2 schema they are top-level. + // 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 ( diff --git a/package/hooks/use-tab-editor.tsx b/package/hooks/use-tab-editor.tsx index 16893724..bf08bab6 100644 --- a/package/hooks/use-tab-editor.tsx +++ b/package/hooks/use-tab-editor.tsx @@ -595,6 +595,12 @@ export const useTabEditor = ({ }, attributes: { spellCheck: 'true', + // Lets CSS target one schema (v1 keeps the dBlock-era + // compensation rules, v2 gets its own rhythm). NOTE: this + // attributes literal shadows DdocEditorProps.attributes from the + // spread above (pre-existing), so the prose-* class list in + // types.ts never reaches this editor. + 'data-schema-version': String(docSchemaVersion), }, }, textDirection: 'auto', @@ -609,6 +615,7 @@ export const useTabEditor = ({ focusSubmittedSuggestionFromEditorEvent, handleCommentClick, handleCommentInteraction, + docSchemaVersion, ], ); diff --git a/package/styles/editor.css b/package/styles/editor.css index f83a6c1e..edf753f4 100644 --- a/package/styles/editor.css +++ b/package/styles/editor.css @@ -63,16 +63,37 @@ } } - h1 { - transform: translateY(-0.5rem); - } + /* v1-only: compensates the dBlock flex row's items-center alignment. + Scoped away from the flat v2 schema, where it would shift headings off + their caret line for no reason. */ + &:not([data-schema-version='2']) { + h1 { + transform: translateY(-0.5rem); + } + + h2 { + transform: translateY(-0.25rem); + } - h2 { - transform: translateY(-0.25rem); + h3 { + transform: translateY(0); + } } - 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 { From 3d3dbf40812f09ebc66e9948372db98e98efe717 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 18:02:55 +0530 Subject: [PATCH 28/77] fix: drag handle cluster geometry (stable width, alignment, line-height centering) --- .../extensions/d-block/components/menu.tsx | 9 ++- .../d-block/dblock-drag-handle.test.tsx | 27 ++++++++ .../extensions/d-block/dblock-drag-handle.tsx | 63 +++++++++++++++---- 3 files changed, 85 insertions(+), 14 deletions(-) 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/dblock-drag-handle.test.tsx b/package/extensions/d-block/dblock-drag-handle.test.tsx index 0f9e9066..45a945af 100644 --- a/package/extensions/d-block/dblock-drag-handle.test.tsx +++ b/package/extensions/d-block/dblock-drag-handle.test.tsx @@ -102,4 +102,31 @@ describe('DBlockDragHandle', () => { ); 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 index 414caacf..9123e49e 100644 --- a/package/extensions/d-block/dblock-drag-handle.tsx +++ b/package/extensions/d-block/dblock-drag-handle.tsx @@ -51,6 +51,11 @@ export const DBlockDragHandle = ({ }) => { const [hovered, setHovered] = useState(null); const [menuOpen, setMenuOpen] = useState(false); + // Vertical correction: the DragHandle plugin top-aligns the handle with + // the hovered block ('left-start'), but the cluster should center on the + // block's FIRST LINE. With default line-height (24px) the offset is 0; + // for larger line-heights/headings it grows to (lineHeight - 24) / 2. + const [lineOffset, setLineOffset] = useState(0); const isBelowLargeScreen = useMediaQuery('(max-width: 1024px)'); const resolveBlock = useCallback((): ResolvedContentItem | null => { @@ -90,6 +95,31 @@ export const DBlockDragHandle = ({ setMenuOpen(false); }, [hovered?.pos]); + useEffect(() => { + if (!hovered) { + setLineOffset(0); + return; + } + try { + // nodeDOM(pos) → div[data-type=d-block] → div[data-node-view-content] + // → the actual block element (p/hN/ul/...), whose computed line-height + // determines where the first line's center sits. + const wrapper = editor.view.nodeDOM(hovered.pos) as HTMLElement | null; + const blockEl = wrapper?.firstElementChild + ?.firstElementChild as HTMLElement | null; + const lineHeight = blockEl + ? parseFloat(getComputedStyle(blockEl).lineHeight) + : NaN; + setLineOffset( + Number.isFinite(lineHeight) && lineHeight > 24 + ? Math.round((lineHeight - 24) / 2) + : 0, + ); + } catch { + setLineOffset(0); + } + }, [editor, hovered]); + if (runtimeState.isPresentationMode && runtimeState.isPreviewMode) { return null; } @@ -98,10 +128,14 @@ export const DBlockDragHandle = ({ const shouldShowEditingControls = !runtimeState.isPreviewMode && !isBelowLargeScreen; - const shouldShowCollapse = Boolean(meta?.isHeading); - const shouldShowCopyLink = + // 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 shouldRenderCopyLinkSlot = runtimeState.isPreviewMode && - Boolean(meta?.isHeading) && !runtimeState.isPreviewEditor && !isBelowLargeScreen; @@ -150,6 +184,7 @@ export const DBlockDragHandle = ({
{shouldShowEditingControls ? ( <> @@ -174,21 +209,23 @@ export const DBlockDragHandle = ({ /> ) : null} - {shouldShowCollapse ? ( - - - - ) : null} - {shouldShowCopyLink ? ( + + + + {shouldRenderCopyLinkSlot ? ( From 813678c2869376e4f7c330ce55e82327ee838184 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 18:20:25 +0530 Subject: [PATCH 29/77] refactor: compute drag handle line offset synchronously in onNodeChange --- .../extensions/d-block/dblock-drag-handle.tsx | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/package/extensions/d-block/dblock-drag-handle.tsx b/package/extensions/d-block/dblock-drag-handle.tsx index 9123e49e..4e46a638 100644 --- a/package/extensions/d-block/dblock-drag-handle.tsx +++ b/package/extensions/d-block/dblock-drag-handle.tsx @@ -30,8 +30,34 @@ import { interface HoveredBlock { node: ProseMirrorNode; pos: number; + // Vertical correction, computed synchronously on node change: the plugin + // top-aligns the handle with the hovered block ('left-start'), but the + // cluster should center on the block's FIRST LINE. 0 at the default + // 24px line-height; (lineHeight - 24) / 2 for taller lines/headings. + lineOffset: number; } +const CLUSTER_HEIGHT = 24; + +const getFirstLineOffset = (editor: Editor, pos: number): number => { + try { + // nodeDOM(pos) → div[data-type=d-block] → div[data-node-view-content] + // → the actual block element (p/hN/ul/...), whose computed line-height + // determines where the first line's center sits. + const wrapper = editor.view.nodeDOM(pos) as HTMLElement | null; + const blockEl = wrapper?.firstElementChild + ?.firstElementChild as HTMLElement | 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; + } +}; + // 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 @@ -51,11 +77,6 @@ export const DBlockDragHandle = ({ }) => { const [hovered, setHovered] = useState(null); const [menuOpen, setMenuOpen] = useState(false); - // Vertical correction: the DragHandle plugin top-aligns the handle with - // the hovered block ('left-start'), but the cluster should center on the - // block's FIRST LINE. With default line-height (24px) the offset is 0; - // for larger line-heights/headings it grows to (lineHeight - 24) / 2. - const [lineOffset, setLineOffset] = useState(0); const isBelowLargeScreen = useMediaQuery('(max-width: 1024px)'); const resolveBlock = useCallback((): ResolvedContentItem | null => { @@ -76,6 +97,7 @@ export const DBlockDragHandle = ({ const handleNodeChange = useCallback( ({ node, + editor: dragHandleEditor, pos, }: { node: ProseMirrorNode | null; @@ -85,7 +107,14 @@ export const DBlockDragHandle = ({ setHovered((prev) => { if (!node) return prev; if (prev && prev.pos === pos && prev.node === node) return prev; - return { node, pos }; + // Computed here, not in an effect: an effect would land the offset + // one paint AFTER the plugin repositions the handle, flashing the + // cluster at the block top before it snaps to the first-line center. + return { + node, + pos, + lineOffset: getFirstLineOffset(dragHandleEditor, pos), + }; }); }, [], @@ -95,31 +124,6 @@ export const DBlockDragHandle = ({ setMenuOpen(false); }, [hovered?.pos]); - useEffect(() => { - if (!hovered) { - setLineOffset(0); - return; - } - try { - // nodeDOM(pos) → div[data-type=d-block] → div[data-node-view-content] - // → the actual block element (p/hN/ul/...), whose computed line-height - // determines where the first line's center sits. - const wrapper = editor.view.nodeDOM(hovered.pos) as HTMLElement | null; - const blockEl = wrapper?.firstElementChild - ?.firstElementChild as HTMLElement | null; - const lineHeight = blockEl - ? parseFloat(getComputedStyle(blockEl).lineHeight) - : NaN; - setLineOffset( - Number.isFinite(lineHeight) && lineHeight > 24 - ? Math.round((lineHeight - 24) / 2) - : 0, - ); - } catch { - setLineOffset(0); - } - }, [editor, hovered]); - if (runtimeState.isPresentationMode && runtimeState.isPreviewMode) { return null; } @@ -184,7 +188,11 @@ export const DBlockDragHandle = ({
{shouldShowEditingControls ? ( <> From 2e72409041327aa0a1de8d5678c2031d9468f546 Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 18:30:30 +0530 Subject: [PATCH 30/77] fix: write drag handle line offset imperatively to avoid split-frame jump --- .../extensions/d-block/dblock-drag-handle.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/package/extensions/d-block/dblock-drag-handle.tsx b/package/extensions/d-block/dblock-drag-handle.tsx index 4e46a638..475a9b7d 100644 --- a/package/extensions/d-block/dblock-drag-handle.tsx +++ b/package/extensions/d-block/dblock-drag-handle.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, 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'; @@ -30,11 +30,6 @@ import { interface HoveredBlock { node: ProseMirrorNode; pos: number; - // Vertical correction, computed synchronously on node change: the plugin - // top-aligns the handle with the hovered block ('left-start'), but the - // cluster should center on the block's FIRST LINE. 0 at the default - // 24px line-height; (lineHeight - 24) / 2 for taller lines/headings. - lineOffset: number; } const CLUSTER_HEIGHT = 24; @@ -77,6 +72,7 @@ export const DBlockDragHandle = ({ }) => { 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 => { @@ -104,17 +100,25 @@ export const DBlockDragHandle = ({ 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; - // Computed here, not in an effect: an effect would land the offset - // one paint AFTER the plugin repositions the handle, flashing the - // cluster at the block top before it snaps to the first-line center. - return { - node, - pos, - lineOffset: getFirstLineOffset(dragHandleEditor, pos), - }; + return { node, pos }; }); }, [], @@ -186,13 +190,9 @@ export const DBlockDragHandle = ({ onNodeChange={handleNodeChange} >
{shouldShowEditingControls ? ( <> From 91b5cc23bf28d779b7219943e2accd95dd36f09f Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 19:04:54 +0530 Subject: [PATCH 31/77] fix: insert uploaded image at mapped placeholder position (Fix 1) --- package/utils/upload-images.test.ts | 93 +++++++++++++++++++++++++++++ package/utils/upload-images.tsx | 52 ++++++++++++---- 2 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 package/utils/upload-images.test.ts diff --git a/package/utils/upload-images.test.ts b/package/utils/upload-images.test.ts new file mode 100644 index 00000000..23a25198 --- /dev/null +++ b/package/utils/upload-images.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { makeEditor } from './make-editor'; +import { startImageUpload } from './upload-images'; +import type { IpfsImageUploadResponse } from '../types'; + +const IPFS_RESULT = { + ipfsUrl: 'https://ipfs.example/x', + encryptionKey: 'k', + nonce: 'n', + ipfsHash: 'h', + authTag: 't', +} as unknown as IpfsImageUploadResponse; + +const makeDeferred = () => { + let resolve!: (v: IpfsImageUploadResponse) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +}; + +const topLevelShapes = (editor: Editor) => + editor.state.doc.content.content.map((block) => ({ + type: block.firstChild?.type.name, + text: block.textContent, + })); + +describe('startImageUpload', () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + it('replaces the empty host paragraph with the image (happy path)', async () => { + editor = makeEditor('

'); + const pos = 2; // inside the empty paragraph of the only dBlock + editor.commands.setTextSelection(pos); + + const deferred = makeDeferred(); + const file = new File(['x'], 'x.png', { type: 'image/png' }); + const uploading = startImageUpload( + file, + editor.view, + pos, + () => deferred.promise, + ); + + deferred.resolve(IPFS_RESULT); + await uploading; + + const shapes = topLevelShapes(editor); + expect( + shapes.some((shape) => shape.type === 'resizableMedia'), + ).toBe(true); + }); + + it('inserts at the MAPPED placeholder position after concurrent edits above', async () => { + editor = makeEditor('

'); + const pos = 2; + editor.commands.setTextSelection(pos); + + const deferred = makeDeferred(); + const file = new File(['x'], 'x.png', { type: 'image/png' }); + const uploading = startImageUpload( + file, + editor.view, + pos, + () => deferred.promise, + ); + + // Concurrent edit while the upload is in flight: a new block lands ABOVE + // the placeholder, shifting every downstream position. + editor.commands.insertContentAt(0, { + type: 'dBlock', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'typed while uploading' }], + }, + ], + }); + + deferred.resolve(IPFS_RESULT); + await uploading; + + const shapes = topLevelShapes(editor); + // The typed content must survive intact... + expect(shapes[0]).toEqual({ + type: 'paragraph', + text: 'typed while uploading', + }); + // ...and the image must land where the placeholder was mapped to + // (the original — now last — block), not at the stale pre-upload offset. + expect(shapes.some((shape) => shape.type === 'resizableMedia')).toBe(true); + }); +}); diff --git a/package/utils/upload-images.tsx b/package/utils/upload-images.tsx index 9b0c8eb1..3c8f0aa4 100644 --- a/package/utils/upload-images.tsx +++ b/package/utils/upload-images.tsx @@ -4,6 +4,7 @@ import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'; import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { IMG_UPLOAD_SETTINGS } from '../components/editor-utils'; import { arrayBufferToBase64, decryptImage, fetchImage } from './security'; import { toByteArray } from 'base64-js'; @@ -61,6 +62,43 @@ function findPlaceholder(state: EditorState, id: any) { return found.length ? found[0].from : null; } +/** + * Insert the finished media node at the placeholder's CURRENT (mapped) + * position and drop the placeholder, in one transaction. The decoration set + * maps the placeholder through every edit made while the upload was in + * flight, so this stays correct under concurrent typing — unlike the + * pre-upload `pos`, which goes stale the moment the doc changes above it. + * Returns false if the placeholder no longer exists (host content deleted + * during upload) — the upload result is dropped in that case. + */ +function replacePlaceholderWithMedia( + view: EditorView, + id: object, + node: ProseMirrorNode, +): boolean { + const placeholderPos = findPlaceholder(view.state, id); + if (placeholderPos == null) return false; + + const tr = view.state.tr; + const $pos = view.state.doc.resolve(placeholderPos); + const host = $pos.parent; + + if (host.isTextblock && host.content.size === 0) { + // Typical flow: the upload was started from an (already emptied) + // paragraph — the media takes the paragraph's place inside its block. + tr.replaceWith($pos.before(), $pos.after(), node); + } else { + // Host gained content while uploading — fit the media in at the + // placeholder without destroying anything (replaceRange* applies + // schema-aware structure fitting). + tr.replaceRangeWith(placeholderPos, placeholderPos, node); + } + + tr.setMeta(uploadKey, { remove: { id } }); + view.dispatch(tr); + return true; +} + export async function startImageUpload( file: File, view: EditorView, @@ -92,7 +130,7 @@ export async function startImageUpload( const { schema } = view.state; const placeholder = findPlaceholder(view.state, id); - if (!placeholder) return; + if (placeholder == null) return; if (ipfsImageUploadFn) { const { ipfsUrl, encryptionKey, nonce, ipfsHash, authTag } = @@ -111,26 +149,18 @@ export async function startImageUpload( height: 'auto', }); - const transaction = view.state.tr - .replaceWith(pos - 2, pos + node.nodeSize, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); + replacePlaceholderWithMedia(view, id, node); } else { const fileReader = new FileReader(); fileReader.readAsDataURL(file); fileReader.onloadend = () => { const { schema } = view.state; - const pos = findPlaceholder(view.state, id); - if (!pos) return; const src = fileReader.result as string; const node = schema.nodes.resizableMedia.create({ src: src, 'media-type': 'img', }); - const transaction = view.state.tr - .replaceWith(pos - 2, pos + node.nodeSize, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); + replacePlaceholderWithMedia(view, id, node); }; } } catch (error) { From 4a760ff7b6d90ce6908269e16a59ca5db059988a Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 19:39:53 +0530 Subject: [PATCH 32/77] fix: stop cursor jumps from action-button embeds and delayed media conversion (Fix 2) --- .../action-button/action-button-node-view.tsx | 52 ++++++++++++----- .../action-button/action-button.test.ts | 46 +++++++++++++++ .../extensions/action-button/action-button.ts | 17 +++--- .../d-block/dblock-media-plugin.test.ts | 56 ++++++++++++++++++ .../extensions/d-block/dblock-media-plugin.ts | 11 ++++ package/extensions/iframe/iframe.ts | 7 ++- package/utils/block-insert.ts | 58 +++++++++++++++++++ 7 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 package/extensions/action-button/action-button.test.ts create mode 100644 package/extensions/d-block/dblock-media-plugin.test.ts create mode 100644 package/utils/block-insert.ts diff --git a/package/extensions/action-button/action-button-node-view.tsx b/package/extensions/action-button/action-button-node-view.tsx index 0e9c7adb..8eb1df61 100644 --- a/package/extensions/action-button/action-button-node-view.tsx +++ b/package/extensions/action-button/action-button-node-view.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { NodeViewProps } from '@tiptap/core'; +import { NodeSelection } from '@tiptap/pm/state'; import { NodeViewWrapper } from '@tiptap/react'; import { useEditingContext } from '../../hooks/use-editing-context'; import { debounce } from '../../utils/debounce'; @@ -103,13 +104,16 @@ const ActionButtonNodeView = ({ const pos = getPos(); if (pos !== undefined) { - const to = pos + node.nodeSize; formattedUrl && editor ?.chain() - .focus(pos) - .deleteRange({ from: pos, to }) + .command(({ tr }) => { + // 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/d-block/dblock-media-plugin.test.ts b/package/extensions/d-block/dblock-media-plugin.test.ts new file mode 100644 index 00000000..7b5f5847 --- /dev/null +++ b/package/extensions/d-block/dblock-media-plugin.test.ts @@ -0,0 +1,56 @@ +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 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..045981d0 100644 --- a/package/extensions/d-block/dblock-media-plugin.ts +++ b/package/extensions/d-block/dblock-media-plugin.ts @@ -190,10 +190,21 @@ export const createDBlockMediaConversionPlugin = ( } const tr = view.state.tr; + const { selection } = view.state; candidates .sort((a, b) => 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 converts on a later scan, once the caret has left. + if ( + selection.from <= candidate.to + 1 && + selection.to >= candidate.from - 1 + ) { + return; + } const node = candidate.type === 'img' ? view.state.schema.nodes.resizableMedia?.create({ 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/utils/block-insert.ts b/package/utils/block-insert.ts new file mode 100644 index 00000000..2f5287dc --- /dev/null +++ b/package/utils/block-insert.ts @@ -0,0 +1,58 @@ +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) { + const dBlockType = schema.nodes.dBlock; + const paragraphType = schema.nodes.paragraph; + if (dBlockType && paragraphType) { + const end = tr.doc.content.size; + tr.insert(end, dBlockType.create(null, paragraphType.create())); + selection = Selection.findFrom(tr.doc.resolve(end), 1, true); + } + } + + if (selection) tr.setSelection(selection); + tr.scrollIntoView(); +} From a8c9f0a07b180bf87042e38b3e07256d6152f66e Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 19:51:09 +0530 Subject: [PATCH 33/77] fix: top-align to-do checkboxes with the first line (TEC-2644) --- package/styles/editor.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/package/styles/editor.css b/package/styles/editor.css index 8a73c7c1..619c5b31 100644 --- a/package/styles/editor.css +++ b/package/styles/editor.css @@ -457,12 +457,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 { From 7b0c48588c4906fea5a3590b57bd9f901baf32ea Mon Sep 17 00:00:00 2001 From: Bhavesh Rawat Date: Wed, 5 Aug 2026 22:31:02 +0530 Subject: [PATCH 34/77] fix: position template overlay statically and align its breakpoint with editor padding Replace the getBoundingClientRect positioning of the template overlay with static offsets matching the container padding (right-20/top-66px desktop, left-9 mobile), switching at the same 768px boundary the editor padding uses. Express the editor paddings as scoped custom properties. --- package/components/split-view/split-view.css | 6 +++++- package/extensions/d-block/dblock-toolbar.tsx | 10 +--------- package/styles/editor.css | 12 ++++++++++-- package/utils/template-utils.tsx | 2 +- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/package/components/split-view/split-view.css b/package/components/split-view/split-view.css index 991fb862..447bf193 100644 --- a/package/components/split-view/split-view.css +++ b/package/components/split-view/split-view.css @@ -63,5 +63,9 @@ specificity regardless of stylesheet import order — both are two-class selectors otherwise. */ [data-split-view-preview] .ProseMirror.main-doc-editor { - padding: 8px 48px 64px 48px; + --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/extensions/d-block/dblock-toolbar.tsx b/package/extensions/d-block/dblock-toolbar.tsx index 3c407536..541fce07 100644 --- a/package/extensions/d-block/dblock-toolbar.tsx +++ b/package/extensions/d-block/dblock-toolbar.tsx @@ -144,19 +144,11 @@ const DBlockTemplateOverlay = ({ return null; } - const panelRect = panel.getBoundingClientRect(); - const blockRect = firstBlock.getBoundingClientRect(); - return createPortal(
{renderTemplateButtons( templateButtons, diff --git a/package/styles/editor.css b/package/styles/editor.css index 619c5b31..60809504 100644 --- a/package/styles/editor.css +++ b/package/styles/editor.css @@ -487,12 +487,20 @@ } .ProseMirror.main-doc-editor { - padding: 24px 16px 20vh 36px; + --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); } @media (min-width: 768px) { .ProseMirror.main-doc-editor { - padding: 72px 80px 20vh; + --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); } } 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) => (