Skip to content

Combined release branch (@scale/superdoc): #3343 + #3714 + #3758 + #3750 + local fixes#1

Open
dawidbudaszewski wants to merge 34 commits into
combined-basefrom
release/combined-main
Open

Combined release branch (@scale/superdoc): #3343 + #3714 + #3758 + #3750 + local fixes#1
dawidbudaszewski wants to merge 34 commits into
combined-basefrom
release/combined-main

Conversation

@dawidbudaszewski

Copy link
Copy Markdown
Owner

Tracking PR for the release/combined-main integration branch that al-pmo
consumes 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.md for the full manifest.

Upstream PRs merged in

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: add FORK.md manifest.

How it's consumed

al-pmo links this build via a link: override in frontend/package.json. The
al-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

shri-scale and others added 30 commits June 16, 2026 10:07
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>
dawidbudaszewski and others added 3 commits June 25, 2026 22:37
…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>
@dawidbudaszewski

dawidbudaszewski commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Local fixes

Everything 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 FORK.md.

Code fixes

# Commit File Problem Fix
1 576e0cd95 packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js A tracked replace could pick up marks from an empty leading run, wiping the replacement's styling. Use the first non-empty run's marks when building the tracked replace.
2 48ab09cad packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js Accept-all / reject-all needed multiple clicks on a mixed review: inline marks resolved in one transaction, block-level (row/paragraph) changes in a second one that raced the first and got dropped as a mismatched transaction. dispatchReviewDecision now takes an appendToTr callback so block resolution (applyRowTrackedChangeResolution) is appended onto the same transaction (positions mapped through tr.mapping). One atomic accept/reject. Fallback handles the block-only case.

Non-code (infra / docs)

# Commit File What
3 877185cad packages/superdoc/package.json Scope the published package as @scale/superdoc (consumed by al-pmo, published to the private CodeArtifact registry).
4 a219d7814 FORK.md Manifest: branch composition, how al-pmo consumes it, how to add PRs / rebuild / publish.

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 (frontend/src/shared/lib/superdoc-diff/*), not here.

Co-authored-by: Cursor <cursoragent@cursor.com>
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