Combined release branch (@scale/superdoc): #3343 + #3714 + #3758 + #3750 + local fixes#1
Open
dawidbudaszewski wants to merge 34 commits into
Open
Combined release branch (@scale/superdoc): #3343 + #3714 + #3758 + #3750 + local fixes#1dawidbudaszewski wants to merge 34 commits into
dawidbudaszewski wants to merge 34 commits into
Conversation
Paragraphs with no explicit w:bidi previously rendered without a `dir` attribute, which inherits the container base direction instead of auto-detecting from content. A fresh paragraph therefore could not pick up direction from the first strong character typed (Google-Docs behaviour). Render `dir="auto"` for unset paragraphs in both the editing node view (ParagraphNodeView) and the viewing painter (inline-direction/rtl-styles), so the browser resolves base direction per UAX superdoc-dev#9. Explicit `rightToLeft` true/false remain hard overrides (`dir="rtl"`/`"ltr"`). `setParagraphDirection` LTR now writes an explicit `false` so a deliberate LTR choice survives auto-detection (reverting to auto is `clearParagraphDirection`). This aligns the implementation with the direction README's stated intent; README updated. Adds unit coverage (ParagraphNodeView, rtl-styles, paragraphDirection) and a behaviour spec.
… line The painter splits a paragraph into separate `.superdoc-line` elements and stamped `dir="auto"` on each, so every wrapped line re-detected direction from its own first strong character (an RTL paragraph whose continuation line begins with Latin text wrongly flipped to LTR). Resolve the base direction once on the paragraph wrapper (`dir="auto"`); lines now inherit it via a new `inheritAuto` flag on `applyRtlStyles` (they leave `dir` unset in the auto case, keep explicit rtl/ltr). The container render path (table cells / text boxes) lacks a per-paragraph wrapper, so each paragraph's lines are grouped under a `display:contents` wrapper that carries the direction without affecting flex layout.
The table and image resize overlays (the column/row resize handles shown on hover) render as siblings of the editor surface rather than inside it. A right-click landing on one of their handles therefore fell outside the context-menu target surfaces, so SuperDoc never called preventDefault and the browser's native context menu appeared instead of the editor menu. Treat targets within `.superdoc-table-resize-overlay` and `.superdoc-image-resize-overlay` as in-editor so the custom context menu (e.g. "Edit table") opens reliably. The table position is still resolved from the cursor coordinates, so the correct menu items are shown. Adds a pure `isWithinResizeOverlay` helper with unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
Add a supported way to redefine a named paragraph style's run-level look (font family, size, weight, color) so it updates live and survives .docx export — the editor equivalent of Google Docs' "Update Heading 1 to match". - editor.commands.updateLinkedStyle(styleId, formatting) rewrites the style across all three converter representations (linkedStyles definition, translatedLinkedStyles, word/styles.xml) under one snapshot/rollback; never throws. - Read helpers: getLinkedStyleFormatting(styleId) and getEffectiveFormattingAtSelection(). - Repaint on every redefinition in both render modes — a linked-styles plugin meta signal (ProseMirror decorations) plus PresentationEditor.refreshLinkedStyles() (clears the FlowBlockCache and re-lays out the painted view). - Styles dropdown gains a per-row "Update to match" (pencil) action. Pure shape conversions are isolated in style-formatting.js. Covered by unit tests (pure mappers) and integration tests (engine, command, helpers, repaint, and export round-trip).
…lean-off
Address review feedback on "Update to match":
- readEffectiveRunFormatting now resolves the selection paragraph's named style
(merged over its basedOn parent) as the base and layers direct marks on top,
instead of reading marks only. Previously, formatting that came purely from a
named style (the common .docx case) was captured as empty and wiped the style.
- Cleared booleans (bold/italic/underline) are now stored as explicit off
({ value: '0' } in definition styles, w:val="0"/w:u w:val="none" in OOXML)
rather than deleting the key, so a redefined style overrides an inherited
basedOn value instead of falling back to it. definitionStylesToFormatting
reads explicit-off back as false.
Adds a new StructuralTrackChanges extension that gives block-level (table) add/remove operations the same review pipeline as inline tracked changes - same data-track-change rendering, same accept/reject command surface, plus block-level entry registration in the comments-plugin. Consumer pattern: compute structural hunks via computeStructuralDiff (or construct them yourself), dispatch via editor.commands.setStructuralDiff (hunks). The extension stamps a trackChange PM attribute on each affected tableRow. Rendering is handled by the painter (reads row.trackedChange, stamps data-track-change* on cells). Accept/reject operates on PM attrs via getBlockTrackedChanges + applyRowTrackedChangeResolution. Pipeline pieces: - Shared blockTrackedChangeAttrSpec helper + TableRow.addAttributes spread - TableRow.trackedChange contract field - pm-adapter populates TableRow.trackedChange from PM attr - painter renderTableRow stamps data-track-change on cells - CSS rules for [data-track-change="insert" | "delete"] - getBlockTrackedChanges walks the doc for block-level entries - applyRowTrackedChangeResolution handles accept/reject by id - acceptTrackedChangeById / rejectTrackedChangeById extended to route inline marks, row attrs, or operationId - trackedTransaction passes through ReplaceSteps that already carry block-level metadata so inline marks don't double-track - comments-plugin walks block-level entries alongside inline marks Existing Diffing extension, compareDocuments, replayDifferences, and inline TrackChanges are untouched. The new extension is opt-in via editorExtensions: [StructuralTrackChanges] (not in starter extensions). Tests: ~30 new unit tests across the touched modules, plus an end-to-end test using a real .docx fixture pair that exercises compute -> set -> accept-all and compute -> set -> reject-all. Out of scope (separate follow-ups): - DOCX round-trip of block-level tracked changes - Block-level entries surfacing in the default SuperDoc bubble (requires mirroring inline's commentsUpdate event payload pipeline) - Cell-level / column-level tracking - A combined acceptAllChanges that batches inline + structural into one transaction (currently two sequential commands; two undo steps) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed changes - Move painter-targeting CSS into BLOCK_TRACK_CHANGE_STYLES in the painter's styles module so document visuals live behind the painter boundary; keep contenteditable-side rules scoped under .sd-editor-scoped .ProseMirror tr. - Drop stale isBlockLevel entries from previous state before merging in freshly-computed block entries, so accept/reject clears the bubble. - renderDOM now emits data-track-change-id and data-track-change-operation alongside data-track-change so HTML round-trips preserve enough state for getBlockTrackedChanges and acceptTrackedChangeById to resolve the row. Adds regression tests: stale-entry pruning in comments-plugin apply(), renderDOM/parseDOM round-trip in blockTrackedChangeAttr, and a styles.ts guard that fails if a bare [data-track-change=...] selector regresses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ensions Consumers (Superdoc Vue host, @superdoc-dev/react wrapper) take their extension list from getStarterExtensions(); without this entry the new block-level extension was exported but never loaded into the editor. Updates the dedicated test that previously asserted opt-in-only behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comments-plugin apply() reducer walked the entire doc for block-level tracked rows on every tr.docChanged, even on docs that had no tracked rows. Combined with in-place mutation of pluginState.trackedChanges and the view.update decoration rebuild path, this created a stream of state references that downstream consumers reacted to, contributing to focus steal in side-by-side layouts where the editor sat next to an unrelated input control. Two structural changes: - Track a hasBlockChanges bit on plugin state, computed once at init and refreshed only when a walk runs. Gate the block walk on (hasBlockChanges OR inputType='acceptReject' meta) so typing in a doc with no tracked rows never triggers the walk. - Stop mutating pluginState.trackedChanges and pluginState.activeThreadId inside apply(); compute next-values locally and return them via the spread at the end. Plugin state must be treated as immutable across apply() per ProseMirror convention. Adds regression tests that lock the gate (hasBlockChanges stays false through typing, flips true once a tracked row appears). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed editors Track which DOMs the bridge has owned and only redirect keystrokes to a stale ProseMirror[contenteditable=true] candidate if it is in that set. Without this guard, an unrelated PM-based editor (Tiptap, Remirror) mounted alongside SuperDoc would receive redirected input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nd export structural API acceptAllTrackedChanges and rejectAllTrackedChanges now also dispatch acceptAllStructuralChanges / rejectAllStructuralChanges when block-level row entries exist, so the single accept-all/reject-all surface resolves both inline marks and table-row tracked changes. Without this, a table-delete operation would leave the row, cell, and table shell in place after accept-all. Also re-export StructuralTrackChanges and computeStructuralDiff from the superdoc package entry so SDK consumers can wire up structural diffs without importing from super-editor directly. git commit -F /tmp/cmsg2.txt
The WeakSet-based ownership check broke header/footer/footnote/endnote
accept/reject + undo (6 behavior tests in story-surface-tracked-change-decide):
story editors weren't registered in the bridge's owned set when sidebar
operations were the first interaction, so the bridge dropped the
stale-target redirect that those flows depend on.
Switch the check to a class-based ancestor lookup
(`closest('.sd-editor-scoped')`). Every SuperDoc editor — including
header/footer/footnote/endnote — is rendered inside a `.sd-editor-scoped`
container, so they're recognized without needing prior bridge activation;
foreign PM editors (Tiptap, Remirror, raw PM) are not, so the focus-steal
fix is preserved.
Bridge unit tests updated to wrap stale editors in a `.sd-editor-scoped`
ancestor (matches production DOM); foreign-editor regression test
unchanged (its `.tiptap.ProseMirror` element has no SuperDoc ancestor and
is correctly ignored).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds StructuralTrackChanges + computeStructuralDiff to the SD-3176
legacy-exports snapshot (intentional growth on superdoc/super-editor).
The named export is what consumers will import via:
import { StructuralTrackChanges, computeStructuralDiff } from "superdoc";
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When insertTrackedChange is called with from === to at a position where bare text can't sit (e.g. at doc end past the last block), PM auto-wraps the inserted text in the schema's required parents (paragraph + run). The previous mark range used insertedNode.nodeSize -- the bare text length -- which then covered the wrapper's open tokens instead of the trailing characters of the actual text. Those trailing chars escaped the trackInsert mark and survived accept/reject as orphan text nodes (ALPMO-245). Capture the doc size delta around the insert step and use it as the mark range. addMark only attaches to text nodes inside the range, so marking wrapper boundaries is a no-op there but pulls in the trailing chars that PM's auto-wrap pushed out of the bare-nodeSize window. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Block-level diff replay sets `trackedChange` on a tableRow via the StructuralTrackChanges extension. The three caches that drive painted output -- the painter's per-page fingerprint in renderer.ts, the measure cache in layout-bridge, and the canonical block version in layout-resolved -- previously hashed only the cells, missing the row-level attribute. Result: applyHunks-style transactions that only mutated `row.trackChange` reused stale cache entries, the page never re-measured or repainted, and visible cells never received their `data-track-change` attribute (so the row stayed unmarked until a full scroll forced a remeasure). Mirror the row.trackedChange fingerprint into all three caches so the same transaction invalidates each one.
…print
computeStructuralDiff previously matched top-level blocks by
sdBlockId. That works in-process but breaks across imports: the docx
importer assigns a fresh sdBlockId on every load (there's no standard
OOXML attribute for tables to carry a stable id), so the AI-review
flow -- where the proposal is a separately-imported docx -- saw every
block as new and flagged the whole document as changed.
Switch the default identityKey to a normalized content fingerprint
(`${typeName}:${normalizedText}`) so identical blocks match across
imports. Document the limitations in the JSDoc: two truly-identical
blocks share a fingerprint and the algorithm treats them as one
(acceptable for current AI-review flow; consumers needing precise
duplicate handling can pass a custom `identityKey`). Consumers whose
proposal preserves sdBlockIds can opt back in via the option.
The block-level tracked-change shortcut in trackedTransaction applied the original step as-is via `newTr.step(step)` but didn't call `map.appendMap(step.getMap())`. In a multi-step transaction where this shortcut fires before another step, the next iteration's `originalStep.map(map)` then mapped through stale positions -- the later step landed in the wrong place or got silently dropped when the mapping returned null. Matches the existing pass-through pattern in replaceStep.js where applying a step as-is is always paired with `map.appendMap`. The StructuralTrackChanges applyHunks path typically dispatches one step per transaction, so the bug rarely hit today, but it becomes a real correctness issue the moment two structural hunks share a transaction or any other tracked change rides alongside a structural one.
`blockTrackedChangeAttrSpec.parseDOM` accepted nodes with
`data-track-change` set but `data-track-change-id` missing, producing
a `trackChange: { kind, id: null, operationId }` shape on the PM
node. That shape:
- can never be resolved by `applyRowTrackedChangeResolution`, which
matches by id and would never find a null
- is already filtered out by `getBlockTrackedChanges` (so the entry
is unactionable, just lingering as a no-op attr on the row)
- violates the documented `id: string` runtime type
- is asymmetric with `renderDOM`, which only emits
`data-track-change-id` when `tc.id` is truthy — a faithful HTML
round-trip always carries the id
Reject the missing-id case at parse time so the doc never holds a
half-formed trackChange shape.
…rmat
Upstream main introduced an OOXML-aligned shape for row tracked changes
(`attrs.trackChange.type === 'rowInsert' | 'rowDelete'`) and a
`buildRowTrackedChangeMeta` reader on the v1 layout-adapter that writes
the resolved metadata to `row.attrs.trackedChange`. Several of our
block-level pieces still spoke the older `{ kind }` shape and wrote to
the top-level `row.trackedChange`, so after the rebase onto main the
producer/consumer pair disagreed and painting silently no-oped:
- `applyHunks` stamped `{ kind, id, operationId }` on the PM tableRow.
The new reader (which keys on `type`) ignored it, so
`row.attrs.trackedChange` was never populated. Switched to
`{ type: 'rowInsert' | 'rowDelete', id, operationId, ... }`. Tests
updated.
- `comments-plugin` block-walk and `getBlockTrackedChanges` accepted
only `{ kind }`. They now accept both shapes and emit the same
normalized `kind` downstream.
- `trackedTransaction.sliceContainsPreMarkedBlockTrackedChange`
looked for `kind`; broadened to also detect `type === 'rowInsert' |
'rowDelete'` so paste/replay slices skip double-tracking under
either shape.
- The legacy fallback path in `table.ts` (v1 layout-adapter) that
populated the top-level `row.trackedChange` from the old shape is
removed; `buildRowTrackedChangeMeta` is now the single producer.
- The painter page-fingerprint, layout-bridge measure-cache, and
layout-resolved canonical block version now hash
`row.attrs.trackedChange` instead of the (now-unpopulated) top-level
field, so applyHunks-style transactions invalidate the right caches.
- `structural-track-changes` JSDoc refreshed to describe the new flow.
Net effect: a block-level diff replay now reliably paints the red/green
row decoration via the helper-based path (`applyRowTrackedChangeToCell`)
that upstream extracted.
Commit fff5326 ("invalidate row caches on tableRow.trackChange attr changes") accidentally added a 738-line block to the bottom of the painter's renderer.ts — a duplicate `deriveBlockVersion` implementation plus supporting helpers (`getSdtMetadataId`, `hasListMarkerProperties`, `hasVerticalPositioning`, etc.) that referenced names (`SdtMetadata`, `TableAttrs`, `TextRun`, `ParagraphAttrs`, `getRunBooleanProp`, `hashTableBorders`, `normalizeBaselineShift`, `applyRtlStyles`, …) which were never imported into renderer.ts. The block was never called from outside (`grep` confirms no external `deriveBlockVersion` reference in the painter package), and the canonical `deriveBlockVersion` lives in `layout-resolved/src/versionSignature.ts` which already has the row-attrs.trackedChange hash from the audit commit. So the only effect of the orphan block was to break `tsc -b` and the upstream CI build. Truncate renderer.ts back to its upstream-main shape (last meaningful line: `isNonBodyStoryBlockId`). The row-level cache invalidation continues to work via `versionSignature.ts` and `layout-bridge/cache.ts` (which read `row.attrs.trackedChange` correctly).
…tchet Upstream's JSDoc ratchet (`packages/superdoc/scripts/check-jsdoc.cjs`) requires every new public-reachable JSDoc file to either carry `// @ts-check` or be on the allowlist. Our two new files (`applyHunks.js`, `computeStructuralDiff.js`) tripped the gate and broke the build stage on PR superdoc-dev#3343. - Add `// @ts-check` to both files. - Add a `StructuralHunk` typedef in applyHunks.js so the hunk array's properties (`changeId`, `kind`, `basePos`, `anchorBasePos`, `proposalNode`) are typed instead of the previous opaque `object[]`. - Narrow `basePos` / `anchorBasePos` from `number | undefined` to `number` via runtime guards so the `tr.mapping.map(...)` calls type-check.
…o root exports Surgical update to satisfy the export-snapshots gate (SD-3212 A0). CI flagged drift in the runtime root exports — the two new named exports from the structural-track-changes feature need to be in the snapshot's import + require name lists.
Last commit's replace_all match also inserted computeStructuralDiff into the types.import / types.require lists. It's a function (runtime export only — has no separate type export), so the public-facade types facade doesn't re-export it. Removing the type entries; the runtime import + require entries stay.
…lass-based selectors Upstream replaced the previous `[data-track-change='insert'|'delete']` attribute selectors with class-based decoration (`.track-row-cell-dec .track-insert-dec.highlighted` etc.) emitted by the new `applyRowTrackedChangeToCell` helper. Our scoped-selector regression test still asserted the old attribute form, so it tripped the unit suite after rebase. Updated to assert the new row-cell class selectors are scoped to `.superdoc-layout` — preserves the original intent (no leaks into the PM mirror) against the current CSS surface.
Generalize the table-row block-tracking path to whole paragraphs so a
paragraph insert/delete is a reviewable structural tracked change
("remove the bullet points" -> reviewable paragraph deletions):
- paragraph schema spreads blockTrackedChangeAttrSpec
- applyHunks stamps a paragraph node directly, not just tableRow children
- acceptRejectRowTrackedChange resolves a top-level block (depth 0) without
the $pos.before(0) "no position before the top-level node" crash
- layout-adapter projects the paragraph trackChange; DomPainter strikes the
whole paragraph block
…or tables and paragraphs) Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…pt/reject-all acceptAllTrackedChanges/rejectAllTrackedChanges resolved inline marks in one transaction and block-level (row/paragraph) tracked changes in a second. The second transaction raced the first and was dropped as a mismatched transaction, so a mixed inline+block review needed multiple clicks to fully resolve. dispatchReviewDecision now takes an appendToTr callback that appends the block resolution (applyRowTrackedChangeResolution) onto the SAME transaction, mapping its positions through tr.mapping so it stays correct after the inline steps. A fallback resolves the block-only case in its own transaction when no inline change dispatched. Co-authored-by: Cursor <cursoragent@cursor.com>
The combined release branch is published to the private (CodeArtifact) registry and consumed by al-pmo as @scale/superdoc; scope the name to match. Co-authored-by: Cursor <cursoragent@cursor.com>
Documents what release/combined-main contains (upstream PRs superdoc-dev#3343 @851b4b9, superdoc-dev#3714, superdoc-dev#3758, superdoc-dev#3750 plus local track-changes fixes), how al-pmo consumes it via the link override, and how to add PRs / rebuild / publish. Co-authored-by: Cursor <cursoragent@cursor.com>
Owner
Author
Local fixesEverything below is fork-only (authored here, not part of any upstream PR). Listed oldest → newest. The upstream PRs (superdoc-dev#3343, superdoc-dev#3714, superdoc-dev#3758, superdoc-dev#3750) are tracked separately in the PR description and Code fixes
Non-code (infra / docs)
NOT in this fork (lives in al-pmo, for reference)The diff layer that drives these primitives — paragraph/alignment detection, numbering-marker merge, table in-place pinning — lives in al-pmo ( |
Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tracking PR for the
release/combined-mainintegration branch that al-pmoconsumes as
@scale/superdoc. Base is vanilla upstream at the merge-base(
combined-base=75297f6a0), so this diff is exactly what the fork adds.See
FORK.mdfor the full manifest.Upstream PRs merged in
@851b4b9) — block-level structural tracked changes for tables and paragraphs (StructuralTrackChanges,computeStructuralDiff,setStructuralDiff). Pinned at the paragraph-capable revision.dir="auto"); RTL/LTR for Arabic.Local fixes (not in any upstream PR)
fix(track-changes): use first non-empty run marks for a tracked replace.fix(track-changes): resolve inline and block tracked changes in one atomic accept-all / reject-all transaction (fixes "accept-all needs two clicks").chore: scope the published package as@scale/superdoc.docs: addFORK.mdmanifest.How it's consumed
al-pmo links this build via a
link:override infrontend/package.json. Theal-pmo-side diff layer (alignment/list/table-pin) lives in al-pmo, not here.
Not for upstream merge
This is a tracking/integration branch, not an upstream contribution. The
block-tracked-changes feature exists upstream as PR superdoc-dev#3343; the combination and
local fixes do not.
Made with Cursor