Skip to content

TEC-2515: editor chrome + flat schema v2 - #552

Merged
bhaveshxrawat merged 82 commits into
mainfrom
integration/tec2515-x-v2
Aug 13, 2026
Merged

TEC-2515: editor chrome + flat schema v2#552
bhaveshxrawat merged 82 commits into
mainfrom
integration/tec2515-x-v2

Conversation

@mbj36

@mbj36 mbj36 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Both TEC-2515 tracks in one branch. #551 is closed and fully absorbed here —
every commit from TEC-2515 is an ancestor of this branch, with authorship
intact.

What's in here

  1. Editor chrome (Bhavesh) — floating drag-handle cluster, chrome moved to
    container padding, template overlay portaled out of the editable DOM,
    Fixes 1-3, to-do checkbox alignment, gutter toolbar shrink.
  2. Flat schema v2 — per-doc schemaVersion marker plus the
    unsupported-version guard, the flat document/column schema behind
    preferredSchemaVersion, block IDs, collapse generalised to both schemas,
    template unwrap, position-mapping audit.
  3. Fixes found by integrating and testing the two together (below).

Version: 4.4.0, merged with main through 4.3.9 (keeps @fileverse/ui 5.2.2).

Two of these are v1 bugs, not v2 bugs

Worth knowing regardless of the schema work — both were live on main:

  • The floating handle was unclickable whenever it mounted before
    .ProseMirror.
    .drag-handle and .ProseMirror are positioned siblings
    with z-index: auto, so DOM insertion order alone decided which painted on
    top. The cluster stayed visible while every click landed on the editor
    behind it. Fixed with an explicit z-index.
  • Collapsing a heading threw the reader to the top of the document. The
    toggle scrolled to the caret unconditionally, and the caret is usually
    somewhere else entirely. It now scrolls only when the transaction actually
    moved the caret.

Other fixes

  • Read-only preview heading chrome for the flat schema (widget decoration;
    v1 builds it in the node view, which flat blocks don't have)
  • getHeadingLinkSlug resolved through the wrapper, so copy-link was dead in v2
  • extractTitleFromContent walked two levels, so every v2 document came out
    untitled — it feeds export filenames from seven call sites
  • URL-to-media conversion and the template overlay were both wrapper-only
  • Typography skipped the trailing paragraph by v1's class attribute; v2's stock
    trailing node has none, so it's identified by position too
  • Reloading a multi-tab document painted the wrong tab for ~100ms: tab switches
    use the 'self' origin so they never reach onChange, leaving the saved blob's
    activeTabId stale while IndexedDB has the true one
  • Tab editor cache kept only 2 editors warm, so rotating across 3+ tabs rebuilt a
    full editor on every switch — the main source of transient memory spikes on
    large multi-tab documents
  • Ported fix(dblock-toolbar): guard destroyed editors before view access #553's destroyed-editor guards onto the rewritten toolbar (the originals
    landed on code this PR deletes)

Verification

tsc clean, 148 tests, build passes. Two probe scripts are committed so this is
repeatable:

  • scripts/parity-sweep.cjs — drives v1 and v2 through the same 20 features and
    compares results: export markdown/HTML, title extraction, counts, TOC,
    comments, search and replace, undo/redo, copy-paste, lists, slides, read-only
    render, node commands, block duplicate/delete, tables, page break, suggestion
    marks, split-view markdown, media, columns. 20/20 match, 0 page errors.
  • scripts/chrome-parity-probe.cjs — trusted mouse input, because a covered
    button still passes a synthetic click: cluster hit-testability, collapse
    toggle, options menu, viewport stability, template insert, and first paint
    after a multi-tab reload. Identical in both schemas.

All six templates were also inserted through the real overlay and compared node
by node; the only difference is the absent wrapper.

Rollout constraint

The version guard must be live in a published release before any v2 document
exists
. A client without the guard that opens a flat document parses it with
dBlock rules and writes that structure back through Yjs — corruption with no
undo, and it cannot be fixed retroactively for code already running in a
browser. Merging and releasing is dormant and safe; the ddocs.new flag that
creates the first v2 document is the step that must wait.

Not covered

Collaboration has not been tested against v2 — it can't produce a v2 document
today, but it must be tested before the flag flips. Chrome desktop only. Binary
exports (PDF/docx/odt) were not compared, nor comment/suggestion flows beyond
the marks. The v1 keymap shrink never happened, so dblock.ts is still ~1090
lines and the original v1 list bugs remain.

mbj36 and others added 30 commits August 4, 2026 17:28
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.
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.
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.
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.
…itorProps

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).
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.
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.
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.
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.
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.
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.
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.)
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.
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.
Three measured spacing problems from the Wikipedia-paste report:

1. Pasted HTML kept stacks of empty <p> (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.
Slices with bare top-level blocks (copied from a flat v2 doc, or parsed
from any external site's HTML) silently demoted headings on paste into
v1: prosemirror's fitter finds no legal closed placement under
(dBlock|columns|pageBreak)+ - canReplaceWith never considers wrapping
and the caret's dBlock already holds its one allowed child - so it
falls back to merging the heading's inline content into the caret
paragraph. v1-native copies never hit this because their slices carry
the dBlock wrappers.

New v1-only transformPasted plugin rewraps every doc-illegal top-level
block of the pasted slice in a dBlock and grows the open depths across
added wrappers, so foreign slices arrive exactly v1-native-shaped.
Also fixes the Cmd+A stray-empty-paragraph paste symptom and external
HTML heading pastes. Inline pastes still merge into the caret block.

Verified against the real clipboard in Chrome across the full
v1/v2 x copy-mode matrix; unit tests prove the repro fails without
the plugin.
The diff pipeline is schema-agnostic end to end - schema-less blob
decode via yDocToProsemirrorJSON, type-generic LCS differ, and the
renderer's dBlock case is a v1-only refinement flat content never
enters. Locked by characterization tests in the app repo
(utils/diff/__tests__/node-diff-flat.test.ts). Persistent blockIds give
v2 diffs better block alignment than v1's attr-less wrappers.
@mbj36
mbj36 marked this pull request as ready for review August 13, 2026 07:27
PreviewDdocEditor (blog publish preview, version history) mounted the
DragHandle chrome even though the surface is statically read-only. The
upstream DragHandle plugin relocates its rendered element outside
React's tree, so when a late-arriving blob flips the schema marker and
the editor rebuilds (v1 -> v2), React re-commits EditorContent against
the relocated .drag-handle sibling anchor and insertBefore throws
NotFoundError - killing the blog publish preview for flat-schema docs.

DBlockToolbarProvider gains a STATIC isPreviewEditor flag (the dynamic
isPreviewMode would reintroduce the unmount crash on every edit/preview
toggle in the main editor) that skips the DragHandle and the template
overlay entirely: read-only heading affordances live inside the node
view, the cluster is hard-hidden for non-editable editors anyway, and
the relocated element also leaked per version switch.
@bhaveshxrawat
bhaveshxrawat merged commit c437d4c into main Aug 13, 2026
2 checks passed
@bhaveshxrawat
bhaveshxrawat deleted the integration/tec2515-x-v2 branch August 13, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants