Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a46f53d
fix(direction): auto-detect paragraph base direction via dir="auto"
shri-scale Jun 11, 2026
cfc9bca
fix(direction): resolve auto direction per paragraph, not per wrapped…
shri-scale Jun 11, 2026
d5bcf94
fix(context-menu): open editor menu on right-click over resize overlays
dawidbudaszewski Jun 17, 2026
4e1fd20
feat(linked-styles): redefine named paragraph styles ("Update to match")
shri-scale Jun 19, 2026
7b4a592
fix(linked-styles): resolve effective formatting and use explicit boo…
shri-scale Jun 19, 2026
f36dbd0
feat(track-changes): block-level structural tracked changes for tables
shri-scale May 16, 2026
b748ce2
fix(track-changes): address PR review on block-level structural track…
shri-scale May 16, 2026
7a6637f
feat(track-changes): register StructuralTrackChanges in getStarterExt…
shri-scale May 16, 2026
6a490ac
fix(track-changes): skip block-level walk for docs without tracked rows
shri-scale May 16, 2026
5c1d26c
fix(presentation-editor): scope stale-target redirect to SuperDoc-own…
shri-scale May 18, 2026
824d367
feat(track-changes): handle block-level changes in accept/rejectAll a…
shri-scale May 18, 2026
b6d4e62
fix(presentation-editor): scope stale-target check via .sd-editor-scoped
shri-scale May 18, 2026
92a454b
chore(types): refresh SD-3176 snapshot for StructuralTrackChanges export
shri-scale May 18, 2026
3af65c4
fix(track-changes): mark full inserted range when PM auto-wraps text
shri-scale May 27, 2026
25b1c23
fix(layout): invalidate row caches on tableRow.trackChange attr changes
shri-scale May 27, 2026
fc1502e
fix(structural-track-changes): default identity key to content finger…
shri-scale May 27, 2026
1a40dcd
fix(track-changes): append step map for block-level pre-marked shortcut
shri-scale Jun 5, 2026
583970d
fix(track-changes): reject id-less trackChange attrs at parse time
shri-scale Jun 5, 2026
9ce64b1
fix(track-changes): align block-level producer with upstream OOXML fo…
shri-scale Jun 10, 2026
69e6995
fix(layout): remove orphan deriveBlockVersion duplicate from painter
shri-scale Jun 10, 2026
afcce8b
chore(structural-track-changes): add // @ts-check to satisfy jsdoc ra…
shri-scale Jun 15, 2026
d002c66
chore(snapshot): add StructuralTrackChanges + computeStructuralDiff t…
shri-scale Jun 15, 2026
5af0ac9
chore(snapshot): scope computeStructuralDiff to runtime exports only
shri-scale Jun 15, 2026
ff185fa
test(painter-dom): update block-level tracked-change scope test for c…
shri-scale Jun 15, 2026
851b4b9
feat(track-changes): extend block-level tracked changes to paragraphs
shri-scale Jun 18, 2026
dd7c3d7
chore: merge PR #3343 at 851b4b9 (block tracked changes for tables an…
dawidbudaszewski Jun 25, 2026
29918e8
chore: merge pr-3714 into combined branch
dawidbudaszewski Jun 25, 2026
82681a8
chore: merge pr-3758 into combined branch
dawidbudaszewski Jun 25, 2026
e16a01d
chore: merge pr-3750 into combined branch
dawidbudaszewski Jun 25, 2026
576e0cd
fix(track-changes): use first non-empty run marks for tracked replace
dawidbudaszewski Jun 25, 2026
48ab09c
fix(track-changes): resolve inline + block changes atomically in acce…
dawidbudaszewski Jun 25, 2026
877185c
chore(superdoc): scope package as @scale/superdoc for private publishing
dawidbudaszewski Jun 25, 2026
a219d78
docs: add FORK.md manifest for the combined release branch
dawidbudaszewski Jun 25, 2026
0a42f87
docs(fork): document manual publish-from-combined-main flow
dawidbudaszewski Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# SuperDoc fork — `@scale/superdoc`

This is a **fork** of [`superdoc-dev/superdoc`](https://github.com/superdoc-dev/superdoc),
maintained so the al-pmo (Dune PMO) correspondence editor can use unreleased
upstream features plus a few local fixes **before** they land in an official
SuperDoc release.

The integration branch is **`release/combined-main`**. It is the branch al-pmo
links against and the branch we publish from as the private package
`@scale/superdoc`.

> If you only want the one-line answer: al-pmo's tracked-change review needs
> block-level (table/paragraph) tracked changes and a couple of fixes that are
> spread across several open upstream PRs. This branch stacks them on one base
> so we can build and consume a single package.

---

## What's on the branch

Base = upstream `main` at merge-base `75297f6a0`. On top of that, in order:

### Upstream PRs merged in

| Upstream PR | Merge marker | What it provides | Why al-pmo needs it |
|---|---|---|---|
| **#3343** (block-level tracked changes) | `dd7c3d728`, tip `851b4b9e6` | Block-level structural tracked changes for **tables and paragraphs** (`StructuralTrackChanges`, `computeStructuralDiff`, `setStructuralDiff`) | Whole-paragraph / whole-table add+remove must be reviewable as a single change, not inline text edits that leave empty blocks behind |
| **#3714** (direction) | `29918e8e9` | Auto-detect paragraph base direction (`dir="auto"`), resolve per paragraph | Correct RTL/LTR for Arabic correspondence |
| **#3758** (linked styles) | `82681a861` | "Update to match" — redefine named paragraph styles; resolve effective formatting | Style fidelity on AI-proposed drafts |
| **#3750** (context menu) | `e16a01dd7` | Open editor context menu on right-click over resize overlays | Editor UX |

> #3343 is pinned at commit **`851b4b9`** specifically — the
> *paragraph-capable* revision of the block-tracked-changes work. Earlier
> revisions only handled tables; rebasing onto `851b4b9` is what restored
> bullet/paragraph block tracking.

### Local fixes on top (NOT in any upstream PR)

| Commit | Change |
|---|---|
| `576e0cd95` | `fix(track-changes)`: use the first **non-empty** run's marks for a tracked replace (avoids a blank-run mark wiping replacement styling) |
| _(this commit)_ | `fix(track-changes)`: resolve inline **and** block tracked changes in **one atomic** accept-all / reject-all transaction — fixes the "accept-all needs two clicks" bug (see below) |
| _(this commit)_ | `chore`: scope the published package as **`@scale/superdoc`** |

#### Atomic accept-all / reject-all

`acceptAllTrackedChanges` / `rejectAllTrackedChanges` previously resolved inline
marks in one transaction and block-level (row/paragraph) changes in a second
one. 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 accepts an `appendToTr` callback so the
block resolution (`applyRowTrackedChangeResolution`) is appended onto the **same**
transaction, with a fallback path for the block-only case.

Files:
- `packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js`

---

## How al-pmo consumes this fork

al-pmo (`ips-applications/applications/dune/al-pmo/frontend`) depends on
`@scale/superdoc`. For local development it uses a **`link:` override** in
`frontend/package.json` pointing at this working tree, so fork changes are
picked up after a rebuild — no publish needed to iterate.

The al-pmo side also carries its own **diff layer** that drives these fork
primitives (`paragraph-diff.ts`, `superdoc-adapter.ts`,
`shared-doc-canvas.tsx`): paragraph/alignment/list detection, numbering-marker
merge, and table-in-place pinning. That logic lives in al-pmo, not here — see
al-pmo's `docs/SUPERDOC_TRACKED_REPLACE_STYLE_FIX.md`.

> Longer term, much of that al-pmo diff layer overlaps with this fork's native
> `compareDocuments` + `replayDifferences` engine
> (`packages/super-editor/src/editors/v1/extensions/diffing/`), which already
> does recursive, attribute-aware diffing (alignment, lists, table rows) and
> replays it as native tracked changes. Migrating al-pmo onto that engine is a
> tracked follow-up.

---

## Working with the branch

```bash
# remotes
# origin = github.com/dawidbudaszewski/superdoc (this fork)
# upstream = github.com/superdoc-dev/superdoc (real SuperDoc)

git checkout release/combined-main

# build the package al-pmo links against
pnpm install
pnpm --filter @scale/superdoc run build # see packages/superdoc/package.json scripts
```

### Adding another upstream PR

```bash
git fetch upstream pull/<PR>/head:pr-<PR>
git merge --no-ff pr-<PR> -m "chore: merge pr-<PR> into combined branch"
# rebuild, relink, smoke-test in al-pmo
```

### Publishing as `@scale/superdoc`

Publish **from this branch (`release/combined-main`)** — no rebuild. The
mechanics live in al-pmo's `scripts/superdoc` (so the rename rules stay in one
place); see that folder's `README.md` → *"Manual publish from
`release/combined-main` (no rebuild)"* for the exact commands. In short:

1. `apply-scale-namespace.mjs "$PWD" <N>` — transient `@scale` rename + `-scale.N`
version stamp (core and react get **different** base versions).
2. `pnpm install --no-frozen-lockfile` then build both `@scale/superdoc` and
`@scale/superdoc-react`.
3. Auth to CodeArtifact and `pnpm publish` both (core first; react pins the exact
core version).
4. `git checkout -- .` to revert the rename — the `@scale` rename is **never
committed**.

Then in al-pmo drop the `link:` override in `frontend/package.json`, pin **both**
published `-scale.N` versions, and `pnpm install`.

---

## Status / caveats

- This branch is **ahead of upstream** and is **not** itself an upstream PR.
The block-tracked-changes feature exists upstream as PR #3343; the combined
branch and the local fixes do not.
- Keep the branch buildable: after any merge or fix, rebuild and smoke-test the
al-pmo tracked-change review (bullets, signature alignment, table row
remove, accept-all in one click).
16 changes: 15 additions & 1 deletion packages/layout-engine/contracts/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { cloneColumnLayout, extractHeaderFooterSpace, normalizeColumnLayout, widthsEqual } from './index.js';
import type { FlowBlock, Layout } from './index.js';
import type { FlowBlock, Layout, TableRow, TrackedChangeMeta } from './index.js';

describe('contracts', () => {
it('accepts a basic FlowBlock structure', () => {
Expand Down Expand Up @@ -85,4 +85,18 @@ describe('contracts', () => {
});
expect(normalizeColumnLayout({ count: 2, gap: 24 }, 624).widths).toEqual([300, 300]);
});

it('TrackedChangeMeta accepts an optional operationId', () => {
const meta: TrackedChangeMeta = { kind: 'delete', id: 'r1', operationId: 'op-table-1' };
expect(meta.operationId).toBe('op-table-1');
});

it('TableRow accepts an optional trackedChange field carrying row-level metadata', () => {
const row: TableRow = {
id: 'row-1',
cells: [],
trackedChange: { kind: 'insert', id: 'r1', operationId: 'op-table-1' },
};
expect(row.trackedChange?.kind).toBe('insert');
});
});
12 changes: 12 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,12 @@ export type TrackedChangeMeta = {
id: string;
overlapParentId?: string;
relationship?: 'parent' | 'child' | 'standalone';
/**
* Optional grouping key. Tracked changes sharing a non-empty `operationId`
* are resolved together — e.g. every row of a deleted table shares one
* `operationId` and the review surface collapses them into one entry.
*/
operationId?: string;
/**
* Internal story key identifying which content story owns this tracked
* change (`'body'`, `'hf:part:…'`, `'fn:…'`, `'en:…'`).
Expand Down Expand Up @@ -926,6 +932,12 @@ export type TableRow = {
cells: TableCell[];
attrs?: TableRowAttrs;
sourceAnchor?: SourceAnchor;
/**
* Row-level tracked-change metadata. Populated by pm-adapter from the
* ProseMirror `trackChange` node attribute. The painter reads this and
* stamps `data-track-change*` on the cell DOM elements.
*/
trackedChange?: TrackedChangeMeta;
};

export type TableBlock = {
Expand Down
15 changes: 15 additions & 0 deletions packages/layout-engine/layout-bridge/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,21 @@ const hashRuns = (block: FlowBlock): string => {
continue;
}

// Row-level tracked change (block-level diff replay sets `trackedChange`
// on a tableRow's attrs). Without folding it into the measure-cache
// fingerprint, applyHunks-style transactions that only mutate the row's
// `trackChange` attr don't invalidate the cache, so the page is never
// re-measured and the visible cells never receive their decoration
// classes. Read from `row.attrs.trackedChange` — the canonical location
// written by the v1 layout-adapter from the OOXML-aligned
// `attrs.trackChange.type` shape. Mirror of the painter page-fingerprint
// fix in renderer.ts and the canonical-version fix in
// layout-resolved/versionSignature.ts.
const rowTC = row.attrs?.trackedChange;
if (rowTC) {
cellHashes.push(`rtc:${rowTC.kind ?? ''}:${rowTC.id ?? ''}:${rowTC.operationId ?? ''}`);
}

for (const cell of row.cells) {
// Include cell-level attributes that affect rendering (borders, padding, etc.)
// This ensures cache invalidation when cell formatting changes (e.g., remove borders).
Expand Down
14 changes: 14 additions & 0 deletions packages/layout-engine/layout-resolved/src/versionSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,20 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
for (const row of rows) {
if (!row || !Array.isArray(row.cells)) continue;
hash = hashNumber(hash, row.cells.length);
// Row-level tracked change (block-level diff replay sets `trackedChange`
// on a tableRow's attrs). Without folding it into the canonical block
// version, applyHunks-style transactions that only mutate the row's
// `trackChange` attr don't bump the version, so the resolved-layout
// pipeline reuses cached entries and the painter never sees the update.
// Read from `row.attrs.trackedChange` — the canonical location written
// by the v1 layout-adapter from the OOXML-aligned `attrs.trackChange.type`
// shape. Mirror of the fixes in renderer.ts and layout-bridge/cache.ts.
const rowTC = row.attrs?.trackedChange;
if (rowTC) {
hash = hashString(hash, rowTC.kind ?? '');
hash = hashString(hash, rowTC.id ?? '');
hash = hashString(hash, rowTC.operationId ?? '');
}
for (const cell of row.cells) {
if (!cell) continue;
const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { applyRtlStyles, resolveTextAlign } from './rtl-styles.js';

const el = () => document.createElement('p');
const rtlAttrs = { paragraphProperties: { rightToLeft: true } };
const ltrAttrs = { paragraphProperties: { rightToLeft: false } };
const unsetAttrs = { paragraphProperties: {} };

describe('applyRtlStyles direction mapping', () => {
it('sets dir="rtl" and direction:rtl for explicit RTL', () => {
const element = el();
expect(applyRtlStyles(element, rtlAttrs as any)).toBe(true);
expect(element.getAttribute('dir')).toBe('rtl');
expect(element.style.direction).toBe('rtl');
});

it('sets dir="ltr" for explicit LTR', () => {
const element = el();
expect(applyRtlStyles(element, ltrAttrs as any)).toBe(false);
expect(element.getAttribute('dir')).toBe('ltr');
expect(element.style.direction).toBe('ltr');
});

it('sets dir="auto" for unset paragraphs (wrapper)', () => {
const element = el();
expect(applyRtlStyles(element, unsetAttrs as any)).toBe(false);
expect(element.getAttribute('dir')).toBe('auto');
expect(element.style.direction).toBe('');
});

it('inheritAuto: leaves dir unset for unset paragraphs (line inherits wrapper)', () => {
const element = el();
expect(applyRtlStyles(element, unsetAttrs as any, true)).toBe(false);
expect(element.getAttribute('dir')).toBeNull();
expect(element.style.direction).toBe('');
});

it('inheritAuto: still stamps explicit rtl/ltr on lines', () => {
const r = el();
applyRtlStyles(r, rtlAttrs as any, true);
expect(r.getAttribute('dir')).toBe('rtl');
const l = el();
applyRtlStyles(l, ltrAttrs as any, true);
expect(l.getAttribute('dir')).toBe('ltr');
});
});

describe('resolveTextAlign with auto', () => {
it('returns start for default alignment when direction is auto', () => {
expect(resolveTextAlign(undefined, false, true)).toBe('start');
});
it('keeps explicit alignment under auto', () => {
expect(resolveTextAlign('center', false, true)).toBe('center');
});
it('unchanged for explicit directions', () => {
expect(resolveTextAlign('justify', true)).toBe('right');
expect(resolveTextAlign('justify', false)).toBe('left');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@ export const isRtlParagraph = (attrs: ParagraphAttrs | undefined): boolean =>
* becomes 'left' (LTR) or 'right' (RTL) to align the last line correctly.
* When no explicit alignment is set the default follows the paragraph direction.
*/
export const resolveTextAlign = (alignment: ParagraphAttrs['alignment'], isRtl: boolean): string => {
export const resolveTextAlign = (alignment: ParagraphAttrs['alignment'], isRtl: boolean, isAuto = false): string => {
switch (alignment) {
case 'center':
case 'right':
case 'left':
return alignment;
case 'justify':
default:
// For auto-direction paragraphs we don't know the resolved side at paint
// time — `start` follows the browser-resolved `dir="auto"`.
if (isAuto) return 'start';
return isRtl ? 'right' : 'left';
}
};
Expand All @@ -38,17 +41,39 @@ export const resolveTextAlign = (alignment: ParagraphAttrs['alignment'], isRtl:
* Apply `dir` and `text-align` to an element based on paragraph attributes.
* Used by both `renderLine` (line elements) and `applyParagraphBlockStyles`
* (fragment wrappers) so the logic stays in one place.
*
* `inheritAuto` is set for per-line elements: a paragraph with no explicit
* direction must resolve its base direction ONCE (on the paragraph wrapper via
* `dir="auto"`); the individual visual lines then inherit it. Stamping
* `dir="auto"` on each line would make every wrapped line re-detect from its
* own first strong character, so an RTL paragraph whose continuation line
* begins with Latin text would wrongly flip to LTR.
*/
export const applyRtlStyles = (element: HTMLElement, attrs: ParagraphAttrs | undefined): boolean => {
const rtl = isRtlParagraph(attrs);
if (rtl) {
export const applyRtlStyles = (
element: HTMLElement,
attrs: ParagraphAttrs | undefined,
inheritAuto = false,
): boolean => {
const dir = getParagraphInlineDirection(attrs); // 'rtl' | 'ltr' | undefined
const rtl = dir === 'rtl';
if (dir === 'rtl') {
element.setAttribute('dir', 'rtl');
element.style.direction = 'rtl';
} else {
} else if (dir === 'ltr') {
element.setAttribute('dir', 'ltr');
element.style.direction = 'ltr';
} else if (inheritAuto) {
// Line-level: inherit the paragraph wrapper's resolved auto direction
// rather than independently auto-detecting per visual line.
element.removeAttribute('dir');
element.style.direction = '';
} else {
// Paragraph wrapper: let the browser detect base direction from content
// (dir="auto"). An absent dir would inherit the container direction instead.
element.setAttribute('dir', 'auto');
element.style.direction = '';
}
element.style.textAlign = resolveTextAlign(attrs?.alignment, rtl);
element.style.textAlign = resolveTextAlign(attrs?.alignment, rtl, dir === undefined);
return rtl;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ParagraphMeasure,
ResolvedFragmentItem,
SdtMetadata,
TrackedChangeMeta,
} from '@superdoc/contracts';
import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils';
import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils';
Expand All @@ -13,6 +14,7 @@ import { DOM_CLASS_NAMES } from '@superdoc/dom-contract';
import { CLASS_NAMES, fragmentStyles } from '../styles.js';
import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js';
import { allowFontSynthesis } from '../runs/font-synthesis.js';
import { applyBlockTrackedChangeToParagraph, resolveTrackedChangesConfig } from '../runs/tracked-changes.js';
import type { BetweenBorderInfo } from './borders/index.js';
import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js';

Expand Down Expand Up @@ -152,6 +154,16 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams):
contentControlsChrome,
});

// Whole-paragraph structural tracked change (insert/delete). The adapter
// stamped paint-ready meta onto block.attrs.trackedChange; decorate the
// fragment so a deleted paragraph strikes through (and collapses in 'final'
// mode) instead of leaving an empty bullet — the paragraph analogue of the
// row-cell decoration.
const blockTrackedChange = (block.attrs as { trackedChange?: TrackedChangeMeta } | undefined)?.trackedChange;
if (blockTrackedChange) {
applyBlockTrackedChangeToParagraph(fragmentEl, blockTrackedChange, resolveTrackedChangesConfig(block));
}

return fragmentEl;
} catch (error) {
console.error('[DomPainter] Fragment rendering failed:', { fragment, error });
Expand Down
Loading
Loading