diff --git a/.github/workflows/ci-behavior.yml b/.github/workflows/ci-behavior.yml
index ba592d68b9..a631113816 100644
--- a/.github/workflows/ci-behavior.yml
+++ b/.github/workflows/ci-behavior.yml
@@ -15,6 +15,7 @@ on:
- 'packages/preset-geometry/**'
- 'tests/behavior/**'
- 'shared/**'
+ - '.github/workflows/ci-behavior.yml'
- '!**/*.md'
merge_group:
workflow_dispatch:
@@ -29,7 +30,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- shard: [1, 2, 3]
+ browser: [chromium, firefox, webkit]
+ shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v6
@@ -56,20 +58,20 @@ jobs:
id: pw-cache
with:
path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}
+ key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-${{ matrix.browser }}
- - name: Install Playwright browsers
+ - name: Install Playwright browser
if: steps.pw-cache.outputs.cache-hit != 'true'
- run: pnpm exec playwright install --with-deps chromium firefox webkit
+ run: pnpm exec playwright install --with-deps ${{ matrix.browser }}
working-directory: tests/behavior
- name: Install Playwright system deps
if: steps.pw-cache.outputs.cache-hit == 'true'
- run: pnpm exec playwright install-deps chromium firefox webkit
+ run: pnpm exec playwright install-deps ${{ matrix.browser }}
working-directory: tests/behavior
- - name: Run behavior tests (shard ${{ matrix.shard }}/3)
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3
+ - name: Run behavior tests (${{ matrix.browser }} shard ${{ matrix.shard }}/4)
+ run: pnpm exec playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/4
working-directory: tests/behavior
validate:
diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml
index 64a43cc3ff..7e59bd1144 100644
--- a/.github/workflows/release-cli.yml
+++ b/.github/workflows/release-cli.yml
@@ -1,13 +1,11 @@
-# Auto-releases CLI on push to:
-# - main (@next channel)
-# - stable (@latest channel)
+# Auto-releases CLI on push to main (@next channel).
+# Stable releases are orchestrated centrally by release-stable.yml.
name: ๐ฆ Release CLI
on:
push:
branches:
- main
- - stable
paths:
# Keep in sync with apps/cli/.releaserc.cjs includePaths (patch-commit-filter).
# Workflow paths trigger CI; includePaths control semantic-release commit analysis.
@@ -19,6 +17,7 @@ on:
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
+ - 'shared/**'
- 'scripts/semantic-release/**'
- 'pnpm-workspace.yaml'
- '!**/*.md'
@@ -29,7 +28,7 @@ permissions:
packages: write
concurrency:
- group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }}
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
diff --git a/.github/workflows/release-create.yml b/.github/workflows/release-create.yml
index fabe7adf34..4bc957a1df 100644
--- a/.github/workflows/release-create.yml
+++ b/.github/workflows/release-create.yml
@@ -17,8 +17,8 @@ permissions:
packages: write
concurrency:
- group: release-create-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
+ cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
release:
@@ -36,6 +36,12 @@ jobs:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
+ - name: Refresh stable branch head
+ if: github.ref_name == 'stable'
+ run: |
+ git fetch origin "${{ github.ref_name }}" --tags
+ git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}"
+
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
diff --git a/.github/workflows/release-esign.yml b/.github/workflows/release-esign.yml
index 2347fbfd82..8431abc0ae 100644
--- a/.github/workflows/release-esign.yml
+++ b/.github/workflows/release-esign.yml
@@ -1,11 +1,11 @@
-# Auto-releases on push to main (@next) or stable (@latest)
+# Auto-releases on push to main (@next).
+# Stable releases are orchestrated centrally by release-stable.yml.
name: ๐ฆ Release esign
on:
push:
branches:
- main
- - stable
paths:
- 'packages/esign/**'
- 'packages/superdoc/**'
@@ -14,6 +14,7 @@ on:
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
+ - 'shared/**'
- 'pnpm-workspace.yaml'
- '!**/*.md'
workflow_dispatch:
@@ -23,8 +24,8 @@ permissions:
packages: write
concurrency:
- group: release-esign-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
+ cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
release:
@@ -42,6 +43,12 @@ jobs:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
+ - name: Refresh stable branch head
+ if: github.ref_name == 'stable'
+ run: |
+ git fetch origin "${{ github.ref_name }}" --tags
+ git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}"
+
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml
index 993cb5fd7e..a8f5f281e7 100644
--- a/.github/workflows/release-mcp.yml
+++ b/.github/workflows/release-mcp.yml
@@ -17,8 +17,8 @@ permissions:
packages: write
concurrency:
- group: release-mcp-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
+ cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
release:
@@ -36,6 +36,12 @@ jobs:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
+ - name: Refresh stable branch head
+ if: github.ref_name == 'stable'
+ run: |
+ git fetch origin "${{ github.ref_name }}" --tags
+ git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}"
+
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
diff --git a/.github/workflows/release-react.yml b/.github/workflows/release-react.yml
index 61ebd7b4a8..30bedd8fdf 100644
--- a/.github/workflows/release-react.yml
+++ b/.github/workflows/release-react.yml
@@ -1,11 +1,11 @@
-# Auto-releases on push to main (@next) or stable (@latest)
+# Auto-releases on push to main (@next).
+# Stable releases are orchestrated centrally by release-stable.yml.
name: ๐ฆ Release react
on:
push:
branches:
- main
- - stable
paths:
- 'packages/react/**'
- 'packages/superdoc/**'
@@ -14,6 +14,7 @@ on:
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
+ - 'shared/**'
- 'pnpm-workspace.yaml'
- '!**/*.md'
workflow_dispatch:
@@ -23,8 +24,8 @@ permissions:
packages: write
concurrency:
- group: release-react-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
+ cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
release:
@@ -42,6 +43,12 @@ jobs:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
+ - name: Refresh stable branch head
+ if: github.ref_name == 'stable'
+ run: |
+ git fetch origin "${{ github.ref_name }}" --tags
+ git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}"
+
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml
index 33488b2651..103bf688de 100644
--- a/.github/workflows/release-sdk.yml
+++ b/.github/workflows/release-sdk.yml
@@ -1,6 +1,5 @@
-# Auto-releases SDK on push to:
-# - main (@next channel)
-# - stable (@latest channel)
+# Auto-releases SDK on push to main (@next channel).
+# Stable releases are orchestrated centrally by release-stable.yml.
# Also supports manual dispatch as a fallback for one-off releases.
name: "\U0001F4E6 Release SDK"
@@ -8,7 +7,6 @@ on:
push:
branches:
- main
- - stable
paths:
# Keep in sync with packages/sdk/.releaserc.cjs includePaths (patch-commit-filter).
- 'packages/sdk/**'
@@ -20,22 +18,23 @@ on:
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
+ - 'shared/**'
- 'scripts/semantic-release/**'
- 'pnpm-workspace.yaml'
- '!**/*.md'
workflow_dispatch:
inputs:
version:
- description: "Version to release (e.g. 1.0.0-next.7). Leave empty to publish the current repo version."
+ description: 'Version to release (e.g. 1.0.0-next.7). Leave empty to publish the current repo version.'
required: false
type: string
dry-run:
- description: "Dry run โ build and validate without publishing"
+ description: 'Dry run โ build and validate without publishing'
required: false
type: boolean
default: false
npm-tag:
- description: "npm dist-tag for Node SDK publish (e.g. latest, next). Only used for manual version override."
+ description: 'npm dist-tag for Node SDK publish (e.g. latest, next). Only used for manual version override.'
required: false
type: string
default: latest
@@ -46,7 +45,7 @@ permissions:
id-token: write # PyPI trusted publishing (OIDC)
concurrency:
- group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }}
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
@@ -85,7 +84,7 @@ jobs:
with:
node-version-file: .nvmrc
cache: pnpm
- registry-url: "https://registry.npmjs.org"
+ registry-url: 'https://registry.npmjs.org'
- uses: oven-sh/setup-bun@v2
with:
@@ -93,7 +92,7 @@ jobs:
- uses: actions/setup-python@v5
with:
- python-version: "3.12"
+ python-version: '3.12'
- name: Install canvas system dependencies
run: |
@@ -233,7 +232,7 @@ jobs:
with:
node-version-file: .nvmrc
cache: pnpm
- registry-url: "https://registry.npmjs.org"
+ registry-url: 'https://registry.npmjs.org'
- uses: oven-sh/setup-bun@v2
with:
@@ -241,7 +240,7 @@ jobs:
- uses: actions/setup-python@v5
with:
- python-version: "3.12"
+ python-version: '3.12'
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml
new file mode 100644
index 0000000000..08611c6a1a
--- /dev/null
+++ b/.github/workflows/release-stable.yml
@@ -0,0 +1,173 @@
+# Sequential stable release orchestrator.
+# This is the only workflow that auto-releases on push to stable.
+name: ๐ฆ Release stable
+
+on:
+ push:
+ branches:
+ - stable
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ packages: write
+ id-token: write
+
+concurrency:
+ # Keep [skip ci] writeback runs out of the shared stable queue so they cannot
+ # replace a real pending stable push while the orchestrator is active.
+ group: ${{ github.event_name == 'push' && contains(github.event.head_commit.message, '[skip ci]') && format('release-stable-skip-{0}', github.run_id) || 'release-stable' }}
+ cancel-in-progress: false
+
+jobs:
+ release:
+ if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]')
+ runs-on: ubuntu-24.04
+ environment: pypi
+ steps:
+ - name: Generate token
+ id: generate_token
+ uses: actions/create-github-app-token@v2
+ with:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: stable
+ token: ${{ steps.generate_token.outputs.token }}
+
+ - name: Refresh stable branch head
+ run: |
+ git fetch origin stable --tags
+ git checkout -B stable origin/stable
+
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ registry-url: 'https://registry.npmjs.org'
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.12
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Cache apt packages
+ uses: actions/cache@v5
+ with:
+ path: ~/apt-cache
+ key: apt-canvas-${{ runner.os }}-v1
+
+ - name: Install canvas system dependencies
+ run: |
+ mkdir -p ~/apt-cache
+ sudo apt-get update
+ sudo apt-get install -y -o Dir::Cache::Archives="$HOME/apt-cache" \
+ build-essential \
+ libcairo2-dev \
+ libpango1.0-dev \
+ libjpeg-dev \
+ libgif-dev \
+ librsvg2-dev \
+ libpixman-1-dev
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Python build tools
+ run: pip install build
+
+ - name: Build packages
+ run: pnpm run build
+
+ - name: Test vscode-ext
+ run: pnpm --prefix apps/vscode-ext run test
+
+ - name: Snapshot SDK tags before release
+ id: sdk_tags_before
+ run: echo "tags=$(git tag --list 'sdk-v*' | sort | tr '\n' ',')" >> "$GITHUB_OUTPUT"
+
+ - name: Release stable packages sequentially
+ id: stable_release
+ env:
+ GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ VSCE_PAT: ${{ secrets.VSCE_PAT }}
+ GITHUB_REF_NAME: stable
+ run: node scripts/release-local-stable.mjs
+
+ - name: Detect SDK release tag at HEAD
+ id: sdk_release
+ run: |
+ BEFORE="${{ steps.sdk_tags_before.outputs.tags }}"
+ AFTER=$(git tag --list 'sdk-v*' | sort | tr '\n' ',')
+ RELEASE_TAG=$(git tag --points-at HEAD --list 'sdk-v*' --sort=-version:refname | head -n 1)
+ if [ -z "$RELEASE_TAG" ]; then
+ echo "release_present=false" >> "$GITHUB_OUTPUT"
+ echo "released=false" >> "$GITHUB_OUTPUT"
+ echo "version=" >> "$GITHUB_OUTPUT"
+ echo "dist_tag=" >> "$GITHUB_OUTPUT"
+ echo "No SDK release tag at HEAD."
+ else
+ echo "release_present=true" >> "$GITHUB_OUTPUT"
+ if [ "$BEFORE" = "$AFTER" ]; then
+ echo "released=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "released=true" >> "$GITHUB_OUTPUT"
+ fi
+ VERSION="${RELEASE_TAG#sdk-v}"
+ if [[ "$VERSION" == *-next.* ]]; then
+ DIST_TAG="next"
+ else
+ DIST_TAG="latest"
+ fi
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
+ if [ "$BEFORE" = "$AFTER" ]; then
+ echo "SDK release tag already present at HEAD: $RELEASE_TAG"
+ else
+ echo "Released SDK v$VERSION"
+ fi
+ fi
+
+ - name: Publish recovered SDK companion Python packages to PyPI
+ if: steps.stable_release.outputs.sdk_python_snapshot_companion_dir != ''
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: ${{ steps.stable_release.outputs.sdk_python_snapshot_companion_dir }}
+ skip-existing: true
+
+ - name: Publish recovered SDK main Python package to PyPI
+ if: steps.stable_release.outputs.sdk_python_snapshot_main_dir != ''
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: ${{ steps.stable_release.outputs.sdk_python_snapshot_main_dir }}
+ skip-existing: true
+
+ - name: Build and verify Python SDK
+ if: steps.sdk_release.outputs.release_present == 'true'
+ run: node packages/sdk/scripts/build-python-sdk.mjs
+
+ - name: Publish companion Python packages to PyPI
+ if: steps.sdk_release.outputs.release_present == 'true'
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: packages/sdk/langs/python/companion-dist/
+ skip-existing: true
+
+ - name: Publish main Python SDK to PyPI
+ if: steps.sdk_release.outputs.release_present == 'true'
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: packages/sdk/langs/python/dist/
+ skip-existing: true
diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml
index 4e6ccbcbda..c3f781da39 100644
--- a/.github/workflows/release-superdoc.yml
+++ b/.github/workflows/release-superdoc.yml
@@ -1,6 +1,5 @@
-# Auto-releases on push to:
-# - main (@next channel)
-# - stable (@latest channel)
+# Auto-releases on push to main (@next channel).
+# Stable releases are orchestrated centrally by release-stable.yml.
# Manual PR preview: dispatch with pr_number to publish @pr-
name: ๐ฆ Release superdoc
@@ -8,7 +7,6 @@ on:
push:
branches:
- main
- - stable
paths:
- 'packages/superdoc/**'
- 'packages/layout-engine/**'
@@ -16,6 +14,7 @@ on:
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
+ - 'shared/**'
- 'pnpm-workspace.yaml'
- '!**/*.md'
workflow_dispatch:
@@ -31,7 +30,7 @@ permissions:
pull-requests: write
concurrency:
- group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }}
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
diff --git a/.github/workflows/release-template-builder.yml b/.github/workflows/release-template-builder.yml
index 5573149cff..a4d4561ca7 100644
--- a/.github/workflows/release-template-builder.yml
+++ b/.github/workflows/release-template-builder.yml
@@ -1,11 +1,11 @@
-# Auto-releases on push to main (@next) or stable (@latest)
+# Auto-releases on push to main (@next).
+# Stable releases are orchestrated centrally by release-stable.yml.
name: ๐ฆ Release template-builder
on:
push:
branches:
- main
- - stable
paths:
- 'packages/template-builder/**'
- 'packages/superdoc/**'
@@ -14,6 +14,7 @@ on:
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
+ - 'shared/**'
- 'pnpm-workspace.yaml'
- '!**/*.md'
workflow_dispatch:
@@ -23,8 +24,8 @@ permissions:
packages: write
concurrency:
- group: release-template-builder-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
+ cancel-in-progress: ${{ github.ref_name != 'stable' }}
jobs:
release:
@@ -42,6 +43,12 @@ jobs:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
+ - name: Refresh stable branch head
+ if: github.ref_name == 'stable'
+ run: |
+ git fetch origin "${{ github.ref_name }}" --tags
+ git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}"
+
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
diff --git a/.github/workflows/release-vscode-ext.yml b/.github/workflows/release-vscode-ext.yml
index 30b127a69c..6e8baecaaa 100644
--- a/.github/workflows/release-vscode-ext.yml
+++ b/.github/workflows/release-vscode-ext.yml
@@ -1,11 +1,11 @@
-# Auto-releases on push to main (@next) or stable (@latest)
+# Auto-releases on push to main (@next).
+# Stable releases are orchestrated centrally by release-stable.yml.
name: ๐ฆ Release vscode-ext
on:
push:
branches:
- main
- - stable
paths:
- 'apps/vscode-ext/**'
- 'packages/superdoc/**'
@@ -14,6 +14,7 @@ on:
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
+ - 'shared/**'
- 'pnpm-workspace.yaml'
- '!**/*.md'
workflow_dispatch:
@@ -22,6 +23,10 @@ permissions:
contents: write
packages: write
+concurrency:
+ group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }}
+ cancel-in-progress: ${{ github.ref_name != 'stable' }}
+
jobs:
release:
runs-on: ubuntu-latest
@@ -38,6 +43,12 @@ jobs:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
+ - name: Refresh stable branch head
+ if: github.ref_name == 'stable'
+ run: |
+ git fetch origin "${{ github.ref_name }}" --tags
+ git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}"
+
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
diff --git a/README.md b/README.md
index 77717b8d6b..9b34c8b2d9 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,8 @@ Special thanks to these community members who have contributed code to SuperDoc:
+
+
Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started.
diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs
index 88175df8ce..950fe38849 100644
--- a/apps/cli/.releaserc.cjs
+++ b/apps/cli/.releaserc.cjs
@@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/ai',
'packages/word-layout',
'packages/preset-geometry',
+ 'shared',
'pnpm-workspace.yaml',
]);
diff --git a/apps/cli/scripts/publish.js b/apps/cli/scripts/publish.js
index 02ab3eeafa..a2c985213c 100644
--- a/apps/cli/scripts/publish.js
+++ b/apps/cli/scripts/publish.js
@@ -81,6 +81,18 @@ export function isAlreadyPublished(packageName, version, authToken, baseEnv = pr
throw new Error(`Failed to check published version for ${packageName}@${version}: ${details}`);
}
+function ensureDistTag(packageName, version, tag, authToken, baseEnv = process.env) {
+ const result = spawnSync('npm', ['dist-tag', 'add', `${packageName}@${version}`, tag], {
+ cwd: repoRoot,
+ stdio: 'inherit',
+ env: createNpmEnv(baseEnv, authToken),
+ });
+
+ if (result.status !== 0) {
+ throw new Error(`Failed to ensure dist-tag "${tag}" for ${packageName}@${version}`);
+ }
+}
+
function runPnpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.env) {
const pkgDir = PACKAGE_DIR_BY_NAME[packageName];
if (!pkgDir) {
@@ -89,7 +101,8 @@ function runPnpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.e
const version = getPackageVersion(packageName);
if (!dryRun && isAlreadyPublished(packageName, version, authToken, baseEnv)) {
- console.log(`Skipping ${packageName}@${version} (already published).`);
+ console.log(`Skipping ${packageName}@${version} (already published, ensuring dist-tag "${tag}").`);
+ ensureDistTag(packageName, version, tag, authToken, baseEnv);
return;
}
diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx
index a19aa9b2fa..666c871990 100644
--- a/apps/docs/document-api/available-operations.mdx
+++ b/apps/docs/document-api/available-operations.mdx
@@ -20,7 +20,7 @@ Use the tables below to see what operations are available and where each one is
| Citations | 15 | 0 | 15 | [Reference](/document-api/reference/citations/index) |
| Comments | 5 | 0 | 5 | [Reference](/document-api/reference/comments/index) |
| Content Controls | 55 | 0 | 55 | [Reference](/document-api/reference/content-controls/index) |
-| Core | 13 | 0 | 13 | [Reference](/document-api/reference/core/index) |
+| Core | 14 | 0 | 14 | [Reference](/document-api/reference/core/index) |
| Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) |
| Cross-References | 5 | 0 | 5 | [Reference](/document-api/reference/cross-refs/index) |
| Diff | 3 | 0 | 3 | [Reference](/document-api/reference/diff/index) |
@@ -148,6 +148,7 @@ Use the tables below to see what operations are available and where each one is
| editor.doc.getHtml(...) | [`getHtml`](/document-api/reference/get-html) |
| editor.doc.markdownToFragment(...) | [`markdownToFragment`](/document-api/reference/markdown-to-fragment) |
| editor.doc.info(...) | [`info`](/document-api/reference/info) |
+| editor.doc.extract(...) | [`extract`](/document-api/reference/extract) |
| editor.doc.clearContent(...) | [`clearContent`](/document-api/reference/clear-content) |
| editor.doc.insert(...) | [`insert`](/document-api/reference/insert) |
| editor.doc.replace(...) | [`replace`](/document-api/reference/replace) |
diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json
index 8887fe4a9e..6d4e645b77 100644
--- a/apps/docs/document-api/reference/_generated-manifest.json
+++ b/apps/docs/document-api/reference/_generated-manifest.json
@@ -130,6 +130,7 @@
"apps/docs/document-api/reference/diff/capture.mdx",
"apps/docs/document-api/reference/diff/compare.mdx",
"apps/docs/document-api/reference/diff/index.mdx",
+ "apps/docs/document-api/reference/extract.mdx",
"apps/docs/document-api/reference/fields/get.mdx",
"apps/docs/document-api/reference/fields/index.mdx",
"apps/docs/document-api/reference/fields/insert.mdx",
@@ -436,6 +437,7 @@
"getHtml",
"markdownToFragment",
"info",
+ "extract",
"clearContent",
"insert",
"replace",
@@ -1016,5 +1018,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
- "sourceHash": "b61fad6a3a330af8a57b78ded260c8d8918486c9829b50804227fbeb15e8bf53"
+ "sourceHash": "c8670fb494b56c19fbd09a7bada35974fbb3c22d938f6a5e01eee6e8467961c0"
}
diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx
index f64034b1be..d928604dd0 100644
--- a/apps/docs/document-api/reference/capabilities/get.mdx
+++ b/apps/docs/document-api/reference/capabilities/get.mdx
@@ -855,6 +855,11 @@ _No fields._
| `operations.diff.compare.dryRun` | boolean | yes | |
| `operations.diff.compare.reasons` | enum[] | no | |
| `operations.diff.compare.tracked` | boolean | yes | |
+| `operations.extract` | object | yes | |
+| `operations.extract.available` | boolean | yes | |
+| `operations.extract.dryRun` | boolean | yes | |
+| `operations.extract.reasons` | enum[] | no | |
+| `operations.extract.tracked` | boolean | yes | |
| `operations.fields.get` | object | yes | |
| `operations.fields.get.available` | boolean | yes | |
| `operations.fields.get.dryRun` | boolean | yes | |
@@ -3071,6 +3076,11 @@ _No fields._
"dryRun": false,
"tracked": false
},
+ "extract": {
+ "available": true,
+ "dryRun": false,
+ "tracked": false
+ },
"fields.get": {
"available": true,
"dryRun": false,
@@ -10179,6 +10189,41 @@ _No fields._
],
"type": "object"
},
+ "extract": {
+ "additionalProperties": false,
+ "properties": {
+ "available": {
+ "type": "boolean"
+ },
+ "dryRun": {
+ "type": "boolean"
+ },
+ "reasons": {
+ "items": {
+ "enum": [
+ "COMMAND_UNAVAILABLE",
+ "HELPER_UNAVAILABLE",
+ "OPERATION_UNAVAILABLE",
+ "TRACKED_MODE_UNAVAILABLE",
+ "DRY_RUN_UNAVAILABLE",
+ "NAMESPACE_UNAVAILABLE",
+ "STYLES_PART_MISSING",
+ "COLLABORATION_ACTIVE"
+ ]
+ },
+ "type": "array"
+ },
+ "tracked": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "available",
+ "tracked",
+ "dryRun"
+ ],
+ "type": "object"
+ },
"fields.get": {
"additionalProperties": false,
"properties": {
@@ -19570,6 +19615,7 @@ _No fields._
"getHtml",
"markdownToFragment",
"info",
+ "extract",
"clearContent",
"insert",
"replace",
diff --git a/apps/docs/document-api/reference/content-controls/create.mdx b/apps/docs/document-api/reference/content-controls/create.mdx
index 177cb4c016..620c897a9e 100644
--- a/apps/docs/document-api/reference/content-controls/create.mdx
+++ b/apps/docs/document-api/reference/content-controls/create.mdx
@@ -27,6 +27,10 @@ Returns a ContentControlMutationResult with the created content control target.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `alias` | string | no | |
+| `at` | SelectionTarget | no | SelectionTarget |
+| `at.end` | SelectionPoint | no | SelectionPoint |
+| `at.kind` | `"selection"` | no | Constant: `"selection"` |
+| `at.start` | SelectionPoint | no | SelectionPoint |
| `content` | string | no | |
| `controlType` | string | no | |
| `kind` | enum | yes | `"block"`, `"inline"` |
@@ -120,6 +124,9 @@ Returns a ContentControlMutationResult with the created content control target.
"alias": {
"type": "string"
},
+ "at": {
+ "$ref": "#/$defs/SelectionTarget"
+ },
"content": {
"type": "string"
},
diff --git a/apps/docs/document-api/reference/core/index.mdx b/apps/docs/document-api/reference/core/index.mdx
index 19c242887f..6f4931ff98 100644
--- a/apps/docs/document-api/reference/core/index.mdx
+++ b/apps/docs/document-api/reference/core/index.mdx
@@ -21,6 +21,7 @@ Primary read and write operations.
| getHtml | `getHtml` | No | `idempotent` | No | No |
| markdownToFragment | `markdownToFragment` | No | `idempotent` | No | No |
| info | `info` | No | `idempotent` | No | No |
+| extract | `extract` | No | `idempotent` | No | No |
| clearContent | `clearContent` | Yes | `conditional` | No | No |
| insert | `insert` | Yes | `non-idempotent` | Yes | Yes |
| replace | `replace` | Yes | `conditional` | Yes | Yes |
diff --git a/apps/docs/document-api/reference/create/heading.mdx b/apps/docs/document-api/reference/create/heading.mdx
index 1d7f43d48c..06bbbdc051 100644
--- a/apps/docs/document-api/reference/create/heading.mdx
+++ b/apps/docs/document-api/reference/create/heading.mdx
@@ -99,7 +99,11 @@ Returns a CreateHeadingResult with the new heading block ID and address.
{
"entityId": "entity-789",
"entityType": "trackedChange",
- "kind": "entity"
+ "kind": "entity",
+ "story": {
+ "kind": "story",
+ "storyType": "body"
+ }
}
]
}
diff --git a/apps/docs/document-api/reference/create/paragraph.mdx b/apps/docs/document-api/reference/create/paragraph.mdx
index e2d1c4a43a..c115b81b33 100644
--- a/apps/docs/document-api/reference/create/paragraph.mdx
+++ b/apps/docs/document-api/reference/create/paragraph.mdx
@@ -97,7 +97,11 @@ Returns a CreateParagraphResult with the new paragraph block ID and address.
{
"entityId": "entity-789",
"entityType": "trackedChange",
- "kind": "entity"
+ "kind": "entity",
+ "story": {
+ "kind": "story",
+ "storyType": "body"
+ }
}
]
}
diff --git a/apps/docs/document-api/reference/extract.mdx b/apps/docs/document-api/reference/extract.mdx
new file mode 100644
index 0000000000..4294047ed3
--- /dev/null
+++ b/apps/docs/document-api/reference/extract.mdx
@@ -0,0 +1,276 @@
+---
+title: extract
+sidebarTitle: extract
+description: Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes โ each with an ID compatible with scrollToElement().
+---
+
+{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}
+
+## Summary
+
+Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes โ each with an ID compatible with scrollToElement().
+
+- Operation ID: `extract`
+- API member path: `editor.doc.extract(...)`
+- Mutates document: `no`
+- Idempotency: `idempotent`
+- Supports tracked mode: `no`
+- Supports dry run: `no`
+- Deterministic target resolution: `yes`
+
+## Expected result
+
+Returns an ExtractResult with blocks (nodeId, type, text, headingLevel), comments (entityId, text, anchoredText, blockId, status, author), tracked changes (entityId, type, excerpt, author, date), and revision.
+
+## Input fields
+
+_No fields._
+
+### Example request
+
+```json
+{}
+```
+
+## Output fields
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `blocks` | object[] | yes | |
+| `comments` | object[] | yes | |
+| `revision` | string | yes | |
+| `trackedChanges` | object[] | yes | |
+
+### Example response
+
+```json
+{
+ "blocks": [
+ {
+ "headingLevel": 1,
+ "nodeId": "node-def456",
+ "tableContext": {
+ "colspan": 1,
+ "columnIndex": 1,
+ "parentRowIndex": 1,
+ "parentTableOrdinal": 1,
+ "rowIndex": 1,
+ "rowspan": 1,
+ "tableOrdinal": 1
+ },
+ "text": "Hello, world.",
+ "type": "example"
+ }
+ ],
+ "comments": [
+ {
+ "anchoredText": "example",
+ "entityId": "entity-789",
+ "status": "open",
+ "text": "Hello, world."
+ }
+ ],
+ "revision": "example",
+ "trackedChanges": [
+ {
+ "author": "Jane Doe",
+ "entityId": "entity-789",
+ "excerpt": "Sample excerpt...",
+ "type": "insert"
+ }
+ ]
+}
+```
+
+## Pre-apply throws
+
+- None
+
+## Non-applied failure codes
+
+- None
+
+## Raw schemas
+
+
+```json
+{
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object"
+}
+```
+
+
+
+```json
+{
+ "additionalProperties": false,
+ "properties": {
+ "blocks": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "headingLevel": {
+ "description": "Heading level (1โ6). Only present for headings.",
+ "type": "integer"
+ },
+ "nodeId": {
+ "description": "Stable block ID โ pass to scrollToElement() for navigation.",
+ "type": "string"
+ },
+ "tableContext": {
+ "additionalProperties": false,
+ "properties": {
+ "colspan": {
+ "description": "Number of columns the cell spans.",
+ "type": "integer"
+ },
+ "columnIndex": {
+ "description": "0-based logical grid column, not the row child order.",
+ "type": "integer"
+ },
+ "parentColumnIndex": {
+ "description": "Column index in the parent table. Set with parentTableOrdinal.",
+ "type": "integer"
+ },
+ "parentRowIndex": {
+ "description": "Row index in the parent table. Set with parentTableOrdinal.",
+ "type": "integer"
+ },
+ "parentTableOrdinal": {
+ "description": "Ordinal of the parent table when the containing table is nested.",
+ "type": "integer"
+ },
+ "rowIndex": {
+ "description": "0-based row index of the containing cell.",
+ "type": "integer"
+ },
+ "rowspan": {
+ "description": "Number of rows the cell spans.",
+ "type": "integer"
+ },
+ "tableOrdinal": {
+ "description": "0-based table ordinal, unique within one extract() result.",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "tableOrdinal",
+ "rowIndex",
+ "columnIndex",
+ "rowspan",
+ "colspan"
+ ],
+ "type": "object"
+ },
+ "text": {
+ "description": "Full plain text content of the block.",
+ "type": "string"
+ },
+ "type": {
+ "description": "Block type: paragraph, heading, listItem, image, tableOfContents.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "nodeId",
+ "type",
+ "text"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "comments": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "anchoredText": {
+ "description": "The document text the comment is anchored to.",
+ "type": "string"
+ },
+ "author": {
+ "description": "Comment author name.",
+ "type": "string"
+ },
+ "blockId": {
+ "description": "Block ID the comment is anchored to.",
+ "type": "string"
+ },
+ "entityId": {
+ "description": "Comment entity ID โ pass to scrollToElement() for navigation.",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "open",
+ "resolved"
+ ],
+ "type": "string"
+ },
+ "text": {
+ "description": "Comment body text.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "entityId",
+ "status"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "revision": {
+ "description": "Document revision at the time of extraction.",
+ "type": "string"
+ },
+ "trackedChanges": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "author": {
+ "description": "Change author name.",
+ "type": "string"
+ },
+ "date": {
+ "description": "Change date (ISO string).",
+ "type": "string"
+ },
+ "entityId": {
+ "description": "Tracked change entity ID โ pass to scrollToElement() for navigation.",
+ "type": "string"
+ },
+ "excerpt": {
+ "description": "Short text excerpt of the changed content.",
+ "type": "string"
+ },
+ "type": {
+ "enum": [
+ "insert",
+ "delete",
+ "format"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "entityId",
+ "type"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "blocks",
+ "comments",
+ "trackedChanges",
+ "revision"
+ ],
+ "type": "object"
+}
+```
+
diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx
index ec6d5c293e..8ddf5e92d2 100644
--- a/apps/docs/document-api/reference/index.mdx
+++ b/apps/docs/document-api/reference/index.mdx
@@ -19,7 +19,7 @@ This reference is sourced from `packages/document-api/src/contract/*`.
| Namespace | Canonical ops | Aliases | Total surface | Reference |
| --- | --- | --- | --- | --- |
-| Core | 13 | 0 | 13 | [Open](/document-api/reference/core/index) |
+| Core | 14 | 0 | 14 | [Open](/document-api/reference/core/index) |
| Blocks | 3 | 0 | 3 | [Open](/document-api/reference/blocks/index) |
| Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) |
| Create | 6 | 0 | 6 | [Open](/document-api/reference/create/index) |
@@ -70,6 +70,7 @@ The tables below are grouped by namespace.
| getHtml | editor.doc.getHtml(...) | Extract the document content as an HTML string. |
| markdownToFragment | editor.doc.markdownToFragment(...) | Convert a Markdown string into an SDM/1 structural fragment. |
| info | editor.doc.info(...) | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. |
+| extract | editor.doc.extract(...) | Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes โ each with an ID compatible with scrollToElement(). |
| clearContent | editor.doc.clearContent(...) | Clear all document body content, leaving a single empty paragraph. |
| insert | editor.doc.insert(...) | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. |
| replace | editor.doc.replace(...) | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. |
diff --git a/apps/docs/document-api/reference/lists/insert.mdx b/apps/docs/document-api/reference/lists/insert.mdx
index ce184c103e..05294706fe 100644
--- a/apps/docs/document-api/reference/lists/insert.mdx
+++ b/apps/docs/document-api/reference/lists/insert.mdx
@@ -98,7 +98,11 @@ Returns a ListsInsertResult with the new list item address and block ID.
{
"entityId": "entity-789",
"entityType": "trackedChange",
- "kind": "entity"
+ "kind": "entity",
+ "story": {
+ "kind": "story",
+ "storyType": "body"
+ }
}
]
}
diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx
index 5b0a3ba124..cfd98e37fb 100644
--- a/apps/docs/document-api/reference/track-changes/decide.mdx
+++ b/apps/docs/document-api/reference/track-changes/decide.mdx
@@ -35,7 +35,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan
{
"decision": "accept",
"target": {
- "id": "id-001"
+ "id": "id-001",
+ "story": {
+ "kind": "story",
+ "storyType": "body"
+ }
}
}
```
@@ -114,6 +118,9 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan
"properties": {
"id": {
"type": "string"
+ },
+ "story": {
+ "$ref": "#/$defs/StoryLocator"
}
},
"required": [
diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx
index c57851388e..f3d9ab8a54 100644
--- a/apps/docs/document-api/reference/track-changes/get.mdx
+++ b/apps/docs/document-api/reference/track-changes/get.mdx
@@ -27,12 +27,17 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | |
+| `story` | StoryLocator | no | StoryLocator |
### Example request
```json
{
- "id": "id-001"
+ "id": "id-001",
+ "story": {
+ "kind": "story",
+ "storyType": "body"
+ }
}
```
@@ -44,6 +49,7 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co
| `address.entityId` | string | yes | |
| `address.entityType` | `"trackedChange"` | yes | Constant: `"trackedChange"` |
| `address.kind` | `"entity"` | yes | Constant: `"entity"` |
+| `address.story` | StoryLocator | no | StoryLocator |
| `author` | string | no | |
| `authorEmail` | string | no | |
| `authorImage` | string | no | |
@@ -63,7 +69,11 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co
"address": {
"entityId": "entity-789",
"entityType": "trackedChange",
- "kind": "entity"
+ "kind": "entity",
+ "story": {
+ "kind": "story",
+ "storyType": "body"
+ }
},
"author": "Jane Doe",
"id": "id-001",
@@ -92,6 +102,9 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co
"properties": {
"id": {
"type": "string"
+ },
+ "story": {
+ "$ref": "#/$defs/StoryLocator"
}
},
"required": [
diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx
index bcb7e86ada..6411a19b16 100644
--- a/apps/docs/document-api/reference/track-changes/list.mdx
+++ b/apps/docs/document-api/reference/track-changes/list.mdx
@@ -26,6 +26,7 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r
| Field | Type | Required | Description |
| --- | --- | --- | --- |
+| `in` | StoryLocator \\| `"all"` | no | One of: StoryLocator, `"all"` |
| `limit` | integer | no | |
| `offset` | integer | no | |
| `type` | enum | no | `"insert"`, `"delete"`, `"format"` |
@@ -61,7 +62,11 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r
"address": {
"entityId": "entity-789",
"entityType": "trackedChange",
- "kind": "entity"
+ "kind": "entity",
+ "story": {
+ "kind": "story",
+ "storyType": "body"
+ }
},
"author": "Jane Doe",
"handle": {
@@ -101,6 +106,17 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r
{
"additionalProperties": false,
"properties": {
+ "in": {
+ "description": "Story scope. Omit for body only, pass a StoryLocator for a single story, or 'all' for body + every revision-capable non-body story.",
+ "oneOf": [
+ {
+ "$ref": "#/$defs/StoryLocator"
+ },
+ {
+ "const": "all"
+ }
+ ]
+ },
"limit": {
"description": "Maximum number of tracked changes to return.",
"type": "integer"
diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json
index d6b1e41a79..f2559eb087 100644
--- a/apps/docs/openapi.json
+++ b/apps/docs/openapi.json
@@ -197,19 +197,110 @@
},
"document": {
"type": "object",
- "description": "PDF or DOCX input provided as either base64 or URL"
+ "description": "PDF or DOCX input. Provide exactly one of `base64` or `url`.",
+ "oneOf": [
+ {
+ "type": "object",
+ "title": "base64",
+ "required": ["base64"],
+ "properties": {
+ "base64": {
+ "type": "string",
+ "format": "byte",
+ "minLength": 100,
+ "description": "Base64-encoded PDF or DOCX file"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "title": "url",
+ "required": ["url"],
+ "properties": {
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "description": "URL to fetch the document from"
+ }
+ }
+ }
+ ]
},
"signer": {
"type": "object",
- "description": "Signer details (name, email, etc.)"
+ "description": "Details of the person applying the signature. `email` and `name` are required; `ip` and `userAgent` are optional and recorded in the audit trail / certificate page when provided. No other fields are accepted โ use `metadata` for application-specific context.",
+ "required": ["email", "name"],
+ "additionalProperties": false,
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "maxLength": 255,
+ "description": "Signer's email address",
+ "example": "jane@example.com"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 255,
+ "description": "Signer's full name as it should appear on the signature",
+ "example": "Jane Smith"
+ },
+ "ip": {
+ "type": "string",
+ "format": "ipv4",
+ "description": "IPv4 address the signer submitted from. Included in the audit trail certificate for compliance.",
+ "example": "203.0.113.42"
+ },
+ "userAgent": {
+ "type": "string",
+ "description": "Browser user agent string the signer submitted from. Included in the audit trail certificate for compliance.",
+ "example": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
+ }
+ }
},
"auditTrail": {
"type": "array",
- "description": "Array of signing events and user interactions"
+ "description": "Complete event trail of user interactions. Must include at least one `submit` event for e-signature compliance.",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["type", "timestamp"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["ready", "scroll", "field_change", "submit"],
+ "description": "Event kind"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO-8601 timestamp for the event"
+ },
+ "data": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Event-specific payload emitted by the e-sign SDK. Shape depends on `type`:\n- `scroll` - `{ percent: number }`\n- `field_change` - `{ fieldId: string, value: string | boolean | number | null, previousValue?: string | boolean | number | null }`\n- `ready`, `submit` - typically omitted"
+ }
+ }
+ }
},
"metadata": {
"type": "object",
- "description": "Optional metadata (IP, user agent, custom fields)"
+ "additionalProperties": true,
+ "description": "Optional application-specific metadata. Free-form object for any context you want to attach to the signing event (e.g. tenantId, contractId, custom audit fields)."
+ },
+ "certificate": {
+ "type": "object",
+ "description": "Configuration for the audit trail certificate page that is appended to the signed PDF.",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Whether to append an audit trail certificate page to the signed document"
+ }
+ }
}
}
},
@@ -792,7 +883,7 @@
"post": {
"summary": "Sign",
"tags": ["Signature"],
- "description": "Sign a PDF or DOCX document with a cryptographic signature.\n\nSend a JSON request body with:\n- `document`: object containing either `base64` or `url`\n- `signer`: object with signer details (name, email, etc.)\n- `auditTrail`: array of signing events\n- `eventId`: optional unique identifier\n- `metadata`: optional metadata (IP, user agent, custom fields)\n\nThe response returns the signed PDF as base64.",
+ "description": "Sign a PDF or DOCX document with a cryptographic signature.\n\nSend a JSON request body with:\n- `document` (required): object containing either `base64` or `url`\n- `signer` (required): signer details โ `email` and `name` are required; `ip` and `userAgent` are optional and, when provided, are recorded in the audit trail certificate. No other signer fields are accepted; use `metadata` for anything application-specific.\n- `auditTrail` (required): array of signing events. Must include at least one `submit` event for e-signature compliance.\n- `eventId` (optional): unique identifier for the signing event\n- `metadata` (optional): free-form object for application-specific context (tenantId, contractId, etc.)\n- `certificate` (optional): `{ enabled: boolean }` โ controls whether an audit trail certificate page is appended (default: `true`)\n\nThe response returns the signed PDF as base64.",
"requestBody": {
"content": {
"application/json": {
diff --git a/apps/docs/snippets/extensions/node-resizer.mdx b/apps/docs/snippets/extensions/node-resizer.mdx
index de8f8962dc..bf7666a633 100644
--- a/apps/docs/snippets/extensions/node-resizer.mdx
+++ b/apps/docs/snippets/extensions/node-resizer.mdx
@@ -6,7 +6,7 @@ import { SuperDocEditor } from '/snippets/components/superdoc-editor.jsx'
Select an image to see resize handles:
-
+
Drag the corner handles to resize. The aspect ratio is automatically maintained.
`}
height="400px"
/>
diff --git a/apps/docs/solutions/esign/backend.mdx b/apps/docs/solutions/esign/backend.mdx
index 4fbb52ec02..560b7590ae 100644
--- a/apps/docs/solutions/esign/backend.mdx
+++ b/apps/docs/solutions/esign/backend.mdx
@@ -251,6 +251,23 @@ The frontend sends the `onSubmit` payload plus a document reference and signer d
}
```
+### Signer fields
+
+When forwarding this payload to `POST /v1/sign`, only the following `signer` fields are accepted:
+
+| Field | Required | Description |
+| ----------- | -------- | --------------------------------------------------------------------------- |
+| `name` | yes | Signer's full name (2โ255 chars). Rendered on the signature. |
+| `email` | yes | Signer's email address (valid email, max 255 chars). |
+| `ip` | no | IPv4 address of the signer. Included in the audit trail certificate. |
+| `userAgent` | no | Browser user agent string. Included in the audit trail certificate. |
+
+No other properties are accepted on `signer` โ the request is rejected with a validation error if extra keys are sent. For application-specific context (tenantId, contractId, etc.), pass a top-level `metadata` object instead.
+
+
+ Do **not** trust `ip` or `userAgent` values from the browser-submitted payload. Derive them server-side from the incoming request (e.g. `req.ip`, `req.headers['user-agent']`) before forwarding to `/v1/sign`, so the audit trail certificate reflects what your server actually observed.
+
+
## API reference
- [Authentication](/api-reference/authentication) - Get your API key
diff --git a/apps/vscode-ext/.releaserc.cjs b/apps/vscode-ext/.releaserc.cjs
index 6ef515b19e..214a88ceae 100644
--- a/apps/vscode-ext/.releaserc.cjs
+++ b/apps/vscode-ext/.releaserc.cjs
@@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/ai',
'packages/word-layout',
'packages/preset-geometry',
+ 'shared',
'pnpm-workspace.yaml',
]);
diff --git a/apps/vscode-ext/package.json b/apps/vscode-ext/package.json
index 1fc001f91c..33b303b3e9 100644
--- a/apps/vscode-ext/package.json
+++ b/apps/vscode-ext/package.json
@@ -77,7 +77,7 @@
"build": "pnpm run compile",
"watch": "pnpm run compile:ext -- --watch",
"package": "vsce package --no-dependencies",
- "publish:vsce": "vsce publish --packagePath *.vsix",
+ "publish:vsce": "vsce publish --packagePath *.vsix --skip-duplicate",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md
index 7934baf06e..f1dc0b3bde 100644
--- a/packages/document-api/src/README.md
+++ b/packages/document-api/src/README.md
@@ -82,7 +82,7 @@ Deterministic outcomes:
- Missing tracked-change capabilities must fail with `CAPABILITY_UNAVAILABLE`.
- Text/format targets that cannot be resolved after remote edits must fail deterministically (`TARGET_NOT_FOUND` / `NO_OP`), never silently mutate the wrong range.
- Tracked entity IDs returned by mutation receipts (`insert` / `replace` / `delete`) and `create.paragraph.trackedChangeRefs` must match canonical IDs from `trackChanges.list`.
-- `trackChanges.get` / `accept` / `reject` accept canonical IDs only.
+- `trackChanges.get` / `trackChanges.decide` accept canonical tracked-change IDs. Include `story` when targeting a non-body change.
## Common Workflows
@@ -699,27 +699,27 @@ List all comments in the document. Optionally include resolved comments.
### `trackChanges.list`
-List tracked changes in the document. Supports filtering by `type` and pagination via `limit`/`offset`.
+List tracked changes in the document. Supports filtering by `type`, pagination via `limit`/`offset`, and story scoping via `in`.
-- **Input**: `TrackChangesListInput | undefined` (`{ limit?, offset?, type? }`)
+- **Input**: `TrackChangesListInput | undefined` (`{ limit?, offset?, type?, in?: StoryLocator | 'all' }`)
- **Output**: `TrackChangesListResult` (`{ items, total }`)
- **Mutates**: No
- **Idempotency**: idempotent
### `trackChanges.get`
-Retrieve full information for a single tracked change by its canonical ID. Throws `TARGET_NOT_FOUND` when the ID is invalid.
+Retrieve full information for a single tracked change by its canonical ID. Include `story` for non-body changes. Throws `TARGET_NOT_FOUND` when the ID is invalid.
-- **Input**: `TrackChangesGetInput` (`{ id }`)
+- **Input**: `TrackChangesGetInput` (`{ id, story? }`)
- **Output**: `TrackChangeInfo` (includes `wordRevisionIds` with raw imported Word OOXML `w:id` values when available)
- **Mutates**: No
- **Idempotency**: idempotent
### `trackChanges.decide`
-Accept or reject a tracked change by ID, or accept/reject all changes with `{ scope: 'all' }`.
+Accept or reject a tracked change by ID, or accept/reject all changes with `{ scope: 'all' }`. Include `story` when the change lives outside the body.
-- **Input**: `ReviewDecideInput` (`{ decision: 'accept' | 'reject', target: { id } | { scope: 'all' } }`)
+- **Input**: `ReviewDecideInput` (`{ decision: 'accept' | 'reject', target: { id, story? } | { scope: 'all' } }`)
- **Output**: `Receipt`
- **Mutates**: Yes
- **Idempotency**: conditional
diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts
index a3d1c9c321..55c8b91181 100644
--- a/packages/document-api/src/contract/schemas.ts
+++ b/packages/document-api/src/contract/schemas.ts
@@ -390,6 +390,7 @@ const SHARED_DEFS: Record = {
kind: { const: 'entity' },
entityType: { const: 'trackedChange' },
entityId: { type: 'string' },
+ story: ref('StoryLocator'),
},
['kind', 'entityType', 'entityId'],
),
@@ -2962,9 +2963,40 @@ const operationSchemas: Record = {
items: objectSchema(
{
nodeId: { type: 'string', description: 'Stable block ID โ pass to scrollToElement() for navigation.' },
- type: { type: 'string', description: 'Block type: paragraph, heading, listItem, table, image, etc.' },
+ type: {
+ type: 'string',
+ description: 'Block type: paragraph, heading, listItem, image, tableOfContents.',
+ },
text: { type: 'string', description: 'Full plain text content of the block.' },
headingLevel: { type: 'integer', description: 'Heading level (1โ6). Only present for headings.' },
+ tableContext: objectSchema(
+ {
+ tableOrdinal: {
+ type: 'integer',
+ description: '0-based table ordinal, unique within one extract() result.',
+ },
+ parentTableOrdinal: {
+ type: 'integer',
+ description: 'Ordinal of the parent table when the containing table is nested.',
+ },
+ parentRowIndex: {
+ type: 'integer',
+ description: 'Row index in the parent table. Set with parentTableOrdinal.',
+ },
+ parentColumnIndex: {
+ type: 'integer',
+ description: 'Column index in the parent table. Set with parentTableOrdinal.',
+ },
+ rowIndex: { type: 'integer', description: '0-based row index of the containing cell.' },
+ columnIndex: {
+ type: 'integer',
+ description: '0-based logical grid column, not the row child order.',
+ },
+ rowspan: { type: 'integer', description: 'Number of rows the cell spans.' },
+ colspan: { type: 'integer', description: 'Number of columns the cell spans.' },
+ },
+ ['tableOrdinal', 'rowIndex', 'columnIndex', 'rowspan', 'colspan'],
+ ),
},
['nodeId', 'type', 'text'],
),
@@ -4707,11 +4739,16 @@ const operationSchemas: Record = {
enum: ['insert', 'delete', 'format'],
description: "Filter by change type: 'insert', 'delete', or 'format'.",
},
+ in: {
+ oneOf: [storyLocatorSchema, { const: 'all' }],
+ description:
+ "Story scope. Omit for body only, pass a StoryLocator for a single story, or 'all' for body + every revision-capable non-body story.",
+ },
}),
output: trackChangesListResultSchema,
},
'trackChanges.get': {
- input: objectSchema({ id: { type: 'string' } }, ['id']),
+ input: objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']),
output: trackChangeInfoSchema,
},
'trackChanges.decide': {
@@ -4721,7 +4758,7 @@ const operationSchemas: Record = {
decision: { enum: ['accept', 'reject'] },
target: {
oneOf: [
- objectSchema({ id: { type: 'string' } }, ['id']),
+ objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']),
objectSchema({ scope: { enum: ['all'] } }, ['scope']),
],
},
diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts
index d03716a034..9aff031ee7 100644
--- a/packages/document-api/src/index.test.ts
+++ b/packages/document-api/src/index.test.ts
@@ -795,6 +795,7 @@ describe('createDocumentApi', () => {
it('delegates trackChanges read operations', () => {
const trackAdpt = makeTrackChangesAdapter();
+ const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const;
const api = createDocumentApi({
find: makeFindAdapter(FIND_RESULT),
get: makeGetAdapter(),
@@ -811,15 +812,20 @@ describe('createDocumentApi', () => {
const listResult = api.trackChanges.list({ limit: 1 });
const getResult = api.trackChanges.get({ id: 'tc-1' });
+ api.trackChanges.list({ in: footnoteStory, type: 'insert' });
+ api.trackChanges.get({ id: 'tc-2', story: footnoteStory });
expect(listResult.total).toBe(0);
expect(getResult.id).toBe('tc-1');
expect(trackAdpt.list).toHaveBeenCalledWith({ limit: 1 });
expect(trackAdpt.get).toHaveBeenCalledWith({ id: 'tc-1' });
+ expect(trackAdpt.list).toHaveBeenCalledWith({ in: footnoteStory, type: 'insert' });
+ expect(trackAdpt.get).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory });
});
it('delegates trackChanges.decide to trackChanges adapter methods', () => {
const trackAdpt = makeTrackChangesAdapter();
+ const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const;
const api = createDocumentApi({
find: makeFindAdapter(FIND_RESULT),
get: makeGetAdapter(),
@@ -836,6 +842,7 @@ describe('createDocumentApi', () => {
const acceptResult = api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1' } });
const rejectResult = api.trackChanges.decide({ decision: 'reject', target: { id: 'tc-1' } });
+ api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-2', story: footnoteStory } });
const acceptAllResult = api.trackChanges.decide({ decision: 'accept', target: { scope: 'all' } });
const rejectAllResult = api.trackChanges.decide({ decision: 'reject', target: { scope: 'all' } });
@@ -845,6 +852,7 @@ describe('createDocumentApi', () => {
expect(rejectAllResult.success).toBe(true);
expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-1' }, undefined);
expect(trackAdpt.reject).toHaveBeenCalledWith({ id: 'tc-1' }, undefined);
+ expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory }, undefined);
expect(trackAdpt.acceptAll).toHaveBeenCalledWith({}, undefined);
expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined);
});
diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts
index 30ec5d433f..06f1a90f64 100644
--- a/packages/document-api/src/track-changes/track-changes.ts
+++ b/packages/document-api/src/track-changes/track-changes.ts
@@ -1,4 +1,5 @@
import type { Receipt, TrackChangeInfo, TrackChangesListQuery, TrackChangesListResult } from '../types/index.js';
+import type { StoryLocator } from '../types/story.types.js';
import type { RevisionGuardOptions } from '../write/write.js';
import { DocumentApiValidationError } from '../errors.js';
@@ -6,14 +7,20 @@ export type TrackChangesListInput = TrackChangesListQuery;
export interface TrackChangesGetInput {
id: string;
+ /** Story containing the tracked change. Omit for body (backward compatible). */
+ story?: StoryLocator;
}
export interface TrackChangesAcceptInput {
id: string;
+ /** Story containing the tracked change. Omit for body (backward compatible). */
+ story?: StoryLocator;
}
export interface TrackChangesRejectInput {
id: string;
+ /** Story containing the tracked change. Omit for body (backward compatible). */
+ story?: StoryLocator;
}
export type TrackChangesAcceptAllInput = Record;
@@ -25,8 +32,8 @@ export type TrackChangesRejectAllInput = Record;
// ---------------------------------------------------------------------------
export type ReviewDecideInput =
- | { decision: 'accept'; target: { id: string } }
- | { decision: 'reject'; target: { id: string } }
+ | { decision: 'accept'; target: { id: string; story?: StoryLocator } }
+ | { decision: 'reject'; target: { id: string; story?: StoryLocator } }
| { decision: 'accept'; target: { scope: 'all' } }
| { decision: 'reject'; target: { scope: 'all' } };
@@ -133,11 +140,13 @@ export function executeTrackChangesDecide(
}
}
+ const story = (target as { story?: StoryLocator }).story;
+
if (input.decision === 'accept') {
if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options);
- return adapter.accept({ id: target.id as string }, options);
+ return adapter.accept({ id: target.id as string, ...(story ? { story } : {}) }, options);
}
if (isAll) return adapter.rejectAll({} as TrackChangesRejectAllInput, options);
- return adapter.reject({ id: target.id as string }, options);
+ return adapter.reject({ id: target.id as string, ...(story ? { story } : {}) }, options);
}
diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts
index 1c9484d051..1de125be74 100644
--- a/packages/document-api/src/types/address.ts
+++ b/packages/document-api/src/types/address.ts
@@ -125,6 +125,10 @@ export type TrackedChangeAddress = {
kind: 'entity';
entityType: 'trackedChange';
entityId: string;
+ /** Story containing this tracked change. Omit for body (backward compatible). */
+ story?: StoryLocator;
+ /** Preferred rendered page instance for repeated stories such as headers and footers. */
+ pageIndex?: number;
};
export type EntityAddress = CommentAddress | TrackedChangeAddress;
diff --git a/packages/document-api/src/types/extract.types.ts b/packages/document-api/src/types/extract.types.ts
index 00f4033156..708b2bc0d9 100644
--- a/packages/document-api/src/types/extract.types.ts
+++ b/packages/document-api/src/types/extract.types.ts
@@ -4,15 +4,59 @@ import type { CommentStatus, TrackChangeType } from './index.js';
// extract
// ---------------------------------------------------------------------------
+/**
+ * Table coordinates for an {@link ExtractBlock} that lives inside a table cell.
+ *
+ * Blocks inside tables are extracted at paragraph granularity (one entry per
+ * paragraph/heading/listItem/image/sdt/tableOfContents in each cell). Group
+ * by these fields to reconstruct cells, rows, or whole tables:
+ *
+ * - cell: group by `tableOrdinal + rowIndex + columnIndex`
+ * - row: group by `tableOrdinal + rowIndex`
+ * - table: group by `tableOrdinal`
+ */
+export interface ExtractTableContext {
+ /** 0-based table ordinal, unique within one `extract()` result. */
+ tableOrdinal: number;
+ /** Ordinal of the parent table when this block is inside a nested table. */
+ parentTableOrdinal?: number;
+ /** Row index within the parent table. Only set with `parentTableOrdinal`. */
+ parentRowIndex?: number;
+ /** Column index within the parent table. Only set with `parentTableOrdinal`. */
+ parentColumnIndex?: number;
+ /** 0-based row index of the containing cell. */
+ rowIndex: number;
+ /** 0-based logical grid column of the containing cell, not the row's child order. */
+ columnIndex: number;
+ /** Number of rows the containing cell spans. 1 for unmerged cells. */
+ rowspan: number;
+ /** Number of columns the containing cell spans. 1 for unmerged cells. */
+ colspan: number;
+}
+
+/**
+ * One addressable unit of document content.
+ *
+ * Extraction is paragraph-granular: tables are NOT returned as a single block.
+ * Paragraph-like descendants of table cells are emitted individually with
+ * `tableContext` attached.
+ *
+ * Block SDTs (structured document tags / content controls) are transparent:
+ * their children emit individually as if they were direct children of the
+ * enclosing container. No wrapper `sdt` block is emitted. This prevents
+ * SDT-wrapped tables from re-flattening through the wrapper's textContent.
+ */
export interface ExtractBlock {
- /** Stable block ID โ pass to `scrollToElement()` for navigation. */
+ /** Stable block ID. Pass to `scrollToElement()` for navigation. */
nodeId: string;
- /** Block type: paragraph, heading, listItem, table, image, etc. */
+ /** Block type: paragraph, heading, listItem, image, tableOfContents. */
type: string;
/** Full plain text content of the block. */
text: string;
- /** Heading level (1โ6). Only present for headings. */
+ /** Heading level (1-6). Only present for headings. */
headingLevel?: number;
+ /** Table coordinates. Only present for blocks inside a table cell. */
+ tableContext?: ExtractTableContext;
}
export interface ExtractComment {
diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts
index 8f3adeb92f..3fa319211e 100644
--- a/packages/document-api/src/types/track-changes.types.ts
+++ b/packages/document-api/src/types/track-changes.types.ts
@@ -1,8 +1,17 @@
import type { TrackedChangeAddress } from './address.js';
import type { DiscoveryOutput } from './discovery.js';
+import type { StoryLocator } from './story.types.js';
export type TrackChangeType = 'insert' | 'delete' | 'format';
+/**
+ * Scope marker used by {@link TrackChangesListQuery.in} to request changes
+ * across every revision-capable story (body + headers + footers + footnotes +
+ * endnotes). Equivalent to a multi-story aggregate list.
+ */
+export const TRACK_CHANGES_IN_ALL = 'all' as const;
+export type TrackChangesInAll = typeof TRACK_CHANGES_IN_ALL;
+
/**
* Raw imported Word OOXML revision IDs (`w:id`) from the source document when available.
*
@@ -36,6 +45,13 @@ export interface TrackChangesListQuery {
limit?: number;
offset?: number;
type?: TrackChangeType;
+ /**
+ * Story scope.
+ * - `undefined` (default) โ body only (backward compatible).
+ * - A {@link StoryLocator} โ only that story.
+ * - `'all'` โ flat list across body + every revision-capable non-body story.
+ */
+ in?: StoryLocator | TrackChangesInAll;
}
/**
diff --git a/packages/esign/.releaserc.cjs b/packages/esign/.releaserc.cjs
index 4eb28dc71d..f4a177a602 100644
--- a/packages/esign/.releaserc.cjs
+++ b/packages/esign/.releaserc.cjs
@@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/ai',
'packages/word-layout',
'packages/preset-geometry',
+ 'shared',
'pnpm-workspace.yaml',
]);
diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts
index 4cc339e09c..968b950327 100644
--- a/packages/layout-engine/contracts/src/index.ts
+++ b/packages/layout-engine/contracts/src/index.ts
@@ -160,6 +160,15 @@ export type RunMark = {
export type TrackedChangeMeta = {
kind: TrackedChangeKind;
id: string;
+ /**
+ * Internal story key identifying which content story owns this tracked
+ * change (`'body'`, `'hf:part:โฆ'`, `'fn:โฆ'`, `'en:โฆ'`).
+ *
+ * Set by the PM adapter during conversion and stamped on the rendered DOM
+ * as `data-story-key` so downstream code can distinguish anchors across
+ * stories without re-resolving the story runtime.
+ */
+ storyKey?: string;
author?: string;
authorEmail?: string;
authorImage?: string;
@@ -1901,6 +1910,16 @@ export type HeaderFooterPage = {
number: number;
fragments: Fragment[];
numberText?: string;
+ /**
+ * Optional page-local block clones backing this page's resolved fragments.
+ * Present when header/footer tokens were laid out per page or per bucket.
+ */
+ blocks?: FlowBlock[];
+ /**
+ * Optional page-local measures aligned with `blocks`.
+ * Present when header/footer tokens were laid out per page or per bucket.
+ */
+ measures?: Measure[];
};
export type HeaderFooterLayout = {
@@ -1980,6 +1999,8 @@ export type {
ResolvedTableItem,
ResolvedImageItem,
ResolvedDrawingItem,
+ ResolvedHeaderFooterPage,
+ ResolvedHeaderFooterLayout,
} from './resolved-layout.js';
export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js';
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index 9170e1e202..8e4355c432 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -1,4 +1,20 @@
-import type { DrawingBlock, FlowMode, Fragment, ImageBlock, Line, TableBlock, TableMeasure } from './index.js';
+import type {
+ DrawingBlock,
+ FlowMode,
+ Fragment,
+ ImageBlock,
+ ImageFragmentMetadata,
+ Line,
+ ListBlock,
+ ListMeasure,
+ PageMargins,
+ ParagraphBlock,
+ ParagraphBorders,
+ ParagraphMeasure,
+ SectionVerticalAlign,
+ TableBlock,
+ TableMeasure,
+} from './index.js';
/** A fully resolved layout ready for the next-generation paint pipeline. */
export type ResolvedLayout = {
@@ -8,8 +24,12 @@ export type ResolvedLayout = {
flowMode: FlowMode;
/** Gap between pages in pixels (0 when unset). */
pageGap: number;
+ /** Pre-computed block versions for painter-side cache invalidation. */
+ blockVersions?: Record;
/** Resolved pages with normalized dimensions. */
pages: ResolvedPage[];
+ /** Document epoch identifier from the source layout. Used for change tracking in the painter. */
+ layoutEpoch?: number;
};
/** A single resolved page with stable identity and normalized dimensions. */
@@ -26,6 +46,25 @@ export type ResolvedPage = {
height: number;
/** Resolved paint items for this page. */
items: ResolvedPaintItem[];
+ /** Page margins from the source page. Used for ruler rendering and header/footer positioning. */
+ margins?: PageMargins;
+ /** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */
+ footnoteReserved?: number;
+ /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */
+ numberText?: string;
+ /** Vertical alignment of content within this page. */
+ vAlign?: SectionVerticalAlign;
+ /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */
+ baseMargins?: { top: number; bottom: number };
+ /** 0-based index of the section this page belongs to. */
+ sectionIndex?: number;
+ /** Header/footer reference IDs for this page's section. */
+ sectionRefs?: {
+ headerRefs?: { default?: string; first?: string; even?: string; odd?: string };
+ footerRefs?: { default?: string; first?: string; even?: string; odd?: string };
+ };
+ /** Page orientation. */
+ orientation?: 'portrait' | 'landscape';
};
/** Union of all resolved paint item kinds. */
@@ -74,8 +113,30 @@ export type ResolvedFragmentItem = {
blockId: string;
/** Index within page.fragments โ bridge to legacy content rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
+ /** Whether this fragment continues from a previous page. */
+ continuesFromPrev?: boolean;
+ /** Whether this fragment continues on the next page. */
+ continuesOnNext?: boolean;
+ /** List marker box width in pixels (para/list-item only). */
+ markerWidth?: number;
/** Pre-resolved paragraph content for non-table paragraph fragments. */
content?: ResolvedParagraphContent;
+ /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */
+ sdtContainerKey?: string | null;
+ /** Pre-computed hash of paragraph borders for between-border grouping. */
+ paragraphBorderHash?: string;
+ /** Pre-extracted paragraph borders for between-border rendering. */
+ paragraphBorders?: ParagraphBorders;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
+ /** Pre-extracted block for paragraph (ParagraphBlock) or list-item (ListBlock) fragments. */
+ block?: ParagraphBlock | ListBlock;
+ /** Pre-extracted measure for paragraph (ParagraphMeasure) or list-item (ListMeasure) fragments. */
+ measure?: ParagraphMeasure | ListMeasure;
};
/** Resolved paragraph content for non-table paragraph/list-item fragments. */
@@ -174,6 +235,14 @@ export type ResolvedTableItem = {
blockId: string;
/** Index within page.fragments โ bridge to legacy rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
+ /** Whether this table fragment continues from a previous page. */
+ continuesFromPrev?: boolean;
+ /** Whether this table fragment continues on the next page. */
+ continuesOnNext?: boolean;
/** Pre-extracted TableBlock (replaces blockLookup.get()). */
block: TableBlock;
/** Pre-extracted TableMeasure (replaces blockLookup.get()). */
@@ -182,6 +251,10 @@ export type ResolvedTableItem = {
cellSpacingPx: number;
/** Pre-computed effective column widths: fragment.columnWidths ?? measure.columnWidths. */
effectiveColumnWidths: number[];
+ /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */
+ sdtContainerKey?: string | null;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
};
/**
@@ -210,8 +283,18 @@ export type ResolvedImageItem = {
blockId: string;
/** Index within page.fragments โ bridge to legacy rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
/** Pre-extracted ImageBlock (replaces blockLookup.get()). */
block: ImageBlock;
+ /** Image metadata for interactive resizing (original dimensions, aspect ratio). */
+ metadata?: ImageFragmentMetadata;
+ /** Pre-computed SDT container key for boundary grouping (typically null for images). */
+ sdtContainerKey?: string | null;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
};
/**
@@ -240,8 +323,16 @@ export type ResolvedDrawingItem = {
blockId: string;
/** Index within page.fragments โ bridge to legacy rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
/** Pre-extracted DrawingBlock (replaces blockLookup.get()). */
block: DrawingBlock;
+ /** Pre-computed SDT container key for boundary grouping (typically null for drawings). */
+ sdtContainerKey?: string | null;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
};
/** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */
@@ -259,6 +350,22 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item;
}
+/** A resolved header/footer page โ mirrors HeaderFooterPage but with resolved items. */
+export type ResolvedHeaderFooterPage = {
+ number: number;
+ numberText?: string;
+ items: ResolvedPaintItem[];
+};
+
+/** A resolved header/footer layout โ mirrors HeaderFooterLayout but with resolved pages. */
+export type ResolvedHeaderFooterLayout = {
+ height: number;
+ minY?: number;
+ maxY?: number;
+ renderHeight?: number;
+ pages: ResolvedHeaderFooterPage[];
+};
+
/** Resolved list marker rendering data with pre-computed positioning. */
export type ResolvedListMarkerItem = {
/** Marker text content (e.g., "1.", "a)", bullet). */
diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
index 79a4423c87..95cfe45a5a 100644
--- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
+++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
@@ -25,6 +25,10 @@ import { remeasureParagraph } from './remeasure';
import { computeDirtyRegions } from './diff';
import { MeasureCache } from './cache';
import { layoutHeaderFooterWithCache, HeaderFooterLayoutCache, type HeaderFooterBatch } from './layoutHeaderFooter';
+import {
+ buildSectionAwareHeaderFooterLayoutKey,
+ buildSectionAwareHeaderFooterMeasurementGroups,
+} from './sectionAwareHeaderFooter';
import { FeatureFlags } from './featureFlags';
import { PageTokenLogger, HeaderFooterCacheLogger, globalMetrics } from './instrumentation';
import { HeaderFooterCacheState, invalidateHeaderFooterCache } from './cacheInvalidation';
@@ -886,10 +890,83 @@ export async function incrementalLayout(
* Values are the actual content heights in pixels.
*/
let headerContentHeightsByRId: Map | undefined;
+ let headerContentHeightsBySectionRef: Map | undefined;
// Check if we have headers via either headerBlocks (by variant) or headerBlocksByRId (by relationship ID)
const hasHeaderBlocks = headerFooter?.headerBlocks && Object.keys(headerFooter.headerBlocks).length > 0;
const hasHeaderBlocksByRId = headerFooter?.headerBlocksByRId && headerFooter.headerBlocksByRId.size > 0;
+ const sectionMetadata = options.sectionMetadata ?? [];
+
+ const measureHeightsByReference = async (
+ kind: 'header' | 'footer',
+ blocksByRId: Map | undefined,
+ constraints: HeaderFooterConstraints,
+ measureFn: HeaderFooterMeasureFn,
+ ): Promise<{
+ heightsByRId?: Map;
+ heightsBySectionRef?: Map;
+ }> => {
+ if (!blocksByRId || blocksByRId.size === 0) {
+ return {};
+ }
+
+ const heightsByRId = new Map();
+ const heightsBySectionRef = new Map();
+ const sectionAwareGroups = buildSectionAwareHeaderFooterMeasurementGroups(
+ kind,
+ blocksByRId,
+ sectionMetadata,
+ constraints,
+ );
+
+ if (sectionAwareGroups.length > 0) {
+ for (const group of sectionAwareGroups) {
+ const blocks = blocksByRId.get(group.rId);
+ if (!blocks || blocks.length === 0) continue;
+
+ const measureConstraints = {
+ maxWidth: group.sectionConstraints.width,
+ maxHeight: group.sectionConstraints.height,
+ };
+ const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
+ const layout = layoutHeaderFooter(blocks, measures, group.sectionConstraints, kind);
+ if (!(layout.height > 0)) continue;
+
+ const nextHeight = Math.max(0, layout.height);
+ const currentHeight = heightsByRId.get(group.rId) ?? 0;
+ if (nextHeight > currentHeight) {
+ heightsByRId.set(group.rId, nextHeight);
+ }
+
+ for (const sectionIndex of group.sectionIndices) {
+ heightsBySectionRef.set(buildSectionAwareHeaderFooterLayoutKey(group.rId, sectionIndex), nextHeight);
+ }
+ }
+
+ return {
+ heightsByRId: heightsByRId.size > 0 ? heightsByRId : undefined,
+ heightsBySectionRef: heightsBySectionRef.size > 0 ? heightsBySectionRef : undefined,
+ };
+ }
+
+ for (const [rId, blocks] of blocksByRId) {
+ if (!blocks || blocks.length === 0) continue;
+
+ const measureConstraints = {
+ maxWidth: constraints.width,
+ maxHeight: constraints.height,
+ };
+ const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
+ const layout = layoutHeaderFooter(blocks, measures, constraints, kind);
+ if (layout.height > 0) {
+ heightsByRId.set(rId, layout.height);
+ }
+ }
+
+ return {
+ heightsByRId: heightsByRId.size > 0 ? heightsByRId : undefined,
+ };
+ };
if (headerFooter?.constraints && (hasHeaderBlocks || hasHeaderBlocksByRId)) {
const hfPreStart = performance.now();
@@ -953,22 +1030,14 @@ export async function incrementalLayout(
// Also extract heights from headerBlocksByRId (for multi-section documents)
// Store each rId's height separately for per-page margin calculation
if (hasHeaderBlocksByRId && headerFooter.headerBlocksByRId) {
- headerContentHeightsByRId = new Map();
- for (const [rId, blocks] of headerFooter.headerBlocksByRId) {
- if (!blocks || blocks.length === 0) continue;
- // Measure blocks to get height
- const measureConstraints = {
- maxWidth: headerFooter.constraints.width,
- maxHeight: headerFooter.constraints.height,
- };
- const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
- // Layout to get actual height โ pass full constraints for page-relative normalization
- const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'header');
- if (layout.height > 0) {
- // Store height by rId for per-page margin calculation
- headerContentHeightsByRId.set(rId, layout.height);
- }
- }
+ const measuredHeights = await measureHeightsByReference(
+ 'header',
+ headerFooter.headerBlocksByRId,
+ headerFooter.constraints,
+ measureFn,
+ );
+ headerContentHeightsByRId = measuredHeights.heightsByRId;
+ headerContentHeightsBySectionRef = measuredHeights.heightsBySectionRef;
}
const hfPreEnd = performance.now();
@@ -993,6 +1062,7 @@ export async function incrementalLayout(
* Values are the actual content heights in pixels.
*/
let footerContentHeightsByRId: Map | undefined;
+ let footerContentHeightsBySectionRef: Map | undefined;
// Check if we have footers via either footerBlocks (by variant) or footerBlocksByRId (by relationship ID)
const hasFooterBlocks = headerFooter?.footerBlocks && Object.keys(headerFooter.footerBlocks).length > 0;
@@ -1064,22 +1134,14 @@ export async function incrementalLayout(
// Also extract heights from footerBlocksByRId (for multi-section documents)
// Store each rId's height separately for per-page margin calculation
if (hasFooterBlocksByRId && headerFooter.footerBlocksByRId) {
- footerContentHeightsByRId = new Map();
- for (const [rId, blocks] of headerFooter.footerBlocksByRId) {
- if (!blocks || blocks.length === 0) continue;
- // Measure blocks to get height
- const measureConstraints = {
- maxWidth: headerFooter.constraints.width,
- maxHeight: headerFooter.constraints.height,
- };
- const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
- // Layout to get actual height โ pass full constraints for page-relative normalization
- const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'footer');
- if (layout.height > 0) {
- // Store height by rId for per-page margin calculation
- footerContentHeightsByRId.set(rId, layout.height);
- }
- }
+ const measuredHeights = await measureHeightsByReference(
+ 'footer',
+ headerFooter.footerBlocksByRId,
+ headerFooter.constraints,
+ measureFn,
+ );
+ footerContentHeightsByRId = measuredHeights.heightsByRId;
+ footerContentHeightsBySectionRef = measuredHeights.heightsBySectionRef;
}
} catch (error) {
console.error('[Layout] Footer pre-layout failed:', error);
@@ -1095,7 +1157,9 @@ export async function incrementalLayout(
...options,
headerContentHeights, // Pass header heights to prevent overlap (per-variant)
footerContentHeights, // Pass footer heights to prevent overlap (per-variant)
+ headerContentHeightsBySectionRef, // Pass header heights by rId+section for exact page-specific margin calculation
headerContentHeightsByRId, // Pass header heights by rId for per-page margin calculation
+ footerContentHeightsBySectionRef, // Pass footer heights by rId+section for exact page-specific margin calculation
footerContentHeightsByRId, // Pass footer heights by rId for per-page margin calculation
remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) =>
remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent),
@@ -1179,7 +1243,9 @@ export async function incrementalLayout(
...options,
headerContentHeights, // Pass header heights to prevent overlap (per-variant)
footerContentHeights, // Pass footer heights to prevent overlap (per-variant)
+ headerContentHeightsBySectionRef, // Pass header heights by rId+section for exact page-specific margin calculation
headerContentHeightsByRId, // Pass header heights by rId for per-page margin calculation
+ footerContentHeightsBySectionRef, // Pass footer heights by rId+section for exact page-specific margin calculation
footerContentHeightsByRId, // Pass footer heights by rId for per-page margin calculation
remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) =>
remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent),
@@ -1771,6 +1837,10 @@ export async function incrementalLayout(
footnoteReservedByPageIndex,
headerContentHeights,
footerContentHeights,
+ headerContentHeightsBySectionRef,
+ headerContentHeightsByRId,
+ footerContentHeightsBySectionRef,
+ footerContentHeightsByRId,
remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) =>
remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent),
});
diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts
index 9eb9fa4018..8d199afbe5 100644
--- a/packages/layout-engine/layout-bridge/src/index.ts
+++ b/packages/layout-engine/layout-bridge/src/index.ts
@@ -56,6 +56,18 @@ export {
export type { HeaderFooterBatch, DigitBucket } from './layoutHeaderFooter';
export { findWordBoundaries, findParagraphBoundaries } from './text-boundaries';
export type { BoundaryRange } from './text-boundaries';
+export {
+ buildSectionAwareHeaderFooterLayoutKey,
+ buildSectionContentWidth,
+ buildEffectiveHeaderFooterRefsBySection,
+ collectReferencedHeaderFooterRIds,
+ buildSectionAwareHeaderFooterMeasurementGroups,
+} from './sectionAwareHeaderFooter';
+export type {
+ HeaderFooterSectionKind,
+ HeaderFooterRefs,
+ SectionAwareHeaderFooterMeasurementGroup,
+} from './sectionAwareHeaderFooter';
export { incrementalLayout, measureCache, normalizeMargin } from './incrementalLayout';
export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './incrementalLayout';
// Re-export computeDisplayPageNumber from layout-engine for section-aware page numbering
@@ -576,6 +588,8 @@ export function selectionToRects(
// (accounts for gaps in PM positions between runs)
const charOffsetFrom = pmPosToCharOffset(block, line, sliceFrom);
const charOffsetTo = pmPosToCharOffset(block, line, sliceTo);
+ const visualCharOffsetFrom = pmPosToVisualCharOffset(block, line, sliceFrom);
+ const visualCharOffsetTo = pmPosToVisualCharOffset(block, line, sliceTo);
// Detect list items by checking for marker presence
const markerWidth = fragment.markerWidth ?? measure.marker?.markerWidth ?? 0;
const isListItemFlag = isListItem(markerWidth, block);
@@ -589,7 +603,7 @@ export function selectionToRects(
const startX = mapPmToX(
block,
line,
- charOffsetFrom,
+ visualCharOffsetFrom,
fragment.width,
alignmentOverride,
isFirstLine,
@@ -598,7 +612,7 @@ export function selectionToRects(
const endX = mapPmToX(
block,
line,
- charOffsetTo,
+ visualCharOffsetTo,
fragment.width,
alignmentOverride,
isFirstLine,
@@ -676,6 +690,8 @@ export function selectionToRects(
sliceTo,
charOffsetFrom,
charOffsetTo,
+ visualCharOffsetFrom,
+ visualCharOffsetTo,
startX,
endX,
rect: { x: rectX, y: rectY, width: rectWidth, height: line.lineHeight },
@@ -686,8 +702,15 @@ export function selectionToRects(
Math.max(charOffsetFrom, charOffsetTo),
),
indent: (block.attrs as { indent?: unknown } | undefined)?.indent,
+ alignment: (block.attrs as { alignment?: unknown } | undefined)?.alignment,
marker: measure.marker,
+ markerWidth,
+ isListItemFlag,
+ alignmentOverride,
lineSegments: line.segments,
+ lineSpaceCount: (line as { spaceCount?: unknown }).spaceCount,
+ lineNaturalWidth: (line as { naturalWidth?: unknown }).naturalWidth,
+ lineMaxWidth: (line as { maxWidth?: unknown }).maxWidth,
});
}
});
@@ -903,13 +926,15 @@ export function selectionToRects(
const charOffsetFrom = pmPosToCharOffset(info.block, line, sliceFrom);
const charOffsetTo = pmPosToCharOffset(info.block, line, sliceTo);
+ const visualCharOffsetFrom = pmPosToVisualCharOffset(info.block, line, sliceFrom);
+ const visualCharOffsetTo = pmPosToVisualCharOffset(info.block, line, sliceTo);
const availableWidth = Math.max(1, cellMeasure.width - padding.left - padding.right);
const isFirstLine = index === 0;
const cellMarkerTextWidth = info.measure?.marker?.markerTextWidth ?? undefined;
const startX = mapPmToX(
info.block,
line,
- charOffsetFrom,
+ visualCharOffsetFrom,
availableWidth,
alignmentOverride,
isFirstLine,
@@ -918,7 +943,7 @@ export function selectionToRects(
const endX = mapPmToX(
info.block,
line,
- charOffsetTo,
+ visualCharOffsetTo,
availableWidth,
alignmentOverride,
isFirstLine,
@@ -1325,6 +1350,83 @@ export function pmPosToCharOffset(block: FlowBlock, line: Line, pmPos: number):
return charOffset;
}
+/**
+ * Convert a ProseMirror position to a rendered character offset within a line.
+ *
+ * Unlike {@link pmPosToCharOffset}, this helper includes visual-only text runs
+ * that do not carry PM positions. That matters for selection highlighting when
+ * a line starts with rendered chrome such as a synthetic footnote number:
+ * the marker consumes horizontal space in the painter, but it is not part of
+ * the editable PM story. Using a PM-only offset would place the highlight too
+ * far left by the marker's width.
+ *
+ * The returned offset is intended for visual X mapping, not for slicing PM text.
+ */
+export function pmPosToVisualCharOffset(block: FlowBlock, line: Line, pmPos: number): number {
+ if (block.kind !== 'paragraph') return 0;
+
+ let visualOffset = 0;
+
+ for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) {
+ const run = block.runs[runIndex];
+ if (!run) continue;
+
+ const text =
+ 'src' in run ||
+ run.kind === 'lineBreak' ||
+ run.kind === 'break' ||
+ run.kind === 'fieldAnnotation' ||
+ run.kind === 'math'
+ ? ''
+ : (run.text ?? '');
+ const runTextLength = text.length;
+ if (runTextLength === 0) {
+ continue;
+ }
+
+ const isFirstRun = runIndex === line.fromRun;
+ const isLastRun = runIndex === line.toRun;
+ const lineStartChar = isFirstRun ? line.fromChar : 0;
+ const lineEndChar = isLastRun ? line.toChar : runTextLength;
+ const runSliceCharCount = lineEndChar - lineStartChar;
+ if (runSliceCharCount <= 0) {
+ continue;
+ }
+
+ const runPmStart = run.pmStart ?? null;
+ const runPmEnd = run.pmEnd ?? (runPmStart != null ? runPmStart + runTextLength : null);
+
+ if (runPmStart == null || runPmEnd == null) {
+ visualOffset += runSliceCharCount;
+ continue;
+ }
+
+ const runPmRange = runPmEnd - runPmStart;
+ const runSlicePmStart = runPmStart + (lineStartChar / runTextLength) * runPmRange;
+ const runSlicePmEnd = runPmStart + (lineEndChar / runTextLength) * runPmRange;
+
+ if (pmPos >= runSlicePmStart && pmPos <= runSlicePmEnd) {
+ const runSlicePmRange = runSlicePmEnd - runSlicePmStart;
+ if (runSlicePmRange <= 0) {
+ return visualOffset;
+ }
+
+ const pmOffsetInSlice = pmPos - runSlicePmStart;
+ const visualOffsetInSlice = Math.round((pmOffsetInSlice / runSlicePmRange) * runSliceCharCount);
+ return visualOffset + Math.min(visualOffsetInSlice, runSliceCharCount);
+ }
+
+ if (pmPos > runSlicePmEnd) {
+ visualOffset += runSliceCharCount;
+ continue;
+ }
+
+ return visualOffset;
+ }
+
+ return visualOffset;
+}
+
// determineColumn, findLineIndexAtY are now in position-hit.ts and re-exported above.
const lineHeightBeforeIndex = (measure: Measure, absoluteLineIndex: number): number => {
diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
index de6310ada4..60afb042ed 100644
--- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
+++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
@@ -322,6 +322,8 @@ export async function layoutHeaderFooterWithCache(
pages: pages.map((p) => ({
number: p.number,
fragments: p.fragments,
+ blocks: p.blocks,
+ measures: p.measures,
})),
};
diff --git a/packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts
new file mode 100644
index 0000000000..121171e712
--- /dev/null
+++ b/packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts
@@ -0,0 +1,231 @@
+import type { FlowBlock, SectionMetadata, SectionRefType } from '@superdoc/contracts';
+import { OOXML_PCT_DIVISOR } from '@superdoc/contracts';
+import type { HeaderFooterConstraints } from '@superdoc/layout-engine';
+
+export type HeaderFooterSectionKind = 'header' | 'footer';
+export type HeaderFooterRefs = Partial>;
+
+export type SectionAwareHeaderFooterMeasurementGroup = {
+ rId: string;
+ sectionIndices: Set;
+ sectionConstraints: HeaderFooterConstraints;
+ effectiveWidth: number;
+};
+
+type TableWidthSpec = {
+ type: 'pct' | 'grid' | 'px';
+ value: number;
+};
+
+const HEADER_FOOTER_VARIANTS: SectionRefType[] = ['default', 'first', 'even', 'odd'];
+
+export function buildSectionAwareHeaderFooterLayoutKey(rId: string, sectionIndex: number): string {
+ return `${rId}::s${sectionIndex}`;
+}
+
+export function buildSectionContentWidth(section: SectionMetadata, fallback: HeaderFooterConstraints): number {
+ const pageWidth = section.pageSize?.w ?? fallback.pageWidth ?? 0;
+ const marginLeft = section.margins?.left ?? fallback.margins?.left ?? 0;
+ const marginRight = section.margins?.right ?? fallback.margins?.right ?? 0;
+
+ return pageWidth - marginLeft - marginRight;
+}
+
+export function buildEffectiveHeaderFooterRefsBySection(
+ sectionMetadata: SectionMetadata[],
+ kind: HeaderFooterSectionKind,
+): Map {
+ const effectiveRefsBySection = new Map();
+ let inheritedRefs: HeaderFooterRefs = {};
+
+ for (const section of sectionMetadata) {
+ const explicitRefs = kind === 'header' ? section.headerRefs : section.footerRefs;
+ const effectiveRefs: HeaderFooterRefs = { ...inheritedRefs };
+
+ for (const variant of HEADER_FOOTER_VARIANTS) {
+ const refId = explicitRefs?.[variant];
+ if (refId) {
+ effectiveRefs[variant] = refId;
+ }
+ }
+
+ if (Object.keys(effectiveRefs).length > 0) {
+ effectiveRefsBySection.set(section.sectionIndex, effectiveRefs);
+ }
+
+ inheritedRefs = effectiveRefs;
+ }
+
+ return effectiveRefsBySection;
+}
+
+export function collectReferencedHeaderFooterRIds(effectiveRefsBySection: Map): Set {
+ const referencedRIds = new Set();
+
+ for (const refs of effectiveRefsBySection.values()) {
+ for (const variant of HEADER_FOOTER_VARIANTS) {
+ const refId = refs[variant];
+ if (refId) {
+ referencedRIds.add(refId);
+ }
+ }
+ }
+
+ return referencedRIds;
+}
+
+function buildConstraintsForSection(
+ section: SectionMetadata,
+ fallback: HeaderFooterConstraints,
+ minWidth?: number,
+): HeaderFooterConstraints {
+ const pageWidth = section.pageSize?.w ?? fallback.pageWidth ?? 0;
+ const pageHeight = section.pageSize?.h ?? fallback.pageHeight;
+ const marginLeft = section.margins?.left ?? fallback.margins?.left ?? 0;
+ const marginRight = section.margins?.right ?? fallback.margins?.right ?? 0;
+ const marginTop = section.margins?.top ?? fallback.margins?.top;
+ const marginBottom = section.margins?.bottom ?? fallback.margins?.bottom;
+ const headerMargin = section.margins?.header ?? fallback.margins?.header;
+ const footerMargin = section.margins?.footer ?? fallback.margins?.footer;
+ const contentWidth = pageWidth - marginLeft - marginRight;
+ const maxWidth = pageWidth - marginLeft;
+ const effectiveWidth = minWidth ? Math.min(Math.max(contentWidth, minWidth), maxWidth) : contentWidth;
+ const sectionMarginTop = marginTop ?? 0;
+ const sectionMarginBottom = marginBottom ?? 0;
+ const sectionHeight =
+ pageHeight != null ? Math.max(1, pageHeight - sectionMarginTop - sectionMarginBottom) : fallback.height;
+
+ return {
+ width: effectiveWidth,
+ height: sectionHeight,
+ pageWidth,
+ pageHeight,
+ margins: {
+ left: marginLeft,
+ right: marginRight,
+ top: marginTop,
+ bottom: marginBottom,
+ header: headerMargin,
+ footer: footerMargin,
+ },
+ overflowBaseHeight: fallback.overflowBaseHeight,
+ };
+}
+
+function getTableWidthSpec(blocks: FlowBlock[]): TableWidthSpec | undefined {
+ let widestSpec: TableWidthSpec | undefined;
+ let maxResolvedWidth = 0;
+
+ for (const block of blocks) {
+ if (block.kind !== 'table') continue;
+
+ const tableWidth = (block as { attrs?: { tableWidth?: { width?: number; value?: number; type?: string } } }).attrs
+ ?.tableWidth;
+ const widthValue = tableWidth?.width ?? tableWidth?.value;
+
+ if (tableWidth?.type === 'pct' && typeof widthValue === 'number' && widthValue > 0) {
+ if (!widestSpec || widestSpec.type !== 'pct' || widthValue > widestSpec.value) {
+ widestSpec = { type: 'pct', value: widthValue };
+ maxResolvedWidth = Number.POSITIVE_INFINITY;
+ }
+ continue;
+ }
+
+ if ((tableWidth?.type === 'px' || tableWidth?.type === 'pixel') && typeof widthValue === 'number') {
+ if (widthValue > maxResolvedWidth) {
+ maxResolvedWidth = widthValue;
+ widestSpec = { type: 'px', value: widthValue };
+ }
+ continue;
+ }
+
+ if (block.columnWidths && block.columnWidths.length > 0) {
+ const gridWidth = block.columnWidths.reduce((sum, columnWidth) => sum + columnWidth, 0);
+ if (gridWidth > maxResolvedWidth) {
+ maxResolvedWidth = gridWidth;
+ widestSpec = { type: 'grid', value: gridWidth };
+ }
+ }
+ }
+
+ return widestSpec;
+}
+
+function resolveTableMinWidth(spec: TableWidthSpec | undefined, contentWidth: number): number {
+ if (!spec) return 0;
+ if (spec.type === 'pct') {
+ return contentWidth * (spec.value / OOXML_PCT_DIVISOR);
+ }
+
+ return spec.value;
+}
+
+export function buildSectionAwareHeaderFooterMeasurementGroups(
+ kind: HeaderFooterSectionKind,
+ blocksByRId: Map | undefined,
+ sectionMetadata: SectionMetadata[],
+ fallbackConstraints: HeaderFooterConstraints,
+): SectionAwareHeaderFooterMeasurementGroup[] {
+ if (!blocksByRId || sectionMetadata.length === 0) {
+ return [];
+ }
+
+ const effectiveRefsBySection = buildEffectiveHeaderFooterRefsBySection(sectionMetadata, kind);
+ const tableWidthSpecByRId = new Map();
+
+ for (const [rId, blocks] of blocksByRId) {
+ const tableWidthSpec = getTableWidthSpec(blocks);
+ if (tableWidthSpec) {
+ tableWidthSpecByRId.set(rId, tableWidthSpec);
+ }
+ }
+
+ const groups = new Map();
+
+ for (const section of sectionMetadata) {
+ const refs = effectiveRefsBySection.get(section.sectionIndex);
+ if (!refs) continue;
+
+ const uniqueRIds = new Set();
+ for (const variant of HEADER_FOOTER_VARIANTS) {
+ const refId = refs[variant];
+ if (refId) {
+ uniqueRIds.add(refId);
+ }
+ }
+
+ for (const rId of uniqueRIds) {
+ if (!blocksByRId.has(rId)) continue;
+
+ const contentWidth = buildSectionContentWidth(section, fallbackConstraints);
+ const tableWidthSpec = tableWidthSpecByRId.get(rId);
+ const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth);
+ const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined);
+ const effectiveWidth = sectionConstraints.width;
+ const groupKey = [
+ rId,
+ `w${effectiveWidth}`,
+ `ph${sectionConstraints.pageHeight ?? ''}`,
+ `mt${sectionConstraints.margins?.top ?? ''}`,
+ `mb${sectionConstraints.margins?.bottom ?? ''}`,
+ `mh${sectionConstraints.margins?.header ?? ''}`,
+ `mf${sectionConstraints.margins?.footer ?? ''}`,
+ ].join('::');
+
+ const existingGroup = groups.get(groupKey);
+ if (existingGroup) {
+ existingGroup.sectionIndices.add(section.sectionIndex);
+ continue;
+ }
+
+ groups.set(groupKey, {
+ rId,
+ sectionIndices: new Set([section.sectionIndex]),
+ sectionConstraints,
+ effectiveWidth,
+ });
+ }
+ }
+
+ return Array.from(groups.values());
+}
diff --git a/packages/layout-engine/layout-bridge/src/text-measurement.ts b/packages/layout-engine/layout-bridge/src/text-measurement.ts
index 9618b1cb38..c8a95d80ae 100644
--- a/packages/layout-engine/layout-bridge/src/text-measurement.ts
+++ b/packages/layout-engine/layout-bridge/src/text-measurement.ts
@@ -20,6 +20,21 @@ let measurementCtx: CanvasRenderingContext2D | null = null;
const TAB_CHAR_LENGTH = 1;
+const getRunCharacterLength = (run: Run | undefined): number => {
+ if (!run) return 0;
+ if (isTabRun(run)) return TAB_CHAR_LENGTH;
+ if (
+ 'src' in run ||
+ run.kind === 'lineBreak' ||
+ run.kind === 'break' ||
+ run.kind === 'fieldAnnotation' ||
+ run.kind === 'math'
+ ) {
+ return 0;
+ }
+ return run.text?.length ?? 0;
+};
+
/**
* Characters considered as spaces for justify alignment calculations.
* Only includes regular space (U+0020) and non-breaking space (U+00A0).
@@ -224,7 +239,8 @@ const getJustifyAdjustment = (
// This ensures measurement matches rendering even when callers don't pass these flags.
const lastRunIndex = block.runs.length - 1;
const lastRun = block.runs[lastRunIndex];
- const derivedIsLastLine = line.toRun >= lastRunIndex;
+ const lastRunLength = getRunCharacterLength(lastRun);
+ const derivedIsLastLine = line.toRun > lastRunIndex || (line.toRun === lastRunIndex && line.toChar >= lastRunLength);
const derivedEndsWithLineBreak = lastRun ? lastRun.kind === 'lineBreak' : false;
// Determine if justify should be applied using shared logic
const shouldJustify = shouldApplyJustify({
diff --git a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
index 04a8903946..1ed4aa8cff 100644
--- a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
+++ b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
@@ -81,6 +81,38 @@ describe('layoutHeaderFooterWithCache', () => {
expect(measureBlock).not.toHaveBeenCalled();
});
+ it('stores page-local block clones for tokenized header/footer pages', async () => {
+ const sections = {
+ default: [
+ {
+ kind: 'paragraph',
+ id: 'page-token-header',
+ runs: [
+ { text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
+ { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ } satisfies FlowBlock,
+ ],
+ };
+ const measureBlock = vi.fn(async () => makeMeasure(12));
+
+ const result = await layoutHeaderFooterWithCache(
+ sections,
+ { width: 300, height: 40 },
+ measureBlock,
+ undefined,
+ undefined,
+ (pageNumber) => ({ displayText: String(pageNumber), totalPages: 2 }),
+ 'header',
+ );
+
+ expect(result.default?.layout.pages).toHaveLength(2);
+ expect(result.default?.layout.pages[0].blocks?.[0].runs[1]?.text).toBe('1');
+ expect(result.default?.layout.pages[1].blocks?.[0].runs[1]?.text).toBe('2');
+ expect(result.default?.layout.pages[0].measures).toHaveLength(1);
+ expect(result.default?.layout.pages[1].measures).toHaveLength(1);
+ });
+
describe('integration test', () => {
it('full pipeline: PM JSON with page tokens โ FlowBlocks โ Measures โ Layout', async () => {
// 1. Create PM JSON with page number tokens (simulates header/footer from SuperConverter)
diff --git a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts
index 96bcf50c5b..cfce735914 100644
--- a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts
+++ b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts
@@ -74,6 +74,104 @@ describe('selectionToRects', () => {
expect(rects[0].x).toBeGreaterThan(tableLayout.pages[0].fragments[0].x);
});
+ it('accounts for visual-only prefix runs when mapping PM selections to X coordinates', () => {
+ const blockWithoutMarker: FlowBlock = {
+ kind: 'paragraph',
+ id: 'note-without-marker',
+ runs: [{ text: ' simple footnote', fontFamily: 'Arial', fontSize: 16, pmStart: 2, pmEnd: 18 }],
+ attrs: {},
+ };
+
+ const blockWithMarker: FlowBlock = {
+ kind: 'paragraph',
+ id: 'note-with-marker',
+ runs: [
+ { text: '1', fontFamily: 'Arial', fontSize: 10 },
+ { text: ' simple footnote', fontFamily: 'Arial', fontSize: 16, pmStart: 2, pmEnd: 18 },
+ ],
+ attrs: {},
+ };
+
+ const measureWithoutMarker: Measure = {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 16, width: 100, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ };
+
+ const measureWithMarker: Measure = {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 1, toChar: 16, width: 110, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ };
+
+ const layoutWithoutMarker: Layout = {
+ pageSize: { w: 300, h: 300 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'note-without-marker',
+ fromLine: 0,
+ toLine: 1,
+ x: 10,
+ y: 20,
+ width: 200,
+ pmStart: 2,
+ pmEnd: 18,
+ },
+ ],
+ },
+ ],
+ };
+
+ const layoutWithMarker: Layout = {
+ pageSize: { w: 300, h: 300 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'note-with-marker',
+ fromLine: 0,
+ toLine: 1,
+ x: 10,
+ y: 20,
+ width: 200,
+ pmStart: 2,
+ pmEnd: 18,
+ },
+ ],
+ },
+ ],
+ };
+
+ const selectionFrom = 3;
+ const selectionTo = 9;
+
+ const rectWithoutMarker = selectionToRects(
+ layoutWithoutMarker,
+ [blockWithoutMarker],
+ [measureWithoutMarker],
+ selectionFrom,
+ selectionTo,
+ )[0];
+ const rectWithMarker = selectionToRects(
+ layoutWithMarker,
+ [blockWithMarker],
+ [measureWithMarker],
+ selectionFrom,
+ selectionTo,
+ )[0];
+
+ expect(rectWithoutMarker).toBeTruthy();
+ expect(rectWithMarker).toBeTruthy();
+ expect(rectWithMarker.x).toBeGreaterThan(rectWithoutMarker.x);
+ expect(rectWithMarker.x - rectWithoutMarker.x).toBeGreaterThan(1);
+ });
+
describe('table cell spacing.before', () => {
it('includes effective spacing.before in rect Y when paragraph has spacing.before', () => {
const rects = selectionToRects(
diff --git a/packages/layout-engine/layout-bridge/test/text-measurement.test.ts b/packages/layout-engine/layout-bridge/test/text-measurement.test.ts
index 7124decaf9..f73f30fffd 100644
--- a/packages/layout-engine/layout-bridge/test/text-measurement.test.ts
+++ b/packages/layout-engine/layout-bridge/test/text-measurement.test.ts
@@ -571,6 +571,25 @@ describe('text measurement utility', () => {
expect(lastX).toBe(lastXNormal);
});
+ it('applies justify spacing to wrapped non-last lines within a single text run', () => {
+ const block = createBlock([{ text: 'A B C D E F', fontFamily: 'Arial', fontSize: 16 }]);
+ (block as any).attrs = { alignment: 'justify' };
+
+ const line = baseLine({
+ fromRun: 0,
+ toRun: 0,
+ fromChar: 0,
+ toChar: 9, // Wrapped line consumes only part of the single text run
+ width: 90,
+ maxWidth: 120,
+ });
+
+ const xWithNaturalWidth = measureCharacterX(block, line, 7, 90);
+ const xWithSlack = measureCharacterX(block, line, 7, 120);
+
+ expect(xWithSlack).toBeGreaterThan(xWithNaturalWidth);
+ });
+
it('skips justify spacing for manual tabs without explicit segments', () => {
const trailingText = 'Item body';
const tabWidth = 48;
diff --git a/packages/layout-engine/layout-engine/src/index.d.ts b/packages/layout-engine/layout-engine/src/index.d.ts
index 795acc4fd0..e57ff6cc02 100644
--- a/packages/layout-engine/layout-engine/src/index.d.ts
+++ b/packages/layout-engine/layout-engine/src/index.d.ts
@@ -48,6 +48,7 @@ export type HeaderFooterConstraints = {
* `left`/`right`: horizontal page-relative conversion.
* `top`/`bottom`: vertical margin-relative conversion and footer band origin.
* `header`: header distance from page top edge (header band origin).
+ * `footer`: footer distance from page bottom edge (footer band origin).
*/
margins?: {
left: number;
@@ -55,6 +56,7 @@ export type HeaderFooterConstraints = {
top?: number;
bottom?: number;
header?: number;
+ footer?: number;
};
/**
* Optional base height used to bound behindDoc overflow handling.
diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts
index 84ff86b583..4063d3abaf 100644
--- a/packages/layout-engine/layout-engine/src/index.test.ts
+++ b/packages/layout-engine/layout-engine/src/index.test.ts
@@ -5849,6 +5849,25 @@ describe('alternateHeaders (odd/even header differentiation)', () => {
expect(layout.pages[1].margins?.top).toBeCloseTo(90, 0);
});
+ it('prefers section-aware header heights over the plain rId fallback', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rIdSharedHeader' } }],
+ headerContentHeightsByRId: new Map([['rIdSharedHeader', 40]]),
+ headerContentHeightsBySectionRef: new Map([['rIdSharedHeader::s0', 100]]),
+ };
+
+ const layout = layoutDocument([tallBlock('p1')], [tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(1);
+
+ const pageOneFragment = layout.pages[0].fragments.find((fragment) => fragment.blockId === 'p1');
+ expect(pageOneFragment).toBeDefined();
+ expect(pageOneFragment!.y).toBeCloseTo(130, 0);
+ expect(layout.pages[0].margins?.top).toBeCloseTo(130, 0);
+ });
+
it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => {
// Most realistic mixed case. Section 1 has 3 pages (docPN 1-3). Section 2
// has titlePg=true and starts on docPN=4.
diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts
index 1b0574b964..77d582b811 100644
--- a/packages/layout-engine/layout-engine/src/index.ts
+++ b/packages/layout-engine/layout-engine/src/index.ts
@@ -153,6 +153,10 @@ function getMeasureHeight(block: FlowBlock, measure: Measure): number {
}
}
+function buildSectionAwareReferenceKey(refId: string, sectionIndex: number): string {
+ return `${refId}::s${sectionIndex}`;
+}
+
// ConstraintBoundary and PageState now come from paginator
/**
@@ -503,6 +507,14 @@ export type LayoutOptions = {
* Values are the actual content heights in pixels.
*/
headerContentHeightsByRId?: Map;
+ /**
+ * Actual measured header content heights per section-specific reference.
+ *
+ * Keys combine the relationship ID and section index using the form
+ * `${rId}::s${sectionIndex}` so the reserve path can distinguish documents
+ * that reuse the same header part across sections with different geometry.
+ */
+ headerContentHeightsBySectionRef?: Map;
/**
* Actual measured footer content heights per relationship ID.
* Used for multi-section documents where each section may have unique
@@ -512,6 +524,14 @@ export type LayoutOptions = {
* Values are the actual content heights in pixels.
*/
footerContentHeightsByRId?: Map;
+ /**
+ * Actual measured footer content heights per section-specific reference.
+ *
+ * Keys combine the relationship ID and section index using the form
+ * `${rId}::s${sectionIndex}` so the reserve path can distinguish documents
+ * that reuse the same footer part across sections with different geometry.
+ */
+ footerContentHeightsBySectionRef?: Map;
/**
* Allow body layout to synthesize page 1 for anchored tables when a document has
* no anchor paragraphs and would otherwise render zero pages.
@@ -554,6 +574,7 @@ export type HeaderFooterConstraints = {
* `left`/`right`: horizontal page-relative conversion.
* `top`/`bottom`: vertical margin-relative conversion and footer band origin.
* `header`: header distance from page top edge (header band origin).
+ * `footer`: footer distance from page bottom edge (footer band origin).
*/
margins?: {
left: number;
@@ -561,6 +582,7 @@ export type HeaderFooterConstraints = {
top?: number;
bottom?: number;
header?: number;
+ footer?: number;
};
/**
* Optional base height used to bound behindDoc overflow handling.
@@ -675,7 +697,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
const headerContentHeights = options.headerContentHeights;
const footerContentHeights = options.footerContentHeights;
const headerContentHeightsByRId = options.headerContentHeightsByRId;
+ const headerContentHeightsBySectionRef = options.headerContentHeightsBySectionRef;
const footerContentHeightsByRId = options.footerContentHeightsByRId;
+ const footerContentHeightsBySectionRef = options.footerContentHeightsBySectionRef;
/**
* Determines the header/footer variant type for a given page based on section settings.
@@ -716,12 +740,23 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
* @param headerRef - Optional relationship ID from section's headerRefs
* @returns The appropriate header content height, or 0 if not found
*/
- const getHeaderHeightForPage = (variantType: 'default' | 'first' | 'even' | 'odd', headerRef?: string): number => {
- // Priority 1: Check per-rId heights if we have a specific rId
+ const getHeaderHeightForPage = (
+ variantType: 'default' | 'first' | 'even' | 'odd',
+ headerRef?: string,
+ sectionIndex?: number,
+ ): number => {
+ // Priority 1: Check section-aware heights when the same part is reused across sections.
+ if (headerRef && sectionIndex != null) {
+ const sectionKey = buildSectionAwareReferenceKey(headerRef, sectionIndex);
+ if (headerContentHeightsBySectionRef?.has(sectionKey)) {
+ return validateContentHeight(headerContentHeightsBySectionRef.get(sectionKey));
+ }
+ }
+ // Priority 2: Check per-rId heights if we have a specific rId
if (headerRef && headerContentHeightsByRId?.has(headerRef)) {
return validateContentHeight(headerContentHeightsByRId.get(headerRef));
}
- // Priority 2: Fall back to per-variant heights
+ // Priority 3: Fall back to per-variant heights
if (headerContentHeights) {
return validateContentHeight(headerContentHeights[variantType]);
}
@@ -737,12 +772,23 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
* @param footerRef - Optional relationship ID from section's footerRefs
* @returns The appropriate footer content height, or 0 if not found
*/
- const getFooterHeightForPage = (variantType: 'default' | 'first' | 'even' | 'odd', footerRef?: string): number => {
- // Priority 1: Check per-rId heights if we have a specific rId
+ const getFooterHeightForPage = (
+ variantType: 'default' | 'first' | 'even' | 'odd',
+ footerRef?: string,
+ sectionIndex?: number,
+ ): number => {
+ // Priority 1: Check section-aware heights when the same part is reused across sections.
+ if (footerRef && sectionIndex != null) {
+ const sectionKey = buildSectionAwareReferenceKey(footerRef, sectionIndex);
+ if (footerContentHeightsBySectionRef?.has(sectionKey)) {
+ return validateContentHeight(footerContentHeightsBySectionRef.get(sectionKey));
+ }
+ }
+ // Priority 2: Check per-rId heights if we have a specific rId
if (footerRef && footerContentHeightsByRId?.has(footerRef)) {
return validateContentHeight(footerContentHeightsByRId.get(footerRef));
}
- // Priority 2: Fall back to per-variant heights
+ // Priority 3: Fall back to per-variant heights
if (footerContentHeights) {
return validateContentHeight(footerContentHeights[variantType]);
}
@@ -811,8 +857,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// Initial effective margins use default variant (will be adjusted per-page)
const headerDistance = margins.header ?? margins.top;
const footerDistance = margins.footer ?? margins.bottom;
- const defaultHeaderHeight = getHeaderHeightForPage('default', undefined);
- const defaultFooterHeight = getFooterHeightForPage('default', undefined);
+ const defaultHeaderHeight = getHeaderHeightForPage('default', undefined, 0);
+ const defaultFooterHeight = getFooterHeightForPage('default', undefined, 0);
const effectiveTopMargin = calculateEffectiveTopMargin(defaultHeaderHeight, headerDistance, margins.top);
const effectiveBottomMargin = calculateEffectiveBottomMargin(defaultFooterHeight, footerDistance, margins.bottom);
@@ -1365,10 +1411,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// Calculate the actual header/footer heights for this page's variant
// Use effectiveVariantType for header height lookup to match the fallback
- const headerHeight = getHeaderHeightForPage(effectiveVariantType, headerRef);
+ const headerHeight = getHeaderHeightForPage(effectiveVariantType, headerRef, activeSectionIndex);
const footerHeight = getFooterHeightForPage(
variantType !== 'default' && !activeSectionRefs?.footerRefs?.[variantType] ? 'default' : variantType,
footerRef,
+ activeSectionIndex,
);
// Adjust margins based on the actual header/footer for this page.
diff --git a/packages/layout-engine/layout-resolved/src/hashUtils.ts b/packages/layout-engine/layout-resolved/src/hashUtils.ts
new file mode 100644
index 0000000000..ff2b4c38ad
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/hashUtils.ts
@@ -0,0 +1,116 @@
+import type { BorderSpec, CellBorders, Run, TableBorders, TableBorderValue } from '@superdoc/contracts';
+
+/**
+ * Hash helpers for block version computation.
+ *
+ * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a circular
+ * dependency (painter-dom -> layout-resolved is not allowed). Keep the two
+ * copies in sync.
+ */
+
+// ---------------------------------------------------------------------------
+// Table/Cell border hashing
+// ---------------------------------------------------------------------------
+
+const isNoneBorder = (value: TableBorderValue): value is { none: true } => {
+ return typeof value === 'object' && value !== null && 'none' in value && (value as { none: true }).none === true;
+};
+
+const isBorderSpec = (value: unknown): value is BorderSpec => {
+ return typeof value === 'object' && value !== null && !('none' in value);
+};
+
+export const hashBorderSpec = (border: BorderSpec): string => {
+ const parts: string[] = [];
+ if (border.style !== undefined) parts.push(`s:${border.style}`);
+ if (border.width !== undefined) parts.push(`w:${border.width}`);
+ if (border.color !== undefined) parts.push(`c:${border.color}`);
+ if (border.space !== undefined) parts.push(`sp:${border.space}`);
+ return parts.join(',');
+};
+
+const hashTableBorderValue = (borderValue: TableBorderValue | undefined): string => {
+ if (borderValue === undefined) return '';
+ if (borderValue === null) return 'null';
+ if (isNoneBorder(borderValue)) return 'none';
+ if (isBorderSpec(borderValue)) {
+ return hashBorderSpec(borderValue);
+ }
+ return '';
+};
+
+export const hashTableBorders = (borders: TableBorders | undefined): string => {
+ if (!borders) return '';
+ const parts: string[] = [];
+ if (borders.top !== undefined) parts.push(`t:[${hashTableBorderValue(borders.top)}]`);
+ if (borders.right !== undefined) parts.push(`r:[${hashTableBorderValue(borders.right)}]`);
+ if (borders.bottom !== undefined) parts.push(`b:[${hashTableBorderValue(borders.bottom)}]`);
+ if (borders.left !== undefined) parts.push(`l:[${hashTableBorderValue(borders.left)}]`);
+ if (borders.insideH !== undefined) parts.push(`ih:[${hashTableBorderValue(borders.insideH)}]`);
+ if (borders.insideV !== undefined) parts.push(`iv:[${hashTableBorderValue(borders.insideV)}]`);
+ return parts.join(';');
+};
+
+export const hashCellBorders = (borders: CellBorders | undefined): string => {
+ if (!borders) return '';
+ const parts: string[] = [];
+ if (borders.top) parts.push(`t:[${hashBorderSpec(borders.top)}]`);
+ if (borders.right) parts.push(`r:[${hashBorderSpec(borders.right)}]`);
+ if (borders.bottom) parts.push(`b:[${hashBorderSpec(borders.bottom)}]`);
+ if (borders.left) parts.push(`l:[${hashBorderSpec(borders.left)}]`);
+ return parts.join(';');
+};
+
+// ---------------------------------------------------------------------------
+// Run property accessors
+// ---------------------------------------------------------------------------
+
+const hasStringProp = (run: Run, prop: string): run is Run & Record => {
+ return prop in run && typeof (run as Record)[prop] === 'string';
+};
+
+const hasNumberProp = (run: Run, prop: string): run is Run & Record => {
+ return prop in run && typeof (run as Record)[prop] === 'number';
+};
+
+const hasBooleanProp = (run: Run, prop: string): run is Run & Record => {
+ return prop in run && typeof (run as Record)[prop] === 'boolean';
+};
+
+export const getRunStringProp = (run: Run, prop: string): string => {
+ if (hasStringProp(run, prop)) {
+ return run[prop];
+ }
+ return '';
+};
+
+export const getRunNumberProp = (run: Run, prop: string): number => {
+ if (hasNumberProp(run, prop)) {
+ return run[prop];
+ }
+ return 0;
+};
+
+export const getRunBooleanProp = (run: Run, prop: string): boolean => {
+ if (hasBooleanProp(run, prop)) {
+ return run[prop];
+ }
+ return false;
+};
+
+export const getRunUnderlineStyle = (run: Run): string => {
+ if ('underline' in run && typeof run.underline === 'boolean') {
+ return run.underline ? 'single' : '';
+ }
+ if ('underline' in run && run.underline && typeof run.underline === 'object') {
+ return (run.underline as { style?: string }).style ?? '';
+ }
+ return '';
+};
+
+export const getRunUnderlineColor = (run: Run): string => {
+ if ('underline' in run && run.underline && typeof run.underline === 'object') {
+ return (run.underline as { color?: string }).color ?? '';
+ }
+ return '';
+};
diff --git a/packages/layout-engine/layout-resolved/src/index.ts b/packages/layout-engine/layout-resolved/src/index.ts
index af3f0a23c7..c504917f6f 100644
--- a/packages/layout-engine/layout-resolved/src/index.ts
+++ b/packages/layout-engine/layout-resolved/src/index.ts
@@ -1,2 +1,3 @@
export { resolveLayout } from './resolveLayout.js';
export type { ResolveLayoutInput } from './resolveLayout.js';
+export { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
diff --git a/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts
new file mode 100644
index 0000000000..b49022cc69
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts
@@ -0,0 +1,33 @@
+import type { ParagraphBorder, ParagraphBorders } from '@superdoc/contracts';
+
+/**
+ * Hashes a single paragraph border for equality comparison.
+ *
+ * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a
+ * circular dependency (painter-dom โ layout-resolved is not allowed).
+ * Keep the two copies in sync.
+ */
+const hashParagraphBorder = (border: ParagraphBorder): string => {
+ const parts: string[] = [];
+ if (border.style !== undefined) parts.push(`s:${border.style}`);
+ if (border.width !== undefined) parts.push(`w:${border.width}`);
+ if (border.color !== undefined) parts.push(`c:${border.color}`);
+ if (border.space !== undefined) parts.push(`sp:${border.space}`);
+ return parts.join(',');
+};
+
+/**
+ * Hashes a full paragraph borders object for grouping comparison.
+ *
+ * Two paragraph fragments with the same hash belong to the same border group
+ * per ECMA-376 ยง17.3.1.24.
+ */
+export const hashParagraphBorders = (borders: ParagraphBorders): string => {
+ const parts: string[] = [];
+ if (borders.top) parts.push(`t:[${hashParagraphBorder(borders.top)}]`);
+ if (borders.right) parts.push(`r:[${hashParagraphBorder(borders.right)}]`);
+ if (borders.bottom) parts.push(`b:[${hashParagraphBorder(borders.bottom)}]`);
+ if (borders.left) parts.push(`l:[${hashParagraphBorder(borders.left)}]`);
+ if (borders.between) parts.push(`bw:[${hashParagraphBorder(borders.between)}]`);
+ return parts.join(';');
+};
diff --git a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts
index de0db6741d..9d3d39ff13 100644
--- a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts
@@ -17,7 +17,7 @@ export function resolveDrawingItem(
): ResolvedDrawingItem {
const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'drawing', 'drawing', 'drawing');
- return {
+ const item: ResolvedDrawingItem = {
kind: 'fragment',
fragmentKind: 'drawing',
id: resolveDrawingFragmentId(fragment),
@@ -31,4 +31,7 @@ export function resolveDrawingItem(
fragmentIndex,
block,
};
+ if (fragment.pmStart != null) item.pmStart = fragment.pmStart;
+ if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd;
+ return item;
}
diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts
new file mode 100644
index 0000000000..7862da9026
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts
@@ -0,0 +1,188 @@
+import { describe, it, expect } from 'vitest';
+import { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
+import type { FlowBlock, HeaderFooterLayout, Measure, ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts';
+
+describe('resolveHeaderFooterLayout', () => {
+ it('resolves a header/footer with one paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 10,
+ width: 468,
+ };
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
+ totalHeight: 18,
+ },
+ ];
+
+ const result = resolveHeaderFooterLayout(layout, blocks, measures);
+ expect(result.pages).toHaveLength(1);
+ const item = result.pages[0].items[0] as ResolvedFragmentItem;
+ expect(item.version).toBeDefined();
+ expect(item.block?.kind).toBe('paragraph');
+ expect(item.measure?.kind).toBe('paragraph');
+ });
+
+ it('preserves height, minY, maxY, renderHeight from input', () => {
+ const layout: HeaderFooterLayout = {
+ height: 100,
+ minY: 5,
+ maxY: 120,
+ renderHeight: 115,
+ pages: [],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ expect(result.height).toBe(100);
+ expect(result.minY).toBe(5);
+ expect(result.maxY).toBe(120);
+ expect(result.renderHeight).toBe(115);
+ });
+
+ it('preserves numberText on pages', () => {
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [
+ { number: 1, fragments: [], numberText: 'i' },
+ { number: 2, fragments: [], numberText: 'ii' },
+ ],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ expect(result.pages[0].numberText).toBe('i');
+ expect(result.pages[1].numberText).toBe('ii');
+ });
+
+ it('returns empty items array for empty fragments array', () => {
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [{ number: 1, fragments: [] }],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ expect(result.pages).toHaveLength(1);
+ expect(result.pages[0].items).toEqual([]);
+ });
+
+ it('leaves block/measure undefined when block entry is missing', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'missing-id',
+ fromLine: 0,
+ toLine: 1,
+ x: 0,
+ y: 0,
+ width: 100,
+ };
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ const item = result.pages[0].items[0] as ResolvedFragmentItem;
+ expect(item.block).toBeUndefined();
+ expect(item.measure).toBeUndefined();
+ });
+
+ it('resolves each page against its own cloned block data', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'page-token',
+ fromLine: 0,
+ toLine: 1,
+ x: 0,
+ y: 0,
+ width: 120,
+ };
+ const pageOneBlocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'page-token',
+ runs: [
+ { text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
+ { text: '1', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ },
+ ];
+ const pageTwoBlocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'page-token',
+ runs: [
+ { text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
+ { text: '2', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ },
+ ];
+ const makeMeasure = (text: string): Measure => ({
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 1, toChar: text.length, width: 120, ascent: 10, descent: 3, lineHeight: 18 },
+ ],
+ totalHeight: 18,
+ });
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [
+ { number: 1, fragments: [paraFragment], blocks: pageOneBlocks, measures: [makeMeasure('Page 1')] },
+ { number: 2, fragments: [paraFragment], blocks: pageTwoBlocks, measures: [makeMeasure('Page 2')] },
+ ],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, pageOneBlocks, [makeMeasure('Page 1')]);
+ const firstItem = result.pages[0].items[0] as ResolvedFragmentItem;
+ const secondItem = result.pages[1].items[0] as ResolvedFragmentItem;
+
+ expect(firstItem.block?.kind).toBe('paragraph');
+ expect(secondItem.block?.kind).toBe('paragraph');
+ expect(firstItem.block?.runs[1]?.text).toBe('1');
+ expect(secondItem.block?.runs[1]?.text).toBe('2');
+ expect(firstItem.version).not.toBe(secondItem.version);
+ });
+
+ it('uses document page indices for sparse header/footer pages', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 0,
+ y: 0,
+ width: 100,
+ };
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [
+ { number: 5, fragments: [paraFragment], numberText: '5' },
+ { number: 50, fragments: [paraFragment], numberText: '50' },
+ { number: 500, fragments: [paraFragment], numberText: '500' },
+ ],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
+ totalHeight: 18,
+ },
+ ];
+
+ const result = resolveHeaderFooterLayout(layout, blocks, measures);
+
+ expect((result.pages[0].items[0] as ResolvedFragmentItem).pageIndex).toBe(4);
+ expect((result.pages[1].items[0] as ResolvedFragmentItem).pageIndex).toBe(49);
+ expect((result.pages[2].items[0] as ResolvedFragmentItem).pageIndex).toBe(499);
+ });
+});
diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
new file mode 100644
index 0000000000..9988a337c3
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
@@ -0,0 +1,44 @@
+import type {
+ FlowBlock,
+ HeaderFooterLayout,
+ Measure,
+ ResolvedHeaderFooterLayout,
+ ResolvedHeaderFooterPage,
+} from '@superdoc/contracts';
+import { buildBlockMap, resolveFragmentItem } from './resolveLayout.js';
+
+/**
+ * Resolves a header/footer layout into a `ResolvedHeaderFooterLayout`.
+ *
+ * Standalone helper invoked per `HeaderFooterLayoutResult` from `incrementalLayout`.
+ * The caller stores results indexed by the same key (type or rId) as the originals;
+ * alignment between fragments and resolved items is guaranteed by construction.
+ */
+export function resolveHeaderFooterLayout(
+ layout: HeaderFooterLayout,
+ blocks: FlowBlock[],
+ measures: Measure[],
+): ResolvedHeaderFooterLayout {
+ const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page) => {
+ const pageBlocks = page.blocks ?? blocks;
+ const pageMeasures = page.measures ?? measures;
+ const blockMap = buildBlockMap(pageBlocks, pageMeasures);
+ const blockVersionCache = new Map();
+
+ return {
+ number: page.number,
+ numberText: page.numberText,
+ items: page.fragments.map((fragment, fragmentIndex) =>
+ resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache),
+ ),
+ };
+ });
+
+ return {
+ height: layout.height,
+ minY: layout.minY,
+ maxY: layout.maxY,
+ renderHeight: layout.renderHeight,
+ pages,
+ };
+}
diff --git a/packages/layout-engine/layout-resolved/src/resolveImage.ts b/packages/layout-engine/layout-resolved/src/resolveImage.ts
index d1747585f9..e09632c7aa 100644
--- a/packages/layout-engine/layout-resolved/src/resolveImage.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveImage.ts
@@ -17,7 +17,7 @@ export function resolveImageItem(
): ResolvedImageItem {
const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'image', 'image', 'image');
- return {
+ const item: ResolvedImageItem = {
kind: 'fragment',
fragmentKind: 'image',
id: resolveImageFragmentId(fragment),
@@ -31,4 +31,8 @@ export function resolveImageItem(
fragmentIndex,
block,
};
+ if (fragment.pmStart != null) item.pmStart = fragment.pmStart;
+ if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd;
+ if (fragment.metadata != null) item.metadata = fragment.metadata;
+ return item;
}
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
index a9df355da8..e6245f491a 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
@@ -90,6 +90,33 @@ describe('resolveLayout', () => {
expect(a).toEqual(b);
});
+ it('includes precomputed block versions for every supplied block', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 0, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'visible', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ { kind: 'paragraph', id: 'p2', runs: [{ text: 'lookup-only', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const measures: Measure[] = [
+ { kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any,
+ { kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+
+ expect(result.blockVersions).toBeDefined();
+ expect(result.blockVersions).toHaveProperty('p1');
+ expect(result.blockVersions).toHaveProperty('p2');
+ expect(result.blockVersions?.p1).not.toBe(result.blockVersions?.p2);
+ });
+
it('defaults pageGap to 0 when layout.pageGap is undefined', () => {
const result = resolveLayout({ layout: baseLayout, flowMode: 'paginated', blocks: [], measures: [] });
expect(result.pageGap).toBe(0);
@@ -638,203 +665,722 @@ describe('resolveLayout', () => {
});
});
- describe('paragraph content resolution', () => {
- const makeLine = (
- overrides: Partial = {},
- ): import('@superdoc/contracts').Line => ({
- fromRun: 0,
- fromChar: 0,
- toRun: 0,
- toChar: 10,
- width: 400,
- ascent: 12,
- descent: 4,
- lineHeight: 20,
- ...overrides,
- });
-
- it('resolves plain paragraph with correct line count and indent', () => {
+ describe('paragraph/list-item block and measure lifting', () => {
+ it('lifts block and measure from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
- },
- ],
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] };
+ const paragraphMeasure: Measure = {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
};
- const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [{ kind: 'text', text: 'Hello world' }] }];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
- ];
- const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const item = result.pages[0].items[0] as any;
- expect(item.content).toBeDefined();
- expect(item.content.lines).toHaveLength(2);
- expect(item.content.lines[0].lineIndex).toBe(0);
- expect(item.content.lines[1].lineIndex).toBe(1);
- expect(item.content.marker).toBeUndefined();
- expect(item.content.dropCap).toBeUndefined();
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [paragraphBlock],
+ measures: [paragraphMeasure],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.block).toBe(paragraphBlock);
+ expect(item.measure).toBe(paragraphMeasure);
});
- it('resolves paragraph with left indent as paddingLeft', () => {
+ it('lifts block and measure from a list-item fragment', () => {
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 0,
+ toLine: 1,
+ x: 108,
+ y: 200,
+ width: 432,
+ markerWidth: 36,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
+ pages: [{ number: 1, fragments: [listItemFragment] }],
+ };
+ const listBlock: FlowBlock = {
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
{
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ id: 'item-a',
+ marker: { text: 'โข', style: {} },
+ paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] },
},
],
};
- const blocks: FlowBlock[] = [
- {
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello' }],
- attrs: { indent: { left: 36 } },
- },
- ];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine()],
- totalHeight: 20,
- },
- ];
-
- const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const line0 = (result.pages[0].items[0] as any).content.lines[0];
- expect(line0.paddingLeftPx).toBe(36);
- expect(line0.textIndentPx).toBe(0);
- });
-
- it('resolves paragraph with hanging indent', () => {
- const layout: Layout = {
- pageSize: { w: 612, h: 792 },
- pages: [
+ const listMeasure: Measure = {
+ kind: 'list',
+ items: [
{
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 },
+ ],
+ totalHeight: 24,
+ },
},
],
+ totalHeight: 24,
};
- const blocks: FlowBlock[] = [
- {
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test' }],
- attrs: { indent: { left: 0, hanging: 36 } },
- },
- ];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
- ];
- const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // First line: textIndent = -36 (firstLine(0) - hanging(36))
- expect(content.lines[0].textIndentPx).toBe(-36);
- // Body line: paddingLeft = hanging value
- expect(content.lines[1].paddingLeftPx).toBe(36);
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [listBlock],
+ measures: [listMeasure],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.block).toBe(listBlock);
+ expect(item.measure).toBe(listMeasure);
});
- it('resolves paragraph with firstLine indent', () => {
+ it('leaves block and measure undefined when the block entry is missing', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'missing',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
- },
- ],
+ pages: [{ number: 1, fragments: [paraFragment] }],
};
- const blocks: FlowBlock[] = [
- {
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test' }],
- attrs: { indent: { left: 36, firstLine: 72 } },
- },
- ];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
- ];
- const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // First line: paddingLeft = leftIndent, textIndent = firstLine
- expect(content.lines[0].paddingLeftPx).toBe(36);
- expect(content.lines[0].textIndentPx).toBe(72);
- // Body line: paddingLeft = leftIndent, no textIndent
- expect(content.lines[1].paddingLeftPx).toBe(36);
- expect(content.lines[1].textIndentPx).toBe(0);
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.block).toBeUndefined();
+ expect(item.measure).toBeUndefined();
});
- it('resolves suppressFirstLineIndent with zero firstLineOffset', () => {
+ it('does not set ResolvedFragmentItem.block on table fragments (they use ResolvedTableItem.block)', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 't1',
+ fromRow: 0,
+ toRow: 1,
+ x: 10,
+ y: 20,
+ width: 400,
+ height: 80,
+ columnWidths: [200, 200],
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
- },
- ],
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = {
+ kind: 'table' as const,
+ id: 't1',
+ rows: [],
+ columnWidths: [200, 200],
+ };
+ const tableMeasure = {
+ kind: 'table' as const,
+ columnWidths: [200, 200],
+ rows: [],
+ totalHeight: 80,
};
- const blocks: FlowBlock[] = [
- {
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello' }],
- attrs: { indent: { left: 36, firstLine: 72 }, suppressFirstLineIndent: true } as any,
- },
- ];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine()],
- totalHeight: 20,
- },
- ];
- const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const line0 = (result.pages[0].items[0] as any).content.lines[0];
- // suppressFirstLineIndent means firstLineOffset = 0, so textIndent = 0
- expect(line0.textIndentPx).toBe(0);
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ // Table items carry block/measure as ResolvedTableItem typed fields.
+ // They should NOT use the optional ResolvedFragmentItem.block path (no fall-through to the default branch).
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.fragmentKind).toBe('table');
+ expect(item.block).toBe(tableBlock);
+ expect(item.measure).toBe(tableMeasure);
});
-
- it('resolves last-line skip justify correctly', () => {
+ });
+ describe('fragment metadata lifting', () => {
+ it('lifts pmStart and pmEnd from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ pmStart: 5,
+ pmEnd: 42,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [
- {
- kind: 'para',
- blockId: 'p1',
- fromLine: 0,
- toLine: 2,
- x: 72,
- y: 100,
- width: 468,
- },
- ],
- },
- ],
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.pmStart).toBe(5);
+ expect(item.pmEnd).toBe(42);
+ });
+
+ it('omits pmStart and pmEnd when not present on paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.pmStart).toBeUndefined();
+ expect(item.pmEnd).toBeUndefined();
+ });
+
+ it('lifts continuesFromPrev and continuesOnNext from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 1,
+ toLine: 3,
+ x: 72,
+ y: 72,
+ width: 468,
+ continuesFromPrev: true,
+ continuesOnNext: true,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.continuesFromPrev).toBe(true);
+ expect(item.continuesOnNext).toBe(true);
+ });
+
+ it('omits continuesFromPrev and continuesOnNext when not set', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.continuesFromPrev).toBeUndefined();
+ expect(item.continuesOnNext).toBeUndefined();
+ });
+
+ it('lifts markerWidth from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ markerWidth: 36,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.markerWidth).toBe(36);
+ });
+
+ it('lifts continuesFromPrev, continuesOnNext, and markerWidth from a list-item fragment', () => {
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 1,
+ toLine: 2,
+ x: 108,
+ y: 200,
+ width: 432,
+ markerWidth: 36,
+ continuesFromPrev: true,
+ continuesOnNext: false,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [listItemFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
+ {
+ id: 'item-a',
+ marker: { text: 'โข', style: {} },
+ paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] },
+ },
+ ],
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'list',
+ items: [
+ {
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 },
+ { fromRun: 0, fromChar: 5, toRun: 0, toChar: 10, width: 180, ascent: 12, descent: 4, lineHeight: 20 },
+ ],
+ totalHeight: 40,
+ },
+ },
+ ],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.continuesFromPrev).toBe(true);
+ expect(item.continuesOnNext).toBe(false);
+ expect(item.markerWidth).toBe(36);
+ });
+
+ it('lifts pmStart, pmEnd, continuesFromPrev, and continuesOnNext from a table fragment', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 3,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 300,
+ pmStart: 10,
+ pmEnd: 200,
+ continuesFromPrev: true,
+ continuesOnNext: false,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] };
+ const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.pmStart).toBe(10);
+ expect(item.pmEnd).toBe(200);
+ expect(item.continuesFromPrev).toBe(true);
+ expect(item.continuesOnNext).toBe(false);
+ });
+
+ it('omits pmStart and pmEnd from table fragment when not set', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 30,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] };
+ const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.pmStart).toBeUndefined();
+ expect(item.pmEnd).toBeUndefined();
+ });
+
+ it('lifts pmStart, pmEnd, and metadata from an image fragment', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 250,
+ pmStart: 15,
+ pmEnd: 16,
+ metadata: {
+ originalWidth: 600,
+ originalHeight: 500,
+ maxWidth: 468,
+ maxHeight: 700,
+ aspectRatio: 1.2,
+ minWidth: 50,
+ minHeight: 42,
+ },
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment] }],
+ };
+ const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 };
+ const blocks: FlowBlock[] = [imageBlock];
+ const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem;
+ expect(item.pmStart).toBe(15);
+ expect(item.pmEnd).toBe(16);
+ expect(item.metadata).toEqual({
+ originalWidth: 600,
+ originalHeight: 500,
+ maxWidth: 468,
+ maxHeight: 700,
+ aspectRatio: 1.2,
+ minWidth: 50,
+ minHeight: 42,
+ });
+ });
+
+ it('omits metadata from image fragment when not set', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 250,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment] }],
+ };
+ const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 };
+ const blocks: FlowBlock[] = [imageBlock];
+ const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem;
+ expect(item.metadata).toBeUndefined();
+ });
+
+ it('lifts pmStart and pmEnd from a drawing fragment', () => {
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ blockId: 'dr1',
+ x: 50,
+ y: 60,
+ width: 200,
+ height: 150,
+ isAnchored: true,
+ zIndex: 3,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ pmStart: 30,
+ pmEnd: 31,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [drawingFragment] }],
+ };
+ const drawingBlock = {
+ kind: 'drawing' as const,
+ id: 'dr1',
+ drawingKind: 'vectorShape' as const,
+ geometry: { width: 200, height: 150 },
+ };
+ const blocks: FlowBlock[] = [drawingBlock as any];
+ const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem;
+ expect(item.pmStart).toBe(30);
+ expect(item.pmEnd).toBe(31);
+ });
+
+ it('omits pmStart and pmEnd from drawing fragment when not set', () => {
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ blockId: 'dr1',
+ x: 50,
+ y: 60,
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [drawingFragment] }],
+ };
+ const drawingBlock = {
+ kind: 'drawing' as const,
+ id: 'dr1',
+ drawingKind: 'vectorShape' as const,
+ geometry: { width: 200, height: 150 },
+ };
+ const blocks: FlowBlock[] = [drawingBlock as any];
+ const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem;
+ expect(item.pmStart).toBeUndefined();
+ expect(item.pmEnd).toBeUndefined();
+ });
+ });
+
+ describe('paragraph content resolution', () => {
+ const makeLine = (
+ overrides: Partial = {},
+ ): import('@superdoc/contracts').Line => ({
+ fromRun: 0,
+ fromChar: 0,
+ toRun: 0,
+ toChar: 10,
+ width: 400,
+ ascent: 12,
+ descent: 4,
+ lineHeight: 20,
+ ...overrides,
+ });
+
+ it('resolves plain paragraph with correct line count and indent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [{ kind: 'text', text: 'Hello world' }] }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.content).toBeDefined();
+ expect(item.content.lines).toHaveLength(2);
+ expect(item.content.lines[0].lineIndex).toBe(0);
+ expect(item.content.lines[1].lineIndex).toBe(1);
+ expect(item.content.marker).toBeUndefined();
+ expect(item.content.dropCap).toBeUndefined();
+ });
+
+ it('resolves paragraph with left indent as paddingLeft', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello' }],
+ attrs: { indent: { left: 36 } },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine()],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const line0 = (result.pages[0].items[0] as any).content.lines[0];
+ expect(line0.paddingLeftPx).toBe(36);
+ expect(line0.textIndentPx).toBe(0);
+ });
+
+ it('resolves paragraph with hanging indent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world test' }],
+ attrs: { indent: { left: 0, hanging: 36 } },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ // First line: textIndent = -36 (firstLine(0) - hanging(36))
+ expect(content.lines[0].textIndentPx).toBe(-36);
+ // Body line: paddingLeft = hanging value
+ expect(content.lines[1].paddingLeftPx).toBe(36);
+ });
+
+ it('resolves paragraph with firstLine indent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world test' }],
+ attrs: { indent: { left: 36, firstLine: 72 } },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ // First line: paddingLeft = leftIndent, textIndent = firstLine
+ expect(content.lines[0].paddingLeftPx).toBe(36);
+ expect(content.lines[0].textIndentPx).toBe(72);
+ // Body line: paddingLeft = leftIndent, no textIndent
+ expect(content.lines[1].paddingLeftPx).toBe(36);
+ expect(content.lines[1].textIndentPx).toBe(0);
+ });
+
+ it('resolves suppressFirstLineIndent with zero firstLineOffset', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello' }],
+ attrs: { indent: { left: 36, firstLine: 72 }, suppressFirstLineIndent: true } as any,
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine()],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const line0 = (result.pages[0].items[0] as any).content.lines[0];
+ // suppressFirstLineIndent means firstLineOffset = 0, so textIndent = 0
+ expect(line0.textIndentPx).toBe(0);
+ });
+
+ it('resolves last-line skip justify correctly', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 2,
+ x: 72,
+ y: 100,
+ width: 468,
+ },
+ ],
+ },
+ ],
};
const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [{ kind: 'text', text: 'Hello world test' }] }];
const measures: Measure[] = [
@@ -847,52 +1393,619 @@ describe('resolveLayout', () => {
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
const content = (result.pages[0].items[0] as any).content;
- expect(content.lines[0].skipJustify).toBe(false);
- expect(content.lines[1].skipJustify).toBe(true); // last line of last fragment
+ expect(content.lines[0].skipJustify).toBe(false);
+ expect(content.lines[1].skipJustify).toBe(true); // last line of last fragment
+ });
+
+ it('does not skip justify when continuesOnNext', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ continuesOnNext: true,
+ },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [{ kind: 'text', text: 'Hello' }] }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine()],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ expect(content.lines[0].skipJustify).toBe(false);
+ });
+
+ it('does not skip justify when paragraph ends with lineBreak', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello' }, { kind: 'lineBreak' }],
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine()],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ expect(content.lines[0].skipJustify).toBe(false);
+ expect(content.paragraphEndsWithLineBreak).toBe(true);
+ });
+
+ it('resolves drop cap on first fragment', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello' }],
+ attrs: {
+ dropCapDescriptor: {
+ mode: 'drop' as const,
+ lines: 3,
+ run: { text: 'H', fontFamily: 'Arial', fontSize: 72 },
+ },
+ },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine()],
+ totalHeight: 20,
+ dropCap: { width: 50, height: 60, lines: 3, mode: 'drop' as const },
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ expect(content.dropCap).toBeDefined();
+ expect(content.dropCap.text).toBe('H');
+ expect(content.dropCap.mode).toBe('drop');
+ expect(content.dropCap.fontFamily).toBe('Arial');
+ expect(content.dropCap.fontSize).toBe(72);
+ expect(content.dropCap.width).toBe(50);
+ expect(content.dropCap.height).toBe(60);
+ });
+
+ it('omits drop cap on continuation fragment', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 1,
+ toLine: 2,
+ x: 72,
+ y: 100,
+ width: 468,
+ continuesFromPrev: true,
+ },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world' }],
+ attrs: {
+ dropCapDescriptor: {
+ mode: 'drop' as const,
+ lines: 3,
+ run: { text: 'H', fontFamily: 'Arial', fontSize: 72 },
+ },
+ },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ dropCap: { width: 50, height: 60, lines: 3, mode: 'drop' as const },
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ expect(content.dropCap).toBeUndefined();
+ });
+
+ it('resolves list marker on first fragment', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ markerWidth: 36,
+ markerTextWidth: 10,
+ },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'List item' }],
+ attrs: {
+ indent: { left: 36, hanging: 36 },
+ wordLayout: {
+ marker: {
+ markerText: '1.',
+ justification: 'left',
+ suffix: 'tab',
+ run: { fontFamily: 'Arial', fontSize: 12 },
+ },
+ },
+ },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine()],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ expect(content.marker).toBeDefined();
+ expect(content.marker.text).toBe('1.');
+ expect(content.marker.justification).toBe('left');
+ expect(content.marker.suffix).toBe('tab');
+ expect(content.marker.run.fontFamily).toBe('Arial');
+ expect(content.marker.run.fontSize).toBe(12);
+ expect(content.lines[0].isListFirstLine).toBe(true);
+ });
+
+ it('omits marker on continuation fragment', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 1,
+ toLine: 2,
+ x: 72,
+ y: 100,
+ width: 468,
+ markerWidth: 36,
+ markerTextWidth: 10,
+ continuesFromPrev: true,
+ },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'List item continued' }],
+ attrs: {
+ indent: { left: 36, hanging: 36 },
+ wordLayout: {
+ marker: {
+ markerText: '1.',
+ justification: 'left',
+ suffix: 'tab',
+ run: { fontFamily: 'Arial', fontSize: 12 },
+ },
+ },
+ },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ expect(content.marker).toBeUndefined();
+ expect(content.lines[0].isListFirstLine).toBe(false);
+ });
+
+ it('does not resolve content for table fragments', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'table',
+ blockId: 't1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 200,
+ },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'table',
+ id: 't1',
+ rows: [],
+ } as any,
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'table',
+ rows: [],
+ totalHeight: 200,
+ } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.content).toBeUndefined();
+ });
+
+ it('resolves available width from fragment width minus positive indents', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello' }],
+ attrs: { indent: { left: 36, right: 36 } },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine()],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const line0 = (result.pages[0].items[0] as any).content.lines[0];
+ // availableWidth = fragment.width - max(0, left) - max(0, right) = 468 - 36 - 36 = 396
+ expect(line0.availableWidth).toBe(396);
+ });
+
+ it('increases availableWidth on first line when hanging indent produces negative textIndent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world test line' }],
+ attrs: { indent: { left: 160, hanging: 160 } },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ // First line: textIndent = -160 (firstLine(0) - hanging(160))
+ // availableWidth should account for the negative textIndent:
+ // base = 468 - max(0, 160) = 308, then adjusted by -(-160) = 308 + 160 = 468
+ expect(content.lines[0].textIndentPx).toBe(-160);
+ expect(content.lines[0].availableWidth).toBe(468);
+ // Body line: no textIndent adjustment, availableWidth stays at base
+ expect(content.lines[1].textIndentPx).toBe(0);
+ expect(content.lines[1].availableWidth).toBe(308);
+ });
+
+ it('decreases availableWidth on first line when firstLine indent produces positive textIndent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world test line' }],
+ attrs: { indent: { left: 36, firstLine: 72 } },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ // First line: textIndent = 72 (firstLine(72) - hanging(0))
+ // availableWidth should be reduced: base = 468 - 36 = 432, then 432 - 72 = 360
+ expect(content.lines[0].textIndentPx).toBe(72);
+ expect(content.lines[0].availableWidth).toBe(360);
+ // Body line: no textIndent, availableWidth = 468 - 36 = 432
+ expect(content.lines[1].textIndentPx).toBe(0);
+ expect(content.lines[1].availableWidth).toBe(432);
+ });
+
+ it('adjusts availableWidth for hanging indent even when line.maxWidth is set (Math.min clamps it)', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world test line' }],
+ attrs: { indent: { left: 160, hanging: 160 } },
+ },
+ ];
+ // The measurer sets maxWidth = contentWidth - firstLineOffset = (468-160) - (-160) = 468
+ // but Math.min(468, fallback=308) clamps it to 308. The textIndent adjustment restores it.
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine({ maxWidth: 468 }), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ // First line: min(468, 308) = 308, then adjusted by -(-160) = 468
+ expect(content.lines[0].textIndentPx).toBe(-160);
+ expect(content.lines[0].availableWidth).toBe(468);
+ });
+
+ it('does not adjust availableWidth for list paragraphs with hanging indent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 2,
+ x: 72,
+ y: 100,
+ width: 468,
+ markerWidth: 36,
+ markerTextWidth: 10,
+ },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'List item text here' }],
+ attrs: {
+ indent: { left: 36, hanging: 36 },
+ wordLayout: {
+ marker: {
+ markerText: '1.',
+ justification: 'left',
+ suffix: 'tab',
+ run: { fontFamily: 'Arial', fontSize: 12 },
+ },
+ },
+ },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ // List first line: textIndentPx should be 0 (list marker occupies the hanging region),
+ // so availableWidth should NOT be adjusted for hanging indent.
+ expect(content.lines[0].textIndentPx).toBe(0);
+ // base = 468 - max(0, 36) = 432 โ stays at 432, no hanging indent adjustment
+ expect(content.lines[0].availableWidth).toBe(432);
});
- it('does not skip justify when continuesOnNext', () => {
+ it('does not adjust availableWidth on continuation fragment even with hanging indent', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ {
+ number: 2,
fragments: [
{
kind: 'para',
blockId: 'p1',
- fromLine: 0,
- toLine: 1,
+ fromLine: 1,
+ toLine: 2,
x: 72,
- y: 100,
+ y: 72,
width: 468,
- continuesOnNext: true,
+ continuesFromPrev: true,
},
],
},
],
};
- const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [{ kind: 'text', text: 'Hello' }] }];
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world test line continued' }],
+ attrs: { indent: { left: 160, hanging: 160 } },
+ },
+ ];
const measures: Measure[] = [
{
kind: 'paragraph',
- lines: [makeLine()],
- totalHeight: 20,
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ // Page 1: first line of paragraph โ gets hanging indent adjustment
+ const page1Content = (result.pages[0].items[0] as any).content;
+ expect(page1Content.lines[0].textIndentPx).toBe(-160);
+ expect(page1Content.lines[0].availableWidth).toBe(468);
+
+ // Page 2: continuation fragment โ first line here is NOT the paragraph's first line,
+ // so textIndentPx should be 0 and availableWidth should NOT be adjusted.
+ const page2Content = (result.pages[1].items[0] as any).content;
+ expect(page2Content.lines[0].textIndentPx).toBe(0);
+ // base = 468 - max(0, 160) = 308 โ no adjustment
+ expect(page2Content.lines[0].availableWidth).toBe(308);
+ });
+
+ it('does not adjust availableWidth when suppressFirstLineIndent is true', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ kind: 'text', text: 'Hello world test line' }],
+ attrs: { indent: { left: 160, hanging: 160 }, suppressFirstLineIndent: true } as any,
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
},
];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
const content = (result.pages[0].items[0] as any).content;
- expect(content.lines[0].skipJustify).toBe(false);
+ // suppressFirstLineIndent zeroes the firstLineOffset, so textIndent = 0
+ // and availableWidth stays at base = 468 - max(0, 160) = 308
+ expect(content.lines[0].textIndentPx).toBe(0);
+ expect(content.lines[0].availableWidth).toBe(308);
});
- it('does not skip justify when paragraph ends with lineBreak', () => {
+ it('does not double-subtract positive firstLine indent when line.maxWidth already accounts for it', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
},
],
};
@@ -900,24 +2013,285 @@ describe('resolveLayout', () => {
{
kind: 'paragraph',
id: 'p1',
- runs: [{ kind: 'text', text: 'Hello' }, { kind: 'lineBreak' }],
+ runs: [{ kind: 'text', text: 'Hello world test line' }],
+ attrs: { indent: { left: 36, firstLine: 72 } },
},
];
+ // The measurer sets maxWidth = contentWidth - firstLineOffset = 432 - 72 = 360
const measures: Measure[] = [
{
kind: 'paragraph',
- lines: [makeLine()],
- totalHeight: 20,
+ lines: [makeLine({ maxWidth: 360 }), makeLine({ fromChar: 10, toChar: 20 })],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const content = (result.pages[0].items[0] as any).content;
+ // First line: min(360, 432) = 360 โ already correct, should NOT subtract again
+ expect(content.lines[0].textIndentPx).toBe(72);
+ expect(content.lines[0].availableWidth).toBe(360);
+ });
+ });
+
+ describe('page metadata fields', () => {
+ it('carries margins through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [],
+ margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36, gutter: 0 },
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].margins).toEqual({
+ top: 72,
+ right: 72,
+ bottom: 72,
+ left: 72,
+ header: 36,
+ footer: 36,
+ gutter: 0,
+ });
+ });
+
+ it('leaves margins undefined when page has no margins', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].margins).toBeUndefined();
+ });
+
+ it('carries footnoteReserved through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [], footnoteReserved: 48 }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].footnoteReserved).toBe(48);
+ });
+
+ it('carries numberText through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [], numberText: 'i' }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].numberText).toBe('i');
+ });
+
+ it('carries vAlign and baseMargins through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [],
+ vAlign: 'center',
+ baseMargins: { top: 72, bottom: 72 },
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].vAlign).toBe('center');
+ expect(result.pages[0].baseMargins).toEqual({ top: 72, bottom: 72 });
+ });
+
+ it('carries sectionIndex through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [], sectionIndex: 2 }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].sectionIndex).toBe(2);
+ });
+
+ it('carries sectionRefs through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [],
+ sectionRefs: {
+ headerRefs: { default: 'hdr1', first: 'hdr-first' },
+ footerRefs: { default: 'ftr1' },
+ },
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].sectionRefs).toEqual({
+ headerRefs: { default: 'hdr1', first: 'hdr-first' },
+ footerRefs: { default: 'ftr1' },
+ });
+ });
+
+ it('carries orientation through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 792, h: 612 },
+ pages: [{ number: 1, fragments: [], orientation: 'landscape' }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].orientation).toBe('landscape');
+ });
+
+ it('leaves optional metadata undefined when not set on source page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const page = result.pages[0];
+ expect(page.margins).toBeUndefined();
+ expect(page.footnoteReserved).toBeUndefined();
+ expect(page.numberText).toBeUndefined();
+ expect(page.vAlign).toBeUndefined();
+ expect(page.baseMargins).toBeUndefined();
+ expect(page.sectionIndex).toBeUndefined();
+ expect(page.sectionRefs).toBeUndefined();
+ expect(page.orientation).toBeUndefined();
+ });
+
+ it('carries all metadata fields together on a fully-populated page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 3,
+ fragments: [],
+ margins: { top: 72, right: 72, bottom: 72, left: 72 },
+ footnoteReserved: 24,
+ numberText: 'iii',
+ vAlign: 'bottom',
+ baseMargins: { top: 96, bottom: 96 },
+ sectionIndex: 1,
+ sectionRefs: {
+ headerRefs: { default: 'h1' },
+ footerRefs: { default: 'f1', even: 'f-even' },
+ },
+ orientation: 'portrait',
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const page = result.pages[0];
+ expect(page.margins).toEqual({ top: 72, right: 72, bottom: 72, left: 72 });
+ expect(page.footnoteReserved).toBe(24);
+ expect(page.numberText).toBe('iii');
+ expect(page.vAlign).toBe('bottom');
+ expect(page.baseMargins).toEqual({ top: 96, bottom: 96 });
+ expect(page.sectionIndex).toBe(1);
+ expect(page.sectionRefs).toEqual({
+ headerRefs: { default: 'h1' },
+ footerRefs: { default: 'f1', even: 'f-even' },
+ });
+ expect(page.orientation).toBe('portrait');
+ });
+ });
+
+ describe('layoutEpoch', () => {
+ it('carries layoutEpoch from source layout to resolved layout', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [],
+ layoutEpoch: 42,
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.layoutEpoch).toBe(42);
+ });
+
+ it('defaults layoutEpoch to undefined when not set', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.layoutEpoch).toBeUndefined();
+ });
+ });
+ describe('sdtContainerKey resolution', () => {
+ it('sets sdtContainerKey for a paragraph with block structuredContent sdt', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-1' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('structuredContent:sdt-1');
+ });
+
+ it('sets sdtContainerKey for a paragraph with documentSection sdt', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'documentSection', id: 'sec-1' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('documentSection:sec-1');
+ });
+
+ it('uses sdBlockId for documentSection when id is absent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'documentSection', sdBlockId: 'blk-99' } },
},
];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- expect(content.lines[0].skipJustify).toBe(false);
- expect(content.paragraphEndsWithLineBreak).toBe(true);
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('documentSection:blk-99');
});
- it('resolves drop cap on first fragment', () => {
+ it('falls back to containerSdt when primary sdt has no container config', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
@@ -931,54 +2305,27 @@ describe('resolveLayout', () => {
{
kind: 'paragraph',
id: 'p1',
- runs: [{ kind: 'text', text: 'Hello' }],
+ runs: [],
attrs: {
- dropCapDescriptor: {
- mode: 'drop' as const,
- lines: 3,
- run: { text: 'H', fontFamily: 'Arial', fontSize: 72 },
- },
+ sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' },
+ containerSdt: { type: 'documentSection', id: 'sec-2' },
},
},
];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine()],
- totalHeight: 20,
- dropCap: { width: 50, height: 60, lines: 3, mode: 'drop' as const },
- },
- ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- expect(content.dropCap).toBeDefined();
- expect(content.dropCap.text).toBe('H');
- expect(content.dropCap.mode).toBe('drop');
- expect(content.dropCap.fontFamily).toBe('Arial');
- expect(content.dropCap.fontSize).toBe(72);
- expect(content.dropCap.width).toBe(50);
- expect(content.dropCap.height).toBe(60);
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('documentSection:sec-2');
});
- it('omits drop cap on continuation fragment', () => {
+ it('returns null (omits sdtContainerKey) for inline structuredContent scope', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
- fragments: [
- {
- kind: 'para',
- blockId: 'p1',
- fromLine: 1,
- toLine: 2,
- x: 72,
- y: 100,
- width: 468,
- continuesFromPrev: true,
- },
- ],
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
},
],
};
@@ -986,109 +2333,190 @@ describe('resolveLayout', () => {
{
kind: 'paragraph',
id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world' }],
- attrs: {
- dropCapDescriptor: {
- mode: 'drop' as const,
- lines: 3,
- run: { text: 'H', fontFamily: 'Arial', fontSize: 72 },
- },
- },
- },
- ];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- dropCap: { width: 50, height: 60, lines: 3, mode: 'drop' as const },
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' } },
},
];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- expect(content.dropCap).toBeUndefined();
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
});
- it('resolves list marker on first fragment', () => {
+ it('omits sdtContainerKey when paragraph has no sdt', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
- fragments: [
- {
- kind: 'para',
- blockId: 'p1',
- fromLine: 0,
- toLine: 1,
- x: 72,
- y: 100,
- width: 468,
- markerWidth: 36,
- markerTextWidth: 10,
- },
- ],
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
},
],
};
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
+ });
+
+ it('sets sdtContainerKey for a list-item fragment from its item paragraph sdt', () => {
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 0,
+ toLine: 1,
+ x: 108,
+ y: 200,
+ width: 432,
+ markerWidth: 36,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [listItemFragment] }],
+ };
const blocks: FlowBlock[] = [
{
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'List item' }],
- attrs: {
- indent: { left: 36, hanging: 36 },
- wordLayout: {
- marker: {
- markerText: '1.',
- justification: 'left',
- suffix: 'tab',
- run: { fontFamily: 'Arial', fontSize: 12 },
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
+ {
+ id: 'item-a',
+ marker: { text: 'โข', style: {} },
+ paragraph: {
+ kind: 'paragraph',
+ id: 'item-a-p',
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'list-sdt-1' } },
},
},
- },
+ ],
},
];
const measures: Measure[] = [
{
- kind: 'paragraph',
- lines: [makeLine()],
- totalHeight: 20,
+ kind: 'list',
+ items: [
+ {
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 },
+ ],
+ totalHeight: 24,
+ },
+ },
+ ],
+ totalHeight: 24,
},
];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- expect(content.marker).toBeDefined();
- expect(content.marker.text).toBe('1.');
- expect(content.marker.justification).toBe('left');
- expect(content.marker.suffix).toBe('tab');
- expect(content.marker.run.fontFamily).toBe('Arial');
- expect(content.marker.run.fontSize).toBe(12);
- expect(content.lines[0].isListFirstLine).toBe(true);
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('structuredContent:list-sdt-1');
});
- it('omits marker on continuation fragment', () => {
+ it('sets sdtContainerKey for a table fragment with sdt', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 30,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = {
+ kind: 'table' as const,
+ id: 'tbl1',
+ rows: [],
+ attrs: { sdt: { type: 'documentSection' as const, id: 'tbl-sec-1' } },
+ };
+ const tableMeasure = {
+ kind: 'table' as const,
+ rows: [],
+ columnWidths: [],
+ totalWidth: 0,
+ totalHeight: 0,
+ };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.sdtContainerKey).toBe('documentSection:tbl-sec-1');
+ });
+
+ it('omits sdtContainerKey for image and drawing fragments', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 250,
+ };
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ blockId: 'dr1',
+ x: 50,
+ y: 60,
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment, drawingFragment] }],
+ };
+ const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 };
+ const drawingBlock = {
+ kind: 'drawing' as const,
+ id: 'dr1',
+ drawingKind: 'vectorShape' as const,
+ geometry: { width: 200, height: 150 },
+ };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [imageBlock, drawingBlock as any],
+ measures: [
+ { kind: 'image', width: 300, height: 250 },
+ { kind: 'drawing', width: 200, height: 150 },
+ ],
+ });
+ const imgItem = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem;
+ const drItem = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedDrawingItem;
+ expect(imgItem.sdtContainerKey).toBeUndefined();
+ expect(drItem.sdtContainerKey).toBeUndefined();
+ });
+
+ it('returns null (omits key) for structuredContent block scope with no id', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
- fragments: [
- {
- kind: 'para',
- blockId: 'p1',
- fromLine: 1,
- toLine: 2,
- x: 72,
- y: 100,
- width: 468,
- markerWidth: 36,
- markerTextWidth: 10,
- continuesFromPrev: true,
- },
- ],
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
},
],
};
@@ -1096,76 +2524,79 @@ describe('resolveLayout', () => {
{
kind: 'paragraph',
id: 'p1',
- runs: [{ kind: 'text', text: 'List item continued' }],
- attrs: {
- indent: { left: 36, hanging: 36 },
- wordLayout: {
- marker: {
- markerText: '1.',
- justification: 'left',
- suffix: 'tab',
- run: { fontFamily: 'Arial', fontSize: 12 },
- },
- },
- },
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'block' } },
},
];
- const measures: Measure[] = [
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
+ });
+
+ it('returns null (omits key) for documentSection with no id or sdBlockId', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
{
kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'documentSection' } },
},
];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- expect(content.marker).toBeUndefined();
- expect(content.lines[0].isListFirstLine).toBe(false);
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
});
+ });
- it('does not resolve content for table fragments', () => {
+ describe('paragraphBorders pre-computation', () => {
+ it('populates paragraphBorders and paragraphBorderHash for a paragraph with borders', () => {
+ const borders = {
+ top: { style: 'solid' as const, width: 4, color: '#000000' },
+ bottom: { style: 'solid' as const, width: 4, color: '#000000' },
+ left: { style: 'solid' as const, width: 4, color: '#000000' },
+ right: { style: 'solid' as const, width: 4, color: '#000000' },
+ between: { style: 'solid' as const, width: 4, color: '#000000' },
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
- fragments: [
- {
- kind: 'table',
- blockId: 't1',
- fromRow: 0,
- toRow: 1,
- x: 72,
- y: 100,
- width: 468,
- height: 200,
- },
- ],
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
},
],
};
- const blocks: FlowBlock[] = [
- {
- kind: 'table',
- id: 't1',
- rows: [],
- } as any,
- ];
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } }];
const measures: Measure[] = [
{
- kind: 'table',
- rows: [],
- totalHeight: 200,
- } as any,
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ },
];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const item = result.pages[0].items[0] as any;
- expect(item.content).toBeUndefined();
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.paragraphBorders).toEqual(borders);
+ expect(item.paragraphBorderHash).toBeDefined();
+ expect(typeof item.paragraphBorderHash).toBe('string');
+ expect(item.paragraphBorderHash!.length).toBeGreaterThan(0);
});
- it('resolves available width from fragment width minus positive indents', () => {
+ it('omits paragraphBorders and paragraphBorderHash when paragraph has no borders', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
@@ -1175,35 +2606,65 @@ describe('resolveLayout', () => {
},
],
};
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.paragraphBorders).toBeUndefined();
+ expect(item.paragraphBorderHash).toBeUndefined();
+ });
+
+ it('produces matching hashes for identical border definitions', () => {
+ const borders = {
+ top: { style: 'solid' as const, width: 4, color: '#000000' },
+ bottom: { style: 'solid' as const, width: 4, color: '#000000' },
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 },
+ { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 },
+ ],
+ },
+ ],
+ };
const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } },
+ { kind: 'paragraph', id: 'p2', runs: [], attrs: { borders: { ...borders } } },
+ ];
+ const measures: Measure[] = [
{
kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello' }],
- attrs: { indent: { left: 36, right: 36 } },
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
},
- ];
- const measures: Measure[] = [
{
kind: 'paragraph',
- lines: [makeLine()],
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
totalHeight: 20,
},
];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const line0 = (result.pages[0].items[0] as any).content.lines[0];
- // availableWidth = fragment.width - max(0, left) - max(0, right) = 468 - 36 - 36 = 396
- expect(line0.availableWidth).toBe(396);
+ const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item0.paragraphBorderHash).toBe(item1.paragraphBorderHash);
});
- it('increases availableWidth on first line when hanging indent produces negative textIndent', () => {
+ it('produces different hashes for different border definitions', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
+ fragments: [
+ { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 },
+ { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 },
+ ],
},
],
};
@@ -1211,280 +2672,445 @@ describe('resolveLayout', () => {
{
kind: 'paragraph',
id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test line' }],
- attrs: { indent: { left: 160, hanging: 160 } },
+ runs: [],
+ attrs: { borders: { top: { style: 'solid' as const, width: 4, color: '#000000' } } },
+ },
+ {
+ kind: 'paragraph',
+ id: 'p2',
+ runs: [],
+ attrs: { borders: { top: { style: 'dashed' as const, width: 2, color: '#FF0000' } } },
},
];
const measures: Measure[] = [
{
kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ },
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
},
];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // First line: textIndent = -160 (firstLine(0) - hanging(160))
- // availableWidth should account for the negative textIndent:
- // base = 468 - max(0, 160) = 308, then adjusted by -(-160) = 308 + 160 = 468
- expect(content.lines[0].textIndentPx).toBe(-160);
- expect(content.lines[0].availableWidth).toBe(468);
- // Body line: no textIndent adjustment, availableWidth stays at base
- expect(content.lines[1].textIndentPx).toBe(0);
- expect(content.lines[1].availableWidth).toBe(308);
+ const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item0.paragraphBorderHash).not.toBe(item1.paragraphBorderHash);
});
- it('decreases availableWidth on first line when firstLine indent produces positive textIndent', () => {
+ it('populates paragraphBorders for list-item fragments', () => {
+ const borders = {
+ top: { style: 'solid' as const, width: 2, color: '#0000FF' },
+ between: { style: 'solid' as const, width: 1, color: '#0000FF' },
+ };
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ markerWidth: 36,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
- },
- ],
+ pages: [{ number: 1, fragments: [listItemFragment] }],
};
const blocks: FlowBlock[] = [
{
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test line' }],
- attrs: { indent: { left: 36, firstLine: 72 } },
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
+ {
+ id: 'item-a',
+ marker: { text: 'โข', style: {} },
+ paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [], attrs: { borders } },
+ },
+ ],
},
];
const measures: Measure[] = [
{
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
+ kind: 'list',
+ items: [
+ {
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 },
+ ],
+ totalHeight: 20,
+ },
+ },
+ ],
+ totalHeight: 20,
},
];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // First line: textIndent = 72 (firstLine(72) - hanging(0))
- // availableWidth should be reduced: base = 468 - 36 = 432, then 432 - 72 = 360
- expect(content.lines[0].textIndentPx).toBe(72);
- expect(content.lines[0].availableWidth).toBe(360);
- // Body line: no textIndent, availableWidth = 468 - 36 = 432
- expect(content.lines[1].textIndentPx).toBe(0);
- expect(content.lines[1].availableWidth).toBe(432);
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.paragraphBorders).toEqual(borders);
+ expect(item.paragraphBorderHash).toBeDefined();
});
- it('adjusts availableWidth for hanging indent even when line.maxWidth is set (Math.min clamps it)', () => {
+ it('does not add paragraphBorders to table items', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 100,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
- },
- ],
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any];
+ const measures: Measure[] = [
+ {
+ kind: 'table',
+ columnWidths: [468],
+ rows: [{ cells: [{ width: 468, height: 100 }] }],
+ } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.paragraphBorders).toBeUndefined();
+ expect(item.paragraphBorderHash).toBeUndefined();
+ });
+ });
+
+ describe('version signature', () => {
+ it('sets version on paragraph fragment items', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ expect(item.version.length).toBeGreaterThan(0);
+ });
+
+ it('sets version on table fragment items', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ height: 100,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any];
+ const measures: Measure[] = [
+ {
+ kind: 'table',
+ columnWidths: [468],
+ rows: [{ cells: [{ width: 468, height: 100 }] }],
+ } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ });
+
+ it('sets version on image fragment items', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 72,
+ y: 0,
+ width: 200,
+ height: 150,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'image', id: 'img1', src: 'test.png', width: 200, height: 150 } as any];
+ const measures: Measure[] = [{ kind: 'image' } as any];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ });
+
+ it('sets version on drawing fragment items', () => {
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ blockId: 'dr1',
+ drawingKind: 'image',
+ x: 72,
+ y: 0,
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [drawingFragment] }],
};
const blocks: FlowBlock[] = [
{
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test line' }],
- attrs: { indent: { left: 160, hanging: 160 } },
- },
- ];
- // The measurer sets maxWidth = contentWidth - firstLineOffset = (468-160) - (-160) = 468
- // but Math.min(468, fallback=308) clamps it to 308. The textIndent adjustment restores it.
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine({ maxWidth: 468 }), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
+ kind: 'drawing',
+ drawingKind: 'image',
+ id: 'dr1',
+ src: 'test.png',
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ } as any,
];
+ const measures: Measure[] = [{ kind: 'drawing' } as any];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // First line: min(468, 308) = 308, then adjusted by -(-160) = 468
- expect(content.lines[0].textIndentPx).toBe(-160);
- expect(content.lines[0].availableWidth).toBe(468);
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
});
- it('does not adjust availableWidth for list paragraphs with hanging indent', () => {
+ it('sets version on list-item fragment items', () => {
+ const listFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [
- {
- kind: 'para',
- blockId: 'p1',
- fromLine: 0,
- toLine: 2,
- x: 72,
- y: 100,
- width: 468,
- markerWidth: 36,
- markerTextWidth: 10,
- },
- ],
- },
- ],
+ pages: [{ number: 1, fragments: [listFragment] }],
};
const blocks: FlowBlock[] = [
{
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'List item text here' }],
- attrs: {
- indent: { left: 36, hanging: 36 },
- wordLayout: {
- marker: {
- markerText: '1.',
- justification: 'left',
- suffix: 'tab',
- run: { fontFamily: 'Arial', fontSize: 12 },
+ kind: 'list',
+ id: 'list1',
+ items: [
+ {
+ id: 'item1',
+ marker: { text: '1.' },
+ paragraph: {
+ kind: 'paragraph',
+ id: 'p-item1',
+ runs: [{ text: 'item', fontFamily: 'Arial', fontSize: 12 }],
},
},
- },
- },
+ ],
+ } as any,
];
const measures: Measure[] = [
{
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
+ kind: 'list',
+ items: [{ itemId: 'item1', paragraph: { kind: 'paragraph', lines: [{ lineHeight: 20 }] } }],
+ } as any,
];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // List first line: textIndentPx should be 0 (list marker occupies the hanging region),
- // so availableWidth should NOT be adjusted for hanging indent.
- expect(content.lines[0].textIndentPx).toBe(0);
- // base = 468 - max(0, 36) = 432 โ stays at 432, no hanging indent adjustment
- expect(content.lines[0].availableWidth).toBe(432);
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
});
- it('does not adjust availableWidth on continuation fragment even with hanging indent', () => {
+ it('produces different versions when block content changes', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
- },
- {
- number: 2,
- fragments: [
- {
- kind: 'para',
- blockId: 'p1',
- fromLine: 1,
- toLine: 2,
- x: 72,
- y: 72,
- width: 468,
- continuesFromPrev: true,
- },
- ],
- },
- ],
+ pages: [{ number: 1, fragments: [paraFragment] }],
};
- const blocks: FlowBlock[] = [
- {
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test line continued' }],
- attrs: { indent: { left: 160, hanging: 160 } },
- },
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any];
+
+ const blocks1: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
+ const blocks2: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'world', fontFamily: 'Arial', fontSize: 12 }] } as any,
];
- const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- // Page 1: first line of paragraph โ gets hanging indent adjustment
- const page1Content = (result.pages[0].items[0] as any).content;
- expect(page1Content.lines[0].textIndentPx).toBe(-160);
- expect(page1Content.lines[0].availableWidth).toBe(468);
-
- // Page 2: continuation fragment โ first line here is NOT the paragraph's first line,
- // so textIndentPx should be 0 and availableWidth should NOT be adjusted.
- const page2Content = (result.pages[1].items[0] as any).content;
- expect(page2Content.lines[0].textIndentPx).toBe(0);
- // base = 468 - max(0, 160) = 308 โ no adjustment
- expect(page2Content.lines[0].availableWidth).toBe(308);
+ const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks1, measures });
+ const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks2, measures });
+ const ver1 = (result1.pages[0].items[0] as any).version;
+ const ver2 = (result2.pages[0].items[0] as any).version;
+ expect(ver1).not.toBe(ver2);
});
- it('does not adjust availableWidth when suppressFirstLineIndent is true', () => {
+ it('produces same version for identical inputs', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
- },
- ],
+ pages: [{ number: 1, fragments: [paraFragment] }],
};
const blocks: FlowBlock[] = [
- {
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test line' }],
- attrs: { indent: { left: 160, hanging: 160 }, suppressFirstLineIndent: true } as any,
- },
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
];
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine(), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any];
+
+ const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const ver1 = (result1.pages[0].items[0] as any).version;
+ const ver2 = (result2.pages[0].items[0] as any).version;
+ expect(ver1).toBe(ver2);
+ });
+
+ it('produces different versions when fragment line range changes', () => {
+ const fragment1: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const fragment2: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 2,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any];
- const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // suppressFirstLineIndent zeroes the firstLineOffset, so textIndent = 0
- // and availableWidth stays at base = 468 - max(0, 160) = 308
- expect(content.lines[0].textIndentPx).toBe(0);
- expect(content.lines[0].availableWidth).toBe(308);
+ const layout1: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [fragment1] }],
+ };
+ const layout2: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [fragment2] }],
+ };
+
+ const result1 = resolveLayout({ layout: layout1, flowMode: 'paginated', blocks, measures });
+ const result2 = resolveLayout({ layout: layout2, flowMode: 'paginated', blocks, measures });
+ const ver1 = (result1.pages[0].items[0] as any).version;
+ const ver2 = (result2.pages[0].items[0] as any).version;
+ expect(ver1).not.toBe(ver2);
});
- it('does not double-subtract positive firstLine indent when line.maxWidth already accounts for it', () => {
+ it('caches block version across fragments sharing the same block', () => {
+ const frag1: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const frag2: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 1,
+ toLine: 2,
+ x: 72,
+ y: 20,
+ width: 468,
+ };
const layout: Layout = {
pageSize: { w: 612, h: 792 },
- pages: [
- {
- number: 1,
- fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 100, width: 468 }],
- },
- ],
+ pages: [{ number: 1, fragments: [frag1, frag2] }],
};
const blocks: FlowBlock[] = [
- {
- kind: 'paragraph',
- id: 'p1',
- runs: [{ kind: 'text', text: 'Hello world test line' }],
- attrs: { indent: { left: 36, firstLine: 72 } },
- },
- ];
- // The measurer sets maxWidth = contentWidth - firstLineOffset = 432 - 72 = 360
- const measures: Measure[] = [
- {
- kind: 'paragraph',
- lines: [makeLine({ maxWidth: 360 }), makeLine({ fromChar: 10, toChar: 20 })],
- totalHeight: 40,
- },
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello world', fontFamily: 'Arial', fontSize: 12 }] } as any,
];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any];
const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
- const content = (result.pages[0].items[0] as any).content;
- // First line: min(360, 432) = 360 โ already correct, should NOT subtract again
- expect(content.lines[0].textIndentPx).toBe(72);
- expect(content.lines[0].availableWidth).toBe(360);
+ const ver1 = (result.pages[0].items[0] as any).version;
+ const ver2 = (result.pages[0].items[1] as any).version;
+
+ // Both versions should be defined
+ expect(ver1).toBeDefined();
+ expect(ver2).toBeDefined();
+ // They should differ (different line ranges)
+ expect(ver1).not.toBe(ver2);
+ // But both share the same block version prefix
+ const prefix1 = ver1.split('|')[0];
+ const prefix2 = ver2.split('|')[0];
+ expect(prefix1).toBe(prefix2);
+ });
+
+ it('uses "missing" for fragments with no matching block', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'nonexistent',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(item.version).toContain('missing');
});
});
});
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index fd16d0b15d..78b5be1f13 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -6,13 +6,18 @@ import type {
Fragment,
DrawingFragment,
ImageFragment,
+ ListItemFragment,
+ ParaFragment,
TableFragment,
Line,
+ ParagraphBorders,
ResolvedLayout,
ResolvedPage,
ResolvedPaintItem,
+ ResolvedFragmentItem,
ResolvedParagraphContent,
ListMeasure,
+ ListBlock,
ParagraphBlock,
ParagraphMeasure,
} from '@superdoc/contracts';
@@ -21,6 +26,9 @@ import { resolveTableItem } from './resolveTable.js';
import { resolveImageItem } from './resolveImage.js';
import { resolveDrawingItem } from './resolveDrawing.js';
import type { BlockMapEntry } from './resolvedBlockLookup.js';
+import { computeSdtContainerKey } from './sdtContainerKey.js';
+import { hashParagraphBorders } from './paragraphBorderHash.js';
+import { deriveBlockVersion, fragmentSignature } from './versionSignature.js';
export type ResolveLayoutInput = {
layout: Layout;
@@ -29,7 +37,7 @@ export type ResolveLayoutInput = {
measures: Measure[];
};
-function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map {
+export function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map {
const map = new Map();
for (let i = 0; i < blocks.length; i++) {
map.set(blocks[i].id, { block: blocks[i], measure: measures[i] });
@@ -122,23 +130,100 @@ function resolveParagraphContentIfApplicable(
return resolveParagraphContent(fragment, entry.block as ParagraphBlock, entry.measure as ParagraphMeasure);
}
-function resolveFragmentItem(
+function resolveFragmentParagraphBorders(
+ fragment: Fragment,
+ blockMap: Map,
+): ParagraphBorders | undefined {
+ const entry = blockMap.get(fragment.blockId);
+ if (!entry) return undefined;
+
+ if (fragment.kind === 'para' && entry.block.kind === 'paragraph') {
+ return (entry.block as ParagraphBlock).attrs?.borders;
+ }
+
+ if (fragment.kind === 'list-item' && entry.block.kind === 'list') {
+ const block = entry.block as ListBlock;
+ const item = block.items.find((listItem) => listItem.id === fragment.itemId);
+ return item?.paragraph.attrs?.borders;
+ }
+
+ return undefined;
+}
+
+function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map): string | null {
+ const entry = blockMap.get(fragment.blockId);
+ if (!entry) return null;
+ const block = entry.block;
+
+ if (fragment.kind === 'para' && block.kind === 'paragraph') {
+ return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
+ }
+
+ if (fragment.kind === 'list-item' && block.kind === 'list') {
+ const listBlock = block as ListBlock;
+ const item = listBlock.items.find((listItem) => listItem.id === fragment.itemId);
+ return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt);
+ }
+
+ if (fragment.kind === 'table' && block.kind === 'table') {
+ return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
+ }
+
+ // image, drawing โ no SDT container keys
+ return null;
+}
+
+function computeBlockVersion(
+ blockId: string,
+ blockMap: Map,
+ cache: Map,
+): string {
+ const cached = cache.get(blockId);
+ if (cached !== undefined) return cached;
+ const entry = blockMap.get(blockId);
+ if (!entry) {
+ cache.set(blockId, 'missing');
+ return 'missing';
+ }
+ const version = deriveBlockVersion(entry.block);
+ cache.set(blockId, version);
+ return version;
+}
+
+export function resolveFragmentItem(
fragment: Fragment,
fragmentIndex: number,
pageIndex: number,
blockMap: Map,
+ blockVersionCache: Map,
): ResolvedPaintItem {
+ const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap);
+ const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache);
+ const version = fragmentSignature(fragment, blockVer);
+
// Route to kind-specific resolvers for types that carry extracted block/measure data.
switch (fragment.kind) {
- case 'table':
- return resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap);
- case 'image':
- return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap);
- case 'drawing':
- return resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap);
- default:
+ case 'table': {
+ const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap);
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ item.version = version;
+ return item;
+ }
+ case 'image': {
+ const item = resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap);
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ item.version = version;
+ return item;
+ }
+ case 'drawing': {
+ const item = resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap);
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ item.version = version;
+ return item;
+ }
+ default: {
// para, list-item โ existing generic resolution
- return {
+ const item: ResolvedFragmentItem = {
kind: 'fragment',
id: resolveFragmentId(fragment),
pageIndex,
@@ -152,12 +237,51 @@ function resolveFragmentItem(
fragmentIndex,
content: resolveParagraphContentIfApplicable(fragment, blockMap),
};
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+
+ // Pre-extract block/measure for para and list-item fragments so the painter
+ // can prefer resolved data over a blockLookup read.
+ const entry = blockMap.get(fragment.blockId);
+ if (entry) {
+ if (fragment.kind === 'para' && entry.block.kind === 'paragraph' && entry.measure.kind === 'paragraph') {
+ item.block = entry.block as ParagraphBlock;
+ item.measure = entry.measure as ParagraphMeasure;
+ } else if (fragment.kind === 'list-item' && entry.block.kind === 'list' && entry.measure.kind === 'list') {
+ item.block = entry.block as ListBlock;
+ item.measure = entry.measure as ListMeasure;
+ }
+ }
+
+ // Pre-compute paragraph border data for between-border grouping
+ const borders = resolveFragmentParagraphBorders(fragment, blockMap);
+ if (borders) {
+ item.paragraphBorders = borders;
+ item.paragraphBorderHash = hashParagraphBorders(borders);
+ }
+
+ if (fragment.kind === 'para') {
+ const para = fragment as ParaFragment;
+ if (para.pmStart != null) item.pmStart = para.pmStart;
+ if (para.pmEnd != null) item.pmEnd = para.pmEnd;
+ if (para.continuesFromPrev != null) item.continuesFromPrev = para.continuesFromPrev;
+ if (para.continuesOnNext != null) item.continuesOnNext = para.continuesOnNext;
+ if (para.markerWidth != null) item.markerWidth = para.markerWidth;
+ } else if (fragment.kind === 'list-item') {
+ const listItem = fragment as ListItemFragment;
+ if (listItem.continuesFromPrev != null) item.continuesFromPrev = listItem.continuesFromPrev;
+ if (listItem.continuesOnNext != null) item.continuesOnNext = listItem.continuesOnNext;
+ if (listItem.markerWidth != null) item.markerWidth = listItem.markerWidth;
+ }
+ item.version = version;
+ return item;
+ }
}
}
export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
const { layout, flowMode, blocks, measures } = input;
const blockMap = buildBlockMap(blocks, measures);
+ const blockVersionCache = new Map();
const pages: ResolvedPage[] = layout.pages.map((page, pageIndex) => ({
id: `page-${pageIndex}`,
@@ -166,14 +290,33 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
width: page.size?.w ?? layout.pageSize.w,
height: page.size?.h ?? layout.pageSize.h,
items: page.fragments.map((fragment, fragmentIndex) =>
- resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap),
+ resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache),
),
+ margins: page.margins,
+ footnoteReserved: page.footnoteReserved,
+ numberText: page.numberText,
+ vAlign: page.vAlign,
+ baseMargins: page.baseMargins,
+ sectionIndex: page.sectionIndex,
+ sectionRefs: page.sectionRefs,
+ orientation: page.orientation,
}));
- return {
+ const resolved: ResolvedLayout = {
version: 1,
flowMode,
pageGap: layout.pageGap ?? 0,
pages,
};
+
+ if (blocks.length > 0) {
+ resolved.blockVersions = Object.fromEntries(
+ blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache)]),
+ );
+ }
+ if (layout.layoutEpoch != null) {
+ resolved.layoutEpoch = layout.layoutEpoch;
+ }
+
+ return resolved;
}
diff --git a/packages/layout-engine/layout-resolved/src/resolveTable.ts b/packages/layout-engine/layout-resolved/src/resolveTable.ts
index f88a692109..588634987e 100644
--- a/packages/layout-engine/layout-resolved/src/resolveTable.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveTable.ts
@@ -25,7 +25,7 @@ export function resolveTableItem(
): ResolvedTableItem {
const { block, measure } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'table', 'table', 'table');
- return {
+ const item: ResolvedTableItem = {
kind: 'fragment',
fragmentKind: 'table',
id: resolveTableFragmentId(fragment),
@@ -42,4 +42,9 @@ export function resolveTableItem(
cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing),
effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths,
};
+ if (fragment.pmStart != null) item.pmStart = fragment.pmStart;
+ if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd;
+ if (fragment.continuesFromPrev != null) item.continuesFromPrev = fragment.continuesFromPrev;
+ if (fragment.continuesOnNext != null) item.continuesOnNext = fragment.continuesOnNext;
+ return item;
}
diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts
new file mode 100644
index 0000000000..4cee08673f
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts
@@ -0,0 +1,40 @@
+import type { SdtMetadata } from '@superdoc/contracts';
+
+/**
+ * Returns a stable key for grouping consecutive fragments in the same SDT container.
+ *
+ * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts`
+ * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package.
+ * Only the key derivation is needed; DOM styling helpers are not.
+ */
+export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null {
+ const metadata = getSdtContainerMetadata(sdt, containerSdt);
+ if (!metadata) return null;
+
+ if (metadata.type === 'structuredContent') {
+ if (metadata.scope !== 'block') return null;
+ if (!metadata.id) return null;
+ return `structuredContent:${metadata.id}`;
+ }
+
+ if (metadata.type === 'documentSection') {
+ const sectionId = metadata.id ?? metadata.sdBlockId;
+ if (!sectionId) return null;
+ return `documentSection:${sectionId}`;
+ }
+
+ return null;
+}
+
+function isSdtContainer(sdt?: SdtMetadata | null): boolean {
+ if (!sdt) return false;
+ if (sdt.type === 'documentSection') return true;
+ if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true;
+ return false;
+}
+
+function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null {
+ if (isSdtContainer(sdt)) return sdt ?? null;
+ if (isSdtContainer(containerSdt)) return containerSdt ?? null;
+ return null;
+}
diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts
new file mode 100644
index 0000000000..8b2b15bb15
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts
@@ -0,0 +1,535 @@
+import type {
+ DrawingBlock,
+ FieldAnnotationRun,
+ FlowBlock,
+ Fragment,
+ ImageBlock,
+ ImageDrawing,
+ ImageRun,
+ ParagraphAttrs,
+ ParagraphBlock,
+ SdtMetadata,
+ ShapeGroupDrawing,
+ TableAttrs,
+ TableBlock,
+ TableCellAttrs,
+ TextRun,
+ VectorShapeDrawing,
+} from '@superdoc/contracts';
+import { hashParagraphBorders } from './paragraphBorderHash.js';
+import {
+ hashCellBorders,
+ hashTableBorders,
+ getRunBooleanProp,
+ getRunNumberProp,
+ getRunStringProp,
+ getRunUnderlineColor,
+ getRunUnderlineStyle,
+} from './hashUtils.js';
+
+// ---------------------------------------------------------------------------
+// SDT metadata helpers
+// ---------------------------------------------------------------------------
+
+const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => {
+ if (!metadata) return '';
+ if ('id' in metadata && metadata.id != null) {
+ return String(metadata.id);
+ }
+ return '';
+};
+
+const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => {
+ if (!metadata) return '';
+ return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : '';
+};
+
+const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => {
+ if (!metadata) return '';
+ return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':');
+};
+
+// ---------------------------------------------------------------------------
+// Clip path helpers
+// ---------------------------------------------------------------------------
+
+const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect('];
+
+const readClipPathValue = (value: unknown): string => {
+ if (typeof value !== 'string') return '';
+ const normalized = value.trim();
+ if (normalized.length === 0) return '';
+ const lower = normalized.toLowerCase();
+ if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return '';
+ return normalized;
+};
+
+const resolveClipPathFromAttrs = (attrs: unknown): string => {
+ if (!attrs || typeof attrs !== 'object') return '';
+ const record = attrs as Record;
+ return readClipPathValue(record.clipPath);
+};
+
+const resolveBlockClipPath = (block: unknown): string => {
+ if (!block || typeof block !== 'object') return '';
+ const record = block as Record;
+ return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs);
+};
+
+// ---------------------------------------------------------------------------
+// List marker validation
+// ---------------------------------------------------------------------------
+
+const hasListMarkerProperties = (
+ attrs: unknown,
+): attrs is {
+ numberingProperties: { numId?: number | string; ilvl?: number };
+ wordLayout?: { marker?: { markerText?: string } };
+} => {
+ if (!attrs || typeof attrs !== 'object') return false;
+ const obj = attrs as Record;
+
+ if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false;
+ const numProps = obj.numberingProperties as Record;
+
+ if ('numId' in numProps) {
+ const numId = numProps.numId;
+ if (typeof numId !== 'number' && typeof numId !== 'string') return false;
+ }
+
+ if ('ilvl' in numProps) {
+ const ilvl = numProps.ilvl;
+ if (typeof ilvl !== 'number') return false;
+ }
+
+ if ('wordLayout' in obj && obj.wordLayout !== undefined) {
+ if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false;
+ const wordLayout = obj.wordLayout as Record;
+
+ if ('marker' in wordLayout && wordLayout.marker !== undefined) {
+ if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false;
+ const marker = wordLayout.marker as Record;
+
+ if ('markerText' in marker && marker.markerText !== undefined) {
+ if (typeof marker.markerText !== 'string') return false;
+ }
+ }
+ }
+
+ return true;
+};
+
+// ---------------------------------------------------------------------------
+// FNV-1a hash helpers (for table block hashing)
+// ---------------------------------------------------------------------------
+
+const hashString = (seed: number, value: string): number => {
+ let hash = seed >>> 0;
+ for (let i = 0; i < value.length; i++) {
+ hash ^= value.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+ return hash >>> 0;
+};
+
+const hashNumber = (seed: number, value: number | undefined | null): number => {
+ const n = Number.isFinite(value) ? (value as number) : 0;
+ let hash = seed ^ n;
+ hash = Math.imul(hash, 16777619);
+ hash ^= hash >>> 13;
+ return hash >>> 0;
+};
+
+// ---------------------------------------------------------------------------
+// deriveBlockVersion
+// ---------------------------------------------------------------------------
+
+/**
+ * Derives a version string for a flow block based on its content and styling properties.
+ *
+ * This version string is used for cache invalidation. When any visual property of the block
+ * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements.
+ *
+ * Duplicated from painters/dom/src/renderer.ts to allow the resolved layout stage to
+ * pre-compute block versions without depending on painter-dom. Keep the two copies in sync
+ * until the painter fully migrates to resolved versions.
+ */
+export const deriveBlockVersion = (block: FlowBlock): string => {
+ if (block.kind === 'paragraph') {
+ const markerVersion = hasListMarkerProperties(block.attrs)
+ ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}`
+ : '';
+
+ const runsVersion = block.runs
+ .map((run) => {
+ if (run.kind === 'image') {
+ const imgRun = run as ImageRun;
+ return [
+ 'img',
+ imgRun.src,
+ imgRun.width,
+ imgRun.height,
+ imgRun.alt ?? '',
+ imgRun.title ?? '',
+ imgRun.clipPath ?? '',
+ imgRun.distTop ?? '',
+ imgRun.distBottom ?? '',
+ imgRun.distLeft ?? '',
+ imgRun.distRight ?? '',
+ readClipPathValue((imgRun as { clipPath?: unknown }).clipPath),
+ ].join(',');
+ }
+
+ if (run.kind === 'lineBreak') {
+ return 'linebreak';
+ }
+
+ if (run.kind === 'tab') {
+ return [run.text ?? '', 'tab'].join(',');
+ }
+
+ if (run.kind === 'fieldAnnotation') {
+ const fieldRun = run as FieldAnnotationRun;
+ const size = fieldRun.size ? `${fieldRun.size.width ?? ''}x${fieldRun.size.height ?? ''}` : '';
+ const highlighted = fieldRun.highlighted !== false ? 1 : 0;
+ return [
+ 'field',
+ fieldRun.variant ?? '',
+ fieldRun.displayLabel ?? '',
+ fieldRun.fieldColor ?? '',
+ fieldRun.borderColor ?? '',
+ highlighted,
+ fieldRun.hidden ? 1 : 0,
+ fieldRun.visibility ?? '',
+ fieldRun.imageSrc ?? '',
+ fieldRun.linkUrl ?? '',
+ fieldRun.rawHtml ?? '',
+ size,
+ fieldRun.fontFamily ?? '',
+ fieldRun.fontSize ?? '',
+ fieldRun.textColor ?? '',
+ fieldRun.textHighlight ?? '',
+ fieldRun.bold ? 1 : 0,
+ fieldRun.italic ? 1 : 0,
+ fieldRun.underline ? 1 : 0,
+ fieldRun.fieldId ?? '',
+ fieldRun.fieldType ?? '',
+ ].join(',');
+ }
+
+ const textRun = run as TextRun;
+ return [
+ textRun.text ?? '',
+ textRun.fontFamily,
+ textRun.fontSize,
+ textRun.bold ? 1 : 0,
+ textRun.italic ? 1 : 0,
+ textRun.color ?? '',
+ textRun.underline?.style ?? '',
+ textRun.underline?.color ?? '',
+ textRun.strike ? 1 : 0,
+ textRun.highlight ?? '',
+ textRun.letterSpacing != null ? textRun.letterSpacing : '',
+ textRun.vertAlign ?? '',
+ textRun.baselineShift != null ? textRun.baselineShift : '',
+ textRun.token ?? '',
+ textRun.trackedChange ? 1 : 0,
+ textRun.comments?.length ?? 0,
+ ].join(',');
+ })
+ .join('|');
+
+ const attrs = block.attrs as ParagraphAttrs | undefined;
+
+ const paragraphAttrsVersion = attrs
+ ? [
+ attrs.alignment ?? '',
+ attrs.spacing?.before ?? '',
+ attrs.spacing?.after ?? '',
+ attrs.spacing?.line ?? '',
+ attrs.spacing?.lineRule ?? '',
+ attrs.indent?.left ?? '',
+ attrs.indent?.right ?? '',
+ attrs.indent?.firstLine ?? '',
+ attrs.indent?.hanging ?? '',
+ attrs.borders ? hashParagraphBorders(attrs.borders) : '',
+ attrs.shading?.fill ?? '',
+ attrs.shading?.color ?? '',
+ attrs.direction ?? '',
+ attrs.rtl ? '1' : '',
+ attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '',
+ ].join(':')
+ : '';
+
+ const sdtAttrs = (block.attrs as ParagraphAttrs | undefined)?.sdt;
+ const sdtVersion = getSdtMetadataVersion(sdtAttrs);
+
+ const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean);
+ return parts.join('|');
+ }
+
+ if (block.kind === 'list') {
+ return block.items.map((item) => `${item.id}:${item.marker.text}:${deriveBlockVersion(item.paragraph)}`).join('|');
+ }
+
+ if (block.kind === 'image') {
+ const imgSdt = (block as ImageBlock).attrs?.sdt;
+ const imgSdtVersion = getSdtMetadataVersion(imgSdt);
+ return [
+ block.src ?? '',
+ block.width ?? '',
+ block.height ?? '',
+ block.alt ?? '',
+ block.title ?? '',
+ resolveBlockClipPath(block),
+ imgSdtVersion,
+ ].join('|');
+ }
+
+ if (block.kind === 'drawing') {
+ if (block.drawingKind === 'image') {
+ const imageLike = block as ImageDrawing;
+ return [
+ 'drawing:image',
+ imageLike.src ?? '',
+ imageLike.width ?? '',
+ imageLike.height ?? '',
+ imageLike.alt ?? '',
+ resolveBlockClipPath(imageLike),
+ ].join('|');
+ }
+ if (block.drawingKind === 'vectorShape') {
+ const vector = block as VectorShapeDrawing;
+ return [
+ 'drawing:vector',
+ vector.shapeKind ?? '',
+ vector.fillColor ?? '',
+ vector.strokeColor ?? '',
+ vector.strokeWidth ?? '',
+ vector.geometry.width,
+ vector.geometry.height,
+ vector.geometry.rotation ?? 0,
+ vector.geometry.flipH ? 1 : 0,
+ vector.geometry.flipV ? 1 : 0,
+ ].join('|');
+ }
+ if (block.drawingKind === 'shapeGroup') {
+ const group = block as ShapeGroupDrawing;
+ const childSignature = group.shapes
+ .map((child) => `${child.shapeType}:${JSON.stringify(child.attrs ?? {})}`)
+ .join(';');
+ return [
+ 'drawing:group',
+ group.geometry.width,
+ group.geometry.height,
+ group.groupTransform ? JSON.stringify(group.groupTransform) : '',
+ childSignature,
+ ].join('|');
+ }
+ if (block.drawingKind === 'chart') {
+ return [
+ 'drawing:chart',
+ block.chartData?.chartType ?? '',
+ block.chartData?.series?.length ?? 0,
+ block.geometry.width,
+ block.geometry.height,
+ block.chartRelId ?? '',
+ ].join('|');
+ }
+ const _exhaustive: never = block;
+ return `drawing:unknown:${(block as DrawingBlock).id}`;
+ }
+
+ if (block.kind === 'table') {
+ const tableBlock = block as TableBlock;
+
+ let hash = 2166136261;
+ hash = hashString(hash, block.id);
+ hash = hashNumber(hash, tableBlock.rows.length);
+ hash = (tableBlock.columnWidths ?? []).reduce((acc, width) => hashNumber(acc, Math.round(width * 1000)), hash);
+
+ const rows = tableBlock.rows ?? [];
+ for (const row of rows) {
+ if (!row || !Array.isArray(row.cells)) continue;
+ hash = hashNumber(hash, row.cells.length);
+ for (const cell of row.cells) {
+ if (!cell) continue;
+ const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []);
+ hash = hashNumber(hash, cellBlocks.length);
+ hash = hashNumber(hash, cell.rowSpan ?? 1);
+ hash = hashNumber(hash, cell.colSpan ?? 1);
+
+ if (cell.attrs) {
+ const cellAttrs = cell.attrs as TableCellAttrs;
+ if (cellAttrs.borders) {
+ hash = hashString(hash, hashCellBorders(cellAttrs.borders));
+ }
+ if (cellAttrs.padding) {
+ const p = cellAttrs.padding;
+ hash = hashNumber(hash, p.top ?? 0);
+ hash = hashNumber(hash, p.right ?? 0);
+ hash = hashNumber(hash, p.bottom ?? 0);
+ hash = hashNumber(hash, p.left ?? 0);
+ }
+ if (cellAttrs.verticalAlign) {
+ hash = hashString(hash, cellAttrs.verticalAlign);
+ }
+ if (cellAttrs.background) {
+ hash = hashString(hash, cellAttrs.background);
+ }
+ }
+
+ for (const cellBlock of cellBlocks) {
+ hash = hashString(hash, cellBlock?.kind ?? 'unknown');
+ if (cellBlock?.kind === 'paragraph') {
+ const paragraphBlock = cellBlock as ParagraphBlock;
+ const runs = paragraphBlock.runs ?? [];
+ hash = hashNumber(hash, runs.length);
+
+ const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined;
+
+ if (attrs) {
+ hash = hashString(hash, attrs.alignment ?? '');
+ hash = hashNumber(hash, attrs.spacing?.before ?? 0);
+ hash = hashNumber(hash, attrs.spacing?.after ?? 0);
+ hash = hashNumber(hash, attrs.spacing?.line ?? 0);
+ hash = hashString(hash, attrs.spacing?.lineRule ?? '');
+ hash = hashNumber(hash, attrs.indent?.left ?? 0);
+ hash = hashNumber(hash, attrs.indent?.right ?? 0);
+ hash = hashNumber(hash, attrs.indent?.firstLine ?? 0);
+ hash = hashNumber(hash, attrs.indent?.hanging ?? 0);
+ hash = hashString(hash, attrs.shading?.fill ?? '');
+ hash = hashString(hash, attrs.shading?.color ?? '');
+ hash = hashString(hash, attrs.direction ?? '');
+ hash = hashString(hash, attrs.rtl ? '1' : '');
+ if (attrs.borders) {
+ hash = hashString(hash, hashParagraphBorders(attrs.borders));
+ }
+ }
+
+ for (const run of runs) {
+ if ('text' in run && typeof run.text === 'string') {
+ hash = hashString(hash, run.text);
+ }
+ hash = hashNumber(hash, run.pmStart ?? -1);
+ hash = hashNumber(hash, run.pmEnd ?? -1);
+
+ hash = hashString(hash, getRunStringProp(run, 'color'));
+ hash = hashString(hash, getRunStringProp(run, 'highlight'));
+ hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : '');
+ hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : '');
+ hash = hashNumber(hash, getRunNumberProp(run, 'fontSize'));
+ hash = hashString(hash, getRunStringProp(run, 'fontFamily'));
+ hash = hashString(hash, getRunUnderlineStyle(run));
+ hash = hashString(hash, getRunUnderlineColor(run));
+ hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : '');
+ hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
+ hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
+ }
+ }
+ }
+ }
+ }
+
+ if (tableBlock.attrs) {
+ const tblAttrs = tableBlock.attrs as TableAttrs;
+ if (tblAttrs.borders) {
+ hash = hashString(hash, hashTableBorders(tblAttrs.borders));
+ }
+ if (tblAttrs.borderCollapse) {
+ hash = hashString(hash, tblAttrs.borderCollapse);
+ }
+ if (tblAttrs.cellSpacing !== undefined) {
+ const cs = tblAttrs.cellSpacing;
+ if (typeof cs === 'number') {
+ hash = hashNumber(hash, cs);
+ } else {
+ const v = (cs as { value?: number; type?: string }).value ?? 0;
+ const t = (cs as { value?: number; type?: string }).type ?? 'px';
+ hash = hashString(hash, `cs:${v}:${t}`);
+ }
+ }
+ if (tblAttrs.sdt) {
+ hash = hashString(hash, tblAttrs.sdt.type);
+ hash = hashString(hash, getSdtMetadataLockMode(tblAttrs.sdt));
+ hash = hashString(hash, getSdtMetadataId(tblAttrs.sdt));
+ }
+ }
+
+ return [block.id, tableBlock.rows.length, hash.toString(16)].join('|');
+ }
+
+ return block.id;
+};
+
+// ---------------------------------------------------------------------------
+// fragmentSignature
+// ---------------------------------------------------------------------------
+
+/**
+ * Computes a change-detection signature for a layout fragment.
+ *
+ * Combines the block-level version with fragment-specific data (line range,
+ * continuation flags, marker width, drawing geometry, table row range, etc.)
+ * so that each fragment has a unique identity for incremental re-rendering.
+ *
+ * Adapted from painters/dom/src/renderer.ts fragmentSignature(). The painter
+ * version accepts a BlockLookup map; this version takes a pre-computed
+ * blockVersion string directly.
+ */
+export const fragmentSignature = (fragment: Fragment, blockVersion: string): string => {
+ if (fragment.kind === 'para') {
+ return [
+ blockVersion,
+ fragment.fromLine,
+ fragment.toLine,
+ fragment.continuesFromPrev ? 1 : 0,
+ fragment.continuesOnNext ? 1 : 0,
+ fragment.markerWidth ?? '',
+ ].join('|');
+ }
+ if (fragment.kind === 'list-item') {
+ return [
+ blockVersion,
+ fragment.itemId,
+ fragment.fromLine,
+ fragment.toLine,
+ fragment.continuesFromPrev ? 1 : 0,
+ fragment.continuesOnNext ? 1 : 0,
+ ].join('|');
+ }
+ if (fragment.kind === 'image') {
+ return [blockVersion, fragment.width, fragment.height].join('|');
+ }
+ if (fragment.kind === 'drawing') {
+ return [
+ blockVersion,
+ fragment.drawingKind,
+ fragment.drawingContentId ?? '',
+ fragment.width,
+ fragment.height,
+ fragment.geometry.width,
+ fragment.geometry.height,
+ fragment.geometry.rotation ?? 0,
+ fragment.scale ?? 1,
+ fragment.zIndex ?? '',
+ ].join('|');
+ }
+ if (fragment.kind === 'table') {
+ const partialSig = fragment.partialRow
+ ? `${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}-${fragment.partialRow.partialHeight}`
+ : '';
+ return [
+ blockVersion,
+ fragment.fromRow,
+ fragment.toRow,
+ fragment.width,
+ fragment.height,
+ fragment.continuesFromPrev ? 1 : 0,
+ fragment.continuesOnNext ? 1 : 0,
+ fragment.repeatHeaderCount ?? 0,
+ partialSig,
+ ].join('|');
+ }
+ return blockVersion;
+};
diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json
index b5757c3844..cf39d4348f 100644
--- a/packages/layout-engine/painters/dom/package.json
+++ b/packages/layout-engine/painters/dom/package.json
@@ -21,11 +21,13 @@
"@superdoc/contracts": "workspace:*",
"@superdoc/dom-contract": "workspace:*",
"@superdoc/font-utils": "workspace:*",
+ "@superdoc/layout-resolved": "workspace:*",
"@superdoc/preset-geometry": "workspace:*",
"@superdoc/url-validation": "workspace:*"
},
"devDependencies": {
"@superdoc/layout-engine": "workspace:*",
+ "@superdoc/layout-resolved": "workspace:*",
"vitest": "catalog:"
}
}
diff --git a/packages/layout-engine/painters/dom/src/between-borders.test.ts b/packages/layout-engine/painters/dom/src/between-borders.test.ts
index f9ae6037fa..2218a18b0d 100644
--- a/packages/layout-engine/painters/dom/src/between-borders.test.ts
+++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts
@@ -1,14 +1,13 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
applyParagraphBorderStyles,
- getFragmentParagraphBorders,
computeBetweenBorderFlags,
createParagraphDecorationLayers,
getParagraphBorderBox,
computeBorderSpaceExpansion,
- type BlockLookup,
type BetweenBorderInfo,
} from './features/paragraph-borders/index.js';
+import { hashParagraphBorders } from './paragraph-hash-utils.js';
/** Helper to create BetweenBorderInfo for tests that previously passed a boolean. */
const betweenOn: BetweenBorderInfo = {
@@ -36,6 +35,8 @@ import type {
ParaFragment,
ListItemFragment,
ImageFragment,
+ ResolvedPaintItem,
+ ResolvedFragmentItem,
} from '@superdoc/contracts';
// ---------------------------------------------------------------------------
@@ -65,24 +66,54 @@ const makeListBlock = (id: string, items: { itemId: string; borders?: ParagraphB
})),
});
-const stubMeasure = { kind: 'paragraph' as const, lines: [], totalHeight: 0 };
-const stubListMeasure = {
- kind: 'list' as const,
- items: [],
- totalHeight: 0,
+/**
+ * Test surrogate for the old BlockLookup โ a list of blocks keyed by id that
+ * `buildResolvedItems` consumes to synthesize per-fragment ResolvedPaintItems.
+ */
+type TestBlockList = ReadonlyArray;
+
+const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): TestBlockList =>
+ entries.map((e) => e.block);
+
+/**
+ * Build resolved items aligned 1:1 with the given fragments.
+ * Looks up each fragment's block (+ list item) to extract paragraph borders,
+ * then produces a ResolvedFragmentItem carrying the borders and a border hash.
+ */
+const buildResolvedItems = (fragments: readonly Fragment[], blocks: TestBlockList): ResolvedPaintItem[] => {
+ const byId = new Map(blocks.map((b) => [b.id, b]));
+ return fragments.map((fragment, index): ResolvedPaintItem => {
+ const block = byId.get(fragment.blockId);
+ let borders: ParagraphBorders | undefined;
+
+ if (fragment.kind === 'para' && block?.kind === 'paragraph') {
+ borders = block.attrs?.borders;
+ } else if (fragment.kind === 'list-item' && block?.kind === 'list') {
+ const item = block.items.find((listItem) => listItem.id === fragment.itemId);
+ borders = item?.paragraph.attrs?.borders;
+ }
+
+ const item: ResolvedFragmentItem = {
+ kind: 'fragment',
+ id: `item:${index}`,
+ pageIndex: 0,
+ x: fragment.x,
+ y: fragment.y,
+ width: fragment.width,
+ height: 'height' in fragment && typeof fragment.height === 'number' ? fragment.height : 0,
+ fragmentKind: fragment.kind,
+ blockId: fragment.blockId,
+ fragmentIndex: index,
+ paragraphBorders: borders,
+ paragraphBorderHash: borders ? hashParagraphBorders(borders) : undefined,
+ };
+ return item;
+ });
};
-const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): BlockLookup => {
- const map: BlockLookup = new Map();
- for (const e of entries) {
- map.set(e.block.id, {
- block: e.block,
- measure: (e.measure ?? (e.block.kind === 'list' ? stubListMeasure : stubMeasure)) as never,
- version: '1',
- });
- }
- return map;
-};
+/** Test helper: run computeBetweenBorderFlags given fragments and the underlying blocks. */
+const runFlags = (fragments: readonly Fragment[], blocks: TestBlockList) =>
+ computeBetweenBorderFlags(fragments, buildResolvedItems(fragments, blocks));
const paraFragment = (blockId: string, overrides?: Partial): ParaFragment => ({
kind: 'para',
@@ -398,56 +429,6 @@ describe('createParagraphDecorationLayers โ gap extension', () => {
});
});
-// ---------------------------------------------------------------------------
-// getFragmentParagraphBorders
-// ---------------------------------------------------------------------------
-
-describe('getFragmentParagraphBorders', () => {
- it('returns borders from a paragraph block', () => {
- const borders: ParagraphBorders = { top: { style: 'solid', width: 1 } };
- const block = makeParagraphBlock('b1', borders);
- const lookup = buildLookup([{ block }]);
- expect(getFragmentParagraphBorders(paraFragment('b1'), lookup)).toEqual(borders);
- });
-
- it('returns undefined for paragraph block without borders', () => {
- const block = makeParagraphBlock('b1');
- const lookup = buildLookup([{ block }]);
- expect(getFragmentParagraphBorders(paraFragment('b1'), lookup)).toBeUndefined();
- });
-
- it('returns borders from a list-item block', () => {
- const borders: ParagraphBorders = { between: { style: 'solid', width: 1 } };
- const block = makeListBlock('l1', [{ itemId: 'i1', borders }]);
- const lookup = buildLookup([{ block }]);
- expect(getFragmentParagraphBorders(listItemFragment('l1', 'i1'), lookup)).toEqual(borders);
- });
-
- it('returns undefined when list item is not found', () => {
- const block = makeListBlock('l1', [{ itemId: 'i1' }]);
- const lookup = buildLookup([{ block }]);
- expect(getFragmentParagraphBorders(listItemFragment('l1', 'missing'), lookup)).toBeUndefined();
- });
-
- it('returns undefined when blockId is not in lookup', () => {
- const lookup = buildLookup([]);
- expect(getFragmentParagraphBorders(paraFragment('missing'), lookup)).toBeUndefined();
- });
-
- it('returns undefined for image fragment', () => {
- const block = makeParagraphBlock('b1', { top: { style: 'solid', width: 1 } });
- const lookup = buildLookup([{ block }]);
- expect(getFragmentParagraphBorders(imageFragment('b1'), lookup)).toBeUndefined();
- });
-
- it('returns undefined for kind/block mismatch (para fragment with list block)', () => {
- const block = makeListBlock('l1', [{ itemId: 'i1' }]);
- const lookup = buildLookup([{ block }]);
- // para fragment referencing a list block
- expect(getFragmentParagraphBorders(paraFragment('l1'), lookup)).toBeUndefined();
- });
-});
-
// ---------------------------------------------------------------------------
// computeBetweenBorderFlags
// ---------------------------------------------------------------------------
@@ -460,7 +441,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.has(0)).toBe(true);
expect(flags.get(0)?.showBetweenBorder).toBe(true);
// Fragment 1 also gets an entry (suppressTopBorder)
@@ -478,7 +459,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.size).toBe(2);
// First fragment: bottom border suppressed (no between separator, single box)
expect(flags.get(0)?.suppressBottomBorder).toBe(true);
@@ -501,7 +482,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
// --- page-split handling ---
@@ -511,7 +492,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1', { continuesOnNext: true }), paraFragment('b2')];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
it('does not flag when next fragment continuesFromPrev (page split continuation)', () => {
@@ -520,7 +501,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2', { continuesFromPrev: true })];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
// --- same-block deduplication ---
@@ -532,7 +513,7 @@ describe('computeBetweenBorderFlags', () => {
paraFragment('b1', { fromLine: 3, toLine: 6 }),
];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
it('does not flag same blockId + same itemId list-item fragments', () => {
@@ -543,7 +524,7 @@ describe('computeBetweenBorderFlags', () => {
listItemFragment('l1', 'i1', { fromLine: 2, toLine: 4 }),
];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
it('flags different itemIds in same list block', () => {
@@ -554,7 +535,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block }]);
const fragments: Fragment[] = [listItemFragment('l1', 'i1'), listItemFragment('l1', 'i2')];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.has(0)).toBe(true);
});
@@ -567,7 +548,7 @@ describe('computeBetweenBorderFlags', () => {
const fragments: Fragment[] = [paraFragment('b1'), imageFragment('img1'), paraFragment('b2')];
// Index 0 can't pair with index 1 (image), index 1 is image (skip)
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.has(0)).toBe(false);
// Index 1 is image, skipped โ but index 1โ2 is imageโpara, image is skipped
expect(flags.size).toBe(0);
@@ -580,7 +561,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block }]);
const fragments: Fragment[] = [paraFragment('b1'), listItemFragment('l1', 'i1')];
- expect(computeBetweenBorderFlags(fragments, lookup).has(0)).toBe(true);
+ expect(runFlags(fragments, lookup).has(0)).toBe(true);
});
it('flags list-item followed by para with matching borders', () => {
@@ -589,7 +570,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block }, { block: b2 }]);
const fragments: Fragment[] = [listItemFragment('l1', 'i1'), paraFragment('b2')];
- expect(computeBetweenBorderFlags(fragments, lookup).has(0)).toBe(true);
+ expect(runFlags(fragments, lookup).has(0)).toBe(true);
});
// --- multiple consecutive ---
@@ -600,7 +581,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }, { block: b3 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2'), paraFragment('b3')];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.has(0)).toBe(true);
expect(flags.get(0)?.showBetweenBorder).toBe(true);
expect(flags.has(1)).toBe(true);
@@ -623,7 +604,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }, { block: b3 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2'), paraFragment('b3')];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.size).toBe(0);
});
@@ -635,7 +616,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
it('does not flag when only second fragment has between border', () => {
@@ -645,19 +626,19 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
// --- edge: empty / single fragment ---
it('returns empty set for empty fragment list', () => {
const lookup = buildLookup([]);
- expect(computeBetweenBorderFlags([], lookup).size).toBe(0);
+ expect(runFlags([], lookup).size).toBe(0);
});
it('returns empty set for single fragment', () => {
const b1 = makeParagraphBlock('b1', MATCHING_BORDERS);
const lookup = buildLookup([{ block: b1 }]);
- expect(computeBetweenBorderFlags([paraFragment('b1')], lookup).size).toBe(0);
+ expect(runFlags([paraFragment('b1')], lookup).size).toBe(0);
});
// --- edge: missing block in lookup ---
@@ -667,7 +648,7 @@ describe('computeBetweenBorderFlags', () => {
// b1 is not in lookup
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
// --- edge: between borders match but other sides differ ---
@@ -686,7 +667,7 @@ describe('computeBetweenBorderFlags', () => {
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
// Full border hash differs (top is different), so not same border group
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
// --- edge: last fragment on page ---
@@ -695,7 +676,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }]);
const fragments: Fragment[] = [paraFragment('b1')];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.has(0)).toBe(false);
});
@@ -709,7 +690,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1', { y: 100, x: 0 }), paraFragment('b2', { y: 0, x: 300 })];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.size).toBe(0);
});
@@ -723,7 +704,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1', { y: 0, x: 50 }), paraFragment('b2', { y: 16, x: 50 })];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.size).toBe(2);
});
@@ -741,7 +722,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1', { y: 0 }), paraFragment('b2', { y: 20 })];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.size).toBe(2);
// First fragment: suppressBottomBorder (not showBetweenBorder)
expect(flags.get(0)?.showBetweenBorder).toBe(false);
@@ -769,7 +750,7 @@ describe('computeBetweenBorderFlags', () => {
paraFragment('b3', { y: 40 }),
];
- const flags = computeBetweenBorderFlags(fragments, lookup);
+ const flags = runFlags(fragments, lookup);
expect(flags.size).toBe(3);
// First: suppress bottom, keep top
expect(flags.get(0)?.suppressBottomBorder).toBe(true);
@@ -796,7 +777,7 @@ describe('computeBetweenBorderFlags', () => {
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
- expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
+ expect(runFlags(fragments, lookup).size).toBe(0);
});
});
diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts
index d2996105ef..caae556b0d 100644
--- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts
+++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts
@@ -8,15 +8,7 @@
* @ooxml w:pPr/w:pBdr/w:between โ between border for grouped paragraphs
* @spec ECMA-376 ยง17.3.1.24 (pBdr)
*/
-import type {
- Fragment,
- ListItemFragment,
- ListBlock,
- ListMeasure,
- ParagraphBlock,
- ParagraphAttrs,
-} from '@superdoc/contracts';
-import type { BlockLookup } from './types.js';
+import type { Fragment, ListItemFragment, ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts';
import { hashParagraphBorders } from '../../paragraph-hash-utils.js';
/**
@@ -33,74 +25,26 @@ export type BetweenBorderInfo = {
gapBelow: number;
};
-/**
- * Extracts the paragraph borders for a fragment, looking up the block data.
- * Handles both paragraph and list-item fragments.
- */
-export const getFragmentParagraphBorders = (
- fragment: Fragment,
- blockLookup: BlockLookup,
-): ParagraphAttrs['borders'] | undefined => {
- const lookup = blockLookup.get(fragment.blockId);
- if (!lookup) return undefined;
-
- if (fragment.kind === 'para' && lookup.block.kind === 'paragraph') {
- return (lookup.block as ParagraphBlock).attrs?.borders;
- }
-
- if (fragment.kind === 'list-item' && lookup.block.kind === 'list') {
- const block = lookup.block as ListBlock;
- const item = block.items.find((entry) => entry.id === fragment.itemId);
- return item?.paragraph.attrs?.borders;
- }
-
- return undefined;
-};
-
-/**
- * Computes the height of a fragment from its measured line heights.
- * Used to calculate the spacing gap between consecutive fragments.
- */
-export const getFragmentHeight = (fragment: Fragment, blockLookup: BlockLookup): number => {
- if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') {
- return fragment.height;
- }
-
- const lookup = blockLookup.get(fragment.blockId);
- if (!lookup) return 0;
-
- if (fragment.kind === 'para' && lookup.measure.kind === 'paragraph') {
- const lines = fragment.lines ?? lookup.measure.lines.slice(fragment.fromLine, fragment.toLine);
- let totalHeight = 0;
- for (const line of lines) {
- totalHeight += line.lineHeight ?? 0;
- }
- return totalHeight;
- }
-
- if (fragment.kind === 'list-item' && lookup.measure.kind === 'list') {
- const listMeasure = lookup.measure as ListMeasure;
- const item = listMeasure.items.find((it) => it.itemId === fragment.itemId);
- if (!item) return 0;
- const lines = item.paragraph.lines.slice(fragment.fromLine, fragment.toLine);
- let totalHeight = 0;
- for (const line of lines) {
- totalHeight += line.lineHeight ?? 0;
- }
- return totalHeight;
- }
-
- return 0;
-};
-
/**
* Whether a between border is effectively absent (nil/none or missing).
*/
-const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => {
+const isBetweenBorderNone = (borders: ResolvedFragmentItem['paragraphBorders']): boolean => {
if (!borders?.between) return true;
return borders.between.style === 'none';
};
+/**
+ * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item)
+ * with pre-computed paragraph border data.
+ */
+function isResolvedFragmentWithBorders(
+ item: ResolvedPaintItem | undefined,
+): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } {
+ return (
+ item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined
+ );
+}
+
/**
* Pre-computes per-fragment between-border rendering info for a page.
*
@@ -126,7 +70,7 @@ const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => {
*/
export const computeBetweenBorderFlags = (
fragments: readonly Fragment[],
- blockLookup: BlockLookup,
+ resolvedItems: readonly ResolvedPaintItem[],
): Map => {
// Phase 1: determine which consecutive pairs form between-border groups
const pairFlags = new Set();
@@ -137,8 +81,9 @@ export const computeBetweenBorderFlags = (
if (frag.kind !== 'para' && frag.kind !== 'list-item') continue;
if (frag.continuesOnNext) continue;
- const borders = getFragmentParagraphBorders(frag, blockLookup);
- if (!borders) continue;
+ const resolvedCur = resolvedItems[i];
+ if (!isResolvedFragmentWithBorders(resolvedCur)) continue;
+ const borders = resolvedCur.paragraphBorders;
const next = fragments[i + 1];
if (next.kind !== 'para' && next.kind !== 'list-item') continue;
@@ -152,9 +97,20 @@ export const computeBetweenBorderFlags = (
)
continue;
- const nextBorders = getFragmentParagraphBorders(next, blockLookup);
- if (!nextBorders) continue;
- if (hashParagraphBorders(borders) !== hashParagraphBorders(nextBorders)) continue;
+ const resolvedNext = resolvedItems[i + 1];
+ if (!isResolvedFragmentWithBorders(resolvedNext)) continue;
+ const nextBorders = resolvedNext.paragraphBorders;
+
+ // Compare using pre-computed hashes when available, falling back to computing on-the-fly.
+ const curHash =
+ 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash
+ ? (resolvedCur as ResolvedFragmentItem).paragraphBorderHash!
+ : hashParagraphBorders(borders);
+ const nextHash =
+ 'paragraphBorderHash' in resolvedNext && (resolvedNext as ResolvedFragmentItem).paragraphBorderHash
+ ? (resolvedNext as ResolvedFragmentItem).paragraphBorderHash!
+ : hashParagraphBorders(nextBorders);
+ if (curHash !== nextHash) continue;
// Skip fragments in different columns (different x positions)
if (frag.x !== next.x) continue;
@@ -175,7 +131,8 @@ export const computeBetweenBorderFlags = (
for (const i of pairFlags) {
const frag = fragments[i];
const next = fragments[i + 1];
- const fragHeight = getFragmentHeight(frag, blockLookup);
+ const resolvedCur = resolvedItems[i];
+ const fragHeight = resolvedCur && 'height' in resolvedCur && resolvedCur.height != null ? resolvedCur.height : 0;
const gapBelow = Math.max(0, next.y - (frag.y + fragHeight));
const isNoBetween = noBetweenPairs.has(i);
diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts
index 16dbf581f1..79084b6abe 100644
--- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts
+++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts
@@ -15,7 +15,7 @@
*/
// Group analysis
-export { computeBetweenBorderFlags, getFragmentParagraphBorders, getFragmentHeight } from './group-analysis.js';
+export { computeBetweenBorderFlags } from './group-analysis.js';
export type { BetweenBorderInfo } from './group-analysis.js';
// DOM layers and CSS
@@ -27,6 +27,3 @@ export {
stampBetweenBorderDataset,
computeBorderSpaceExpansion,
} from './border-layer.js';
-
-// Shared types
-export type { BlockLookup, BlockLookupEntry } from './types.js';
diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts
deleted file mode 100644
index 12cdf624c8..0000000000
--- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Shared types for the DomPainter rendering pipeline.
- *
- * BlockLookup is the canonical definition โ renderer.ts and feature modules
- * both import from here to avoid circular dependencies.
- */
-import type { FlowBlock, Measure } from '@superdoc/contracts';
-
-export type BlockLookupEntry = {
- block: FlowBlock;
- measure: Measure;
- version: string;
-};
-
-export type BlockLookup = Map;
diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index 3a1e8f57bf..75fc7c10d5 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js';
import { DomPainter } from './renderer.js';
+import { resolveLayout } from '@superdoc/layout-resolved';
import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js';
import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js';
import type {
@@ -26,14 +27,9 @@ const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageG
* rewriting every call site.
*/
function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } & DomPainterOptions) {
- const { blocks: initBlocks, measures: initMeasures, ...painterOpts } = opts;
+ const { blocks: initBlocks, measures: initMeasures, headerProvider, footerProvider, ...painterOpts } = opts;
let lastPaintSnapshot: PaintSnapshot | null = null;
- const painter = createDomPainter({
- ...painterOpts,
- onPaintSnapshot: (snapshot) => {
- lastPaintSnapshot = snapshot;
- },
- });
+
let currentBlocks: FlowBlock[] = initBlocks ?? [];
let currentMeasures: Measure[] = initMeasures ?? [];
let currentResolved: ResolvedLayout = emptyResolved;
@@ -41,18 +37,75 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] }
let headerMeasures: Measure[] | undefined;
let footerBlocks: FlowBlock[] | undefined;
let footerMeasures: Measure[] | undefined;
+ let resolvedLayoutOverridden = false;
+
+ /**
+ * Resolve decoration items from the currently-registered decoration blocks/measures
+ * (plus body blocks, which historically also carry decoration block ids in tests).
+ * This lets tests keep using providers that return `{ fragments, height }` without items:
+ * the wrapper synthesizes `items` by running the fragments through `resolveLayout`.
+ */
+ const resolveDecorationItems = (
+ fragments: readonly import('@superdoc/contracts').Fragment[],
+ kind: 'header' | 'footer',
+ ): import('@superdoc/contracts').ResolvedPaintItem[] | undefined => {
+ const decorationBlocks = kind === 'header' ? headerBlocks : footerBlocks;
+ const decorationMeasures = kind === 'header' ? headerMeasures : footerMeasures;
+ const mergedBlocks = [...(currentBlocks ?? []), ...(decorationBlocks ?? [])];
+ const mergedMeasures = [...(currentMeasures ?? []), ...(decorationMeasures ?? [])];
+ if (mergedBlocks.length !== mergedMeasures.length || mergedBlocks.length === 0) {
+ return undefined;
+ }
+ const fakeLayout: Layout = { pageSize: { w: 400, h: 500 }, pages: [{ number: 1, fragments: [...fragments] }] };
+ try {
+ const resolved = resolveLayout({
+ layout: fakeLayout,
+ flowMode: opts.flowMode ?? 'paginated',
+ blocks: mergedBlocks,
+ measures: mergedMeasures,
+ });
+ return resolved.pages[0]?.items;
+ } catch {
+ return undefined;
+ }
+ };
+
+ const wrapProvider = (
+ provider: import('./renderer.js').PageDecorationProvider | undefined,
+ kind: 'header' | 'footer',
+ ): import('./renderer.js').PageDecorationProvider | undefined => {
+ if (!provider) return undefined;
+ return (pageNumber, pageMargins, page) => {
+ const payload = provider(pageNumber, pageMargins, page);
+ if (!payload) return payload;
+ if (payload.items) return payload;
+ const items = resolveDecorationItems(payload.fragments, kind);
+ return items ? { ...payload, items } : payload;
+ };
+ };
+
+ const painter = createDomPainter({
+ ...painterOpts,
+ headerProvider: wrapProvider(headerProvider, 'header'),
+ footerProvider: wrapProvider(footerProvider, 'footer'),
+ onPaintSnapshot: (snapshot) => {
+ lastPaintSnapshot = snapshot;
+ },
+ });
return {
paint(layout: Layout, mount: HTMLElement, mapping?: unknown) {
+ const effectiveResolved = resolvedLayoutOverridden
+ ? currentResolved
+ : resolveLayout({
+ layout,
+ flowMode: opts.flowMode ?? 'paginated',
+ blocks: currentBlocks,
+ measures: currentMeasures,
+ });
const input: DomPainterInput = {
- resolvedLayout: currentResolved,
+ resolvedLayout: effectiveResolved,
sourceLayout: layout,
- blocks: currentBlocks,
- measures: currentMeasures,
- headerBlocks,
- headerMeasures,
- footerBlocks,
- footerMeasures,
};
painter.paint(input, mount, mapping as any);
},
@@ -73,6 +126,7 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] }
},
setResolvedLayout(rl: ResolvedLayout | null) {
currentResolved = rl ?? emptyResolved;
+ resolvedLayoutOverridden = true;
},
setProviders: painter.setProviders,
setVirtualizationPins: painter.setVirtualizationPins,
@@ -1357,7 +1411,10 @@ describe('DomPainter', () => {
expect(lines[1].style.wordSpacing).toBe('');
});
- it('renders an error placeholder when a legacy table fragment is missing its lookup entry', () => {
+ it('surfaces a missing-block error from resolveLayout when a table fragment references an unknown block', () => {
+ // Previous behavior: painter rendered a placeholder for missing lookup entries.
+ // New behavior: resolveLayout validates block/measure integrity upstream and throws
+ // before the painter runs. Missing-block bugs are now caught at the resolved stage.
const missingTableLayout: Layout = {
pageSize: { w: 300, h: 300 },
pages: [
@@ -1379,19 +1436,8 @@ describe('DomPainter', () => {
],
};
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {
- // Intentionally empty - suppress expected error logging during this regression test.
- });
-
const painter = createTestPainter({ blocks: [], measures: [] });
- expect(() => painter.paint(missingTableLayout, mount)).not.toThrow();
-
- const placeholder = mount.querySelector('.render-error-placeholder') as HTMLElement | null;
- expect(placeholder).toBeTruthy();
- expect(placeholder?.textContent).toContain('[Render Error: missing-table]');
- expect(consoleErrorSpy).toHaveBeenCalled();
-
- consoleErrorSpy.mockRestore();
+ expect(() => painter.paint(missingTableLayout, mount)).toThrow(/Missing block\/measure/);
});
it('renders an error placeholder when table-cell line rendering throws', () => {
@@ -1680,8 +1726,9 @@ describe('DomPainter', () => {
});
it('throws if blocks and measures length mismatch', () => {
+ // Block/measure integrity is now validated at the resolve-layout stage.
const painter = createTestPainter({ blocks: [block], measures: [] });
- expect(() => painter.paint(layout, mount)).toThrow(/same number of blocks/);
+ expect(() => painter.paint(layout, mount)).toThrow();
});
it('renders placeholder content for empty lines', () => {
@@ -3029,7 +3076,7 @@ describe('DomPainter', () => {
expect(appliedWordSpacing).toBeCloseTo(expectedWordSpacing, 5);
});
- it('reuses fragment DOM nodes when layout geometry changes', () => {
+ it('rebuilds fragment DOM nodes when layout geometry changes to keep line epochs in sync', () => {
const painter = createTestPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);
@@ -3051,9 +3098,12 @@ describe('DomPainter', () => {
painter.paint(movedLayout, mount);
const fragmentAfter = mount.querySelector('.superdoc-fragment') as HTMLElement;
+ const lineAfter = fragmentAfter.querySelector('.superdoc-line') as HTMLElement;
- expect(fragmentAfter).toBe(fragmentBefore);
+ expect(fragmentAfter).not.toBe(fragmentBefore);
expect(fragmentAfter.style.left).toBe('60px');
+ expect(fragmentAfter.dataset.layoutEpoch).toBeTruthy();
+ expect(lineAfter.dataset.layoutEpoch).toBe(fragmentAfter.dataset.layoutEpoch);
});
it('rebuilds fragment DOM when block content changes via setData', () => {
@@ -4967,6 +5017,8 @@ describe('DomPainter', () => {
fragmentKind: 'list-item',
blockId: 'list-1',
fragmentIndex: 0,
+ block: listBlock as import('@superdoc/contracts').ListBlock,
+ measure: listMeasure as import('@superdoc/contracts').ListMeasure,
},
],
},
@@ -4996,6 +5048,8 @@ describe('DomPainter', () => {
fragmentKind: 'list-item',
blockId: 'list-1',
fragmentIndex: 0,
+ block: listBlock as import('@superdoc/contracts').ListBlock,
+ measure: listMeasure as import('@superdoc/contracts').ListMeasure,
},
],
},
@@ -5016,10 +5070,13 @@ describe('DomPainter', () => {
painter.paint(updatedLayout, mount);
const updatedWrapper = mount.querySelector('.superdoc-fragment-list-item') as HTMLElement;
- expect(updatedWrapper).toBe(initialWrapper);
+ const updatedLine = updatedWrapper.querySelector('.superdoc-line') as HTMLElement;
+ expect(updatedWrapper).not.toBe(initialWrapper);
expect(updatedWrapper.style.left).toBe('90px');
expect(updatedWrapper.style.top).toBe('55px');
expect(updatedWrapper.style.width).toBe('310px');
+ expect(updatedWrapper.dataset.layoutEpoch).toBeTruthy();
+ expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch);
});
it('applies resolved zIndex only to anchored media fragments', () => {
@@ -5110,6 +5167,7 @@ describe('DomPainter', () => {
fragmentKind: 'drawing',
blockId: 'drawing-anchored',
fragmentIndex: 0,
+ block: anchoredDrawingBlock as import('@superdoc/contracts').DrawingBlock,
},
{
kind: 'fragment',
@@ -5123,6 +5181,7 @@ describe('DomPainter', () => {
fragmentKind: 'drawing',
blockId: 'drawing-inline',
fragmentIndex: 1,
+ block: inlineDrawingBlock as import('@superdoc/contracts').DrawingBlock,
},
],
},
@@ -5261,6 +5320,8 @@ describe('DomPainter', () => {
fragmentKind: 'para',
blockId: 'resolved-indent',
fragmentIndex: 0,
+ block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock,
+ measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure,
content: {
lines: [
{
@@ -5354,6 +5415,8 @@ describe('DomPainter', () => {
fragmentKind: 'para',
blockId: 'resolved-marker',
fragmentIndex: 0,
+ block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock,
+ measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure,
content: {
lines: [
{
@@ -5447,6 +5510,8 @@ describe('DomPainter', () => {
fragmentKind: 'para',
blockId: 'resolved-drop-cap',
fragmentIndex: 0,
+ block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock,
+ measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure,
content: {
lines: [
{
diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts
index fcbe74c7f4..a7a701be12 100644
--- a/packages/layout-engine/painters/dom/src/index.ts
+++ b/packages/layout-engine/painters/dom/src/index.ts
@@ -1,7 +1,16 @@
-import type { FlowBlock, Fragment, Layout, Measure, Page, PageMargins, ResolvedLayout } from '@superdoc/contracts';
+import type { FlowBlock, Layout, Measure, PageMargins, ResolvedLayout, Page } from '@superdoc/contracts';
import { DomPainter } from './renderer.js';
+import { resolveLayout } from '@superdoc/layout-resolved';
import type { PageStyles } from './styles.js';
-import type { DomPainterInput, PaintSnapshot, PositionMapping, RulerOptions, FlowMode } from './renderer.js';
+import type {
+ DomPainterInput,
+ PageDecorationPayload,
+ PageDecorationProvider,
+ PaintSnapshot,
+ PositionMapping,
+ RulerOptions,
+ FlowMode,
+} from './renderer.js';
// Re-export constants
export { DOM_CLASS_NAMES } from './constants.js';
@@ -55,32 +64,7 @@ export type { PmPositionValidationStats } from './pm-position-validation.js';
export type LayoutMode = 'vertical' | 'horizontal' | 'book';
export type { FlowMode } from './renderer.js';
-export type PageDecorationPayload = {
- fragments: Fragment[];
- height: number;
- /**
- * Decoration fragments are expressed in header/footer-local coordinates.
- * Header/footer layout normalizes page- and margin-relative anchors before
- * they reach the painter.
- */
- /** Optional measured content height; when provided, footer content will be bottom-aligned within its box. */
- contentHeight?: number;
- offset?: number;
- marginLeft?: number;
- contentWidth?: number;
- headerFooterRefId?: string;
- sectionType?: string;
- /** Minimum Y coordinate from layout; negative when content extends above y=0 */
- minY?: number;
- box?: { x: number; y: number; width: number; height: number };
- hitRegion?: { x: number; y: number; width: number; height: number };
-};
-
-export type PageDecorationProvider = (
- pageNumber: number,
- pageMargins?: PageMargins,
- page?: Page,
-) => PageDecorationPayload | null;
+export type { PageDecorationPayload, PageDecorationProvider } from './renderer.js';
export type DomPainterOptions = {
/**
@@ -132,32 +116,16 @@ export type DomPainterOptions = {
type LegacyDomPainterState = {
blocks: FlowBlock[];
measures: Measure[];
- headerBlocks?: FlowBlock[];
- headerMeasures?: Measure[];
- footerBlocks?: FlowBlock[];
- footerMeasures?: Measure[];
resolvedLayout: ResolvedLayout | null;
};
-type BlockMeasurePair = {
- blocks: FlowBlock[];
- measures: Measure[];
-};
-
export type DomPainterHandle = {
paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping): void;
/**
* Legacy compatibility API.
* New callers should pass block/measure data via `paint(input, mount)`.
*/
- setData(
- blocks: FlowBlock[],
- measures: Measure[],
- headerBlocks?: FlowBlock[],
- headerMeasures?: Measure[],
- footerBlocks?: FlowBlock[],
- footerMeasures?: Measure[],
- ): void;
+ setData(blocks: FlowBlock[], measures: Measure[]): void;
/**
* Legacy compatibility API.
* New callers should pass resolved data via `paint(input, mount)`.
@@ -177,26 +145,6 @@ function assertRequiredBlockMeasurePair(label: string, blocks: FlowBlock[], meas
}
}
-function normalizeOptionalBlockMeasurePair(
- label: 'header' | 'footer',
- blocks: FlowBlock[] | undefined,
- measures: Measure[] | undefined,
-): BlockMeasurePair | undefined {
- const hasBlocks = blocks !== undefined;
- const hasMeasures = measures !== undefined;
-
- if (hasBlocks !== hasMeasures) {
- throw new Error(`${label}Blocks and ${label}Measures must both be provided or both be omitted.`);
- }
-
- if (!hasBlocks || !hasMeasures) {
- return undefined;
- }
-
- assertRequiredBlockMeasurePair(label, blocks, measures);
- return { blocks, measures };
-}
-
function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: number | undefined): ResolvedLayout {
return {
version: 1,
@@ -207,7 +155,7 @@ function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: numb
}
function isDomPainterInput(value: DomPainterInput | Layout): value is DomPainterInput {
- return 'resolvedLayout' in value && 'sourceLayout' in value && 'blocks' in value && 'measures' in value;
+ return 'resolvedLayout' in value && 'sourceLayout' in value;
}
function buildLegacyPaintInput(
@@ -216,15 +164,26 @@ function buildLegacyPaintInput(
flowMode: FlowMode | undefined,
pageGap: number | undefined,
): DomPainterInput {
+ // Derive a resolved layout from the legacy block/measure state when the caller
+ // has not supplied one via `setResolvedLayout`. The painter now reads all body
+ // fragment data from the resolved layout, so an empty resolved layout would
+ // produce a blank render.
+ let resolvedLayout: ResolvedLayout;
+ if (legacyState.resolvedLayout) {
+ resolvedLayout = legacyState.resolvedLayout;
+ } else if (legacyState.blocks.length === 0 && legacyState.measures.length === 0) {
+ resolvedLayout = createEmptyResolvedLayout(flowMode, pageGap);
+ } else {
+ resolvedLayout = resolveLayout({
+ layout,
+ flowMode: flowMode ?? 'paginated',
+ blocks: legacyState.blocks,
+ measures: legacyState.measures,
+ });
+ }
return {
- resolvedLayout: legacyState.resolvedLayout ?? createEmptyResolvedLayout(flowMode, pageGap),
+ resolvedLayout,
sourceLayout: layout,
- blocks: legacyState.blocks,
- measures: legacyState.measures,
- headerBlocks: legacyState.headerBlocks,
- headerMeasures: legacyState.headerMeasures,
- footerBlocks: legacyState.footerBlocks,
- footerMeasures: legacyState.footerMeasures,
};
}
@@ -258,24 +217,10 @@ export const createDomPainter = (options: DomPainterOptions): DomPainterHandle =
: buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap);
painter.paint(normalizedInput, mount, mapping);
},
- setData(
- blocks: FlowBlock[],
- measures: Measure[],
- headerBlocks?: FlowBlock[],
- headerMeasures?: Measure[],
- footerBlocks?: FlowBlock[],
- footerMeasures?: Measure[],
- ) {
+ setData(blocks: FlowBlock[], measures: Measure[]) {
assertRequiredBlockMeasurePair('body', blocks, measures);
- const normalizedHeader = normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures);
- const normalizedFooter = normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures);
-
legacyState.blocks = blocks;
legacyState.measures = measures;
- legacyState.headerBlocks = normalizedHeader?.blocks;
- legacyState.headerMeasures = normalizedHeader?.measures;
- legacyState.footerBlocks = normalizedFooter?.blocks;
- legacyState.footerMeasures = normalizedFooter?.measures;
},
setResolvedLayout(resolvedLayout: ResolvedLayout | null) {
legacyState.resolvedLayout = resolvedLayout;
diff --git a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts
new file mode 100644
index 0000000000..21dea44686
--- /dev/null
+++ b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { DomPainter } from './renderer.js';
+
+function makeFragment(blockId: string, pmStart: number, pmEnd: number) {
+ const fragment = document.createElement('div');
+ fragment.dataset.blockId = blockId;
+ fragment.dataset.pmStart = String(pmStart);
+ fragment.dataset.pmEnd = String(pmEnd);
+
+ const span = document.createElement('span');
+ span.dataset.pmStart = String(pmStart);
+ span.dataset.pmEnd = String(pmEnd);
+ fragment.appendChild(span);
+
+ return { fragment, span };
+}
+
+const shiftByTwo = {
+ map(pos: number) {
+ return pos + 2;
+ },
+ maps: [{}],
+};
+
+describe('DomPainter.updatePositionAttributes', () => {
+ it('does not remap footnote fragments with body transaction mappings', () => {
+ const painter = new DomPainter();
+ const { fragment, span } = makeFragment('footnote-1-abc', 2, 30);
+
+ (painter as any).updatePositionAttributes(fragment, shiftByTwo);
+
+ expect(fragment.dataset.pmStart).toBe('2');
+ expect(fragment.dataset.pmEnd).toBe('30');
+ expect(span.dataset.pmStart).toBe('2');
+ expect(span.dataset.pmEnd).toBe('30');
+ });
+
+ it('still remaps body fragments when the mapping applies', () => {
+ const painter = new DomPainter();
+ const { fragment, span } = makeFragment('body-paragraph-1', 25, 30);
+
+ (painter as any).updatePositionAttributes(fragment, shiftByTwo);
+
+ expect(fragment.dataset.pmStart).toBe('27');
+ expect(fragment.dataset.pmEnd).toBe('32');
+ expect(span.dataset.pmStart).toBe('27');
+ expect(span.dataset.pmEnd).toBe('32');
+ });
+});
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index ce6869b26d..72762ca504 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -120,8 +120,6 @@ import {
} from './utils/sdt-helpers.js';
import {
computeBetweenBorderFlags,
- getFragmentParagraphBorders,
- getFragmentHeight,
createParagraphDecorationLayers,
applyParagraphBorderStyles,
applyParagraphShadingStyles,
@@ -249,29 +247,25 @@ export type RenderedLineInfo = {
/**
* Input to `DomPainter.paint()`.
*
- * `resolvedLayout` is the canonical resolved data. The remaining fields are
- * bridge data carried for internal rendering of non-paragraph fragments
- * (tables, images, drawings) that have not yet been migrated to resolved items.
+ * `resolvedLayout` is the canonical resolved data the painter reads from.
+ * `sourceLayout` is the raw Layout retained for legacy internal access paths.
*/
export type DomPainterInput = {
resolvedLayout: ResolvedLayout;
- /** Raw Layout for internal fragment access (bridge โ will be removed once all fragment types are resolved). */
+ /** Raw Layout for internal fragment access. */
sourceLayout: Layout;
- blocks: FlowBlock[];
- measures: Measure[];
- headerBlocks?: FlowBlock[];
- headerMeasures?: Measure[];
- footerBlocks?: FlowBlock[];
- footerMeasures?: Measure[];
};
-type OptionalBlockMeasurePair = {
- blocks: FlowBlock[];
- measures: Measure[];
-};
-
-type PageDecorationPayload = {
+export type PageDecorationPayload = {
fragments: Fragment[];
+ /**
+ * Resolved items aligned 1:1 with `fragments`. Same length, same order.
+ * When omitted, the painter treats fragments as having no resolved metadata
+ * (no paragraph borders, no SDT container keys).
+ */
+ items?: ResolvedPaintItem[];
+ /** Minimum Y coordinate from layout; negative when content extends above y=0. */
+ minY?: number;
height: number;
/** Optional measured content height to aid bottom alignment in footers. */
contentHeight?: number;
@@ -334,10 +328,6 @@ type PainterOptions = {
onPaintSnapshot?: (snapshot: PaintSnapshot) => void;
};
-// BlockLookup lives in the shared types module (single source of truth)
-import type { BlockLookupEntry, BlockLookup } from './features/paragraph-borders/types.js';
-export type { BlockLookup, BlockLookupEntry };
-
type FragmentDomState = {
key: string;
signature: string;
@@ -1229,7 +1219,6 @@ const applyLinkDataset = (element: HTMLElement, dataset?: Record
* ```
*/
export class DomPainter {
- private blockLookup: BlockLookup;
private readonly options: PainterOptions;
private mount: HTMLElement | null = null;
private doc: Document | null = null;
@@ -1305,7 +1294,6 @@ export class DomPainter {
this.options = options;
this.layoutMode = options.layoutMode ?? 'vertical';
this.isSemanticFlow = (options.flowMode ?? 'paginated') === 'semantic';
- this.blockLookup = new Map();
this.headerProvider = options.headerProvider;
this.footerProvider = options.footerProvider;
@@ -1584,71 +1572,10 @@ export class DomPainter {
};
}
- /**
- * Builds a new block lookup from the input data, merging header/footer blocks,
- * and tracks which blocks changed since the last paint cycle.
- */
- private normalizeOptionalBlockMeasurePair(
- label: 'header' | 'footer',
- blocks: FlowBlock[] | undefined,
- measures: Measure[] | undefined,
- ): OptionalBlockMeasurePair | undefined {
- const hasBlocks = blocks !== undefined;
- const hasMeasures = measures !== undefined;
-
- if (hasBlocks !== hasMeasures) {
- throw new Error(
- `DomPainter.paint requires ${label}Blocks and ${label}Measures to both be provided or both be omitted`,
- );
- }
-
- if (!hasBlocks || !hasMeasures) {
- return undefined;
- }
-
- return { blocks, measures };
- }
-
- private updateBlockLookup(input: DomPainterInput): void {
- const { blocks, measures, headerBlocks, headerMeasures, footerBlocks, footerMeasures } = input;
-
- // Build lookup for main document blocks
- const nextLookup = this.buildBlockLookup(blocks, measures);
-
- const normalizedHeader = this.normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures);
- if (normalizedHeader) {
- const headerLookup = this.buildBlockLookup(normalizedHeader.blocks, normalizedHeader.measures);
- headerLookup.forEach((entry, id) => {
- nextLookup.set(id, entry);
- });
- }
-
- const normalizedFooter = this.normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures);
- if (normalizedFooter) {
- const footerLookup = this.buildBlockLookup(normalizedFooter.blocks, normalizedFooter.measures);
- footerLookup.forEach((entry, id) => {
- nextLookup.set(id, entry);
- });
- }
-
- // Track changed blocks
- const changed = new Set();
- nextLookup.forEach((entry, id) => {
- const previous = this.blockLookup.get(id);
- if (!previous || previous.version !== entry.version) {
- changed.add(id);
- }
- });
- this.blockLookup = nextLookup;
- this.changedBlocks = changed;
- }
-
public paint(input: DomPainterInput, mount: HTMLElement, mapping?: PositionMapping): void {
const layout = input.sourceLayout;
this.resolvedLayout = input.resolvedLayout;
-
- // Update block lookup and change tracking (absorbs former setData logic)
- this.updateBlockLookup(input);
+ this.changedBlocks.clear();
if (!(mount instanceof HTMLElement)) {
throw new Error('DomPainter.paint requires a valid HTMLElement mount');
@@ -1665,8 +1592,12 @@ export class DomPainter {
// Complex transactions (paste, multi-step replace, etc.) fall back to full rebuild.
const isSimpleTransaction = mapping && mapping.maps.length === 1;
if (mapping && !isSimpleTransaction) {
- // Complex transaction - force all fragments to rebuild (safe fallback)
- this.blockLookup.forEach((_, id) => this.changedBlocks.add(id));
+ // Complex transaction, force all body fragments to rebuild (safe fallback).
+ for (const page of input.resolvedLayout.pages) {
+ for (const item of page.items) {
+ if ('blockId' in item) this.changedBlocks.add(item.blockId);
+ }
+ }
this.currentMapping = null;
} else {
this.currentMapping = mapping ?? null;
@@ -1689,7 +1620,7 @@ export class DomPainter {
}
this.layoutVersion += 1;
- this.layoutEpoch = layout.layoutEpoch ?? 0;
+ this.layoutEpoch = this.resolvedLayout?.layoutEpoch ?? layout.layoutEpoch ?? 0;
this.mount = mount;
this.beginPaintSnapshot(layout);
@@ -2200,6 +2131,7 @@ export class DomPainter {
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
+ const resolvedPage = this.getResolvedPage(pageIndex);
const el = this.doc.createElement('div');
el.classList.add(CLASS_NAMES.page);
applyStyles(el, pageStyles(width, height, this.getEffectivePageStyles()));
@@ -2210,7 +2142,7 @@ export class DomPainter {
// Render per-page ruler if enabled (suppressed in semantic flow mode)
if (!this.isSemanticFlow && this.options.ruler?.enabled) {
- const rulerEl = this.renderPageRuler(width, page);
+ const rulerEl = this.renderPageRuler(width, page, resolvedPage);
if (rulerEl) {
el.appendChild(rulerEl);
}
@@ -2220,12 +2152,13 @@ export class DomPainter {
pageNumber: page.number,
totalPages: this.totalPages,
section: 'body',
- pageNumberText: page.numberText,
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
- const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);
- const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup);
+ const resolvedItems = resolvedPage?.items ?? [];
+ const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered);
+ const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems);
page.fragments.forEach((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
@@ -2234,9 +2167,8 @@ export class DomPainter {
this.renderFragment(fragment, contextBase, sdtBoundary, betweenBorderFlags.get(index), resolvedItem),
);
});
- this.renderDecorationsForPage(el, page, pageIndex);
- this.renderColumnSeparators(el, page, width, height);
-
+ this.renderDecorationsForPage(el, page, pageIndex, resolvedPage);
+ this.renderColumnSeparators(el, page, width, height, resolvedPage);
return el;
}
@@ -2258,18 +2190,18 @@ export class DomPainter {
* - Uses DEFAULT_PAGE_HEIGHT_PX (1056px = 11 inches) if page.size.h is not available
* - Defaults margins to 0 if not explicitly provided
*/
- private renderPageRuler(pageWidthPx: number, page: Page): HTMLElement | null {
+ private renderPageRuler(pageWidthPx: number, page: Page, resolvedPage?: ResolvedPage | null): HTMLElement | null {
if (!this.doc) {
console.warn('[renderPageRuler] Cannot render ruler: document is not available.');
return null;
}
- if (!page.margins) {
+ const margins = resolvedPage?.margins ?? page.margins;
+ if (!margins) {
console.warn(`[renderPageRuler] Cannot render ruler for page ${page.number}: margins not available.`);
return null;
}
- const margins = page.margins;
const leftMargin = margins.left ?? 0;
const rightMargin = margins.right ?? 0;
@@ -2317,14 +2249,23 @@ export class DomPainter {
}
}
- private renderColumnSeparators(pageEl: HTMLElement, page: Page, pageWidth: number, pageHeight: number): void {
+ private renderColumnSeparators(
+ pageEl: HTMLElement,
+ page: Page,
+ pageWidth: number,
+ pageHeight: number,
+ resolvedPage?: ResolvedPage | null,
+ ): void {
if (!this.doc) return;
- if (!page.margins) return;
+ pageEl.querySelectorAll('[data-superdoc-column-separator="true"]').forEach((separator) => separator.remove());
+
+ const pageMargins = resolvedPage?.margins ?? page.margins;
+ if (!pageMargins) return;
- const leftMargin = page.margins.left ?? 0;
- const rightMargin = page.margins.right ?? 0;
- const topMargin = page.margins.top ?? 0;
- const bottomMargin = page.margins.bottom ?? 0;
+ const leftMargin = pageMargins.left ?? 0;
+ const rightMargin = pageMargins.right ?? 0;
+ const topMargin = pageMargins.top ?? 0;
+ const bottomMargin = pageMargins.bottom ?? 0;
const contentWidth = pageWidth - leftMargin - rightMargin;
// Prefer columnRegions (per-region configs for pages with continuous
@@ -2356,6 +2297,7 @@ export class DomPainter {
for (const separatorX of separatorPositions) {
const separatorEl = this.doc.createElement('div');
+ separatorEl.dataset.superdocColumnSeparator = 'true';
separatorEl.style.position = 'absolute';
separatorEl.style.left = `${separatorX}px`;
@@ -2402,11 +2344,15 @@ export class DomPainter {
return separatorPositions;
}
-
- private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void {
+ private renderDecorationsForPage(
+ pageEl: HTMLElement,
+ page: Page,
+ pageIndex: number,
+ resolvedPage?: ResolvedPage | null,
+ ): void {
if (this.isSemanticFlow) return;
- this.renderDecorationSection(pageEl, page, pageIndex, 'header');
- this.renderDecorationSection(pageEl, page, pageIndex, 'footer');
+ this.renderDecorationSection(pageEl, page, pageIndex, 'header', resolvedPage);
+ this.renderDecorationSection(pageEl, page, pageIndex, 'footer', resolvedPage);
}
/**
@@ -2414,16 +2360,12 @@ export class DomPainter {
* Used to determine special Y positioning for page-relative anchored media
* in header/footer decoration sections.
*/
- private isPageRelativeAnchoredFragment(fragment: Fragment): boolean {
+ private isPageRelativeAnchoredFragment(fragment: Fragment, resolvedItem: ResolvedPaintItem | undefined): boolean {
if (fragment.kind !== 'image' && fragment.kind !== 'drawing') {
return false;
}
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup) {
- return false;
- }
- const block = lookup.block;
- if (block.kind !== 'image' && block.kind !== 'drawing') {
+ const block = resolvedItem && 'block' in resolvedItem ? resolvedItem.block : undefined;
+ if (!block || (block.kind !== 'image' && block.kind !== 'drawing')) {
return false;
}
return block.anchor?.vRelativeFrom === 'page';
@@ -2444,17 +2386,19 @@ export class DomPainter {
page: Page,
kind: 'header' | 'footer',
effectiveOffset: number,
+ resolvedPage?: ResolvedPage | null,
): number {
if (kind === 'header') {
return effectiveOffset;
}
- const bottomMargin = page.margins?.bottom;
+ const pageMargins = resolvedPage?.margins ?? page.margins;
+ const bottomMargin = pageMargins?.bottom;
if (bottomMargin == null) {
return effectiveOffset;
}
- const footnoteReserve = page.footnoteReserved ?? 0;
+ const footnoteReserve = resolvedPage?.footnoteReserved ?? page.footnoteReserved ?? 0;
const adjustedBottomMargin = Math.max(0, bottomMargin - footnoteReserve);
const styledPageHeight = Number.parseFloat(pageEl.style.height || '');
const pageHeight =
@@ -2465,11 +2409,18 @@ export class DomPainter {
return Math.max(0, pageHeight - adjustedBottomMargin);
}
- private renderDecorationSection(pageEl: HTMLElement, page: Page, pageIndex: number, kind: 'header' | 'footer'): void {
+ private renderDecorationSection(
+ pageEl: HTMLElement,
+ page: Page,
+ pageIndex: number,
+ kind: 'header' | 'footer',
+ resolvedPage?: ResolvedPage | null,
+ ): void {
if (!this.doc) return;
const provider = kind === 'header' ? this.headerProvider : this.footerProvider;
const className = kind === 'header' ? CLASS_NAMES.pageHeader : CLASS_NAMES.pageFooter;
const existing = pageEl.querySelector(`.${className}`);
+ // Provider still receives legacy page โ its signature is not changed in this PR
const data = provider ? provider(page.number, page.margins, page) : null;
if (!data || data.fragments.length === 0) {
@@ -2482,7 +2433,8 @@ export class DomPainter {
container.innerHTML = '';
const baseOffset = data.offset ?? (kind === 'footer' ? pageEl.clientHeight - data.height : 0);
const marginLeft = data.marginLeft ?? 0;
- const marginRight = page.margins?.right ?? 0;
+ const pageMargins = resolvedPage?.margins ?? page.margins;
+ const marginRight = pageMargins?.right ?? 0;
// For footers, if content is taller than reserved space, expand container upward
// The container bottom stays anchored at footerMargin from page bottom
@@ -2522,7 +2474,7 @@ export class DomPainter {
// Header page-relative anchors use raw inner-layout Y and are handled with
// the simpler effectiveOffset subtraction (unchanged from the baseline).
const footerAnchorPageOriginY =
- kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset) : 0;
+ kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset, resolvedPage) : 0;
const footerAnchorContainerOffsetY = kind === 'footer' ? footerAnchorPageOriginY - effectiveOffset : 0;
// For footers, calculate offset to push content to bottom of container
@@ -2533,9 +2485,10 @@ export class DomPainter {
const contentHeight =
typeof data.contentHeight === 'number'
? data.contentHeight
- : data.fragments.reduce((max, f) => {
+ : data.fragments.reduce((max, f, fi) => {
+ const resolvedItem = data.items?.[fi];
const fragHeight =
- 'height' in f && typeof f.height === 'number' ? f.height : this.estimateFragmentHeight(f);
+ 'height' in f && typeof f.height === 'number' ? f.height : this.estimateFragmentHeight(f, resolvedItem);
return Math.max(max, f.y + Math.max(0, fragHeight));
}, 0);
// Offset to push content to bottom of container
@@ -2547,12 +2500,13 @@ export class DomPainter {
pageNumber: page.number,
totalPages: this.totalPages,
section: kind,
- pageNumberText: page.numberText,
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
// Compute between-border flags for header/footer paragraph fragments
- const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, this.blockLookup);
+ const decorationItems = data.items ?? [];
+ const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, decorationItems);
// Separate behindDoc fragments from normal fragments.
// Prefer explicit fragment.behindDoc when present. Keep zIndex===0 as a
@@ -2565,10 +2519,11 @@ export class DomPainter {
const fragment = data.fragments[fi];
let isBehindDoc = false;
if (fragment.kind === 'image' || fragment.kind === 'drawing') {
+ const resolvedItem = decorationItems[fi] as ResolvedDrawingItem | undefined;
isBehindDoc =
fragment.behindDoc === true ||
(fragment.behindDoc == null && 'zIndex' in fragment && fragment.zIndex === 0) ||
- this.shouldRenderBehindPageContent(fragment, kind);
+ this.shouldRenderBehindPageContent(fragment, kind, resolvedItem);
}
if (isBehindDoc) {
behindDocFragments.push({ fragment, originalIndex: fi });
@@ -2589,8 +2544,15 @@ export class DomPainter {
// By inserting at the beginning and using z-index: 0, they render below body content
// which also has z-index values but comes later in DOM order.
behindDocFragments.forEach(({ fragment, originalIndex }) => {
- const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex));
- const isPageRelative = this.isPageRelativeAnchoredFragment(fragment);
+ const resolvedItem = data.items?.[originalIndex];
+ const fragEl = this.renderFragment(
+ fragment,
+ context,
+ undefined,
+ betweenBorderFlags.get(originalIndex),
+ resolvedItem,
+ );
+ const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem);
let pageY: number;
if (isPageRelative && kind === 'footer') {
@@ -2613,8 +2575,15 @@ export class DomPainter {
// Render normal fragments in the header/footer container
normalFragments.forEach(({ fragment, originalIndex }) => {
- const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex));
- const isPageRelative = this.isPageRelativeAnchoredFragment(fragment);
+ const resolvedItem = data.items?.[originalIndex];
+ const fragEl = this.renderFragment(
+ fragment,
+ context,
+ undefined,
+ betweenBorderFlags.get(originalIndex),
+ resolvedItem,
+ );
+ const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem);
if (isPageRelative && kind === 'footer') {
// Footer page-relative: fragment.y is normalized to band-local coords
@@ -2719,6 +2688,7 @@ export class DomPainter {
}
private patchPage(state: PageDomState, page: Page, pageSize: { w: number; h: number }, pageIndex: number): void {
+ const resolvedPage = this.getResolvedPage(pageIndex);
const pageEl = state.element;
applyStyles(pageEl, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles()));
this.applySemanticPageOverrides(pageEl);
@@ -2728,14 +2698,15 @@ export class DomPainter {
const existing = new Map(state.fragments.map((frag) => [frag.key, frag]));
const nextFragments: FragmentDomState[] = [];
- const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);
- const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup);
+ const resolvedItems = resolvedPage?.items ?? [];
+ const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered);
+ const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems);
const contextBase: FragmentRenderContext = {
pageNumber: page.number,
totalPages: this.totalPages,
section: 'body',
- pageNumberText: page.numberText,
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
@@ -2745,9 +2716,11 @@ export class DomPainter {
const sdtBoundary = sdtBoundaries.get(index);
const betweenInfo = betweenBorderFlags.get(index);
const resolvedItem = this.getResolvedFragmentItem(pageIndex, index);
+ const resolvedSig = (resolvedItem as { version?: string } | undefined)?.version ?? '';
if (current) {
existing.delete(key);
+ const geometryChanged = hasFragmentGeometryChanged(current.fragment, fragment);
const sdtBoundaryMismatch = shouldRebuildForSdtBoundary(current.element, sdtBoundary);
// Detect mismatch in any between-border property
const betweenBorderMismatch =
@@ -2764,8 +2737,9 @@ export class DomPainter {
current.element.dataset.pmStart != null &&
this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart;
const needsRebuild =
+ geometryChanged ||
this.changedBlocks.has(fragment.blockId) ||
- current.signature !== fragmentSignature(fragment, this.blockLookup) ||
+ current.signature !== resolvedSig ||
sdtBoundaryMismatch ||
betweenBorderMismatch ||
mappingUnreliable;
@@ -2774,7 +2748,7 @@ export class DomPainter {
const replacement = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem);
pageEl.replaceChild(replacement, current.element);
current.element = replacement;
- current.signature = fragmentSignature(fragment, this.blockLookup);
+ current.signature = resolvedSig;
} else if (this.currentMapping) {
// Fragment NOT rebuilt - update position attributes to reflect document changes
this.updatePositionAttributes(current.element, this.currentMapping);
@@ -2798,7 +2772,7 @@ export class DomPainter {
key,
fragment,
element: fresh,
- signature: fragmentSignature(fragment, this.blockLookup),
+ signature: resolvedSig,
context: contextBase,
});
});
@@ -2813,7 +2787,8 @@ export class DomPainter {
});
state.fragments = nextFragments;
- this.renderDecorationsForPage(pageEl, page, pageIndex);
+ this.renderDecorationsForPage(pageEl, page, pageIndex, resolvedPage);
+ this.renderColumnSeparators(pageEl, page, pageSize.w, pageSize.h, resolvedPage);
}
/**
@@ -2826,6 +2801,10 @@ export class DomPainter {
if (fragmentEl.closest('.superdoc-page-header, .superdoc-page-footer')) {
return;
}
+ // Notes use local story positions, so body mappings must not rewrite them.
+ if (isNonBodyStoryBlockId(fragmentEl.dataset.blockId)) {
+ return;
+ }
// Wrap mapping logic in try-catch to prevent corrupted mappings from crashing paint cycle
try {
@@ -2873,6 +2852,7 @@ export class DomPainter {
if (!this.doc) {
throw new Error('DomPainter.createPageState requires a document');
}
+ const resolvedPage = this.getResolvedPage(pageIndex);
const el = this.doc.createElement('div');
el.classList.add(CLASS_NAMES.page);
applyStyles(el, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles()));
@@ -2883,11 +2863,13 @@ export class DomPainter {
pageNumber: page.number,
totalPages: this.totalPages,
section: 'body',
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
- const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);
- const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup);
+ const resolvedItems = resolvedPage?.items ?? [];
+ const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered);
+ const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems);
const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
const resolvedItem = this.getResolvedFragmentItem(pageIndex, index);
@@ -2899,18 +2881,18 @@ export class DomPainter {
resolvedItem,
);
el.appendChild(fragmentEl);
+ const initSig = (resolvedItem as { version?: string } | undefined)?.version ?? '';
return {
key: fragmentKey(fragment),
- signature: fragmentSignature(fragment, this.blockLookup),
+ signature: initSig,
fragment,
element: fragmentEl,
context: contextBase,
};
});
- this.renderDecorationsForPage(el, page, pageIndex);
- this.renderColumnSeparators(el, page, pageSize.w, pageSize.h);
-
+ this.renderDecorationsForPage(el, page, pageIndex, resolvedPage);
+ this.renderColumnSeparators(el, page, pageSize.w, pageSize.h, resolvedPage);
return { element: el, fragments: fragmentStates };
}
@@ -2995,27 +2977,31 @@ export class DomPainter {
resolvedItem?: ResolvedFragmentItem,
): HTMLElement {
try {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
- throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
- }
-
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
- const block = lookup.block as ParagraphBlock;
- const measure = lookup.measure as ParagraphMeasure;
+ // Pre-extracted block/measure from the resolved item.
+ if (resolvedItem?.block?.kind !== 'paragraph' || resolvedItem?.measure?.kind !== 'paragraph') {
+ throw new Error(`DomPainter: missing resolved paragraph block/measure for fragment ${fragment.blockId}`);
+ }
+ const block = resolvedItem.block as ParagraphBlock;
+ const measure = resolvedItem.measure as ParagraphMeasure;
const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined;
const content = resolvedItem?.content;
+ // Prefer resolved item metadata over legacy fragment reads
+ const paraContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev;
+ const paraContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext;
+ const paraMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth;
+
const fragmentEl = this.doc.createElement('div');
fragmentEl.classList.add(CLASS_NAMES.fragment);
// For TOC entries, override white-space to prevent wrapping
const isTocEntry = block.attrs?.isTocEntry;
// For fragments with markers, allow overflow to show markers positioned at negative left
- const hasMarker = !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker;
+ const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker;
// SDT containers need overflow visible for tooltips/labels positioned above
const hasSdtContainer =
block.attrs?.sdt?.type === 'documentSection' ||
@@ -3042,10 +3028,10 @@ export class DomPainter {
fragmentEl.classList.add('superdoc-toc-entry');
}
- if (fragment.continuesFromPrev) {
+ if (paraContinuesFromPrev) {
fragmentEl.dataset.continuesFromPrev = 'true';
}
- if (fragment.continuesOnNext) {
+ if (paraContinuesOnNext) {
fragmentEl.dataset.continuesOnNext = 'true';
}
@@ -3101,7 +3087,7 @@ export class DomPainter {
} else {
const dropCapDescriptor = block.attrs?.dropCapDescriptor;
const dropCapMeasure = measure.dropCap;
- if (dropCapDescriptor && dropCapMeasure && !fragment.continuesFromPrev) {
+ if (dropCapDescriptor && dropCapMeasure && !paraContinuesFromPrev) {
const dropCapEl = this.renderDropCap(dropCapDescriptor, dropCapMeasure);
fragmentEl.appendChild(dropCapEl);
}
@@ -3227,7 +3213,7 @@ export class DomPainter {
const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak';
const listFirstLineTextStartPx =
- !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker
+ !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker
? resolvePainterListTextStartPx({
wordLayout,
indentLeftPx: paraIndentLeft,
@@ -3238,8 +3224,8 @@ export class DomPainter {
: undefined;
const shouldUseSharedInlinePrefixGeometry =
- !fragment.continuesFromPrev &&
- fragment.markerWidth &&
+ !paraContinuesFromPrev &&
+ paraMarkerWidth &&
wordLayout?.marker?.justification === 'left' &&
wordLayout.firstLineIndentMode !== true &&
typeof fragment.markerTextWidth === 'number' &&
@@ -3257,7 +3243,7 @@ export class DomPainter {
let listTabWidth = 0;
let markerStartPos = 0;
- if (!fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker) {
+ if (!paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker) {
const markerTextWidth = fragment.markerTextWidth!;
const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0);
const markerJustification = wordLayout.marker.justification ?? 'left';
@@ -3292,8 +3278,7 @@ export class DomPainter {
lines.forEach((line, index) => {
const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true;
- const hasListFirstLineMarker =
- index === 0 && !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker;
+ const hasListFirstLineMarker = index === 0 && !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker;
const shouldUseResolvedListTextStart =
hasListFirstLineMarker && hasExplicitSegmentPositioning && listFirstLineTextStartPx != null;
@@ -3307,7 +3292,7 @@ export class DomPainter {
}
// Adjust availableWidth for first-line text indent (hanging indent).
- const isFirstLine = index === 0 && !fragment.continuesFromPrev;
+ const isFirstLine = index === 0 && !paraContinuesFromPrev;
const isListFirstLine = Boolean(hasListFirstLineMarker && fragment.markerTextWidth);
if (isFirstLine && !isListFirstLine && !hasExplicitSegmentPositioning) {
availableWidthOverride = adjustAvailableWidthForTextIndent(
@@ -3318,7 +3303,7 @@ export class DomPainter {
}
const isLastLineOfFragment = index === lines.length - 1;
- const isLastLineOfParagraph = isLastLineOfFragment && !fragment.continuesOnNext;
+ const isLastLineOfParagraph = isLastLineOfFragment && !paraContinuesOnNext;
const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak;
const lineEl = this.renderLine(
@@ -3354,7 +3339,7 @@ export class DomPainter {
if (paraIndentRight && paraIndentRight > 0) {
lineEl.style.paddingRight = `${paraIndentRight}px`;
}
- if (!fragment.continuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) {
+ if (!paraContinuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) {
if (!hasExplicitSegmentPositioning) {
lineEl.style.textIndent = `${firstLineOffset}px`;
}
@@ -3533,23 +3518,27 @@ export class DomPainter {
resolvedItem?: ResolvedFragmentItem,
): HTMLElement {
try {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
- throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
- }
-
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
- const block = lookup.block as ListBlock;
- const measure = lookup.measure as ListMeasure;
+ // Pre-extracted block/measure from the resolved item.
+ if (resolvedItem?.block?.kind !== 'list' || resolvedItem?.measure?.kind !== 'list') {
+ throw new Error(`DomPainter: missing resolved list block/measure for fragment ${fragment.blockId}`);
+ }
+ const block = resolvedItem.block as ListBlock;
+ const measure = resolvedItem.measure as ListMeasure;
const item = block.items.find((entry) => entry.id === fragment.itemId);
const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId);
if (!item || !itemMeasure) {
throw new Error(`DomPainter: missing list item ${fragment.itemId}`);
}
+ // Prefer resolved item metadata over legacy fragment reads
+ const listContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev;
+ const listContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext;
+ const listMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth;
+
const fragmentEl = this.doc.createElement('div');
fragmentEl.classList.add(CLASS_NAMES.fragment, `${CLASS_NAMES.fragment}-list-item`);
applyStyles(fragmentEl, fragmentStyles);
@@ -3575,10 +3564,10 @@ export class DomPainter {
sdtBoundary,
);
- if (fragment.continuesFromPrev) {
+ if (listContinuesFromPrev) {
fragmentEl.dataset.continuesFromPrev = 'true';
}
- if (fragment.continuesOnNext) {
+ if (listContinuesOnNext) {
fragmentEl.dataset.continuesOnNext = 'true';
}
@@ -3593,7 +3582,7 @@ export class DomPainter {
if (marker) {
markerEl.textContent = marker.markerText ?? null;
markerEl.style.display = 'inline-block';
- markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`;
+ markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`;
markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`;
markerEl.style.textAlign = marker.justification ?? 'left';
@@ -3608,7 +3597,7 @@ export class DomPainter {
// Fallback: legacy behavior
markerEl.textContent = item.marker.text;
markerEl.style.display = 'inline-block';
- markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`;
+ markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`;
markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`;
if (item.marker.align) {
markerEl.style.textAlign = item.marker.align;
@@ -3673,17 +3662,11 @@ export class DomPainter {
resolvedItem?: ResolvedImageItem,
): HTMLElement {
try {
- // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item
- // is a legacy ResolvedFragmentItem without the block field.
- const block: ImageBlock =
- resolvedItem?.block ??
- (() => {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'image' || lookup.measure.kind !== 'image') {
- throw new Error(`DomPainter: missing image block for fragment ${fragment.blockId}`);
- }
- return lookup.block as ImageBlock;
- })();
+ // Pre-extracted block from the resolved item.
+ if (resolvedItem?.block?.kind !== 'image') {
+ throw new Error(`DomPainter: missing resolved image block for fragment ${fragment.blockId}`);
+ }
+ const block = resolvedItem.block as ImageBlock;
if (!this.doc) {
throw new Error('DomPainter: document is not available');
@@ -3708,16 +3691,19 @@ export class DomPainter {
}
// Add PM position markers for transaction targeting
- if (fragment.pmStart != null) {
- fragmentEl.dataset.pmStart = String(fragment.pmStart);
+ const imgPmStart = resolvedItem?.pmStart ?? fragment.pmStart;
+ if (imgPmStart != null) {
+ fragmentEl.dataset.pmStart = String(imgPmStart);
}
- if (fragment.pmEnd != null) {
- fragmentEl.dataset.pmEnd = String(fragment.pmEnd);
+ const imgPmEnd = resolvedItem?.pmEnd ?? fragment.pmEnd;
+ if (imgPmEnd != null) {
+ fragmentEl.dataset.pmEnd = String(imgPmEnd);
}
// Add metadata for interactive image resizing (skip watermarks - they should not be interactive)
- if (fragment.metadata && !block.attrs?.vmlWatermark) {
- fragmentEl.setAttribute('data-image-metadata', JSON.stringify(fragment.metadata));
+ const imgMetadata = resolvedItem?.metadata ?? fragment.metadata;
+ if (imgMetadata && !block.attrs?.vmlWatermark) {
+ fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata));
}
// behindDoc images are supported via z-index; suppress noisy debug logs
@@ -3878,17 +3864,11 @@ export class DomPainter {
resolvedItem?: ResolvedDrawingItem,
): HTMLElement {
try {
- // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item
- // is a legacy ResolvedFragmentItem without the block field.
- const block: DrawingBlock =
- resolvedItem?.block ??
- (() => {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'drawing' || lookup.measure.kind !== 'drawing') {
- throw new Error(`DomPainter: missing drawing block for fragment ${fragment.blockId}`);
- }
- return lookup.block as DrawingBlock;
- })();
+ // Pre-extracted block from the resolved item.
+ if (resolvedItem?.block?.kind !== 'drawing') {
+ throw new Error(`DomPainter: missing resolved drawing block for fragment ${fragment.blockId}`);
+ }
+ const block = resolvedItem.block as DrawingBlock;
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
@@ -4889,28 +4869,14 @@ export class DomPainter {
cellSpacingPx: number;
effectiveColumnWidths: number[];
} {
- if (resolvedItem) {
- return {
- block: resolvedItem.block,
- measure: resolvedItem.measure,
- cellSpacingPx: resolvedItem.cellSpacingPx,
- effectiveColumnWidths: resolvedItem.effectiveColumnWidths,
- };
- }
-
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'table' || lookup.measure.kind !== 'table') {
- throw new Error(`DomPainter: missing table block for fragment ${fragment.blockId}`);
+ if (!resolvedItem) {
+ throw new Error(`DomPainter: missing resolved table item for fragment ${fragment.blockId}`);
}
-
- const block = lookup.block as TableBlock;
- const measure = lookup.measure as TableMeasure;
-
return {
- block,
- measure,
- cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing),
- effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths,
+ block: resolvedItem.block,
+ measure: resolvedItem.measure,
+ cellSpacingPx: resolvedItem.cellSpacingPx,
+ effectiveColumnWidths: resolvedItem.effectiveColumnWidths,
};
}
@@ -4998,6 +4964,11 @@ export class DomPainter {
// Inner cell fragments still use legacy applyFragmentFrame via deps closure.
if (resolvedItem) {
this.applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section);
+ // Re-apply the SDT group width override after the resolved frame, so block-SDT
+ // containers can stretch table fragments to match sibling paragraph widths.
+ if (sdtBoundary?.widthOverride != null) {
+ el.style.width = `${sdtBoundary.widthOverride}px`;
+ }
}
return el;
@@ -5383,6 +5354,14 @@ export class DomPainter {
elem.style.zIndex = '1';
applyRunDataAttributes(elem as HTMLElement, (run as TextRun).dataAttrs);
+ // SD-2454: bookmark marker runs carry a data-bookmark-name attribute.
+ // Surface the bookmark name as a native `title` tooltip so hovering the
+ // opening bracket identifies which bookmark is being marked.
+ const bookmarkName = (run as TextRun).dataAttrs?.['data-bookmark-name'];
+ if (bookmarkName) {
+ (elem as HTMLElement).title = bookmarkName;
+ }
+
// Assert PM positions are present for cursor fallback
assertPmPositions(run, 'paragraph text run');
@@ -6693,6 +6672,7 @@ export class DomPainter {
elem.dataset.trackChangeId = meta.id;
elem.dataset.trackChangeKind = meta.kind;
+ elem.dataset.storyKey = meta.storyKey ?? 'body';
if (meta.author) {
elem.dataset.trackChangeAuthor = meta.author;
}
@@ -6801,8 +6781,14 @@ export class DomPainter {
/**
* Applies PM position data attributes from a legacy Fragment.
* Extracted from applyFragmentFrame for use in the resolved wrapper path.
+ * When a resolvedItem is provided, its fields take precedence over fragment fields.
*/
- private applyFragmentPmAttributes(el: HTMLElement, fragment: Fragment, section?: 'body' | 'header' | 'footer'): void {
+ private applyFragmentPmAttributes(
+ el: HTMLElement,
+ fragment: Fragment,
+ section?: 'body' | 'header' | 'footer',
+ resolvedItem?: ResolvedFragmentItem | ResolvedTableItem | ResolvedImageItem | ResolvedDrawingItem,
+ ): void {
// Footnote content is read-only: prevent cursor placement and typing
if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) {
el.setAttribute('contenteditable', 'false');
@@ -6812,22 +6798,28 @@ export class DomPainter {
if (section === 'body' || section === undefined) {
assertFragmentPmPositions(fragment, 'paragraph fragment');
}
- if (fragment.pmStart != null) {
- el.dataset.pmStart = String(fragment.pmStart);
+ // Narrow to ResolvedFragmentItem to access para-specific resolved fields
+ const resolvedFrag = resolvedItem as ResolvedFragmentItem | undefined;
+ const pmStart = resolvedFrag?.pmStart ?? (fragment as ParaFragment).pmStart;
+ if (pmStart != null) {
+ el.dataset.pmStart = String(pmStart);
} else {
delete el.dataset.pmStart;
}
- if (fragment.pmEnd != null) {
- el.dataset.pmEnd = String(fragment.pmEnd);
+ const pmEnd = resolvedFrag?.pmEnd ?? (fragment as ParaFragment).pmEnd;
+ if (pmEnd != null) {
+ el.dataset.pmEnd = String(pmEnd);
} else {
delete el.dataset.pmEnd;
}
- if (fragment.continuesFromPrev) {
+ const continuesFromPrev = resolvedFrag?.continuesFromPrev ?? (fragment as ParaFragment).continuesFromPrev;
+ if (continuesFromPrev) {
el.dataset.continuesFromPrev = 'true';
} else {
delete el.dataset.continuesFromPrev;
}
- if (fragment.continuesOnNext) {
+ const continuesOnNext = resolvedFrag?.continuesOnNext ?? (fragment as ParaFragment).continuesOnNext;
+ if (continuesOnNext) {
el.dataset.continuesOnNext = 'true';
} else {
delete el.dataset.continuesOnNext;
@@ -6846,21 +6838,24 @@ export class DomPainter {
private shouldRenderBehindPageContent(
fragment: ImageFragment | DrawingFragment,
section: 'header' | 'footer',
+ resolvedItem?: ResolvedDrawingItem,
): boolean {
if (fragment.behindDoc === true || (fragment.behindDoc == null && 'zIndex' in fragment && fragment.zIndex === 0)) {
return true;
}
- return section === 'header' && fragment.kind === 'drawing' && this.isHeaderWordArtWatermark(fragment);
+ return (
+ section === 'header' &&
+ fragment.kind === 'drawing' &&
+ this.isHeaderWordArtWatermark(resolvedItem?.block)
+ );
}
- private isHeaderWordArtWatermark(fragment: DrawingFragment): boolean {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'drawing' || lookup.block.drawingKind !== 'vectorShape') {
+ private isHeaderWordArtWatermark(block: DrawingBlock | undefined): boolean {
+ if (!block || block.kind !== 'drawing' || block.drawingKind !== 'vectorShape') {
return false;
}
- const block = lookup.block;
const attrs = (block.attrs as Record | undefined) ?? {};
const hasTextContent = Array.isArray(block.textContent?.parts) && block.textContent.parts.length > 0;
@@ -6911,7 +6906,7 @@ export class DomPainter {
el.style.height = `${item.height}px`;
}
- this.applyFragmentPmAttributes(el, fragment, section);
+ this.applyFragmentPmAttributes(el, fragment, section, item);
}
/**
@@ -6928,8 +6923,9 @@ export class DomPainter {
section?: 'body' | 'header' | 'footer',
): void {
this.applyResolvedFragmentFrame(el, item, fragment, section);
- el.style.left = `${item.x - fragment.markerWidth}px`;
- el.style.width = `${item.width + fragment.markerWidth}px`;
+ const mw = item.markerWidth ?? fragment.markerWidth;
+ el.style.left = `${item.x - mw}px`;
+ el.style.width = `${item.width + mw}px`;
}
/**
@@ -6942,45 +6938,17 @@ export class DomPainter {
* @param fragment - The fragment to estimate height for
* @returns Estimated height in pixels, or 0 if height cannot be determined
*/
- private estimateFragmentHeight(fragment: Fragment): number {
- const lookup = this.blockLookup.get(fragment.blockId);
- const measure = lookup?.measure;
-
- if (fragment.kind === 'para' && measure?.kind === 'paragraph') {
- return measure.totalHeight;
- }
-
- if (fragment.kind === 'list-item' && measure?.kind === 'list') {
- return measure.totalHeight;
+ private estimateFragmentHeight(fragment: Fragment, resolvedItem?: ResolvedPaintItem): number {
+ if (resolvedItem && 'height' in resolvedItem && typeof resolvedItem.height === 'number') {
+ return resolvedItem.height;
}
-
- if (fragment.kind === 'table') {
- return fragment.height;
- }
-
- if (fragment.kind === 'image' || fragment.kind === 'drawing') {
+ // Atomic fragment kinds carry their own height on the fragment.
+ if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') {
return fragment.height;
}
-
return 0;
}
- private buildBlockLookup(blocks: FlowBlock[], measures: Measure[]): BlockLookup {
- if (blocks.length !== measures.length) {
- throw new Error('DomPainter requires the same number of blocks and measures');
- }
-
- const lookup: BlockLookup = new Map();
- blocks.forEach((block, index) => {
- lookup.set(block.id, {
- block,
- measure: measures[index],
- version: deriveBlockVersion(block),
- });
- });
- return lookup;
- }
-
/**
* All dataset keys used for SDT metadata.
* Shared between applySdtDataset and clearSdtDataset to ensure consistency.
@@ -7147,37 +7115,20 @@ export class DomPainter {
}
}
-const getFragmentSdtContainerKey = (fragment: Fragment, blockLookup: BlockLookup): string | null => {
- const lookup = blockLookup.get(fragment.blockId);
- if (!lookup) return null;
- const block = lookup.block;
-
- if (fragment.kind === 'para' && block.kind === 'paragraph') {
- const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs;
- return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt);
- }
-
- if (fragment.kind === 'list-item' && block.kind === 'list') {
- const item = block.items.find((listItem) => listItem.id === fragment.itemId);
- const attrs = item?.paragraph.attrs;
- return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt);
- }
-
- if (fragment.kind === 'table' && block.kind === 'table') {
- const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs;
- return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt);
- }
-
- return null;
-};
-
const computeSdtBoundaries = (
fragments: readonly Fragment[],
- blockLookup: BlockLookup,
+ resolvedItems: readonly ResolvedPaintItem[],
sdtLabelsRendered: Set,
): Map => {
const boundaries = new Map();
- const containerKeys = fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup));
+ const containerKeys: (string | null)[] = fragments.map((_frag, idx) => {
+ const item = resolvedItems[idx];
+ if (item && 'sdtContainerKey' in item) {
+ const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey;
+ return key ?? null;
+ }
+ return null;
+ });
let i = 0;
while (i < fragments.length) {
@@ -7206,7 +7157,7 @@ const computeSdtBoundaries = (
let paddingBottomOverride: number | undefined;
if (!isEnd) {
const nextFragment = fragments[k + 1];
- const currentHeight = getFragmentHeight(fragment, blockLookup);
+ const currentHeight = (resolvedItems[k] as { height?: number } | undefined)?.height ?? 0;
const currentBottom = fragment.y + currentHeight;
const gapToNext = nextFragment.y - currentBottom;
if (gapToNext > 0) {
@@ -7234,7 +7185,7 @@ const computeSdtBoundaries = (
return boundaries;
};
-// getFragmentParagraphBorders, computeBetweenBorderFlags โ moved to features/paragraph-borders/
+// computeBetweenBorderFlags โ moved to features/paragraph-borders/
const fragmentKey = (fragment: Fragment): string => {
if (fragment.kind === 'para') {
@@ -7262,65 +7213,22 @@ const fragmentKey = (fragment: Fragment): string => {
return _exhaustiveCheck;
};
-const fragmentSignature = (fragment: Fragment, lookup: BlockLookup): string => {
- const base = lookup.get(fragment.blockId)?.version ?? 'missing';
- if (fragment.kind === 'para') {
- // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection
- return [
- base,
- fragment.fromLine,
- fragment.toLine,
- fragment.continuesFromPrev ? 1 : 0,
- fragment.continuesOnNext ? 1 : 0,
- fragment.markerWidth ?? '', // Include markerWidth to trigger re-render when list status changes
- ].join('|');
- }
- if (fragment.kind === 'list-item') {
- return [
- base,
- fragment.itemId,
- fragment.fromLine,
- fragment.toLine,
- fragment.continuesFromPrev ? 1 : 0,
- fragment.continuesOnNext ? 1 : 0,
- ].join('|');
- }
- if (fragment.kind === 'image') {
- return [base, fragment.width, fragment.height].join('|');
- }
- if (fragment.kind === 'drawing') {
- return [
- base,
- fragment.drawingKind,
- fragment.drawingContentId ?? '',
- fragment.width,
- fragment.height,
- fragment.geometry.width,
- fragment.geometry.height,
- fragment.geometry.rotation ?? 0,
- fragment.scale ?? 1,
- fragment.zIndex ?? '',
- ].join('|');
- }
- if (fragment.kind === 'table') {
- // Include all properties that affect table fragment rendering
- const partialSig = fragment.partialRow
- ? `${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}-${fragment.partialRow.partialHeight}`
- : '';
- return [
- base,
- fragment.fromRow,
- fragment.toRow,
- fragment.width,
- fragment.height,
- fragment.continuesFromPrev ? 1 : 0,
- fragment.continuesOnNext ? 1 : 0,
- fragment.repeatHeaderCount ?? 0,
- partialSig,
- ].join('|');
- }
- return base;
-};
+const hasFragmentGeometryChanged = (previous: Fragment, next: Fragment): boolean =>
+ previous.x !== next.x ||
+ previous.y !== next.y ||
+ previous.width !== next.width ||
+ ('height' in previous &&
+ 'height' in next &&
+ typeof previous.height === 'number' &&
+ typeof next.height === 'number' &&
+ previous.height !== next.height);
+
+const isNonBodyStoryBlockId = (blockId: string | undefined): boolean =>
+ typeof blockId === 'string' &&
+ (blockId.startsWith('footnote-') ||
+ blockId.startsWith('endnote-') ||
+ blockId.startsWith('__sd_semantic_footnote-') ||
+ blockId.startsWith('__sd_semantic_endnote-'));
const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => {
if (!metadata) return '';
@@ -7489,6 +7397,19 @@ const deriveBlockVersion = (block: FlowBlock): string => {
// Handle TextRun (kind is 'text' or undefined)
const textRun = run as TextRun;
+ const trackedChangeVersion = textRun.trackedChange
+ ? [
+ textRun.trackedChange.kind ?? '',
+ textRun.trackedChange.id ?? '',
+ textRun.trackedChange.storyKey ?? '',
+ textRun.trackedChange.author ?? '',
+ textRun.trackedChange.authorEmail ?? '',
+ textRun.trackedChange.authorImage ?? '',
+ textRun.trackedChange.date ?? '',
+ textRun.trackedChange.before ? JSON.stringify(textRun.trackedChange.before) : '',
+ textRun.trackedChange.after ? JSON.stringify(textRun.trackedChange.after) : '',
+ ].join(':')
+ : '';
return [
textRun.text ?? '',
textRun.fontFamily,
@@ -7506,8 +7427,8 @@ const deriveBlockVersion = (block: FlowBlock): string => {
textRun.baselineShift != null ? textRun.baselineShift : '',
// Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection
textRun.token ?? '',
- // Tracked changes - force re-render when added or removed tracked change
- textRun.trackedChange ? 1 : 0,
+ // Tracked changes - force re-render when any rendered tracked-change metadata changes.
+ trackedChangeVersion,
// Comment annotations - force re-render when comments are enabled/disabled
textRun.comments?.length ?? 0,
].join(',');
diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts
index b548d37107..302f1d60a6 100644
--- a/packages/layout-engine/painters/dom/src/styles.ts
+++ b/packages/layout-engine/painters/dom/src/styles.ts
@@ -197,6 +197,21 @@ const LINK_AND_TOC_STYLES = `
}
}
+/* SD-2454: bookmark bracket indicators.
+ * When the showBookmarks layout option is enabled, the pm-adapter emits
+ * [ and ] marker TextRuns at bookmark start/end positions. Mirror Word's
+ * visual treatment: subtle gray, non-selectable so users can't accidentally
+ * include the brackets in copied text. The bookmark name is surfaced via
+ * the native title tooltip on the opening bracket. */
+[data-bookmark-marker="start"],
+[data-bookmark-marker="end"] {
+ color: #8b8b8b;
+ user-select: none;
+ cursor: default;
+ font-weight: normal;
+}
+
+
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.superdoc-link {
diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts
index 97f27781e7..b14f510638 100644
--- a/packages/layout-engine/painters/dom/src/virtualization.test.ts
+++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createDomPainter } from './index.js';
+import { resolveLayout } from '@superdoc/layout-resolved';
import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js';
import type { FlowBlock, Measure, Layout, Fragment, PageMargins, ResolvedLayout } from '@superdoc/contracts';
@@ -21,11 +22,18 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] }
return {
paint(layout: Layout, mount: HTMLElement, mapping?: unknown) {
+ const effectiveResolved =
+ currentBlocks.length === 0 && currentMeasures.length === 0
+ ? currentResolved
+ : resolveLayout({
+ layout,
+ flowMode: opts.flowMode ?? 'paginated',
+ blocks: currentBlocks,
+ measures: currentMeasures,
+ });
const input: DomPainterInput = {
- resolvedLayout: currentResolved,
+ resolvedLayout: effectiveResolved,
sourceLayout: layout,
- blocks: currentBlocks,
- measures: currentMeasures,
};
painter.paint(input, mount, mapping as any);
},
diff --git a/packages/layout-engine/painters/dom/tsconfig.json b/packages/layout-engine/painters/dom/tsconfig.json
index e1df276edc..bf7c501521 100644
--- a/packages/layout-engine/painters/dom/tsconfig.json
+++ b/packages/layout-engine/painters/dom/tsconfig.json
@@ -12,6 +12,7 @@
"references": [
{ "path": "../../contracts/tsconfig.json" },
{ "path": "../../dom-contract/tsconfig.json" },
+ { "path": "../../layout-resolved/tsconfig.json" },
{ "path": "../../measuring/dom/tsconfig.json" },
{ "path": "../../../../shared/common/tsconfig.json" }
]
diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts
index 250b1237f6..89e75b5e76 100644
--- a/packages/layout-engine/pm-adapter/src/converter-context.ts
+++ b/packages/layout-engine/pm-adapter/src/converter-context.ts
@@ -54,6 +54,23 @@ export type ConverterContext = {
* Used by table creation paths to determine which style to apply to new tables.
*/
defaultTableStyleId?: string;
+ /**
+ * When true, emit visible gray `[` and `]` marker TextRuns at bookmarkStart
+ * and bookmarkEnd positions โ matching Word's "Show bookmarks" feature
+ * (File > Options > Advanced). Off by default because bookmarks are a
+ * structural concept, not a visual one. SD-2454.
+ */
+ showBookmarks?: boolean;
+
+ /**
+ * Populated by the bookmark-start inline converter during conversion: the
+ * set of bookmark numeric ids (as strings) that actually rendered a start
+ * marker. The bookmark-end converter reads this set to suppress emitting
+ * an orphan `]` for a start it also suppressed (e.g. `_Tocโฆ` / `_Refโฆ`
+ * auto-generated bookmarks filtered out by the `showBookmarks` feature).
+ * SD-2454.
+ */
+ renderedBookmarkIds?: Set;
};
/**
diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts
index dc49f5a900..3779a0b1a3 100644
--- a/packages/layout-engine/pm-adapter/src/converters/image.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/image.ts
@@ -338,7 +338,9 @@ export function imageNodeToBlock(
export function handleImageNode(node: PMNode, context: NodeHandlerContext): ImageBlock | void {
const { blocks, recordBlockKind, nextBlockId, positions, trackedChangesConfig } = context;
- const trackedMeta = trackedChangesConfig.enabled ? collectTrackedChangeFromMarks(node.marks ?? []) : undefined;
+ const trackedMeta = trackedChangesConfig.enabled
+ ? collectTrackedChangeFromMarks(node.marks ?? [], context.storyKey)
+ : undefined;
if (shouldHideTrackedNode(trackedMeta, trackedChangesConfig)) {
return;
}
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts
new file mode 100644
index 0000000000..5c68518d39
--- /dev/null
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts
@@ -0,0 +1,44 @@
+import type { TextRun } from '@superdoc/contracts';
+import type { PMNode } from '../../types.js';
+import { textNodeToRun } from './text-run.js';
+import { type InlineConverterParams } from './common.js';
+
+/**
+ * Converts a `bookmarkEnd` PM node.
+ *
+ * SD-2454: when `converterContext.showBookmarks` is true, emit a visible gray
+ * `]` marker at the bookmark end. Matches Word's "Show bookmarks" rendering.
+ * Returns void (no visual output) when the option is off, preserving today's
+ * behavior where bookmarkEnd is an invisible structural marker.
+ *
+ * The PM schema does not store the bookmark name on bookmarkEnd โ only the
+ * numeric `id` that matches the corresponding bookmarkStart. We therefore
+ * don't set a tooltip on the closing bracket (Word also omits the name on
+ * the closing bracket's hover). Styling and identification happen on the
+ * opening bracket.
+ */
+export function bookmarkEndNodeToRun(params: InlineConverterParams): TextRun | void {
+ const { node, converterContext } = params;
+ if (converterContext?.showBookmarks !== true) return;
+
+ const nodeAttrs =
+ typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record) : {};
+ const bookmarkId = typeof nodeAttrs.id === 'string' || typeof nodeAttrs.id === 'number' ? String(nodeAttrs.id) : '';
+
+ // Only emit `]` if we emitted the matching `[`. Keeps brackets paired and
+ // prevents an orphan closing bracket for a suppressed auto-generated
+ // bookmark (`_Tocโฆ`, `_Refโฆ`, `_GoBack`).
+ const rendered = converterContext?.renderedBookmarkIds;
+ if (rendered && bookmarkId && !rendered.has(bookmarkId)) return;
+
+ const run = textNodeToRun({
+ ...params,
+ node: { type: 'text', text: ']', marks: [...(node.marks ?? [])] } as PMNode,
+ });
+ run.dataAttrs = {
+ ...(run.dataAttrs ?? {}),
+ 'data-bookmark-marker': 'end',
+ ...(bookmarkId ? { 'data-bookmark-id': bookmarkId } : {}),
+ };
+ return run;
+}
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts
new file mode 100644
index 0000000000..21d5b1f0ef
--- /dev/null
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi } from 'vitest';
+import type { TextRun } from '@superdoc/contracts';
+import type { PMNode } from '../../types.js';
+import type { InlineConverterParams } from './common.js';
+
+vi.mock('./text-run.js', () => ({
+ textNodeToRun: vi.fn(
+ (params: InlineConverterParams): TextRun => ({
+ text: params.node.text || '',
+ fontFamily: params.defaultFont,
+ fontSize: params.defaultSize,
+ }),
+ ),
+}));
+
+import { bookmarkStartNodeToBlocks } from './bookmark-start.js';
+import { bookmarkEndNodeToRun } from './bookmark-end.js';
+
+function makeParams(
+ node: PMNode,
+ opts: { showBookmarks?: boolean; bookmarks?: Map; renderedBookmarkIds?: Set } = {},
+): InlineConverterParams {
+ return {
+ node,
+ positions: new WeakMap(),
+ defaultFont: 'Calibri',
+ defaultSize: 16,
+ inheritedMarks: [],
+ sdtMetadata: undefined,
+ hyperlinkConfig: { enableRichHyperlinks: false },
+ themeColors: undefined,
+ runProperties: undefined,
+ paragraphProperties: undefined,
+ converterContext: {
+ translatedNumbering: {},
+ translatedLinkedStyles: { docDefaults: {}, latentStyles: {}, styles: {} },
+ showBookmarks: opts.showBookmarks ?? false,
+ renderedBookmarkIds: opts.renderedBookmarkIds,
+ } as unknown as InlineConverterParams['converterContext'],
+ enableComments: false,
+ visitNode: vi.fn(),
+ bookmarks: opts.bookmarks,
+ tabOrdinal: 0,
+ paragraphAttrs: {},
+ nextBlockId: vi.fn(),
+ } as InlineConverterParams;
+}
+
+describe('bookmarkStartNodeToBlocks (SD-2454)', () => {
+ it('emits no visible run when showBookmarks is off (default)', () => {
+ const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } };
+ const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: false }));
+ expect(result).toBeUndefined();
+ });
+
+ it('emits a `[` TextRun with bookmark-name data attr when showBookmarks is on', () => {
+ const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } };
+ const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true }));
+ expect(result).toBeDefined();
+ expect(result!.text).toBe('[');
+ expect(result!.dataAttrs).toEqual({
+ 'data-bookmark-name': 'chapter1',
+ 'data-bookmark-marker': 'start',
+ });
+ });
+
+ // Matches Word behavior: `_Tocโฆ`, `_Refโฆ`, `_GoBack` etc. are hidden from
+ // Show Bookmarks because they are internally generated for headings,
+ // fields, or navigation โ showing them would clutter the document.
+ it.each(['_Toc1234', '_Ref506192326', '_GoBack'])('suppresses marker for auto-generated bookmark "%s"', (name) => {
+ const node: PMNode = { type: 'bookmarkStart', attrs: { name, id: '1' } };
+ const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true }));
+ expect(result).toBeUndefined();
+ });
+
+ it('still records bookmark position for cross-reference resolution regardless of showBookmarks', () => {
+ const bookmarks = new Map();
+ const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } };
+ const params = makeParams(node, { showBookmarks: false, bookmarks });
+ // Seed the position map
+ params.positions.set(node, { start: 42, end: 42 });
+ bookmarkStartNodeToBlocks(params);
+ expect(bookmarks.get('chapter1')).toBe(42);
+ });
+});
+
+describe('bookmarkEndNodeToRun (SD-2454)', () => {
+ it('emits no run when showBookmarks is off (default)', () => {
+ const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } };
+ const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: false }));
+ expect(result).toBeUndefined();
+ });
+
+ it('emits a `]` TextRun when the matching start was rendered', () => {
+ const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } };
+ const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['1']) }));
+ expect(result).toBeDefined();
+ expect(result!.text).toBe(']');
+ expect(result!.dataAttrs).toEqual({
+ 'data-bookmark-marker': 'end',
+ 'data-bookmark-id': '1',
+ });
+ });
+
+ it('suppresses `]` when the matching start was also suppressed (no orphan brackets)', () => {
+ const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '42' } };
+ // Start with id 42 was suppressed โ renderedBookmarkIds does not include it
+ const result = bookmarkEndNodeToRun(
+ makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['99']) }),
+ );
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts
index 779c95934b..a91ff1193a 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts
@@ -1,26 +1,68 @@
-import { type InlineConverterParams } from './common';
+import type { TextRun } from '@superdoc/contracts';
+import type { PMNode } from '../../types.js';
+import { textNodeToRun } from './text-run.js';
+import { type InlineConverterParams } from './common.js';
-export function bookmarkStartNodeToBlocks({
- node,
- positions,
- bookmarks,
- visitNode,
- inheritedMarks,
- sdtMetadata,
- runProperties,
-}: InlineConverterParams): void {
- // Track bookmark position for cross-reference resolution
+/**
+ * Converts a `bookmarkStart` PM node.
+ *
+ * Primary job: record the bookmark's PM position in the `bookmarks` Map so
+ * cross-reference navigation (goToAnchor) can resolve `#`
+ * hrefs to a document position.
+ *
+ * SD-2454: when `converterContext.showBookmarks` is true, also emit a visible
+ * gray `[` marker at the bookmark start, matching Word's opt-in "Show
+ * bookmarks" feature. The marker is a regular TextRun so it flows through
+ * pagination and line breaking like any other character; `dataAttrs` tag it
+ * so DomPainter can style it gray and set a tooltip with the bookmark name.
+ *
+ * When `showBookmarks` is false (the default), the converter still descends
+ * into any content inside the bookmark span but emits no visual output.
+ */
+export function bookmarkStartNodeToBlocks(params: InlineConverterParams): TextRun | void {
+ const { node, positions, bookmarks, visitNode, inheritedMarks, sdtMetadata, runProperties, converterContext } =
+ params;
const nodeAttrs =
typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record) : {};
const bookmarkName = typeof nodeAttrs.name === 'string' ? nodeAttrs.name : undefined;
+
if (bookmarkName && bookmarks) {
const nodePos = positions.get(node);
if (nodePos) {
bookmarks.set(bookmarkName, nodePos.start);
}
}
- // Process any content inside the bookmark (usually empty)
+
+ // Word hides `_Tocโฆ` / `_Refโฆ` / other `_`-prefixed bookmarks from its Show
+ // Bookmarks rendering because they're autogenerated (headings, fields).
+ // Mirror that so opt-in markers don't pollute every heading and xref target.
+ const shouldRender =
+ converterContext?.showBookmarks === true && typeof bookmarkName === 'string' && !bookmarkName.startsWith('_');
+
+ let run: TextRun | undefined;
+ if (shouldRender) {
+ run = textNodeToRun({
+ ...params,
+ node: { type: 'text', text: '[', marks: [...(node.marks ?? [])] } as PMNode,
+ });
+ run.dataAttrs = {
+ ...(run.dataAttrs ?? {}),
+ 'data-bookmark-name': bookmarkName!,
+ 'data-bookmark-marker': 'start',
+ };
+ // Record the id so the matching bookmarkEnd converter knows to emit `]`.
+ // Without this, suppressing a `_`-prefixed start leaves an orphan `]`.
+ const bookmarkIdRaw = nodeAttrs.id;
+ const bookmarkId =
+ typeof bookmarkIdRaw === 'string' || typeof bookmarkIdRaw === 'number' ? String(bookmarkIdRaw) : '';
+ if (bookmarkId && converterContext?.renderedBookmarkIds) {
+ converterContext.renderedBookmarkIds.add(bookmarkId);
+ }
+ }
+
if (Array.isArray(node.content)) {
node.content.forEach((child) => visitNode(child, inheritedMarks, sdtMetadata, runProperties));
}
+
+ return run;
}
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts
index 002611fd3d..8f1c4b2d02 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts
@@ -38,6 +38,7 @@ export class NotInlineNodeError extends Error {
export type InlineConverterParams = {
node: PMNode;
positions: PositionMap;
+ storyKey?: string;
inheritedMarks: PMMark[];
defaultFont: string;
defaultSize: number;
@@ -60,6 +61,7 @@ export type BlockConverterOptions = {
nextBlockId: BlockIdGenerator;
nextId: () => string;
positions: WeakMap;
+ storyKey?: string;
trackedChangesConfig: NodeHandlerContext['trackedChangesConfig'];
defaultFont: string;
defaultSize: number;
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts
new file mode 100644
index 0000000000..4f5097bbdd
--- /dev/null
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi } from 'vitest';
+import type { TextRun } from '@superdoc/contracts';
+import type { PMNode } from '../../types.js';
+import type { InlineConverterParams } from './common.js';
+
+vi.mock('./text-run.js', () => ({
+ textNodeToRun: vi.fn(
+ (params: InlineConverterParams): TextRun => ({
+ text: params.node.text || '',
+ fontFamily: params.defaultFont,
+ fontSize: params.defaultSize,
+ }),
+ ),
+}));
+
+import { crossReferenceNodeToRun } from './cross-reference.js';
+
+function makeParams(
+ attrs: Record,
+ overrides: Partial = {},
+): InlineConverterParams {
+ const node: PMNode = { type: 'crossReference', attrs };
+ return {
+ node,
+ positions: new WeakMap(),
+ defaultFont: 'Calibri',
+ defaultSize: 16,
+ inheritedMarks: [],
+ sdtMetadata: undefined,
+ hyperlinkConfig: { enableRichHyperlinks: false },
+ themeColors: undefined,
+ runProperties: undefined,
+ paragraphProperties: undefined,
+ converterContext: {} as unknown as InlineConverterParams['converterContext'],
+ enableComments: false,
+ visitNode: vi.fn(),
+ bookmarks: undefined,
+ tabOrdinal: 0,
+ paragraphAttrs: {},
+ nextBlockId: vi.fn(),
+ ...overrides,
+ } as InlineConverterParams;
+}
+
+describe('crossReferenceNodeToRun (SD-2495)', () => {
+ it('emits a TextRun carrying the resolved display text', () => {
+ const run = crossReferenceNodeToRun(
+ makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w \\h' }),
+ );
+ expect(run).not.toBeNull();
+ expect(run!.text).toBe('15');
+ });
+
+ it('synthesizes an internal link when the instruction has the \\h switch', () => {
+ const run = crossReferenceNodeToRun(
+ makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w \\h' }),
+ );
+ expect(run!.link).toBeDefined();
+ expect(run!.link?.anchor).toBe('_Ref506192326');
+ });
+
+ it('does not attach a link when the \\h switch is absent', () => {
+ const run = crossReferenceNodeToRun(
+ makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w' }),
+ );
+ expect(run!.link).toBeUndefined();
+ });
+
+ it('still emits a TextRun (not null) when the cached text is empty', () => {
+ const run = crossReferenceNodeToRun(
+ makeParams({ resolvedText: '', target: '_Ref_missing', instruction: 'REF _Ref_missing \\h' }),
+ );
+ expect(run).not.toBeNull();
+ expect(run!.text).toBe('');
+ // Still links to target so surrounding layout isn't broken and the click target
+ // is preserved if the text later becomes non-empty via a re-import.
+ expect(run!.link?.anchor).toBe('_Ref_missing');
+ });
+
+ it('does not match a literal `h` character as the \\h switch', () => {
+ // Guards against naive substring check โ instruction like `REF bh-target`
+ // must not produce a hyperlink just because `h` appears somewhere.
+ const run = crossReferenceNodeToRun(
+ makeParams({ resolvedText: 'label', target: 'bh-target', instruction: 'REF bh-target' }),
+ );
+ expect(run!.link).toBeUndefined();
+ });
+
+ it('matches the \\H switch case-insensitively per ECMA-376 ยง17.16.1', () => {
+ const run = crossReferenceNodeToRun(
+ makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\H' }),
+ );
+ expect(run!.link?.anchor).toBe('_Ref506192326');
+ });
+
+ it('forwards node.marks to textNodeToRun so surrounding styles (italic, textStyle) survive', async () => {
+ // Guards against SD-2537's "preserve surrounding run styling" AC โ
+ // a refactor that dropped node.marks from the synthesized text node
+ // would silently strip italic/color from every cross-reference.
+ const { textNodeToRun } = await import('./text-run.js');
+ vi.mocked(textNodeToRun).mockClear();
+ const marks = [
+ { type: 'italic', attrs: {} },
+ { type: 'textStyle', attrs: { color: '#ff0000' } },
+ ];
+ const node: PMNode = {
+ type: 'crossReference',
+ attrs: { resolvedText: '15', target: '_Ref1', instruction: 'REF _Ref1 \\h' },
+ marks,
+ };
+ crossReferenceNodeToRun(makeParams(node.attrs as Record, { node }));
+
+ const call = vi.mocked(textNodeToRun).mock.calls.at(-1)?.[0];
+ expect(call?.node?.marks).toEqual(marks);
+ });
+});
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts
index c321a37099..d74ad61cbd 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts
@@ -1,25 +1,41 @@
import type { TextRun } from '@superdoc/contracts';
-import type { PMNode, PMMark } from '../../types.js';
+import type { PMNode } from '../../types.js';
import { textNodeToRun } from './text-run.js';
-import { applyMarksToRun } from '../../marks/index.js';
-import { applyInlineRunProperties, type InlineConverterParams } from './common.js';
+import { buildFlowRunLink } from '../../marks/links.js';
+import { type InlineConverterParams } from './common.js';
/**
* Converts a crossReference PM node to a TextRun with the resolved display text.
+ *
+ * Renders Word REF / NOTEREF / STYLEREF fields imported from DOCX. Uses the
+ * cached result text from Word (`attrs.resolvedText`) โ we do not recompute
+ * outline numbers for `\w`/`\r`/`\n` switches, we trust Word's cache.
+ *
+ * When the instruction carries the `\h` switch, the reference renders as an
+ * internal hyperlink pointing at `#` so clicks navigate to the
+ * corresponding bookmark via the existing anchor-link navigation path.
*/
export function crossReferenceNodeToRun(params: InlineConverterParams): TextRun | null {
- const { node, positions, defaultFont, defaultSize, inheritedMarks, sdtMetadata, runProperties, converterContext } =
- params;
+ const { node, positions, sdtMetadata } = params;
const attrs = (node.attrs ?? {}) as Record;
- const resolvedText = (attrs.resolvedText as string) || (attrs.target as string) || '';
- if (!resolvedText) return null;
+ const resolvedText = typeof attrs.resolvedText === 'string' ? attrs.resolvedText : '';
+ const target = typeof attrs.target === 'string' ? attrs.target : '';
+ const instruction = typeof attrs.instruction === 'string' ? attrs.instruction : '';
const run = textNodeToRun({
...params,
node: { type: 'text', text: resolvedText, marks: [...(node.marks ?? [])] } as PMNode,
});
+ // \h switch - case-insensitive per ECMA-376 ยง17.16.1.
+ if (target && /\\h\b/i.test(instruction)) {
+ const synthesized = buildFlowRunLink({ anchor: target });
+ if (synthesized) {
+ run.link = run.link ? { ...run.link, ...synthesized, anchor: target } : synthesized;
+ }
+ }
+
const pos = positions.get(node);
if (pos) {
run.pmStart = pos.start;
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts
index 25d369f31c..8f3d263cd6 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts
@@ -88,6 +88,9 @@ describe('tokenNodeToRun', () => {
enableRichHyperlinks: false,
},
undefined,
+ undefined,
+ true,
+ undefined,
);
});
@@ -107,6 +110,9 @@ describe('tokenNodeToRun', () => {
enableRichHyperlinks: false,
},
undefined,
+ undefined,
+ true,
+ undefined,
);
});
@@ -128,6 +134,9 @@ describe('tokenNodeToRun', () => {
enableRichHyperlinks: false,
},
undefined,
+ undefined,
+ true,
+ undefined,
);
});
@@ -146,6 +155,9 @@ describe('tokenNodeToRun', () => {
expect.any(Array),
hyperlinkConfig,
undefined,
+ undefined,
+ true,
+ undefined,
);
});
@@ -198,6 +210,9 @@ describe('tokenNodeToRun', () => {
enableRichHyperlinks: false,
},
undefined,
+ undefined,
+ true,
+ undefined,
);
});
@@ -214,6 +229,9 @@ describe('tokenNodeToRun', () => {
[],
{ enableRichHyperlinks: false },
undefined,
+ undefined,
+ true,
+ undefined,
);
});
@@ -232,6 +250,9 @@ describe('tokenNodeToRun', () => {
enableRichHyperlinks: false,
},
undefined,
+ undefined,
+ true,
+ undefined,
);
});
@@ -261,6 +282,9 @@ describe('tokenNodeToRun', () => {
[],
{ enableRichHyperlinks: false },
undefined,
+ undefined,
+ true,
+ undefined,
);
});
});
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts
index 12580d9b04..fe77a18543 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts
@@ -19,6 +19,7 @@ import { TOKEN_INLINE_TYPES } from '../../constants.js';
export function tokenNodeToRun({
node,
positions,
+ storyKey,
defaultFont,
defaultSize,
inheritedMarks,
@@ -58,7 +59,7 @@ export function tokenNodeToRun({
const effectiveMarks = nodeMarks.length > 0 ? nodeMarks : marksAsAttrs;
const marks = [...effectiveMarks, ...(inheritedMarks ?? [])];
- applyMarksToRun(run, marks, hyperlinkConfig, themeColors);
+ applyMarksToRun(run, marks, hyperlinkConfig, themeColors, undefined, true, storyKey);
applyInlineRunProperties(run, runProperties, converterContext);
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts
new file mode 100644
index 0000000000..11aaf36913
--- /dev/null
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect, vi } from 'vitest';
+import type { TextRun } from '@superdoc/contracts';
+import type { PMNode } from '../../types.js';
+import type { InlineConverterParams } from './common.js';
+
+vi.mock('./text-run.js', () => ({
+ textNodeToRun: vi.fn(
+ (params: InlineConverterParams): TextRun => ({
+ text: params.node.text || '',
+ fontFamily: params.defaultFont,
+ fontSize: params.defaultSize,
+ }),
+ ),
+}));
+
+vi.mock('../../sdt/index.js', () => ({
+ getNodeInstruction: vi.fn((node: PMNode) => {
+ const attrs = (node.attrs ?? {}) as Record;
+ return typeof attrs.instruction === 'string' ? attrs.instruction : '';
+ }),
+}));
+
+vi.mock('@superdoc/style-engine/ooxml', () => ({
+ resolveRunProperties: vi.fn(() => ({})),
+}));
+
+import { pageReferenceNodeToBlock } from './page-reference.js';
+
+function makeParams(
+ attrs: Record,
+ overrides: Partial = {},
+): InlineConverterParams {
+ const node: PMNode = {
+ type: 'pageReference',
+ attrs,
+ content: [{ type: 'text', text: '15' } as PMNode],
+ };
+ return {
+ node,
+ positions: new WeakMap(),
+ defaultFont: 'Calibri',
+ defaultSize: 16,
+ inheritedMarks: [],
+ sdtMetadata: undefined,
+ hyperlinkConfig: { enableRichHyperlinks: false },
+ themeColors: undefined,
+ runProperties: undefined,
+ paragraphProperties: undefined,
+ converterContext: {} as unknown as InlineConverterParams['converterContext'],
+ enableComments: false,
+ visitNode: vi.fn(),
+ bookmarks: undefined,
+ tabOrdinal: 0,
+ paragraphAttrs: {},
+ nextBlockId: vi.fn(),
+ ...overrides,
+ } as InlineConverterParams;
+}
+
+describe('pageReferenceNodeToBlock', () => {
+ it('emits a pageReference token run with the resolved fallback text and bookmarkId', () => {
+ const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\h' })) as TextRun | undefined;
+ expect(run).toBeDefined();
+ expect(run!.token).toBe('pageReference');
+ expect(run!.pageRefMetadata?.bookmarkId).toBe('_Toc123');
+ });
+
+ it('synthesizes an internal link when the instruction has the \\h switch', () => {
+ const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\h' })) as TextRun | undefined;
+ expect(run!.link).toBeDefined();
+ expect(run!.link?.anchor).toBe('_Toc123');
+ });
+
+ it('does not attach a link when the \\h switch is absent', () => {
+ const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123' })) as TextRun | undefined;
+ expect(run!.link).toBeUndefined();
+ });
+
+ it('does not match a literal `h` character as the \\h switch', () => {
+ // Guards against naive substring check โ instruction like `PAGEREF bh-target`
+ // must not produce a hyperlink just because `h` appears somewhere.
+ const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF bh-target' })) as TextRun | undefined;
+ expect(run!.link).toBeUndefined();
+ });
+
+ it('handles bookmark ids wrapped in quotes in the instruction', () => {
+ const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF "_Toc123" \\h' })) as TextRun | undefined;
+ expect(run!.pageRefMetadata?.bookmarkId).toBe('_Toc123');
+ expect(run!.link?.anchor).toBe('_Toc123');
+ });
+
+ it('matches the \\h switch case-insensitively', () => {
+ // Word field switches are case-insensitive โ `\H` should produce a link
+ // just like `\h`.
+ const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\H' })) as TextRun | undefined;
+ expect(run!.link?.anchor).toBe('_Toc123');
+ });
+});
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts
index 8d647b58eb..aa4c335907 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts
@@ -3,6 +3,7 @@ import { type InlineConverterParams } from './common';
import { getNodeInstruction } from '../../sdt/index.js';
import type { PMNode, PMMark } from '../../types.js';
import { textNodeToRun } from './text-run.js';
+import { buildFlowRunLink } from '../../marks/links.js';
import { type RunProperties, resolveRunProperties } from '@superdoc/style-engine/ooxml';
export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun | void {
@@ -60,14 +61,23 @@ export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun
// Copy PM positions from parent pageReference node
if (pageRefPos) {
- (tokenRun as TextRun).pmStart = pageRefPos.start;
- (tokenRun as TextRun).pmEnd = pageRefPos.end;
+ tokenRun.pmStart = pageRefPos.start;
+ tokenRun.pmEnd = pageRefPos.end;
}
- (tokenRun as TextRun).token = 'pageReference';
- (tokenRun as TextRun).pageRefMetadata = {
+ tokenRun.token = 'pageReference';
+ tokenRun.pageRefMetadata = {
bookmarkId,
instruction,
};
+
+ // \h switch - case-insensitive per ECMA-376 ยง17.16.1.
+ if (/\\h\b/i.test(instruction)) {
+ const synthesized = buildFlowRunLink({ anchor: bookmarkId });
+ if (synthesized) {
+ tokenRun.link = tokenRun.link ? { ...tokenRun.link, ...synthesized, anchor: bookmarkId } : synthesized;
+ }
+ }
+
if (sdtMetadata) {
tokenRun.sdt = sdtMetadata;
}
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts
index 06dd66a9ff..ffe2ab1ca7 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts
@@ -221,9 +221,15 @@ describe('tabNodeToRun', () => {
tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs });
expect(applyMarksToRunMock).toHaveBeenCalledTimes(1);
- expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [
- { type: 'underline', attrs: { underlineType: 'single' } },
- ]);
+ expect(applyMarksToRunMock).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: 'tab' }),
+ [{ type: 'underline', attrs: { underlineType: 'single' } }],
+ undefined,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ );
});
it('calls applyMarksToRun with inherited marks', () => {
@@ -238,9 +244,15 @@ describe('tabNodeToRun', () => {
tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs, inheritedMarks });
expect(applyMarksToRunMock).toHaveBeenCalledTimes(1);
- expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [
- { type: 'underline', attrs: { underlineType: 'single' } },
- ]);
+ expect(applyMarksToRunMock).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: 'tab' }),
+ [{ type: 'underline', attrs: { underlineType: 'single' } }],
+ undefined,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ );
});
it('combines node marks and inherited marks', () => {
@@ -258,10 +270,15 @@ describe('tabNodeToRun', () => {
tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs, inheritedMarks });
expect(applyMarksToRunMock).toHaveBeenCalledTimes(1);
- expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [
- { type: 'bold' },
- { type: 'underline', attrs: { underlineType: 'single' } },
- ]);
+ expect(applyMarksToRunMock).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: 'tab' }),
+ [{ type: 'bold' }, { type: 'underline', attrs: { underlineType: 'single' } }],
+ undefined,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ );
});
it('does not call applyMarksToRun when no marks present', () => {
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts
index dfde920094..da8b2bd4ff 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts
@@ -15,6 +15,7 @@ import { type InlineConverterParams } from './common.js';
export function tabNodeToRun({
node,
positions,
+ storyKey,
tabOrdinal,
paragraphAttrs,
inheritedMarks,
@@ -42,7 +43,7 @@ export function tabNodeToRun({
// Apply marks (e.g., underline) to the tab run
const marks = [...(node.marks ?? []), ...(inheritedMarks ?? [])];
if (marks.length > 0) {
- applyMarksToRun(run, marks);
+ applyMarksToRun(run, marks, undefined, undefined, undefined, true, storyKey);
}
return run;
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts
index 4787983d58..e9c3d29a9a 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts
@@ -74,6 +74,7 @@ describe('textNodeToRun', () => {
undefined,
undefined,
false,
+ undefined,
);
});
@@ -125,6 +126,7 @@ describe('textNodeToRun', () => {
undefined,
undefined,
false,
+ undefined,
);
});
@@ -147,6 +149,7 @@ describe('textNodeToRun', () => {
undefined,
undefined,
false,
+ undefined,
);
});
@@ -171,6 +174,7 @@ describe('textNodeToRun', () => {
undefined,
undefined,
false,
+ undefined,
);
});
@@ -220,6 +224,7 @@ describe('textNodeToRun', () => {
undefined,
undefined,
false,
+ undefined,
);
});
@@ -298,6 +303,7 @@ describe('textNodeToRun', () => {
undefined,
undefined,
false,
+ undefined,
);
});
@@ -337,6 +343,7 @@ describe('textNodeToRun', () => {
undefined,
undefined,
false,
+ undefined,
);
});
});
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts
index 06722ac7bc..c051b8fe8e 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts
@@ -28,6 +28,7 @@ import { applyInlineRunProperties, type InlineConverterParams } from './common.j
export function textNodeToRun({
node,
positions,
+ storyKey,
defaultFont,
defaultSize,
inheritedMarks = [],
@@ -59,6 +60,7 @@ export function textNodeToRun({
themeColors,
converterContext?.backgroundColor,
enableComments,
+ storyKey,
);
if (sdtMetadata) {
run.sdt = sdtMetadata;
@@ -89,6 +91,7 @@ export function tokenNodeToRun(
token: TextRun['token'],
hyperlinkConfig: HyperlinkConfig = DEFAULT_HYPERLINK_CONFIG,
themeColors?: ThemeColorPalette,
+ storyKey?: string,
): TextRun {
// Tokens carry a placeholder character so measurers reserve width; painters will replace it with the real value.
const run: TextRun = {
@@ -115,7 +118,7 @@ export function tokenNodeToRun(
const effectiveMarks = nodeMarks.length > 0 ? nodeMarks : marksAsAttrs;
const marks = [...effectiveMarks, ...(inheritedMarks ?? [])];
- applyMarksToRun(run, marks, hyperlinkConfig, themeColors);
+ applyMarksToRun(run, marks, hyperlinkConfig, themeColors, undefined, true, storyKey);
// If marksAsAttrs carried font styling, mark the run so downstream defaults don't overwrite it.
if (marksAsAttrs.length > 0) {
diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts
index dfa44dbf3c..cbcb6ca314 100644
--- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts
@@ -2731,6 +2731,7 @@ describe('paragraph converters', () => {
applyMarksToRun,
undefined,
true,
+ undefined,
);
const paraBlock = blocks[0] as ParagraphBlock;
diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts
index 0bc5a4d59b..6f29bb2452 100644
--- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts
@@ -49,6 +49,7 @@ import { structuredContentNodeToBlocks } from './inline-converters/structured-co
import { pageReferenceNodeToBlock } from './inline-converters/page-reference.js';
import { fieldAnnotationNodeToRun } from './inline-converters/field-annotation.js';
import { bookmarkStartNodeToBlocks } from './inline-converters/bookmark-start.js';
+import { bookmarkEndNodeToRun } from './inline-converters/bookmark-end.js';
import { tabNodeToRun } from './inline-converters/tab.js';
import { tokenNodeToRun } from './inline-converters/generic-token.js';
import { imageNodeToRun } from './inline-converters/image.js';
@@ -249,7 +250,10 @@ const toTrackChangeAttrs = (value: unknown): Record | undefined
// Paragraph-mark revisions are stored in paragraphProperties.runProperties (pPr/rPr), not inline text marks.
// Convert them into mark-like metadata so tracked-change filtering can reuse the same projection pipeline.
-const getParagraphMarkTrackedChange = (paragraphProperties: ParagraphProperties): TrackedChangeMeta | undefined => {
+const getParagraphMarkTrackedChange = (
+ paragraphProperties: ParagraphProperties,
+ storyKey?: string,
+): TrackedChangeMeta | undefined => {
const runProperties =
paragraphProperties?.runProperties && typeof paragraphProperties.runProperties === 'object'
? (paragraphProperties.runProperties as Record)
@@ -271,7 +275,7 @@ const getParagraphMarkTrackedChange = (paragraphProperties: ParagraphProperties)
if (trackDeleteAttrs) {
marks.push({ type: 'trackDelete', attrs: trackDeleteAttrs });
}
- return collectTrackedChangeFromMarks(marks);
+ return collectTrackedChangeFromMarks(marks, storyKey);
};
const isEmptyTextRun = (run: Run): boolean => {
@@ -509,6 +513,7 @@ export function paragraphToFlowBlocks({
para,
nextBlockId,
positions,
+ storyKey,
trackedChangesConfig,
bookmarks,
hyperlinkConfig = DEFAULT_HYPERLINK_CONFIG,
@@ -572,7 +577,7 @@ export function paragraphToFlowBlocks({
if (paragraphProps.runProperties?.vanish) {
return blocks;
}
- const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps);
+ const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps, storyKey);
// Get the PM position of the empty paragraph for caret rendering
const paraPos = positions.get(para);
const emptyRun: TextRun = {
@@ -619,6 +624,7 @@ export function paragraphToFlowBlocks({
applyMarksToRun,
themeColors,
enableComments,
+ storyKey,
);
// Ghost list artifact suppression only applies in markup/review modes.
@@ -726,6 +732,7 @@ export function paragraphToFlowBlocks({
const inlineConverterParams = {
node: node,
positions,
+ storyKey,
defaultFont,
defaultSize,
inheritedMarks: inheritedMarks ?? [],
@@ -748,6 +755,7 @@ export function paragraphToFlowBlocks({
nextBlockId: stableNextBlockId,
nextId,
positions,
+ storyKey,
trackedChangesConfig,
defaultFont,
defaultSize,
@@ -862,6 +870,7 @@ export function paragraphToFlowBlocks({
applyMarksToRun,
themeColors,
enableComments,
+ storyKey,
);
if (trackedChangesConfig.enabled && filteredRuns.length === 0) {
return;
@@ -927,6 +936,9 @@ const INLINE_CONVERTERS_REGISTRY: Record = {
bookmarkStart: {
inlineConverter: bookmarkStartNodeToBlocks,
},
+ bookmarkEnd: {
+ inlineConverter: bookmarkEndNodeToRun,
+ },
tab: {
inlineConverter: tabNodeToRun,
},
@@ -1082,6 +1094,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext):
para: node,
nextBlockId,
positions,
+ storyKey: context.storyKey,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
@@ -1110,6 +1123,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext):
para: node,
nextBlockId,
positions,
+ storyKey: context.storyKey,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts
index 452383b756..a570c8dd6f 100644
--- a/packages/layout-engine/pm-adapter/src/converters/table.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/table.ts
@@ -108,6 +108,7 @@ function normalizeLegacyBorderStyle(value: string | undefined): BorderStyle {
type TableParserDependencies = {
nextBlockId: BlockIdGenerator;
positions: PositionMap;
+ storyKey?: string;
trackedChangesConfig: TrackedChangesConfig;
bookmarks: Map;
hyperlinkConfig: HyperlinkConfig;
@@ -340,6 +341,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
para: childNode,
nextBlockId: context.nextBlockId,
positions: context.positions,
+ storyKey: context.storyKey,
trackedChangesConfig: context.trackedChangesConfig,
bookmarks: context.bookmarks,
hyperlinkConfig: context.hyperlinkConfig,
@@ -361,6 +363,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
para: nestedNode,
nextBlockId: context.nextBlockId,
positions: context.positions,
+ storyKey: context.storyKey,
trackedChangesConfig: context.trackedChangesConfig,
bookmarks: context.bookmarks,
hyperlinkConfig: context.hyperlinkConfig,
@@ -376,6 +379,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
const tableBlock = tableNodeToBlock(nestedNode, {
nextBlockId: context.nextBlockId,
positions: context.positions,
+ storyKey: context.storyKey,
trackedChangesConfig: context.trackedChangesConfig,
bookmarks: context.bookmarks,
hyperlinkConfig: context.hyperlinkConfig,
@@ -398,6 +402,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
const tableBlock = tableNodeToBlock(childNode, {
nextBlockId: context.nextBlockId,
positions: context.positions,
+ storyKey: context.storyKey,
trackedChangesConfig: context.trackedChangesConfig,
bookmarks: context.bookmarks,
hyperlinkConfig: context.hyperlinkConfig,
@@ -414,7 +419,9 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
if (childNode.type === 'image' && context.converters?.imageNodeToBlock) {
const mergedMarks = [...(childNode.marks ?? [])];
- const trackedMeta = context.trackedChangesConfig ? collectTrackedChangeFromMarks(mergedMarks) : undefined;
+ const trackedMeta = context.trackedChangesConfig
+ ? collectTrackedChangeFromMarks(mergedMarks, context.storyKey)
+ : undefined;
if (shouldHideTrackedNode(trackedMeta, context.trackedChangesConfig)) {
continue;
}
@@ -788,6 +795,7 @@ export function tableNodeToBlock(
{
nextBlockId,
positions,
+ storyKey,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
@@ -804,6 +812,7 @@ export function tableNodeToBlock(
const parserDeps: TableParserDependencies = {
nextBlockId,
positions,
+ storyKey,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
@@ -1037,6 +1046,7 @@ export function handleTableNode(node: PMNode, context: NodeHandlerContext): void
const tableBlock = tableNodeToBlock(node, {
nextBlockId,
positions,
+ storyKey: context.storyKey,
trackedChangesConfig,
bookmarks,
hyperlinkConfig,
diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts
index 4f387dd52b..441b210e73 100644
--- a/packages/layout-engine/pm-adapter/src/index.test.ts
+++ b/packages/layout-engine/pm-adapter/src/index.test.ts
@@ -3656,6 +3656,25 @@ describe('toFlowBlocks', () => {
expect(blocks[0].attrs?.trackedChangesEnabled).toBe(true);
});
+ it('propagates storyKey into tracked change metadata for non-body stories', () => {
+ const pmDoc = buildDocWithMarks([
+ {
+ type: 'trackInsert',
+ attrs: {
+ id: 'ins-story',
+ },
+ },
+ ]);
+
+ const { blocks } = toFlowBlocks(pmDoc, { storyKey: 'hf:part:rId7' });
+ const run = blocks[0].runs[0] as never;
+ expect(run.trackedChange).toMatchObject({
+ kind: 'insert',
+ id: 'ins-story',
+ storyKey: 'hf:part:rId7',
+ });
+ });
+
it('hides insertions when trackedChangesMode is original', () => {
const pmDoc = {
type: 'doc',
@@ -3875,6 +3894,14 @@ describe('toFlowBlocks', () => {
const reviewImage = reviewBlocks.find((block): block is ImageBlock => block.kind === 'image');
expect(reviewImage?.attrs?.trackedChange).toMatchObject({ id: 'del-img', kind: 'delete' });
+ const { blocks: storyBlocks } = toFlowBlocks(pmDoc, { storyKey: 'hf:part:rId7' });
+ const storyImage = storyBlocks.find((block): block is ImageBlock => block.kind === 'image');
+ expect(storyImage?.attrs?.trackedChange).toMatchObject({
+ id: 'del-img',
+ kind: 'delete',
+ storyKey: 'hf:part:rId7',
+ });
+
const { blocks: finalBlocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'final' });
expect(finalBlocks.some((block) => block.kind === 'image')).toBe(false);
diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts
index 4ffd9da91d..1223d7ec7e 100644
--- a/packages/layout-engine/pm-adapter/src/internal.ts
+++ b/packages/layout-engine/pm-adapter/src/internal.ts
@@ -155,6 +155,12 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions):
defaultFont,
defaultSize,
);
+ if (options?.showBookmarks !== undefined) {
+ converterContext.showBookmarks = options.showBookmarks;
+ }
+ if (converterContext.showBookmarks) {
+ converterContext.renderedBookmarkIds = new Set();
+ }
const blocks: FlowBlock[] = [];
const bookmarks = new Map();
@@ -189,6 +195,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions):
recordBlockKind,
nextBlockId,
blockIdPrefix: idPrefix,
+ storyKey: options?.storyKey,
positions,
defaultFont,
defaultSize,
diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts
index 3c2ee5467f..493b43232f 100644
--- a/packages/layout-engine/pm-adapter/src/marks/application.ts
+++ b/packages/layout-engine/pm-adapter/src/marks/application.ts
@@ -451,7 +451,7 @@ const deriveTrackedChangeId = (kind: TrackedChangeKind, attrs: Record {
+export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): TrackedChangeMeta | undefined => {
const kind = pickTrackedChangeKind(mark.type);
if (!kind) return undefined;
const attrs = mark.attrs ?? {};
@@ -475,6 +475,9 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark): TrackedChangeMeta
meta.before = normalizeRunMarkList((attrs as { before?: unknown }).before);
meta.after = normalizeRunMarkList((attrs as { after?: unknown }).after);
}
+ if (typeof storyKey === 'string' && storyKey.length > 0) {
+ meta.storyKey = storyKey;
+ }
return meta;
};
@@ -522,10 +525,10 @@ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => {
* @param marks - Array of ProseMirror marks to process
* @returns The highest-priority TrackedChangeMeta, or undefined if none found
*/
-export const collectTrackedChangeFromMarks = (marks?: PMMark[]): TrackedChangeMeta | undefined => {
+export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta | undefined => {
if (!marks || !marks.length) return undefined;
return marks.reduce((current, mark) => {
- const meta = buildTrackedChangeMetaFromMark(mark);
+ const meta = buildTrackedChangeMetaFromMark(mark, storyKey);
if (!meta) return current;
return selectTrackedChangeMeta(current, meta);
}, undefined);
@@ -835,6 +838,7 @@ export const applyMarksToRun = (
themeColors?: ThemeColorPalette,
backgroundColor?: string,
enableComments = true,
+ storyKey?: string,
): void => {
// If comments are disabled, clear any existing annotations before processing marks.
if (!enableComments && 'comments' in run && (run as TextRun).comments) {
@@ -856,7 +860,7 @@ export const applyMarksToRun = (
case TRACK_FORMAT_MARK: {
// Tracked change marks only apply to TextRun
if (!isTabRun) {
- const tracked = buildTrackedChangeMetaFromMark(mark);
+ const tracked = buildTrackedChangeMetaFromMark(mark, storyKey);
if (tracked) {
run.trackedChange = selectTrackedChangeMeta(run.trackedChange, tracked);
}
diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts
index 767398b0f1..81a2b15d01 100644
--- a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts
+++ b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts
@@ -473,5 +473,124 @@ describe('document-part-object', () => {
expect(callArgs[1].tocInstruction).toBeUndefined();
});
});
+
+ // ==================== Pending section-break emission (SD-2557) ====================
+ describe('pending section break at SDT boundary', () => {
+ const sectionFixture = (startParagraphIndex: number) => ({
+ ranges: [
+ {
+ sectionIndex: 0,
+ startParagraphIndex: 0,
+ endParagraphIndex: 0,
+ sectPr: null,
+ margins: null,
+ headerRefs: {},
+ footerRefs: {},
+ type: 'nextPage',
+ },
+ {
+ sectionIndex: 1,
+ startParagraphIndex,
+ endParagraphIndex: 10,
+ sectPr: null,
+ margins: null,
+ headerRefs: {},
+ footerRefs: {},
+ type: 'nextPage',
+ },
+ ],
+ currentSectionIndex: 0,
+ currentParagraphIndex: startParagraphIndex,
+ });
+
+ // For the TOC branch, per-child emission now lives inside `processTocChildren`
+ // (which is mocked in these tests). The non-TOC branch below exercises the
+ // inline per-child emission path directly.
+ it('emits a section break before a docPartObj non-TOC child at a section boundary', () => {
+ // Repro for SD-2557 at the non-TOC path: same root cause โ the handler
+ // processes child paragraphs but previously skipped the section-break check.
+ const node: PMNode = {
+ type: 'documentPartObject',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }],
+ };
+ vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Building Block Gallery');
+ vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bb-1');
+ vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
+ vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' });
+
+ // currentParagraphIndex === nextSection.startParagraphIndex โ the first
+ // child paragraph is the start of section 1.
+ mockContext.sectionState = sectionFixture(3) as unknown as NodeHandlerContext['sectionState'];
+
+ handleDocumentPartObjectNode(node, mockContext);
+
+ const sectionBreak = mockContext.blocks.find((b) => b.kind === 'sectionBreak');
+ expect(sectionBreak).toBeDefined();
+ expect(mockContext.sectionState!.currentSectionIndex).toBe(1);
+ // Counter must advance past the child paragraph so subsequent body
+ // content sees the correct paragraph index.
+ expect(mockContext.sectionState!.currentParagraphIndex).toBe(4);
+ });
+
+ it('does not emit a section break when the child is not at a section boundary', () => {
+ const node: PMNode = {
+ type: 'documentPartObject',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }],
+ };
+ vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Building Block Gallery');
+ vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bb-1');
+ vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
+ vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' });
+
+ // currentParagraphIndex (2) < startParagraphIndex (5): not at boundary.
+ const state = sectionFixture(5);
+ state.currentParagraphIndex = 2;
+ mockContext.sectionState = state as unknown as NodeHandlerContext['sectionState'];
+
+ handleDocumentPartObjectNode(node, mockContext);
+
+ expect(mockContext.blocks.find((b) => b.kind === 'sectionBreak')).toBeUndefined();
+ expect(mockContext.sectionState!.currentSectionIndex).toBe(0);
+ // Counter still advances past the processed child.
+ expect(mockContext.sectionState!.currentParagraphIndex).toBe(3);
+ });
+
+ it('is a no-op when sectionState is undefined', () => {
+ const node: PMNode = {
+ type: 'documentPartObject',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }],
+ };
+ vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Building Block Gallery');
+ vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bb-1');
+ vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
+ vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' });
+
+ mockContext.sectionState = undefined;
+
+ expect(() => handleDocumentPartObjectNode(node, mockContext)).not.toThrow();
+ expect(mockContext.blocks.find((b) => b.kind === 'sectionBreak')).toBeUndefined();
+ });
+
+ it('passes sectionState through to processTocChildren for TOC gallery', () => {
+ const node: PMNode = {
+ type: 'documentPartObject',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'TOC Entry' }] }],
+ };
+ vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Table of Contents');
+ vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1');
+ vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
+ vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' });
+
+ const state = sectionFixture(3);
+ mockContext.sectionState = state as unknown as NodeHandlerContext['sectionState'];
+
+ handleDocumentPartObjectNode(node, mockContext);
+
+ // processTocChildren is mocked; just verify it received sectionState
+ // so the helper-inside-processTocChildren pattern can work end-to-end.
+ const callArgs = vi.mocked(tocModule.processTocChildren).mock.calls[0];
+ expect(callArgs[2]).toMatchObject({ sectionState: state });
+ });
+ });
});
});
diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts
index 045ca3c91b..facc5b8ef8 100644
--- a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts
+++ b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts
@@ -6,6 +6,7 @@
*/
import type { PMNode, NodeHandlerContext } from '../types.js';
+import { emitPendingSectionBreakForParagraph } from '../sections/index.js';
import { getDocPartGallery, getDocPartObjectId, getNodeInstruction, resolveNodeSdtMetadata } from './metadata.js';
import { processTocChildren } from './toc.js';
@@ -14,6 +15,14 @@ import { processTocChildren } from './toc.js';
* Processes TOC children for Table of Contents galleries.
* For other gallery types (page numbers, etc.), processes child paragraphs normally.
*
+ * If a preceding paragraph carried a `w:sectPr` whose next section starts at
+ * this SDT, emit the pending section break BEFORE processing children so the
+ * SDT's paragraphs render on the new page (see SD-2557). `findParagraphsWithSectPr`
+ * doesn't recurse into `documentPartObject`, so its child paragraphs don't bump
+ * `currentParagraphIndex` โ and without this call, the deferred break would only
+ * fire on the next body paragraph AFTER the SDT, leaving e.g. a TOC on the
+ * prior page with the cover content.
+ *
* @param node - Document part object node to process
* @param context - Shared handler context
*/
@@ -27,12 +36,14 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC
positions,
bookmarks,
hyperlinkConfig,
+ sectionState,
converters,
converterContext,
enableComments,
trackedChangesConfig,
themeColors,
} = context;
+
const docPartGallery = getDocPartGallery(node);
const docPartObjectId = getDocPartObjectId(node);
const tocInstruction = getNodeInstruction(node);
@@ -50,15 +61,21 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC
hyperlinkConfig,
enableComments,
trackedChangesConfig,
+ themeColors,
converters,
converterContext,
+ sectionState,
},
{ blocks, recordBlockKind },
);
} else if (paragraphToFlowBlocks) {
- // For non-ToC gallery types (page numbers, etc.), process child paragraphs normally
+ // For non-ToC gallery types (page numbers, etc.), process child paragraphs normally.
+ // `findParagraphsWithSectPr` recurses into documentPartObject (SD-2557), so child
+ // paragraph indices ARE counted โ we must mirror that by emitting pending section
+ // breaks and advancing currentParagraphIndex per child.
for (const child of node.content) {
if (child.type === 'paragraph') {
+ emitPendingSectionBreakForParagraph({ sectionState, nextBlockId, blocks, recordBlockKind });
const childBlocks = paragraphToFlowBlocks({
para: child,
nextBlockId,
@@ -75,6 +92,7 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC
blocks.push(block);
recordBlockKind?.(block.kind);
}
+ if (sectionState) sectionState.currentParagraphIndex++;
}
}
}
diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts
index 86a6e2a70a..d05216e23b 100644
--- a/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts
+++ b/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts
@@ -3,8 +3,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
-import { applyTocMetadata, processTocChildren } from './toc.js';
-import type { PMNode } from '../types.js';
+import { applyTocMetadata, processTocChildren, handleTableOfContentsNode } from './toc.js';
+import type { PMNode, NodeHandlerContext } from '../types.js';
import type { FlowBlock, ParagraphBlock, SdtMetadata } from '@superdoc/contracts';
describe('toc', () => {
@@ -96,7 +96,9 @@ describe('toc', () => {
expect(blocks[0].attrs?.isTocEntry).toBe(true);
});
- it('handles null metadata values', () => {
+ it('does not fabricate sdt metadata when gallery is missing', () => {
+ // A direct `tableOfContents` PM node has no enclosing w:sdt in OOXML,
+ // so we must not invent a docPartObject SDT metadata entry for it.
const blocks: ParagraphBlock[] = [
{
kind: 'paragraph',
@@ -112,12 +114,7 @@ describe('toc', () => {
});
expect(blocks[0].attrs?.isTocEntry).toBe(true);
- expect(blocks[0].attrs?.sdt).toEqual({
- type: 'docPartObject',
- gallery: null,
- uniqueId: null,
- instruction: null,
- });
+ expect(blocks[0].attrs?.sdt).toBeUndefined();
expect(blocks[0].attrs?.tocInstruction).toBeUndefined();
});
@@ -479,5 +476,194 @@ describe('toc', () => {
}),
);
});
+
+ it('forwards themeColors to the paragraph converter', () => {
+ const children: PMNode[] = [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Entry' }],
+ },
+ ];
+ const blocks: FlowBlock[] = [];
+ const themeColors = { accent1: '#ff0000' } as never;
+
+ const mockParagraphConverter = vi.fn(() => [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [{ text: 'Entry', fontFamily: 'Arial', fontSize: 12 }],
+ } as ParagraphBlock,
+ ]);
+
+ processTocChildren(
+ children,
+ { docPartGallery: 'Table of Contents' },
+ {
+ nextBlockId: () => 'id',
+ positions: new Map(),
+ bookmarks: new Map(),
+ hyperlinkConfig: mockHyperlinkConfig,
+ enableComments: true,
+ converters: { paragraphToFlowBlocks: mockParagraphConverter } as never,
+ converterContext: mockConverterContext,
+ themeColors,
+ },
+ { blocks },
+ );
+
+ expect(mockParagraphConverter).toHaveBeenCalledWith(expect.objectContaining({ themeColors }));
+ });
+ });
+
+ // ==================== handleTableOfContentsNode (direct node) ====================
+ describe('handleTableOfContentsNode', () => {
+ const baseContext = (overrides: Partial = {}): NodeHandlerContext => {
+ const paragraphConverter = vi.fn((params: { para: PMNode }) => {
+ const text = (params.para.content as { text: string }[] | undefined)?.[0]?.text ?? '';
+ return [
+ {
+ kind: 'paragraph',
+ id: `p-${text}`,
+ runs: [{ text, fontFamily: 'Arial', fontSize: 12 }],
+ } as ParagraphBlock,
+ ];
+ });
+ return {
+ blocks: [],
+ recordBlockKind: vi.fn(),
+ nextBlockId: (kind: string) => `${kind}-id`,
+ positions: new Map(),
+ defaultFont: 'Arial',
+ defaultSize: 12,
+ bookmarks: new Map(),
+ hyperlinkConfig: { mode: 'preserve' } as never,
+ enableComments: true,
+ converterContext: { docx: {} } as never,
+ converters: { paragraphToFlowBlocks: paragraphConverter } as never,
+ trackedChangesConfig: undefined as never,
+ ...overrides,
+ };
+ };
+
+ const sectionStateAt = (startParagraphIndex: number, currentParagraphIndex: number) =>
+ ({
+ ranges: [
+ {
+ sectionIndex: 0,
+ startParagraphIndex: 0,
+ endParagraphIndex: 0,
+ sectPr: null,
+ margins: null,
+ headerRefs: {},
+ footerRefs: {},
+ type: 'nextPage',
+ },
+ {
+ sectionIndex: 1,
+ startParagraphIndex,
+ endParagraphIndex: 99,
+ sectPr: null,
+ margins: null,
+ headerRefs: {},
+ footerRefs: {},
+ type: 'nextPage',
+ },
+ ],
+ currentSectionIndex: 0,
+ currentParagraphIndex,
+ }) as unknown as NodeHandlerContext['sectionState'];
+
+ it('advances currentParagraphIndex once per child paragraph', () => {
+ const node: PMNode = {
+ type: 'tableOfContents',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'Ch 1' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'Ch 2' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'Ch 3' }] },
+ ],
+ };
+ const ctx = baseContext({ sectionState: sectionStateAt(100, 5) });
+
+ handleTableOfContentsNode(node, ctx);
+
+ // 3 TOC children processed โ counter advanced 3 times
+ expect(ctx.sectionState!.currentParagraphIndex).toBe(8);
+ });
+
+ it('emits a section break before the TOC child that starts the next section', () => {
+ // Section boundary sits at paragraph index 6: third child of the TOC.
+ // currentParagraphIndex starts at 4; children consume indices 4, 5, 6.
+ const node: PMNode = {
+ type: 'tableOfContents',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'A' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'C' }] },
+ ],
+ };
+ const ctx = baseContext({ sectionState: sectionStateAt(6, 4) });
+
+ handleTableOfContentsNode(node, ctx);
+
+ const breakIndex = ctx.blocks.findIndex((b) => b.kind === 'sectionBreak');
+ const thirdEntryIndex = ctx.blocks.findIndex((b) => b.kind === 'paragraph' && (b as ParagraphBlock).id === 'p-C');
+ expect(breakIndex).toBeGreaterThanOrEqual(0);
+ expect(breakIndex).toBeLessThan(thirdEntryIndex);
+ expect(ctx.sectionState!.currentSectionIndex).toBe(1);
+ });
+
+ it('does not fabricate attrs.sdt on entries (no enclosing SDT)', () => {
+ const node: PMNode = {
+ type: 'tableOfContents',
+ attrs: { instruction: 'TOC \\o "1-3"' },
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Entry' }] }],
+ };
+ const ctx = baseContext();
+
+ handleTableOfContentsNode(node, ctx);
+
+ const entry = ctx.blocks[0] as ParagraphBlock;
+ expect(entry.attrs?.isTocEntry).toBe(true);
+ expect(entry.attrs?.tocInstruction).toBe('TOC \\o "1-3"');
+ expect(entry.attrs?.sdt).toBeUndefined();
+ });
+
+ it('is a no-op when sectionState is undefined', () => {
+ const node: PMNode = {
+ type: 'tableOfContents',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Entry' }] }],
+ };
+ const ctx = baseContext();
+
+ expect(() => handleTableOfContentsNode(node, ctx)).not.toThrow();
+ expect(ctx.blocks.find((b) => b.kind === 'sectionBreak')).toBeUndefined();
+ expect((ctx.blocks[0] as ParagraphBlock).attrs?.isTocEntry).toBe(true);
+ });
+
+ it('forwards themeColors to the paragraph converter', () => {
+ const paragraphConverter = vi.fn((params: { para: PMNode }) => {
+ const text = (params.para.content as { text: string }[] | undefined)?.[0]?.text ?? '';
+ return [
+ {
+ kind: 'paragraph',
+ id: `p-${text}`,
+ runs: [{ text, fontFamily: 'Arial', fontSize: 12 }],
+ } as ParagraphBlock,
+ ];
+ });
+ const themeColors = { accent1: '#112233' } as never;
+ const node: PMNode = {
+ type: 'tableOfContents',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Entry' }] }],
+ };
+ const ctx = baseContext({
+ converters: { paragraphToFlowBlocks: paragraphConverter } as never,
+ themeColors,
+ });
+
+ handleTableOfContentsNode(node, ctx);
+
+ expect(paragraphConverter).toHaveBeenCalledWith(expect.objectContaining({ themeColors }));
+ });
});
});
diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.ts
index dd5246dccb..116bdcd556 100644
--- a/packages/layout-engine/pm-adapter/src/sdt/toc.ts
+++ b/packages/layout-engine/pm-adapter/src/sdt/toc.ts
@@ -17,6 +17,7 @@ import type {
ConverterContext,
ThemeColorPalette,
} from '../types.js';
+import { emitPendingSectionBreakForParagraph } from '../sections/index.js';
import { applySdtMetadataToParagraphBlocks, getNodeInstruction } from './metadata.js';
/**
@@ -38,8 +39,10 @@ export function applyTocMetadata(
if (block.kind === 'paragraph') {
if (!block.attrs) block.attrs = {};
block.attrs.isTocEntry = true;
- // Store TOC metadata as SDT for proper typing
- if (!block.attrs.sdt) {
+ // Only fabricate SDT metadata when the TOC came from a w:sdt/w:docPartObj
+ // wrapper (gallery is set). A direct `tableOfContents` PM node has no
+ // enclosing SDT, so inventing one here would mislead downstream consumers.
+ if (!block.attrs.sdt && metadata.gallery) {
block.attrs.sdt = {
type: 'docPartObject',
gallery: metadata.gallery,
@@ -86,7 +89,9 @@ export function applyTocMetadata(
export function processTocChildren(
children: readonly PMNode[],
metadata: {
- docPartGallery: string;
+ // Optional: only set when the TOC is wrapped in a w:sdt/w:docPartObj.
+ // Direct `tableOfContents` PM nodes omit this โ no SDT metadata is fabricated.
+ docPartGallery?: string;
docPartObjectId?: string;
tocInstruction?: string;
sdtMetadata?: SdtMetadata;
@@ -101,6 +106,7 @@ export function processTocChildren(
converters: NestedConverters;
converterContext: ConverterContext;
themeColors?: ThemeColorPalette;
+ sectionState?: NodeHandlerContext['sectionState'];
},
outputArrays: {
blocks: FlowBlock[];
@@ -113,6 +119,16 @@ export function processTocChildren(
children.forEach((child) => {
if (child.type === 'paragraph') {
+ // SD-2557: emit any pending section break before this child. `findParagraphsWithSectPr`
+ // recurses into documentPartObject, so TOC child paragraph indices are part of the
+ // section-range counting โ advance the counter after processing to stay in sync.
+ emitPendingSectionBreakForParagraph({
+ sectionState: context.sectionState,
+ nextBlockId: context.nextBlockId,
+ blocks,
+ recordBlockKind,
+ });
+
// Direct paragraph child - convert and tag
const paragraphBlocks = paragraphConverter({
para: child,
@@ -121,6 +137,7 @@ export function processTocChildren(
trackedChangesConfig: context.trackedChangesConfig,
bookmarks: context.bookmarks,
hyperlinkConfig: context.hyperlinkConfig,
+ themeColors: context.themeColors,
converters: context.converters,
enableComments: context.enableComments,
converterContext: context.converterContext,
@@ -140,6 +157,8 @@ export function processTocChildren(
blocks.push(block);
recordBlockKind?.(block.kind);
});
+
+ if (context.sectionState) context.sectionState.currentParagraphIndex++;
} else if (child.type === 'tableOfContents' && Array.isArray(child.content)) {
// Nested tableOfContents - recurse with potentially different instruction
const childInstruction = getNodeInstruction(child);
@@ -156,8 +175,13 @@ export function processTocChildren(
}
/**
- * Handle table of contents nodes.
- * Processes child paragraphs and marks them as TOC entries.
+ * Handle direct `tableOfContents` PM nodes (not wrapped in a `documentPartObject`
+ * SDT). Delegates to `processTocChildren` โ the single code path that also
+ * services `handleDocumentPartObjectNode`. This keeps the section-range
+ * counting contract intact: `findParagraphsWithSectPr` counts every
+ * `tableOfContents` child, and `processTocChildren` advances
+ * `sectionState.currentParagraphIndex` per child so deferred section breaks
+ * fire at the right paragraph boundary (SD-2557).
*
* @param node - Table of contents node to process
* @param context - Shared handler context
@@ -165,45 +189,25 @@ export function processTocChildren(
export function handleTableOfContentsNode(node: PMNode, context: NodeHandlerContext): void {
if (!Array.isArray(node.content)) return;
- const {
- blocks,
- recordBlockKind,
- nextBlockId,
- positions,
- trackedChangesConfig,
- bookmarks,
- hyperlinkConfig,
- converters,
- converterContext,
- themeColors,
- enableComments,
- } = context;
- const tocInstruction = getNodeInstruction(node);
- const paragraphToFlowBlocks = converters.paragraphToFlowBlocks;
-
- node.content.forEach((child) => {
- if (child.type === 'paragraph') {
- const paragraphBlocks = paragraphToFlowBlocks({
- para: child,
- nextBlockId,
- positions,
- trackedChangesConfig,
- bookmarks,
- themeColors,
- hyperlinkConfig,
- converters,
- enableComments,
- converterContext,
- });
- paragraphBlocks.forEach((block) => {
- if (block.kind === 'paragraph') {
- if (!block.attrs) block.attrs = {};
- block.attrs.isTocEntry = true;
- if (tocInstruction) block.attrs.tocInstruction = tocInstruction;
- }
- blocks.push(block);
- recordBlockKind?.(block.kind);
- });
- }
- });
+ processTocChildren(
+ node.content,
+ {
+ // No enclosing SDT โ omit gallery so applyTocMetadata does not fabricate
+ // a docPartObject sdt entry on each TOC paragraph.
+ tocInstruction: getNodeInstruction(node),
+ },
+ {
+ nextBlockId: context.nextBlockId,
+ positions: context.positions,
+ bookmarks: context.bookmarks,
+ trackedChangesConfig: context.trackedChangesConfig,
+ hyperlinkConfig: context.hyperlinkConfig,
+ enableComments: context.enableComments,
+ themeColors: context.themeColors,
+ converters: context.converters,
+ converterContext: context.converterContext,
+ sectionState: context.sectionState,
+ },
+ { blocks: context.blocks, recordBlockKind: context.recordBlockKind },
+ );
}
diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.ts b/packages/layout-engine/pm-adapter/src/sections/analysis.ts
index 3a0cae9013..925b0561f5 100644
--- a/packages/layout-engine/pm-adapter/src/sections/analysis.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/analysis.ts
@@ -96,7 +96,23 @@ export function findParagraphsWithSectPr(doc: PMNode): {
return;
}
- if (node.type === 'index' || node.type === 'bibliography' || node.type === 'tableOfAuthorities') {
+ // Recurse into container node types that wrap body paragraphs. Children
+ // of these nodes are counted as paragraphs for section-range purposes and
+ // their handlers increment `currentParagraphIndex` + call the section-break
+ // emission helper per child.
+ //
+ // `documentPartObject` / `tableOfContents` are important for SD-2557:
+ // Word stores the closing sectPr of a TOC section on the trailing empty
+ // paragraph INSIDE the SDT. Without recursion, that sectPr is invisible to
+ // section-range analysis and the nextPage break between TOC and the next
+ // body section is silently dropped.
+ if (
+ node.type === 'index' ||
+ node.type === 'bibliography' ||
+ node.type === 'tableOfAuthorities' ||
+ node.type === 'documentPartObject' ||
+ node.type === 'tableOfContents'
+ ) {
getNodeChildren(node).forEach(visitNode);
}
};
diff --git a/packages/layout-engine/pm-adapter/src/sections/breaks.ts b/packages/layout-engine/pm-adapter/src/sections/breaks.ts
index 8501b2230a..4b4ed6f030 100644
--- a/packages/layout-engine/pm-adapter/src/sections/breaks.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/breaks.ts
@@ -190,3 +190,55 @@ export function shouldRequirePageBoundary(current: SectionRange, next: SectionRa
export function hasIntrinsicBoundarySignals(_: SectionRange): boolean {
return false;
}
+
+/**
+ * Minimal mutable sectionState shape used by section-break emission helpers.
+ * Kept local so callers can pass `NodeHandlerContext['sectionState']` directly.
+ */
+interface SectionStateMutable {
+ ranges: SectionRange[];
+ currentSectionIndex: number;
+ currentParagraphIndex: number;
+}
+
+/**
+ * Emit a pending section break before a paragraph if the current paragraph
+ * index matches the start of the next section.
+ *
+ * Centralizes the "check, emit, advance" pattern used by paragraph and SDT
+ * handlers. SDT handlers that process children as an opaque block (e.g.
+ * TOC/docPartObj where child paragraphs aren't counted by
+ * `findParagraphsWithSectPr`) should call this ONCE at the SDT boundary โ
+ * if the SDT sits at a section boundary, this emits the break so the SDT's
+ * contents render on the new page.
+ *
+ * No-op when:
+ * - sectionState is undefined or has no ranges
+ * - currentParagraphIndex doesn't match the next section's startParagraphIndex
+ *
+ * Side effects (when emitted):
+ * - Pushes a sectionBreak block onto `blocks`
+ * - Invokes `recordBlockKind`
+ * - Increments `sectionState.currentSectionIndex`
+ */
+export function emitPendingSectionBreakForParagraph(args: {
+ sectionState: SectionStateMutable | undefined;
+ nextBlockId: BlockIdGenerator;
+ blocks: FlowBlock[];
+ recordBlockKind?: (kind: FlowBlock['kind']) => void;
+}): void {
+ const { sectionState, nextBlockId, blocks, recordBlockKind } = args;
+ if (!sectionState || sectionState.ranges.length === 0) return;
+
+ const nextSection = sectionState.ranges[sectionState.currentSectionIndex + 1];
+ if (!nextSection || sectionState.currentParagraphIndex !== nextSection.startParagraphIndex) return;
+
+ const currentSection = sectionState.ranges[sectionState.currentSectionIndex];
+ const requiresPageBoundary =
+ shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection);
+ const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined;
+ const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs);
+ blocks.push(sectionBreak);
+ recordBlockKind?.(sectionBreak.kind);
+ sectionState.currentSectionIndex++;
+}
diff --git a/packages/layout-engine/pm-adapter/src/sections/index.ts b/packages/layout-engine/pm-adapter/src/sections/index.ts
index 64b41423fb..5f293b3d9d 100644
--- a/packages/layout-engine/pm-adapter/src/sections/index.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/index.ts
@@ -41,4 +41,5 @@ export {
isSectionBreakBlock,
signaturesEqual,
shallowObjectEquals,
+ emitPendingSectionBreakForParagraph,
} from './breaks.js';
diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts
index c3b185d1d1..cb84f6ffe6 100644
--- a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts
+++ b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts
@@ -745,7 +745,15 @@ describe('tracked-changes', () => {
const applyMarksToRun = vi.fn();
applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun);
- expect(applyMarksToRun).toHaveBeenCalledWith(run, beforeMarks, hyperlinkConfig, undefined, undefined, true);
+ expect(applyMarksToRun).toHaveBeenCalledWith(
+ run,
+ beforeMarks,
+ hyperlinkConfig,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ );
});
it('should handle errors in applyMarksToRun by resetting formatting', () => {
diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.ts
index 687f48c4a1..e69c9ee99b 100644
--- a/packages/layout-engine/pm-adapter/src/tracked-changes.ts
+++ b/packages/layout-engine/pm-adapter/src/tracked-changes.ts
@@ -213,7 +213,7 @@ export const deriveTrackedChangeId = (kind: TrackedChangeKind, attrs: Record {
+export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): TrackedChangeMeta | undefined => {
const kind = pickTrackedChangeKind(mark.type);
if (!kind) return undefined;
const attrs = mark.attrs ?? {};
@@ -237,6 +237,9 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark): TrackedChangeMeta
meta.before = normalizeRunMarkList((attrs as { before?: unknown }).before);
meta.after = normalizeRunMarkList((attrs as { after?: unknown }).after);
}
+ if (typeof storyKey === 'string' && storyKey.length > 0) {
+ meta.storyKey = storyKey;
+ }
return meta;
};
@@ -363,9 +366,11 @@ export const applyFormatChangeMarks = (
themeColors?: ThemeColorPalette,
backgroundColor?: string,
enableComments?: boolean,
+ storyKey?: string,
) => void,
themeColors?: ThemeColorPalette,
enableComments = true,
+ storyKey?: string,
): void => {
const tracked = run.trackedChange;
if (!tracked || tracked.kind !== 'format') {
@@ -402,7 +407,7 @@ export const applyFormatChangeMarks = (
resetRunFormatting(run);
try {
- applyMarksToRun(run, beforeMarks as PMMark[], hyperlinkConfig, themeColors, undefined, enableComments);
+ applyMarksToRun(run, beforeMarks as PMMark[], hyperlinkConfig, themeColors, undefined, enableComments, storyKey);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('[PM-Adapter] Error applying format change marks, resetting formatting:', error);
@@ -433,9 +438,11 @@ export const applyTrackedChangesModeToRuns = (
themeColors?: ThemeColorPalette,
backgroundColor?: string,
enableComments?: boolean,
+ storyKey?: string,
) => void,
themeColors?: ThemeColorPalette,
enableComments = true,
+ storyKey?: string,
): Run[] => {
if (!config) {
return runs;
@@ -451,7 +458,7 @@ export const applyTrackedChangesModeToRuns = (
// Apply format changes even when not filtering insertions/deletions
runs.forEach((run) => {
if (isTextRun(run)) {
- applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun, themeColors, enableComments);
+ applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun, themeColors, enableComments, storyKey);
}
});
}
@@ -491,6 +498,7 @@ export const applyTrackedChangesModeToRuns = (
applyMarksToRun,
themeColors,
enableComments,
+ storyKey,
);
}
});
diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts
index 5a98b205ed..d60515c39b 100644
--- a/packages/layout-engine/pm-adapter/src/types.ts
+++ b/packages/layout-engine/pm-adapter/src/types.ts
@@ -93,6 +93,13 @@ export interface AdapterOptions {
*/
blockIdPrefix?: string;
+ /**
+ * Story key for the document being converted. Used to stamp tracked-change
+ * metadata so rendered DOM anchors can distinguish body, header/footer, and
+ * note stories.
+ */
+ storyKey?: string;
+
/**
* Optional list of ProseMirror node type names that should be treated as atom/leaf nodes
* for position mapping. Use this to keep PM positions correct when custom atom nodes exist.
@@ -122,6 +129,13 @@ export interface AdapterOptions {
*/
emitSectionBreaks?: boolean;
+ /**
+ * When true, render visible gray `[` / `]` marker runs at bookmarkStart and
+ * bookmarkEnd positions (SD-2454). Matches Word's opt-in "Show bookmarks"
+ * behavior. Off by default because bookmarks are structural, not visual.
+ */
+ showBookmarks?: boolean;
+
/**
* Optional instrumentation hook for fidelity logging.
*/
@@ -279,6 +293,7 @@ export interface NodeHandlerContext {
// ID generation & positions
nextBlockId: BlockIdGenerator;
blockIdPrefix?: string;
+ storyKey?: string;
positions: PositionMap;
// Style & defaults
@@ -333,6 +348,7 @@ export type ParagraphToFlowBlocksParams = {
para: PMNode;
nextBlockId: BlockIdGenerator;
positions: PositionMap;
+ storyKey?: string;
trackedChangesConfig: TrackedChangesConfig;
hyperlinkConfig: HyperlinkConfig;
themeColors?: ThemeColorPalette;
@@ -348,6 +364,7 @@ export type ParagraphToFlowBlocksParams = {
export type TableNodeToBlockParams = {
nextBlockId: BlockIdGenerator;
positions: PositionMap;
+ storyKey?: string;
trackedChangesConfig: TrackedChangesConfig;
bookmarks: Map;
hyperlinkConfig: HyperlinkConfig;
diff --git a/packages/layout-engine/pm-adapter/src/utilities.test.ts b/packages/layout-engine/pm-adapter/src/utilities.test.ts
index 1218404f3b..f7cb642cf4 100644
--- a/packages/layout-engine/pm-adapter/src/utilities.test.ts
+++ b/packages/layout-engine/pm-adapter/src/utilities.test.ts
@@ -752,6 +752,36 @@ describe('Media Utilities', () => {
expect(result[0].src).toBe('data:image/png;base64,base64data');
});
+ it('hydrates word/media src from media storage key', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'image',
+ id: '1',
+ src: 'word/media/image.png',
+ runs: [],
+ },
+ ];
+ const mediaFiles = { 'media/image.png': 'base64data' };
+
+ const result = hydrateImageBlocks(blocks, mediaFiles);
+ expect(result[0].src).toBe('data:image/png;base64,base64data');
+ });
+
+ it('hydrates media src from word/media storage key', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'image',
+ id: '1',
+ src: 'media/image.png',
+ runs: [],
+ },
+ ];
+ const mediaFiles = { 'word/media/image.png': 'base64data' };
+
+ const result = hydrateImageBlocks(blocks, mediaFiles);
+ expect(result[0].src).toBe('data:image/png;base64,base64data');
+ });
+
it('uses rId fallback when direct path does not match', () => {
const blocks: FlowBlock[] = [
{
diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts
index 6db6d38956..d09d3245ad 100644
--- a/packages/layout-engine/pm-adapter/src/utilities.ts
+++ b/packages/layout-engine/pm-adapter/src/utilities.ts
@@ -1001,9 +1001,18 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record {
+ if (!value) return [] as string[];
+ const normalized = normalizeMediaKey(value);
+ if (!normalized) return [value];
+ const withoutWordPrefix = normalized.startsWith('word/') ? normalized.slice(5) : normalized;
+ const withWordPrefix = normalized.startsWith('word/') ? normalized : `word/${normalized}`;
+ return [value, normalized, withoutWordPrefix, withWordPrefix];
+ };
+
const candidates = new Set();
- candidates.add(src);
- if (attrSrc) candidates.add(attrSrc);
+ addPathCandidates(src).forEach((candidate) => candidates.add(candidate));
+ if (attrSrc) addPathCandidates(attrSrc).forEach((candidate) => candidates.add(candidate));
if (relId) {
const inferredExt = extension ?? inferExtensionFromPath(src) ?? 'jpeg';
candidates.add(`word/media/${relId}.${inferredExt}`);
diff --git a/packages/react/.releaserc.cjs b/packages/react/.releaserc.cjs
index d875ffa4f1..2a427aab25 100644
--- a/packages/react/.releaserc.cjs
+++ b/packages/react/.releaserc.cjs
@@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/ai',
'packages/word-layout',
'packages/preset-geometry',
+ 'shared',
'pnpm-workspace.yaml',
]);
diff --git a/packages/react/package.json b/packages/react/package.json
index 5acbc31285..b1263315ef 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -21,6 +21,7 @@
"build": "vite build",
"dev": "vite build --watch",
"test": "vitest run",
+ "pretype-check": "node ../../apps/cli/scripts/ensure-superdoc-build.js --types",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"prepublishOnly": "pnpm run build"
diff --git a/packages/sdk/.releaserc.cjs b/packages/sdk/.releaserc.cjs
index 6c7f92118a..bf9c50fca5 100644
--- a/packages/sdk/.releaserc.cjs
+++ b/packages/sdk/.releaserc.cjs
@@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/ai',
'packages/word-layout',
'packages/preset-geometry',
+ 'shared',
'pnpm-workspace.yaml',
]);
diff --git a/packages/sdk/scripts/__tests__/release-order.test.mjs b/packages/sdk/scripts/__tests__/release-order.test.mjs
index 6f8c94d8cb..2622b88e70 100644
--- a/packages/sdk/scripts/__tests__/release-order.test.mjs
+++ b/packages/sdk/scripts/__tests__/release-order.test.mjs
@@ -94,15 +94,21 @@ test('release-sdk auto workflow resumes releases from sdk-v tags at HEAD', async
);
});
-test('release-sdk auto workflow runs on stable and main', async () => {
+test('release-sdk auto workflow stays on main while stable uses the central orchestrator', async () => {
const content = await readRepoFile('.github/workflows/release-sdk.yml');
+ const stableWorkflow = await readRepoFile('.github/workflows/release-stable.yml');
assert.ok(
content.includes(' - main'),
'.github/workflows/release-sdk.yml: auto-release must continue to run on main',
);
- assert.ok(
+ assert.equal(
content.includes(' - stable'),
- '.github/workflows/release-sdk.yml: auto-release must run on stable',
+ false,
+ '.github/workflows/release-sdk.yml: stable releases should be handled by release-stable.yml',
+ );
+ assert.ok(
+ stableWorkflow.includes(' - stable'),
+ '.github/workflows/release-stable.yml: the central stable orchestrator must run on stable',
);
});
diff --git a/packages/sdk/scripts/publish-node-sdk.mjs b/packages/sdk/scripts/publish-node-sdk.mjs
index 536f45854e..c491a6a68f 100644
--- a/packages/sdk/scripts/publish-node-sdk.mjs
+++ b/packages/sdk/scripts/publish-node-sdk.mjs
@@ -82,13 +82,26 @@ function isAlreadyPublished(packageName, version, authToken, baseEnv = process.e
throw new Error(`Failed to check published version for ${packageName}@${version}: ${details}`);
}
+function ensureDistTag(packageName, version, tag, authToken, baseEnv = process.env) {
+ const result = spawnSync('npm', ['dist-tag', 'add', `${packageName}@${version}`, tag], {
+ cwd: REPO_ROOT,
+ stdio: 'inherit',
+ env: createNpmEnv(baseEnv, authToken),
+ });
+
+ if (result.status !== 0) {
+ throw new Error(`Failed to ensure dist-tag "${tag}" for ${packageName}@${version}`);
+ }
+}
+
function runNpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.env) {
const pkgDir = PACKAGE_DIR_BY_NAME[packageName];
if (!pkgDir) throw new Error(`No package directory mapping for ${packageName}`);
const version = getPackageVersion(packageName);
if (!dryRun && isAlreadyPublished(packageName, version, authToken, baseEnv)) {
- console.log(`Skipping ${packageName}@${version} (already published).`);
+ console.log(`Skipping ${packageName}@${version} (already published, ensuring dist-tag "${tag}").`);
+ ensureDistTag(packageName, version, tag, authToken, baseEnv);
return;
}
diff --git a/packages/super-editor/src/editors/v1/assets/styles/layout/global.css b/packages/super-editor/src/editors/v1/assets/styles/layout/global.css
index ba6b428a27..53a4bcaa5b 100644
--- a/packages/super-editor/src/editors/v1/assets/styles/layout/global.css
+++ b/packages/super-editor/src/editors/v1/assets/styles/layout/global.css
@@ -17,7 +17,7 @@
}
.presentation-editor__selection-caret {
- animation: superdoc-caret-blink 1.2s steps(2, start) infinite;
+ animation: superdoc-caret-blink 1.2s ease-in-out infinite;
}
.presentation-editor__permission-overlay {
@@ -36,6 +36,6 @@
}
55%,
100% {
- opacity: 0;
+ opacity: 0.55;
}
}
diff --git a/packages/super-editor/src/editors/v1/components/SuperEditor.vue b/packages/super-editor/src/editors/v1/components/SuperEditor.vue
index 91e6c8ccf7..08fcc9bd0b 100644
--- a/packages/super-editor/src/editors/v1/components/SuperEditor.vue
+++ b/packages/super-editor/src/editors/v1/components/SuperEditor.vue
@@ -59,6 +59,13 @@ const activeEditor = computed(() => {
return editor.value;
});
+const contextMenuEditor = computed(() => {
+ if (editor.value instanceof PresentationEditor) {
+ return editor.value;
+ }
+ return activeEditor.value;
+});
+
const contextMenuDisabled = computed(() => {
const active = activeEditor.value;
return active?.options ? Boolean(active.options.disableContextMenu) : Boolean(props.options.disableContextMenu);
@@ -1298,8 +1305,8 @@ onBeforeUnmount(() => {
{
*
* @param {MouseEvent} event - The context menu event in capture phase
*/
+const getContextMenuTargets = () => {
+ const targets = new Set();
+ const surface = getEditorSurfaceElement(props.editor);
+ if (surface) {
+ targets.add(surface);
+ }
+
+ const activeEditor = resolveContextMenuCommandEditor(props.editor);
+ const activeDom = activeEditor?.view?.dom;
+ if (activeDom instanceof HTMLElement) {
+ targets.add(activeDom);
+ }
+
+ return [...targets];
+};
+
+const isEventWithinContextMenuTargets = (event) => {
+ const target = event?.target;
+ if (!(target instanceof Node)) {
+ return false;
+ }
+
+ return getContextMenuTargets().some(
+ (surface) => surface === target || (typeof surface?.contains === 'function' && surface.contains(target)),
+ );
+};
+
const handleRightClickCapture = (event) => {
try {
- if (shouldHandleContextMenu(event)) {
+ if (isEventWithinContextMenuTargets(event) && shouldHandleContextMenu(event)) {
event[CONTEXT_MENU_HANDLED_FLAG] = true;
}
} catch (error) {
@@ -325,6 +352,10 @@ const handleRightClickCapture = (event) => {
};
const handleRightClick = async (event) => {
+ if (!isEventWithinContextMenuTargets(event)) {
+ return;
+ }
+
if (!shouldHandleContextMenu(event)) {
return;
}
@@ -393,6 +424,7 @@ const handleRightClick = async (event) => {
const executeCommand = async (item) => {
if (props.editor) {
+ const commandEditor = resolveContextMenuCommandEditor(props.editor);
const currentPos = currentContext.value?.pos;
const shouldReseatTableSelection =
currentContext.value?.event?.type === 'contextmenu' &&
@@ -406,11 +438,14 @@ const executeCommand = async (item) => {
}
// First call the action if needed on the item
- item.action ? await item.action(props.editor, currentContext.value) : null;
+ item.action ? await item.action(commandEditor, currentContext.value) : null;
if (item.component) {
const menuElement = menuRef.value;
- const componentProps = getPropsByItemId(item.id, props);
+ const componentProps = getPropsByItemId(item.id, {
+ ...props,
+ editor: commandEditor,
+ });
// Convert viewport-relative coordinates (used by fixed-position ContextMenu)
// to container-relative coordinates (used by absolute-position GenericPopover)
@@ -469,7 +504,6 @@ const closeMenu = (options = { restoreCursor: true }) => {
/**
* Lifecycle hooks on mount and onBeforeUnmount
*/
-let contextMenuTarget = null;
let contextMenuOpenHandler = null;
let contextMenuCloseHandler = null;
@@ -481,6 +515,8 @@ onMounted(() => {
// call event.preventDefault() which suppresses mousedown events
document.addEventListener('keydown', handleGlobalKeyDown);
document.addEventListener('pointerdown', handleGlobalOutsideClick);
+ document.addEventListener('contextmenu', handleRightClickCapture, true);
+ document.addEventListener('contextmenu', handleRightClick);
// Close menu if the editor becomes read-only while it's open
props.editor.on('update', handleEditorUpdate);
@@ -507,13 +543,6 @@ onMounted(() => {
};
props.editor.on('contextMenu:open', contextMenuOpenHandler);
- // Attach context menu to the active surface (flow view.dom or presentation host)
- contextMenuTarget = getEditorSurfaceElement(props.editor);
- if (contextMenuTarget) {
- contextMenuTarget.addEventListener('contextmenu', handleRightClickCapture, true);
- contextMenuTarget.addEventListener('contextmenu', handleRightClick);
- }
-
contextMenuCloseHandler = () => {
cleanupCustomItems();
isOpen.value = false;
@@ -527,6 +556,8 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleGlobalKeyDown);
document.removeEventListener('pointerdown', handleGlobalOutsideClick);
+ document.removeEventListener('contextmenu', handleRightClickCapture, true);
+ document.removeEventListener('contextmenu', handleRightClick);
cleanupCustomItems();
@@ -540,8 +571,6 @@ onBeforeUnmount(() => {
props.editor.off('contextMenu:close', contextMenuCloseHandler);
}
props.editor.off('update', handleEditorUpdate);
- contextMenuTarget?.removeEventListener('contextmenu', handleRightClickCapture, true);
- contextMenuTarget?.removeEventListener('contextmenu', handleRightClick);
} catch (error) {
console.warn('[ContextMenu] Error during cleanup:', error);
}
diff --git a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js
index 37d506ae29..59bf0533a0 100644
--- a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js
+++ b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js
@@ -4,6 +4,7 @@ import TableActions from '../toolbar/TableActions.vue';
import LinkInput from '../toolbar/LinkInput.vue';
import CellBackgroundPicker from './CellBackgroundPicker.vue';
import { TEXTS, ICONS, TRIGGERS } from './constants.js';
+import { resolveContextMenuCommandEditor } from './utils.js';
import { isTrackedChangeActionAllowed } from '@extensions/track-changes/permission-helpers.js';
import { readClipboardRaw } from '../../core/utilities/clipboardUtils.js';
import { handleClipboardPaste } from '../../core/InputRule.js';
@@ -377,7 +378,8 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
icon: ICONS.paste,
isDefault: true,
action: async (editor) => {
- const { view } = editor ?? {};
+ const targetEditor = resolveContextMenuCommandEditor(editor);
+ const { view } = targetEditor ?? {};
if (!view) return;
// Save the current selection before focusing. When the context menu
// is open, its hidden search input holds focus, so the PM editor's
@@ -404,7 +406,7 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
view.dispatch(tr.setSelection(SelectionType.create(doc, safeFrom, safeTo)));
}
}
- const handled = handleClipboardPaste({ editor, view }, html, text);
+ const handled = handleClipboardPaste({ editor: targetEditor, view }, html, text);
if (!handled) {
const pasteEvent = createPasteEventShim({ html, text });
@@ -418,8 +420,8 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
return;
}
- if (text && editor.commands?.insertContent) {
- editor.commands.insertContent(text, { contentType: 'text' });
+ if (text && targetEditor.commands?.insertContent) {
+ targetEditor.commands.insertContent(text, { contentType: 'text' });
}
}
},
diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js
index 3c298e56a3..90f339f1c8 100644
--- a/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js
+++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js
@@ -19,10 +19,16 @@ vi.mock('@extensions/context-menu', () => ({
},
}));
-vi.mock('../utils.js', () => ({
- getPropsByItemId: vi.fn(() => ({ editor: {} })),
- getEditorContext: vi.fn(),
-}));
+vi.mock('../utils.js', async () => {
+ const actual = await vi.importActual('../utils.js');
+
+ return {
+ ...actual,
+ getPropsByItemId: vi.fn(() => ({ editor: {} })),
+ getEditorContext: vi.fn(),
+ resolveContextMenuCommandEditor: vi.fn((editor) => editor),
+ };
+});
vi.mock('../menuItems.js', () => ({
getItems: vi.fn(),
@@ -50,6 +56,7 @@ describe('ContextMenu.vue', () => {
let mockProps;
let mockGetItems;
let mockGetEditorContext;
+ let mockResolveContextMenuCommandEditor;
let commonMocks;
beforeEach(async () => {
@@ -77,10 +84,13 @@ describe('ContextMenu.vue', () => {
};
const { getItems } = await import('../menuItems.js');
- const { getEditorContext } = await import('../utils.js');
+ const { getEditorContext, resolveContextMenuCommandEditor } = await import('../utils.js');
mockGetItems = getItems;
mockGetEditorContext = getEditorContext;
+ mockResolveContextMenuCommandEditor = resolveContextMenuCommandEditor;
+ mockResolveContextMenuCommandEditor.mockReset();
+ mockResolveContextMenuCommandEditor.mockImplementation((editor) => editor);
mockGetItems.mockReturnValue(
createMockMenuItems(1, [
@@ -101,6 +111,21 @@ describe('ContextMenu.vue', () => {
});
});
+ const getDocumentContextMenuHandler = (capture = false) => {
+ const match = commonMocks.spies.docAddEventListener.mock.calls.find(
+ (call) => call[0] === 'contextmenu' && (capture ? call[2] === true : call[2] !== true),
+ );
+ return match?.[1];
+ };
+
+ const setEventTarget = (event, target = surfaceElementMock) => {
+ Object.defineProperty(event, 'target', {
+ value: target,
+ configurable: true,
+ });
+ return event;
+ };
+
describe('component mounting and lifecycle', () => {
it('should mount without errors', () => {
const wrapper = mount(ContextMenu, { props: mockProps });
@@ -130,11 +155,15 @@ describe('ContextMenu.vue', () => {
surfaceElementMock = presentationHost;
const wrapper = mount(ContextMenu, { props: mockProps });
- expect(presentationHost.addEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
+ expect(commonMocks.spies.docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true);
+ expect(commonMocks.spies.docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
+ expect(presentationHost.addEventListener).not.toHaveBeenCalled();
expect(mockEditor.view.dom.addEventListener).not.toHaveBeenCalledWith('contextmenu', expect.any(Function));
wrapper.unmount();
- expect(presentationHost.removeEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
+ expect(commonMocks.spies.docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true);
+ expect(commonMocks.spies.docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
+ expect(presentationHost.removeEventListener).not.toHaveBeenCalled();
});
});
@@ -225,7 +254,7 @@ describe('ContextMenu.vue', () => {
});
it('should pass right-click context (including event) to custom renderers', async () => {
- const rightClickEvent = new MouseEvent('contextmenu', { clientX: 120, clientY: 160 });
+ const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 120, clientY: 160 }));
const contextFromEvent = {
selectedText: '',
@@ -259,9 +288,7 @@ describe('ContextMenu.vue', () => {
mount(ContextMenu, { props: mockProps });
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu',
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
await contextMenuHandler(rightClickEvent);
@@ -283,11 +310,9 @@ describe('ContextMenu.vue', () => {
mockEditor.state.selection.to = 15;
mockEditor.posAtCoords = vi.fn(() => ({ pos: 10 }));
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu',
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
- const rightClickEvent = new MouseEvent('contextmenu', { clientX: 120, clientY: 160 });
+ const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 120, clientY: 160 }));
await contextMenuHandler(rightClickEvent);
@@ -305,11 +330,9 @@ describe('ContextMenu.vue', () => {
mockEditor.posAtCoords = vi.fn(() => ({ pos: 25 }));
// Find the bubble phase handler (not capture phase which has `true` as third arg)
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu' && call[2] !== true,
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
- const rightClickEvent = new MouseEvent('contextmenu', { clientX: 120, clientY: 160 });
+ const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 120, clientY: 160 }));
await contextMenuHandler(rightClickEvent);
@@ -339,10 +362,9 @@ describe('ContextMenu.vue', () => {
const target = document.createElement('span');
target.dataset.pmStart = '10';
tableFragment.appendChild(target);
+ surfaceElementMock.appendChild(tableFragment);
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu' && call[2] !== true,
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
await contextMenuHandler({
type: 'contextmenu',
@@ -379,10 +401,9 @@ describe('ContextMenu.vue', () => {
const target = document.createElement('span');
target.dataset.pmStart = '10';
tableFragment.appendChild(target);
+ surfaceElementMock.appendChild(tableFragment);
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu' && call[2] !== true,
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
await contextMenuHandler({
type: 'contextmenu',
@@ -401,9 +422,7 @@ describe('ContextMenu.vue', () => {
mockGetEditorContext.mockClear();
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu',
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
const event = {
ctrlKey: true,
@@ -413,6 +432,7 @@ describe('ContextMenu.vue', () => {
type: 'contextmenu',
detail: 0,
button: 2,
+ target: surfaceElementMock,
};
await contextMenuHandler(event);
@@ -426,9 +446,7 @@ describe('ContextMenu.vue', () => {
mockGetEditorContext.mockClear();
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu',
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
const keyboardEvent = {
preventDefault: vi.fn(),
@@ -437,6 +455,7 @@ describe('ContextMenu.vue', () => {
detail: 0,
button: 0,
type: 'contextmenu',
+ target: surfaceElementMock,
};
await contextMenuHandler(keyboardEvent);
@@ -446,7 +465,7 @@ describe('ContextMenu.vue', () => {
});
it('should reuse the computed context instead of re-reading clipboard for custom renders', async () => {
- const rightClickEvent = new MouseEvent('contextmenu', { clientX: 200, clientY: 240 });
+ const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 200, clientY: 240 }));
mockGetEditorContext.mockReset();
mockGetEditorContext.mockResolvedValue({
@@ -478,9 +497,7 @@ describe('ContextMenu.vue', () => {
mount(ContextMenu, { props: mockProps });
- const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
- (call) => call[0] === 'contextmenu',
- )[1];
+ const contextMenuHandler = getDocumentContextMenuHandler();
await contextMenuHandler(rightClickEvent);
@@ -799,6 +816,56 @@ describe('ContextMenu.vue', () => {
);
});
+ it('should execute item action with the active editor resolved from a wrapper', async () => {
+ const activeEditor = createMockEditor();
+ const editorWrapper = {
+ ...mockEditor,
+ getActiveEditor: vi.fn(() => activeEditor),
+ };
+ const mockAction = vi.fn();
+
+ mockResolveContextMenuCommandEditor.mockImplementation((editor) => {
+ return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor;
+ });
+
+ mockGetItems.mockReturnValue([
+ {
+ id: 'test-section',
+ items: [
+ {
+ id: 'test-item',
+ label: 'Test Item',
+ showWhen: (context) => context.trigger === TRIGGERS.slash,
+ action: mockAction,
+ },
+ ],
+ },
+ ]);
+
+ const wrapper = mount(ContextMenu, {
+ props: {
+ ...mockProps,
+ editor: editorWrapper,
+ },
+ });
+
+ const onContextMenuOpen = editorWrapper.on.mock.calls.find((call) => call[0] === 'contextMenu:open')[1];
+ await onContextMenuOpen({ menuPosition: { left: '100px', top: '200px' } });
+ await nextTick();
+
+ await wrapper.find('.context-menu-item').trigger('click');
+
+ expect(editorWrapper.getActiveEditor).toHaveBeenCalled();
+ expect(mockAction).toHaveBeenCalledWith(
+ activeEditor,
+ expect.objectContaining({
+ hasSelection: expect.any(Boolean),
+ selectedText: expect.any(String),
+ trigger: expect.any(String),
+ }),
+ );
+ });
+
it('should open popover for component items', async () => {
const MockComponent = { template: 'Mock Component
' };
mockGetItems.mockReturnValue([
@@ -837,12 +904,7 @@ describe('ContextMenu.vue', () => {
beforeEach(() => {
mount(ContextMenu, { props: mockProps });
// Find the capture phase contextmenu listener
- const captureCall = surfaceElementMock.addEventListener.mock.calls.find(
- (call) =>
- call[0] === 'contextmenu' &&
- (call[2] === true || call[2]?.capture === true || call[1]?.name === 'handleRightClickCapture'),
- );
- captureHandler = captureCall?.[1];
+ captureHandler = getDocumentContextMenuHandler(true);
});
it('should set __sdHandledByContextMenu flag when editor is editable', () => {
@@ -855,6 +917,7 @@ describe('ContextMenu.vue', () => {
clientX: 120,
clientY: 150,
preventDefault: vi.fn(),
+ target: surfaceElementMock,
};
captureHandler(event);
@@ -873,6 +936,7 @@ describe('ContextMenu.vue', () => {
clientX: 120,
clientY: 150,
preventDefault: vi.fn(),
+ target: surfaceElementMock,
};
captureHandler(event);
@@ -891,6 +955,7 @@ describe('ContextMenu.vue', () => {
clientX: 120,
clientY: 150,
preventDefault: vi.fn(),
+ target: surfaceElementMock,
};
captureHandler(event);
@@ -908,6 +973,7 @@ describe('ContextMenu.vue', () => {
clientX: 120,
clientY: 150,
preventDefault: vi.fn(),
+ target: surfaceElementMock,
};
captureHandler(event);
@@ -925,6 +991,7 @@ describe('ContextMenu.vue', () => {
clientX: 120,
clientY: 150,
preventDefault: vi.fn(),
+ target: surfaceElementMock,
};
captureHandler(event);
@@ -942,6 +1009,7 @@ describe('ContextMenu.vue', () => {
clientX: 0,
clientY: 0,
preventDefault: vi.fn(),
+ target: surfaceElementMock,
};
captureHandler(event);
@@ -958,6 +1026,7 @@ describe('ContextMenu.vue', () => {
throw new Error('Test error');
},
preventDefault: vi.fn(),
+ target: surfaceElementMock,
};
// Should not throw, error should be caught
@@ -976,10 +1045,8 @@ describe('ContextMenu.vue', () => {
wrapper.unmount();
// Verify the capture listener was removed (check for contextmenu with capture flag)
- const removeCall = surfaceElementMock.removeEventListener.mock.calls.find(
- (call) =>
- call[0] === 'contextmenu' &&
- (call[2] === true || call[2]?.capture === true || call[1]?.name === 'handleRightClickCapture'),
+ const removeCall = commonMocks.spies.docRemoveEventListener.mock.calls.find(
+ (call) => call[0] === 'contextmenu' && call[2] === true,
);
expect(removeCall).toBeDefined();
});
diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js
index eda40d1836..a5e5e4e9c8 100644
--- a/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js
+++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js
@@ -781,6 +781,36 @@ describe('menuItems.js', () => {
expect(insertContent).toHaveBeenCalledWith('fallback text', { contentType: 'text' });
});
+
+ it('should resolve PresentationEditor wrappers to the active editor for paste', async () => {
+ const editor = createMockEditor();
+ editor.view.dom.focus = vi.fn();
+ editor.view.pasteText = vi.fn();
+
+ const presentationEditor = {
+ getActiveEditor: vi.fn(() => editor),
+ };
+
+ clipboardMocks.readClipboardRaw.mockResolvedValue({
+ html: '',
+ text: 'wrapped paste',
+ });
+ clipboardMocks.handleClipboardPaste.mockReturnValue(false);
+
+ const pasteAction = getItems(
+ createMockContext({
+ editor: presentationEditor,
+ trigger: TRIGGERS.click,
+ }),
+ )
+ .find((section) => section.id === 'clipboard')
+ ?.items.find((item) => item.id === 'paste')?.action;
+
+ await pasteAction(presentationEditor);
+
+ expect(presentationEditor.getActiveEditor).toHaveBeenCalled();
+ expect(editor.view.pasteText).toHaveBeenCalledWith('wrapped paste', expect.any(Object));
+ });
});
describe('getItems - cell selection context', () => {
diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js
index ded839ef1b..3d65bc42c4 100644
--- a/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js
+++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js
@@ -99,6 +99,10 @@ export function createMockView(options = {}) {
const mockState = createMockState(options.state || {});
const coordsAtPos = options.coordsAtPos || vi.fn(() => ({ left: 100, top: 200 }));
const posAtCoords = options.posAtCoords || vi.fn(() => ({ pos: 12 }));
+ const dom = document.createElement('div');
+ dom.addEventListener = vi.fn();
+ dom.removeEventListener = vi.fn();
+ dom.getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0 }));
return {
state: mockState,
@@ -106,11 +110,7 @@ export function createMockView(options = {}) {
posAtCoords,
dispatch: vi.fn(),
focus: vi.fn(),
- dom: {
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0 })),
- },
+ dom,
};
}
@@ -405,15 +405,13 @@ export function assertEventListenersSetup(editor, documentSpies) {
// call event.preventDefault() which suppresses mousedown events
expect(docAddEventListener).toHaveBeenCalledWith('keydown', expect.any(Function));
expect(docAddEventListener).toHaveBeenCalledWith('pointerdown', expect.any(Function));
+ expect(docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true);
+ expect(docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
// Check editor listeners
expect(editor.on).toHaveBeenCalledWith('update', expect.any(Function));
expect(editor.on).toHaveBeenCalledWith('contextMenu:open', expect.any(Function));
expect(editor.on).toHaveBeenCalledWith('contextMenu:close', expect.any(Function));
-
- // Check DOM listeners
- const domTarget = editor.presentationEditor?.element || editor.view.dom;
- expect(domTarget.addEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
}
/**
@@ -427,15 +425,13 @@ export function assertEventListenersCleanup(editor, documentSpies) {
// call event.preventDefault() which suppresses mousedown events
expect(docRemoveEventListener).toHaveBeenCalledWith('keydown', expect.any(Function));
expect(docRemoveEventListener).toHaveBeenCalledWith('pointerdown', expect.any(Function));
+ expect(docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true);
+ expect(docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
// Check editor listeners cleanup (now with specific handlers to prevent leaks)
expect(editor.off).toHaveBeenCalledWith('contextMenu:open', expect.any(Function));
expect(editor.off).toHaveBeenCalledWith('contextMenu:close', expect.any(Function));
expect(editor.off).toHaveBeenCalledWith('update', expect.any(Function));
-
- // Check DOM listeners cleanup
- const domTarget = editor.presentationEditor?.element || editor.view.dom;
- expect(domTarget.removeEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function));
}
/**
diff --git a/packages/super-editor/src/editors/v1/components/context-menu/utils.js b/packages/super-editor/src/editors/v1/components/context-menu/utils.js
index b5b3e9b82b..04cdc09228 100644
--- a/packages/super-editor/src/editors/v1/components/context-menu/utils.js
+++ b/packages/super-editor/src/editors/v1/components/context-menu/utils.js
@@ -11,6 +11,10 @@ import { isList } from '@core/commands/list-helpers';
import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js';
import { hasExpandedSelection } from '@utils/selectionUtils.js';
import { selectedRect } from 'prosemirror-tables';
+
+export const resolveContextMenuCommandEditor = (editor) => {
+ return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor;
+};
/**
* Get props by item id
*
@@ -22,10 +26,10 @@ import { selectedRect } from 'prosemirror-tables';
*/
export const getPropsByItemId = (itemId, props) => {
// Common props that are needed regardless of trigger type
- const editor = props.editor;
+ const editor = resolveContextMenuCommandEditor(props.editor);
const baseProps = {
- editor: markRaw(props.editor),
+ editor: markRaw(editor),
};
switch (itemId) {
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue
index 1ea11f143c..2a7f980c18 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue
+++ b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue
@@ -39,30 +39,37 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ compactSideGroups: {
+ type: Boolean,
+ default: false,
+ },
});
const currentItem = ref(null);
const { isHighContrastMode } = useHighContrastMode();
// Matches media query from SuperDoc.vue
const isMobile = window.matchMedia('(max-width: 768px)').matches;
-const styleMap = {
- left: {
- minWidth: '120px',
- justifyContent: 'flex-start',
- },
- right: {
- minWidth: '120px',
- justifyContent: 'flex-end',
- },
- default: {
+
+const getPositionStyle = computed(() => {
+ if (props.position === 'left') {
+ return {
+ minWidth: props.compactSideGroups ? 'auto' : '120px',
+ justifyContent: 'flex-start',
+ };
+ }
+
+ if (props.position === 'right') {
+ return {
+ minWidth: props.compactSideGroups ? 'auto' : '120px',
+ justifyContent: 'flex-end',
+ };
+ }
+
+ return {
// Only grow if not on a mobile device
flexGrow: isMobile ? 0 : 1,
justifyContent: 'center',
- },
-};
-
-const getPositionStyle = computed(() => {
- return styleMap[props.position] || styleMap.default;
+ };
});
const isButton = (item) => item.type === 'button';
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js
index 27f497687d..9858bfe498 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js
@@ -17,9 +17,11 @@ function createMockToolbar() {
config: {
toolbarGroups: ['left', 'center', 'right'],
toolbarButtonsExclude: [],
+ responsiveToContainer: false,
},
getToolbarItemByGroup: () => [],
getToolbarItemByName: () => null,
+ getAvailableWidth: () => 1200,
onToolbarResize: vi.fn(),
emitCommand: vi.fn(),
overflowItems: [],
@@ -30,6 +32,7 @@ function createMockToolbar() {
describe('Toolbar', () => {
afterEach(() => {
vi.restoreAllMocks();
+ vi.unstubAllGlobals();
});
it('removes resize and keydown listeners on unmount (not only on KeepAlive deactivate)', () => {
@@ -111,4 +114,93 @@ describe('Toolbar', () => {
addSpy.mockRestore();
removeSpy.mockRestore();
});
+
+ it('does not restore selection when active editor is header/footer', async () => {
+ const restoreSelection = vi.fn();
+ const mockToolbar = createMockToolbar();
+ mockToolbar.activeEditor = {
+ options: { isHeaderOrFooter: true },
+ commands: { restoreSelection },
+ };
+
+ const ButtonGroupStub = defineComponent({
+ emits: ['item-clicked'],
+ template: '',
+ });
+
+ const wrapper = mount(Toolbar, {
+ global: {
+ stubs: { ButtonGroup: ButtonGroupStub },
+ plugins: [
+ (app) => {
+ app.config.globalProperties.$toolbar = mockToolbar;
+ },
+ ],
+ },
+ });
+
+ await wrapper.find('[data-test="emit-item-clicked"]').trigger('click');
+ expect(restoreSelection).not.toHaveBeenCalled();
+ });
+
+ it('does not attach ResizeObserver when responsiveToContainer is disabled', () => {
+ const observe = vi.fn();
+ const disconnect = vi.fn();
+ const ResizeObserverMock = vi.fn(() => ({ observe, disconnect }));
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
+
+ const mockToolbar = {
+ ...createMockToolbar(),
+ toolbarContainer: document.createElement('div'),
+ };
+
+ const wrapper = mount(Toolbar, {
+ global: {
+ stubs: { ButtonGroup: true },
+ plugins: [
+ (app) => {
+ app.config.globalProperties.$toolbar = mockToolbar;
+ },
+ ],
+ },
+ });
+
+ expect(ResizeObserverMock).not.toHaveBeenCalled();
+ expect(observe).not.toHaveBeenCalled();
+
+ wrapper.unmount();
+ });
+
+ it('attaches ResizeObserver to the container when responsiveToContainer is enabled', () => {
+ const observe = vi.fn();
+ const disconnect = vi.fn();
+ const ResizeObserverMock = vi.fn(() => ({ observe, disconnect }));
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
+
+ const container = document.createElement('div');
+ const mockToolbar = {
+ ...createMockToolbar(),
+ config: { ...createMockToolbar().config, responsiveToContainer: true },
+ toolbarContainer: container,
+ };
+
+ const wrapper = mount(Toolbar, {
+ global: {
+ stubs: { ButtonGroup: true },
+ plugins: [
+ (app) => {
+ app.config.globalProperties.$toolbar = mockToolbar;
+ },
+ ],
+ },
+ });
+
+ expect(ResizeObserverMock).toHaveBeenCalledTimes(1);
+ expect(observe).toHaveBeenCalledWith(container);
+ expect(disconnect).not.toHaveBeenCalled();
+
+ wrapper.unmount();
+
+ expect(disconnect).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue
index 59ecd7ace3..d6dc0ca991 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue
+++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue
@@ -11,6 +11,7 @@ import {
} from 'vue';
import { throttle } from './helpers.js';
import ButtonGroup from './ButtonGroup.vue';
+import { RESPONSIVE_BREAKPOINTS } from './constants.js';
/**
* The default font-family to use for toolbar UI surfaces when no custom font is configured.
@@ -23,6 +24,8 @@ const { proxy } = getCurrentInstance();
const emit = defineEmits(['command', 'toggle', 'select']);
let toolbarKey = ref(1);
+const compactSideGroups = ref(false);
+let containerResizeObserver = null;
/**
* Computed property that determines the font-family to use for toolbar UI surfaces.
@@ -49,6 +52,9 @@ const getFilteredItems = (position) => {
return proxy.$toolbar.getToolbarItemByGroup(position).filter((item) => !excludeButtonsList.includes(item.name.value));
};
+const updateCompactSideGroups = () => {
+ compactSideGroups.value = proxy.$toolbar.getAvailableWidth() <= RESPONSIVE_BREAKPOINTS.lg;
+};
const onKeyDown = async (e) => {
if (e.metaKey && e.key === 'f') {
const searchItem = proxy.$toolbar.getToolbarItemByName('search');
@@ -65,6 +71,7 @@ const onKeyDown = async (e) => {
const onWindowResized = async () => {
await proxy.$toolbar.onToolbarResize();
+ updateCompactSideGroups();
toolbarKey.value += 1;
};
const onResizeThrottled = throttle(onWindowResized, 300);
@@ -72,11 +79,25 @@ const onResizeThrottled = throttle(onWindowResized, 300);
function teardownWindowListeners() {
window.removeEventListener('resize', onResizeThrottled);
window.removeEventListener('keydown', onKeyDown);
+ containerResizeObserver?.disconnect();
+ containerResizeObserver = null;
}
function setupWindowListeners() {
+ teardownWindowListeners();
window.addEventListener('resize', onResizeThrottled);
window.addEventListener('keydown', onKeyDown);
+ if (
+ typeof ResizeObserver !== 'undefined' &&
+ proxy.$toolbar.config?.responsiveToContainer &&
+ proxy.$toolbar.toolbarContainer
+ ) {
+ containerResizeObserver = new ResizeObserver(() => {
+ onResizeThrottled();
+ });
+ containerResizeObserver.observe(proxy.$toolbar.toolbarContainer);
+ }
+ updateCompactSideGroups();
}
onMounted(setupWindowListeners);
@@ -89,7 +110,10 @@ const handleCommand = ({ item, argument, option }) => {
};
const restoreSelection = () => {
- proxy.$toolbar.activeEditor?.commands?.restoreSelection();
+ const editor = proxy.$toolbar.activeEditor;
+ if (!editor) return;
+ if (editor.options?.isHeaderOrFooter) return;
+ editor.commands?.restoreSelection();
};
/**
@@ -121,6 +145,7 @@ const handleToolbarMousedown = (e) => {
tabindex="0"
v-if="showLeftSide"
:toolbar-items="getFilteredItems('left')"
+ :compact-side-groups="compactSideGroups"
:ui-font-family="uiFontFamily"
position="left"
@command="handleCommand"
@@ -131,6 +156,7 @@ const handleToolbarMousedown = (e) => {
tabindex="0"
:toolbar-items="getFilteredItems('center')"
:overflow-items="proxy.$toolbar.overflowItems"
+ :compact-side-groups="compactSideGroups"
:ui-font-family="uiFontFamily"
position="center"
@command="handleCommand"
@@ -140,6 +166,7 @@ const handleToolbarMousedown = (e) => {
tabindex="0"
v-if="showRightSide"
:toolbar-items="getFilteredItems('right')"
+ :compact-side-groups="compactSideGroups"
:ui-font-family="uiFontFamily"
position="right"
@command="handleCommand"
@@ -162,12 +189,6 @@ const handleToolbarMousedown = (e) => {
z-index: var(--sd-ui-toolbar-z-index, 10);
}
-@media (max-width: 1280px) {
- .superdoc-toolbar-group-side {
- min-width: auto !important;
- }
-}
-
@media (max-width: 768px) {
.superdoc-toolbar {
padding: 4px 10px;
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue
index 6747576dcb..62720a3858 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue
+++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue
@@ -291,21 +291,19 @@ const caretIcon = computed(() => {
height: 10px;
}
-@media (max-width: 1280px) {
- .toolbar-item--doc-mode .button-label {
- display: none;
- }
+.toolbar-item--doc-mode-compact .button-label {
+ display: none;
+}
- .toolbar-item--doc-mode .toolbar-icon {
- margin-right: 5px;
- }
+.toolbar-item--doc-mode-compact .toolbar-icon {
+ margin-right: 5px;
+}
- .toolbar-item--linked-styles {
- width: auto !important;
- }
+.toolbar-item--linked-styles-compact {
+ width: auto !important;
+}
- .toolbar-item--linked-styles .button-label {
- display: none;
- }
+.toolbar-item--linked-styles-compact .button-label {
+ display: none;
}
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js
index 4217900c4e..ab5f4d14f1 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js
@@ -54,6 +54,13 @@ export const TOOLBAR_FONT_SIZES = [
{ label: '96', key: '96pt', props: { 'data-item': 'btn-fontSize-option' } },
];
+export const RESPONSIVE_BREAKPOINTS = {
+ sm: 768,
+ md: 1024,
+ lg: 1280,
+ xl: 1410,
+};
+
export const HEADLESS_ITEM_MAP = {
undo: 'undo',
redo: 'redo',
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js
index e627e9115d..f277c31a8e 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js
@@ -15,7 +15,7 @@ import { scrollToElement } from './scroll-helpers.js';
import checkIconSvg from '@superdoc/common/icons/check.svg?raw';
import SearchInput from './SearchInput.vue';
-import { TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js';
+import { RESPONSIVE_BREAKPOINTS, TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js';
import { getQuickFormatList } from '@extensions/linked-styles/index.js';
const closeDropdown = (dropdown) => {
@@ -996,18 +996,33 @@ export const makeDefaultItems = ({
}),
});
- // Responsive toolbar calculations
- const breakpoints = {
- sm: 768,
- md: 1024,
- lg: 1280,
- xl: 1410,
- };
+ // Responsive toolbar calculations.
+ // `availableWidth` comes from SuperToolbar and represents either:
+ // - container width when `responsiveToContainer: true`
+ // - viewport/document width when `responsiveToContainer: false`
+
+ // Extra headroom to prevent toolbar jitter at the XL edge.
+ const XL_OVERFLOW_SAFETY_BUFFER = 20;
const stickyItemsWidth = 120;
const toolbarPadding = 32;
const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler'];
const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo'];
+ const shouldUseLgCompactStyles = availableWidth <= RESPONSIVE_BREAKPOINTS.lg;
+
+ if (shouldUseLgCompactStyles) {
+ documentMode.attributes.value = {
+ ...documentMode.attributes.value,
+ className: `${documentMode.attributes.value.className} toolbar-item--doc-mode-compact`,
+ };
+ }
+
+ if (shouldUseLgCompactStyles) {
+ linkedStyles.attributes.value = {
+ ...linkedStyles.attributes.value,
+ className: `${linkedStyles.attributes.value.className} toolbar-item--linked-styles-compact`,
+ };
+ }
let toolbarItems = [
undo,
@@ -1054,7 +1069,7 @@ export const makeDefaultItems = ({
}
// Hide separators on small screens
- if (availableWidth <= breakpoints.md && hideButtons) {
+ if (availableWidth <= RESPONSIVE_BREAKPOINTS.md && hideButtons) {
toolbarItems = toolbarItems.filter((item) => item.type !== 'separator');
}
@@ -1089,7 +1104,11 @@ export const makeDefaultItems = ({
toolbarItems.forEach((item) => {
const itemWidth = controlSizes.get(item.name.value) || controlSizes.get('default');
- if (availableWidth < breakpoints.xl && itemsToHideXL.includes(item.name.value) && hideButtons) {
+ if (
+ availableWidth < RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER &&
+ itemsToHideXL.includes(item.name.value) &&
+ hideButtons
+ ) {
overflowItems.push(item);
if (item.name.value === 'linkedStyles') {
const linkedStylesIdx = toolbarItems.findIndex((item) => item.name.value === 'linkedStyles');
@@ -1098,7 +1117,7 @@ export const makeDefaultItems = ({
return;
}
- if (availableWidth < breakpoints.sm && itemsToHideSM.includes(item.name.value) && hideButtons) {
+ if (availableWidth < RESPONSIVE_BREAKPOINTS.sm && itemsToHideSM.includes(item.name.value) && hideButtons) {
overflowItems.push(item);
return;
}
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js
new file mode 100644
index 0000000000..e6fe42684b
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest';
+
+import { makeDefaultItems } from './defaultItems.js';
+import { RESPONSIVE_BREAKPOINTS } from './constants.js';
+
+const stubProxy = new Proxy(
+ {},
+ {
+ get: () => 'stub',
+ },
+);
+
+const superToolbar = {
+ config: { mode: 'docx', superdoc: { config: { modules: { ai: {} } } } },
+ activeEditor: null,
+ emitCommand: () => {},
+};
+
+function getItemNames(list) {
+ return list.map((item) => item.name.value);
+}
+
+function buildItems(availableWidth) {
+ return makeDefaultItems({
+ superToolbar,
+ toolbarIcons: stubProxy,
+ toolbarTexts: stubProxy,
+ toolbarFonts: [],
+ hideButtons: true,
+ availableWidth,
+ });
+}
+
+describe('makeDefaultItems XL overflow boundary (SD-2328)', () => {
+ const XL_OVERFLOW_SAFETY_BUFFER = 20;
+ const XL_CUTOFF = RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER;
+ const XL_ITEMS = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler'];
+
+ it(`moves XL items into overflow at ${XL_CUTOFF - 1}px (below cutoff)`, () => {
+ const { defaultItems, overflowItems } = buildItems(XL_CUTOFF - 1);
+ const overflowNames = getItemNames(overflowItems);
+ const visibleNames = getItemNames(defaultItems);
+
+ for (const name of XL_ITEMS) {
+ expect(overflowNames).toContain(name);
+ expect(visibleNames).not.toContain(name);
+ }
+ });
+
+ it(`keeps XL items visible at ${XL_CUTOFF}px (on cutoff)`, () => {
+ const { defaultItems, overflowItems } = buildItems(XL_CUTOFF);
+ const overflowNames = getItemNames(overflowItems);
+ const visibleNames = getItemNames(defaultItems);
+
+ for (const name of XL_ITEMS) {
+ expect(visibleNames).toContain(name);
+ expect(overflowNames).not.toContain(name);
+ }
+ });
+
+ it(`keeps XL items visible at ${XL_CUTOFF + 1}px (above cutoff)`, () => {
+ const { defaultItems, overflowItems } = buildItems(XL_CUTOFF + 1);
+ const overflowNames = getItemNames(overflowItems);
+ const visibleNames = getItemNames(defaultItems);
+
+ for (const name of XL_ITEMS) {
+ expect(visibleNames).toContain(name);
+ expect(overflowNames).not.toContain(name);
+ }
+ });
+});
+
+describe('makeDefaultItems LG compact styles', () => {
+ const LG_BREAKPOINT = RESPONSIVE_BREAKPOINTS.lg;
+
+ function getItem(defaultItems, overflowItems, name) {
+ return [...defaultItems, ...overflowItems].find((item) => item.name.value === name);
+ }
+
+ it(`applies compact classes at ${LG_BREAKPOINT}px (on breakpoint)`, () => {
+ const { defaultItems, overflowItems } = buildItems(LG_BREAKPOINT);
+ const documentMode = getItem(defaultItems, overflowItems, 'documentMode');
+ const linkedStyles = getItem(defaultItems, overflowItems, 'linkedStyles');
+
+ expect(documentMode.attributes.value.className).toContain('toolbar-item--doc-mode-compact');
+ expect(linkedStyles.attributes.value.className).toContain('toolbar-item--linked-styles-compact');
+ });
+
+ it(`does not apply compact classes at ${LG_BREAKPOINT + 1}px (above breakpoint)`, () => {
+ const { defaultItems, overflowItems } = buildItems(LG_BREAKPOINT + 1);
+ const documentMode = getItem(defaultItems, overflowItems, 'documentMode');
+ const linkedStyles = getItem(defaultItems, overflowItems, 'linkedStyles');
+
+ expect(documentMode.attributes.value.className).not.toContain('toolbar-item--doc-mode-compact');
+ expect(linkedStyles.attributes.value.className).not.toContain('toolbar-item--linked-styles-compact');
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
index 3457ea05c6..792b87998f 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
@@ -386,6 +386,16 @@ export class SuperToolbar extends EventEmitter {
return this.toolbarItems.find((item) => item.name.value === name);
}
+ /**
+ * Get the width used for responsive toolbar decisions.
+ * @returns {number} Available width in pixels
+ */
+ getAvailableWidth() {
+ const documentWidth = document.documentElement.clientWidth; // take into account the scrollbar
+ const containerWidth = this.toolbarContainer?.offsetWidth ?? 0;
+ return this.config.responsiveToContainer ? containerWidth : documentWidth;
+ }
+
/**
* Create toolbar items based on configuration
* @private
@@ -397,9 +407,7 @@ export class SuperToolbar extends EventEmitter {
* @returns {void}
*/
#makeToolbarItems({ superToolbar, icons, texts, fonts, hideButtons, isDev = false } = {}) {
- const documentWidth = document.documentElement.clientWidth; // take into account the scrollbar
- const containerWidth = this.toolbarContainer?.offsetWidth ?? 0;
- const availableWidth = this.config.responsiveToContainer ? containerWidth : documentWidth;
+ const availableWidth = this.getAvailableWidth();
const { defaultItems, overflowItems } = makeDefaultItems({
superToolbar,
@@ -767,9 +775,10 @@ export class SuperToolbar extends EventEmitter {
return;
}
- // If the editor wasn't focused and this is a mark toggle, queue it and keep the button active
- // until the next selection update (after the user clicks into the editor).
- if (!wasFocused && isMarkToggle) {
+ // Queue unfocused mark toggles only for body editors.
+ // Header/footer mark toggles execute immediately to avoid waiting for
+ // selectionUpdate and requiring an extra selection change.
+ if (!wasFocused && isMarkToggle && !this.activeEditor?.options?.isHeaderOrFooter) {
this.pendingMarkCommands.push({ command, argument, item });
const labelAttr = item?.labelAttr?.value;
if (labelAttr && argument) {
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.test.js
new file mode 100644
index 0000000000..7d841b31b4
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.test.js
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { SuperToolbar } from './super-toolbar.js';
+
+vi.mock('prosemirror-history', () => ({
+ undoDepth: () => 0,
+ redoDepth: () => 0,
+}));
+
+vi.mock('@core/helpers/getActiveFormatting.js', () => ({
+ getActiveFormatting: vi.fn(() => []),
+}));
+
+vi.mock('@helpers/isInTable.js', () => ({
+ isInTable: vi.fn(() => false),
+}));
+
+vi.mock('@extensions/linked-styles/index.js', () => ({
+ getQuickFormatList: vi.fn(() => []),
+}));
+
+vi.mock('@extensions/track-changes/permission-helpers.js', () => ({
+ collectTrackedChanges: vi.fn(() => []),
+ isTrackedChangeActionAllowed: vi.fn(() => true),
+}));
+
+vi.mock('./defaultItems.js', () => ({
+ makeDefaultItems: () => ({ defaultItems: [], overflowItems: [] }),
+}));
+
+describe('SuperToolbar getAvailableWidth', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns document width when responsiveToContainer is false', () => {
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1600);
+
+ const container = document.createElement('div');
+ Object.defineProperty(container, 'offsetWidth', { value: 900 });
+
+ const context = {
+ toolbarContainer: container,
+ config: { responsiveToContainer: false },
+ };
+
+ expect(SuperToolbar.prototype.getAvailableWidth.call(context)).toBe(1600);
+ });
+
+ it('returns container width when responsiveToContainer is true', () => {
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1600);
+
+ const container = document.createElement('div');
+ Object.defineProperty(container, 'offsetWidth', { value: 900 });
+
+ const context = {
+ toolbarContainer: container,
+ config: { responsiveToContainer: true },
+ };
+
+ expect(SuperToolbar.prototype.getAvailableWidth.call(context)).toBe(900);
+ });
+
+ it('falls back to 0 when responsiveToContainer is true but no container is set', () => {
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1600);
+
+ const context = {
+ toolbarContainer: null,
+ config: { responsiveToContainer: true },
+ };
+
+ expect(SuperToolbar.prototype.getAvailableWidth.call(context)).toBe(0);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts b/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts
new file mode 100644
index 0000000000..98223daf8d
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Editor } from './Editor.ts';
+
+describe('Editor.setOptions', () => {
+ it('preserves non-enumerable option metadata across updates', () => {
+ const parentEditor = { id: 'parent-editor' };
+ const options: Record = { editable: false };
+ Object.defineProperty(options, 'parentEditor', {
+ enumerable: false,
+ configurable: true,
+ get() {
+ return parentEditor;
+ },
+ });
+
+ const context = {
+ options,
+ view: {
+ setProps: vi.fn(),
+ updateState: vi.fn(),
+ },
+ state: { doc: null },
+ isDestroyed: false,
+ };
+
+ Editor.prototype.setOptions.call(context as unknown as Editor, { documentMode: 'editing' });
+
+ expect((context.options as { parentEditor?: unknown }).parentEditor).toBe(parentEditor);
+ expect(Object.getOwnPropertyDescriptor(context.options, 'parentEditor')?.enumerable).toBe(false);
+ expect(context.view.updateState).toHaveBeenCalledWith(context.state);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts
index 61cfe81c35..a6b7da0fcc 100644
--- a/packages/super-editor/src/editors/v1/core/Editor.ts
+++ b/packages/super-editor/src/editors/v1/core/Editor.ts
@@ -84,6 +84,8 @@ import { syncPackageMetadata } from './opc/sync-package-metadata.js';
import { readSettingsRoot, parseProtectionState } from '../document-api-adapters/document-settings.js';
import { applyEffectiveEditability, getProtectionStorage } from '../extensions/protection/editability.js';
import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewModeSelectionWithoutStructuredContent.js';
+import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-statistics.js';
+import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js';
declare const __APP_VERSION__: string | undefined;
declare const version: string | undefined;
@@ -1864,11 +1866,28 @@ export class Editor extends EventEmitter {
* Set editor options and update state.
*/
setOptions(options: Partial = {}): void {
- this.options = {
- ...this.options,
+ const previousOptions = this.options ?? {};
+ const nextOptions = {
+ ...previousOptions,
...options,
};
+ // Preserve non-enumerable option metadata (for example the story editor's
+ // `parentEditor` getter) across option updates. Plain object spreading drops
+ // those descriptors, which breaks commit routing for child/story editors.
+ const previousDescriptors = Object.getOwnPropertyDescriptors(previousOptions);
+ for (const [key, descriptor] of Object.entries(previousDescriptors)) {
+ if (descriptor.enumerable) {
+ continue;
+ }
+ if (Object.prototype.hasOwnProperty.call(options, key)) {
+ continue;
+ }
+ Object.defineProperty(nextOptions, key, descriptor);
+ }
+
+ this.options = nextOptions;
+
if ((this.options.isNewFile || !this.options.ydoc) && this.options.isCommentsEnabled) {
this.options.shouldLoadComments = true;
}
@@ -1909,7 +1928,7 @@ export class Editor extends EventEmitter {
}
}
- if (emitUpdate) {
+ if (emitUpdate && this.state) {
this.emit('update', { editor: this, transaction: this.state.tr });
}
}
@@ -3132,6 +3151,9 @@ export class Editor extends EventEmitter {
compression,
}: ExportDocxParams = {}): Promise | string | undefined> {
try {
+ const exportHostEditor = resolveMainBodyEditor(this);
+ commitLiveStorySessionRuntimes(exportHostEditor);
+
// Use provided comments, or fall back to imported comments from converter
const effectiveComments = comments ?? this.converter.comments ?? [];
diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts
index 94cf113f76..6c3cd27d22 100644
--- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts
+++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts
@@ -9,6 +9,7 @@ type CoreCommandNames =
| 'first'
| 'command'
| 'insertTabChar'
+ | 'insertTabCharacter'
| 'insertTabNode'
| 'setMeta'
| 'splitBlock'
@@ -21,6 +22,7 @@ type CoreCommandNames =
| 'unsetAllMarks'
| 'toggleMark'
| 'toggleMarkCascade'
+ | 'isStyleTokenEnabled'
| 'clearNodes'
| 'setNode'
| 'toggleNode'
@@ -46,6 +48,7 @@ type CoreCommandNames =
| 'increaseListIndent'
| 'decreaseListIndent'
| 'changeListLevel'
+ | 'updateNumberingProperties'
| 'removeNumberingProperties'
| 'insertListItemAt'
| 'setListTypeAt'
@@ -53,7 +56,14 @@ type CoreCommandNames =
| 'restoreSelection'
| 'setTextSelection'
| 'insertTableAt'
- | 'getSelectionMarks';
+ | 'getSelectionMarks'
+ | 'backspaceEmptyRunParagraph'
+ | 'backspaceSkipEmptyRun'
+ | 'backspaceNextToRun'
+ | 'backspaceAcrossRuns'
+ | 'deleteSkipEmptyRun'
+ | 'deleteNextToRun'
+ | 'skipTab';
export type CoreCommandSignatures = {
[K in CoreCommandNames]: ExtractCommandSignature<(typeof CoreCommandExports)[K]>;
diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/resolveHeaderFooterSelection.js b/packages/super-editor/src/editors/v1/core/commands/helpers/resolveHeaderFooterSelection.js
new file mode 100644
index 0000000000..b2cb432c42
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/commands/helpers/resolveHeaderFooterSelection.js
@@ -0,0 +1,5 @@
+export function resolveHeaderFooterSelection({ tr }) {
+ // Keep selection resolution centralized here so header/footer-specific fallback
+ // logic can be reintroduced in one place if we need it again.
+ return tr?.selection;
+}
diff --git a/packages/super-editor/src/editors/v1/core/commands/setMark.js b/packages/super-editor/src/editors/v1/core/commands/setMark.js
index ac7e9c860c..dc19c5a7b0 100644
--- a/packages/super-editor/src/editors/v1/core/commands/setMark.js
+++ b/packages/super-editor/src/editors/v1/core/commands/setMark.js
@@ -1,13 +1,11 @@
import { Attribute } from '../Attribute.js';
import { getMarkType } from '../helpers/getMarkType.js';
import { isTextSelection } from '../helpers/isTextSelection.js';
+import { resolveHeaderFooterSelection } from './helpers/resolveHeaderFooterSelection.js';
import { addParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js';
-function canSetMark(editor, state, tr, newMarkType) {
- let { selection } = tr;
- if (editor.options.isHeaderOrFooter) {
- selection = editor.options.lastSelection;
- }
+function canSetMark(state, tr, newMarkType) {
+ const selection = resolveHeaderFooterSelection({ tr });
let cursor = null;
if (isTextSelection(selection)) {
@@ -53,11 +51,8 @@ function canSetMark(editor, state, tr, newMarkType) {
* @param attributes Attributes to add.
*/
//prettier-ignore
-export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch, editor }) => {
- let { selection } = tr;
- if (editor.options.isHeaderOrFooter) {
- selection = editor.options.lastSelection;
- }
+export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => {
+ const selection = resolveHeaderFooterSelection({ tr });
const { empty, ranges } = selection;
const type = getMarkType(typeOrName, state.schema);
@@ -107,5 +102,5 @@ export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch,
}
}
- return canSetMark(editor, state, tr, type);
+ return canSetMark(state, tr, type);
};
diff --git a/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js b/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js
index 8f02995acb..1cd337979f 100644
--- a/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js
+++ b/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js
@@ -1,3 +1,5 @@
+import { resolveHeaderFooterSelection } from './helpers/resolveHeaderFooterSelection.js';
+
/**
* Remove all marks in the current selection.
*
@@ -11,11 +13,8 @@
* only, the undo path restores the exact marks that were visible to the user.
*/
//prettier-ignore
-export const unsetAllMarks = () => ({ tr, dispatch, editor }) => {
- let { selection } = tr;
- if (editor.options.isHeaderOrFooter) {
- selection = editor.options.lastSelection;
- }
+export const unsetAllMarks = () => ({ tr, dispatch }) => {
+ const selection = resolveHeaderFooterSelection({ tr });
const { empty, ranges } = selection;
if (dispatch) {
diff --git a/packages/super-editor/src/editors/v1/core/commands/unsetMark.js b/packages/super-editor/src/editors/v1/core/commands/unsetMark.js
index d1d0589fae..7a060d7134 100644
--- a/packages/super-editor/src/editors/v1/core/commands/unsetMark.js
+++ b/packages/super-editor/src/editors/v1/core/commands/unsetMark.js
@@ -1,5 +1,6 @@
import { getMarkRange } from '../helpers/getMarkRange.js';
import { getMarkType } from '../helpers/getMarkType.js';
+import { resolveHeaderFooterSelection } from './helpers/resolveHeaderFooterSelection.js';
import { removeParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js';
/**
@@ -8,12 +9,9 @@ import { removeParagraphRunProperty } from '../helpers/syncParagraphRunPropertie
* @param options.extendEmptyMarkRange Removes the mark even across the current selection.
*/
//prettier-ignore
-export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch, editor }) => {
+export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch }) => {
const { extendEmptyMarkRange = false } = options;
- let { selection } = tr;
- if (editor.options.isHeaderOrFooter) {
- selection = editor.options.lastSelection;
- }
+ const selection = resolveHeaderFooterSelection({ tr });
const type = getMarkType(typeOrName, state.schema);
const { $from, empty, ranges } = selection;
diff --git a/packages/super-editor/src/editors/v1/core/extensions/editable.js b/packages/super-editor/src/editors/v1/core/extensions/editable.js
index 137e5f756f..e13efefc4b 100644
--- a/packages/super-editor/src/editors/v1/core/extensions/editable.js
+++ b/packages/super-editor/src/editors/v1/core/extensions/editable.js
@@ -2,21 +2,17 @@ import { Plugin, PluginKey } from 'prosemirror-state';
import { __endComposition } from 'prosemirror-view';
import { Extension } from '../Extension.js';
-const handleBackwardReplaceInsertText = (view, event) => {
+const handleInsertTextBeforeInput = (view, event) => {
const isInsertTextInput = event?.inputType === 'insertText';
const hasTextData = typeof event?.data === 'string' && event.data.length > 0;
- const hasNonEmptySelection = !view.state.selection.empty;
+ const isComposing = event?.isComposing === true;
- if (!isInsertTextInput || !hasTextData || !hasNonEmptySelection) {
+ if (!isInsertTextInput || !hasTextData || isComposing) {
return false;
}
const selection = view.state.selection;
- const anchor = selection.anchor ?? selection.from;
- const head = selection.head ?? selection.to;
- const isBackwardSelection = anchor > head;
-
- if (!isBackwardSelection) {
+ if (selection.empty) {
return false;
}
@@ -104,9 +100,11 @@ export const Editable = Extension.create({
__endComposition(view);
}
- // Backward (right-to-left) replacement can be misinterpreted downstream as
- // deleteContentBackward. Handle this narrow case explicitly at beforeinput level.
- if (handleBackwardReplaceInsertText(view, event)) {
+ // When typing over an existing selection, browser-native text input
+ // can widen the replace range around hidden inline content in story
+ // editors. Apply the replacement against the PM selection directly
+ // before the browser mutates the DOM.
+ if (handleInsertTextBeforeInput(view, event)) {
return true;
}
return false;
diff --git a/packages/super-editor/src/editors/v1/core/extensions/editable.test.js b/packages/super-editor/src/editors/v1/core/extensions/editable.test.js
index b50af1241a..8d149f9172 100644
--- a/packages/super-editor/src/editors/v1/core/extensions/editable.test.js
+++ b/packages/super-editor/src/editors/v1/core/extensions/editable.test.js
@@ -34,7 +34,7 @@ const isKeyBlocked = (editor, key, opts = {}) => {
return blocked === true;
};
-describe('Editable extension backward replace handling', () => {
+describe('Editable extension insertText beforeinput handling', () => {
let editor = null;
afterEach(() => {
@@ -64,6 +64,53 @@ describe('Editable extension backward replace handling', () => {
expect(editor.state.doc.textContent).toBe('Z');
});
+
+ it('replaces forward non-empty selection on beforeinput insertText', () => {
+ ({ editor } = initTestEditor({
+ mode: 'text',
+ content: 'PREAMBLE
',
+ }));
+
+ const range = findTextRange(editor.state.doc, 'PREAMBLE');
+ expect(range).not.toBeNull();
+
+ const forwardSelection = TextSelection.create(editor.state.doc, range.from, range.to);
+ editor.view.dispatch(editor.state.tr.setSelection(forwardSelection));
+
+ const beforeInputEvent = new InputEvent('beforeinput', {
+ data: 'Z',
+ inputType: 'insertText',
+ bubbles: true,
+ cancelable: true,
+ });
+ editor.view.dom.dispatchEvent(beforeInputEvent);
+
+ expect(editor.state.doc.textContent).toBe('Z');
+ });
+
+ it('does not intercept collapsed beforeinput insertText', () => {
+ ({ editor } = initTestEditor({
+ mode: 'text',
+ content: 'QA
',
+ }));
+
+ const range = findTextRange(editor.state.doc, 'QA');
+ expect(range).not.toBeNull();
+
+ const cursor = TextSelection.create(editor.state.doc, range.to, range.to);
+ editor.view.dispatch(editor.state.tr.setSelection(cursor));
+
+ const beforeInputEvent = new InputEvent('beforeinput', {
+ data: '!',
+ inputType: 'insertText',
+ bubbles: true,
+ cancelable: true,
+ });
+ const prevented = !editor.view.dom.dispatchEvent(beforeInputEvent);
+
+ expect(prevented).toBe(false);
+ expect(editor.state.doc.textContent).toBe('QA');
+ });
});
describe('Editable extension โ allowSelectionInViewMode', () => {
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts
index 0ece308cb5..420b3b10bc 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts
@@ -11,6 +11,12 @@
* - Toggle visibility between static decoration content and live editors
* - Manage dimming overlay for body content during editing
* - Control selection overlay visibility to prevent double caret rendering
+ *
+ * @deprecated (legacy)
+ * This visible child-PM overlay predates the story-session/hidden-host
+ * editing model. PresentationEditor no longer routes header/footer editing
+ * through this overlay, and it remains only as retired legacy scaffolding
+ * until the surrounding dead code is deleted.
*/
import type { HeaderFooterRegion } from './types.js';
@@ -184,6 +190,9 @@ export class EditorOverlayManager {
// Find the editor container (first child with super-editor class)
const editorContainer = editorHost.querySelector('.super-editor');
if (editorContainer instanceof HTMLElement) {
+ // Reset any stale transform from prior footer sessions before
+ // reapplying the top offset for the current region.
+ editorContainer.style.transform = '';
// Instead of top: 0, position from the calculated offset
editorContainer.style.top = `${contentOffset}px`;
}
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts
index 23d1f9412d..dd6eb27e81 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts
@@ -8,11 +8,14 @@ const { mockLayoutHeaderFooterWithCache, mockComputeDisplayPageNumber, mockMeasu
mockMeasureBlock: vi.fn(),
}));
-vi.mock('@superdoc/layout-bridge', () => ({
- OOXML_PCT_DIVISOR: 5000,
- computeDisplayPageNumber: mockComputeDisplayPageNumber,
- layoutHeaderFooterWithCache: mockLayoutHeaderFooterWithCache,
-}));
+vi.mock('@superdoc/layout-bridge', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ computeDisplayPageNumber: mockComputeDisplayPageNumber,
+ layoutHeaderFooterWithCache: mockLayoutHeaderFooterWithCache,
+ };
+});
vi.mock('@superdoc/measuring-dom', () => ({
measureBlock: mockMeasureBlock,
@@ -129,4 +132,73 @@ describe('layoutPerRIdHeaderFooters', () => {
expect(deps.headerLayoutsByRId.has('rId-header-first')).toBe(true);
expect(deps.headerLayoutsByRId.has('rId-header-orphan')).toBe(false);
});
+
+ it('lays out first-page header refs in multi-section documents with per-section constraints', async () => {
+ const headerBlocksByRId = new Map([
+ ['rId-header-default', [makeBlock('block-default')]],
+ ['rId-header-first', [makeBlock('block-first')]],
+ ['rId-header-section-1', [makeBlock('block-section-1')]],
+ ]);
+
+ const headerFooterInput = {
+ headerBlocksByRId,
+ footerBlocksByRId: undefined,
+ headerBlocks: undefined,
+ footerBlocks: undefined,
+ constraints: {
+ width: 400,
+ height: 80,
+ pageWidth: 600,
+ pageHeight: 800,
+ margins: {
+ top: 50,
+ right: 50,
+ bottom: 50,
+ left: 50,
+ header: 20,
+ },
+ },
+ };
+
+ const layout = {
+ pages: [
+ { number: 1, fragments: [], sectionIndex: 0 },
+ { number: 2, fragments: [], sectionIndex: 1 },
+ ],
+ } as unknown as Layout;
+
+ const sectionMetadata: SectionMetadata[] = [
+ {
+ sectionIndex: 0,
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 20 },
+ headerRefs: {
+ default: 'rId-header-default',
+ first: 'rId-header-first',
+ },
+ },
+ {
+ sectionIndex: 1,
+ margins: { top: 55, right: 55, bottom: 55, left: 55, header: 20 },
+ headerRefs: {
+ default: 'rId-header-section-1',
+ },
+ },
+ ];
+
+ const deps = {
+ headerLayoutsByRId: new Map(),
+ footerLayoutsByRId: new Map(),
+ };
+
+ await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, deps);
+
+ const laidOutBlockIds = new Set(
+ mockLayoutHeaderFooterWithCache.mock.calls.map((call) => call[0].default?.[0]?.id).filter(Boolean),
+ );
+
+ expect(laidOutBlockIds).toEqual(new Set(['block-default', 'block-first', 'block-section-1']));
+ expect(deps.headerLayoutsByRId.has('rId-header-default::s0')).toBe(true);
+ expect(deps.headerLayoutsByRId.has('rId-header-first::s0')).toBe(true);
+ expect(deps.headerLayoutsByRId.has('rId-header-section-1::s1')).toBe(true);
+ });
});
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts
index e705c4a41b..1228456dc2 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts
@@ -1,6 +1,13 @@
-import type { FlowBlock, HeaderFooterLayout, Layout, SectionMetadata, SectionRefType } from '@superdoc/contracts';
-import { OOXML_PCT_DIVISOR } from '@superdoc/contracts';
-import { computeDisplayPageNumber, layoutHeaderFooterWithCache } from '@superdoc/layout-bridge';
+import type { FlowBlock, HeaderFooterLayout, Layout, SectionMetadata } from '@superdoc/contracts';
+import {
+ computeDisplayPageNumber,
+ layoutHeaderFooterWithCache,
+ buildSectionAwareHeaderFooterLayoutKey,
+ buildSectionContentWidth,
+ buildEffectiveHeaderFooterRefsBySection,
+ collectReferencedHeaderFooterRIds,
+ buildSectionAwareHeaderFooterMeasurementGroups,
+} from '@superdoc/layout-bridge';
import type { HeaderFooterLayoutResult, HeaderFooterConstraints } from '@superdoc/layout-bridge';
import { measureBlock } from '@superdoc/measuring-dom';
@@ -13,211 +20,6 @@ export type HeaderFooterPerRidLayoutInput = {
};
type Constraints = HeaderFooterConstraints;
-type HeaderFooterRefs = Partial>;
-const HEADER_FOOTER_VARIANTS: SectionRefType[] = ['default', 'first', 'even', 'odd'];
-
-/**
- * Compute the content width for a section, falling back to global constraints.
- */
-function buildSectionContentWidth(section: SectionMetadata, fallback: Constraints): number {
- const pageW = section.pageSize?.w ?? fallback.pageWidth ?? 0;
- const marginL = section.margins?.left ?? fallback.margins?.left ?? 0;
- const marginR = section.margins?.right ?? fallback.margins?.right ?? 0;
- return pageW - marginL - marginR;
-}
-
-/**
- * Build constraints for a section using its margins/pageSize, falling back to global.
- * When a table's grid width exceeds the content width, use the grid width instead (SD-1837).
- * Word allows auto-width tables in headers/footers to extend beyond the body margins.
- */
-function buildConstraintsForSection(section: SectionMetadata, fallback: Constraints, minWidth?: number): Constraints {
- const pageW = section.pageSize?.w ?? fallback.pageWidth ?? 0;
- const pageH = section.pageSize?.h ?? fallback.pageHeight;
- const marginL = section.margins?.left ?? fallback.margins?.left ?? 0;
- const marginR = section.margins?.right ?? fallback.margins?.right ?? 0;
- const marginT = section.margins?.top ?? fallback.margins?.top;
- const marginB = section.margins?.bottom ?? fallback.margins?.bottom;
- const marginHeader = section.margins?.header ?? fallback.margins?.header;
- const contentWidth = pageW - marginL - marginR;
- // Allow tables to extend beyond right margin when grid width > content width.
- // Capped at pageWidth - marginLeft to avoid going past the page edge.
- const maxWidth = pageW - marginL;
- const effectiveWidth = minWidth ? Math.min(Math.max(contentWidth, minWidth), maxWidth) : contentWidth;
-
- // Recompute body content height if section has its own page size / vertical margins
- const sectionMarginTop = marginT ?? 0;
- const sectionMarginBottom = marginB ?? 0;
- const sectionHeight = pageH != null ? Math.max(1, pageH - sectionMarginTop - sectionMarginBottom) : fallback.height;
-
- return {
- width: effectiveWidth,
- height: sectionHeight,
- pageWidth: pageW,
- pageHeight: pageH,
- margins: { left: marginL, right: marginR, top: marginT, bottom: marginB, header: marginHeader },
- overflowBaseHeight: fallback.overflowBaseHeight,
- };
-}
-
-/**
- * Table width specification extracted from footer/header blocks.
- * Used to compute the minimum constraint width per section.
- */
-type TableWidthSpec = {
- /** 'pct' for percentage-based, 'grid' for auto-width using grid columns, 'px' for fixed pixel */
- type: 'pct' | 'grid' | 'px';
- /** For 'pct': OOXML percentage value (e.g. 5161 = 103.22%). For 'grid'/'px': width in pixels. */
- value: number;
-};
-
-/**
- * Extract table width specifications from a set of blocks.
- * Returns the spec for the widest table, distinguishing percentage-based from auto/fixed.
- *
- * For percentage tables (tblW type="pct"), the width must be resolved per-section since it
- * depends on the section's content width. The measuring-dom clamps pct tables to the constraint
- * width, so we must pre-expand the constraint to contentWidth * pct/5000.
- *
- * For auto-width tables (no tblW or tblW type="auto"), the grid columns are the layout basis.
- */
-function getTableWidthSpec(blocks: FlowBlock[]): TableWidthSpec | undefined {
- let result: TableWidthSpec | undefined;
- let maxResolvedWidth = 0;
-
- for (const block of blocks) {
- if (block.kind !== 'table') continue;
-
- const tableWidth = (block as { attrs?: { tableWidth?: { width?: number; value?: number; type?: string } } }).attrs
- ?.tableWidth;
- const widthValue = tableWidth?.width ?? tableWidth?.value;
-
- if (tableWidth?.type === 'pct' && typeof widthValue === 'number' && widthValue > 0) {
- // Percentage-based table: store the raw pct value for per-section resolution.
- // Use a nominal large value for comparison so pct tables take priority.
- if (!result || result.type !== 'pct' || widthValue > result.value) {
- result = { type: 'pct', value: widthValue };
- maxResolvedWidth = Infinity; // pct always takes priority
- }
- } else if ((tableWidth?.type === 'px' || tableWidth?.type === 'pixel') && typeof widthValue === 'number') {
- // Fixed pixel width
- if (widthValue > maxResolvedWidth) {
- maxResolvedWidth = widthValue;
- result = { type: 'px', value: widthValue };
- }
- } else if (block.columnWidths && block.columnWidths.length > 0) {
- // Auto-width: use grid columns as minimum width
- const gridTotal = block.columnWidths.reduce((sum, w) => sum + w, 0);
- if (gridTotal > maxResolvedWidth) {
- maxResolvedWidth = gridTotal;
- result = { type: 'grid', value: gridTotal };
- }
- }
- }
-
- return result;
-}
-
-/**
- * Resolve the minimum constraint width for a section based on its table width spec.
- * For percentage-based tables, computes the percentage of the section's content width.
- * For auto/grid tables, returns the grid total directly.
- *
- * The measuring-dom clamps pct tables to Math.min(resolvedWidth, maxWidth), so for
- * pct > 100% the table would be limited to the constraint. We pre-compute the resolved
- * pct width and use it as the minimum constraint so the table can overflow properly.
- */
-function resolveTableMinWidth(spec: TableWidthSpec | undefined, contentWidth: number): number {
- if (!spec) return 0;
- if (spec.type === 'pct') {
- return contentWidth * (spec.value / OOXML_PCT_DIVISOR);
- }
- return spec.value; // grid or px: already in pixels
-}
-
-function getRefsForKind(section: SectionMetadata, kind: 'header' | 'footer'): HeaderFooterRefs | undefined {
- return kind === 'header' ? section.headerRefs : section.footerRefs;
-}
-
-/**
- * Resolve the effective header/footer references for each section.
- *
- * Word inherits missing header/footer references from the previous section. This
- * helper applies that inheritance for every supported variant so downstream
- * layout only measures content that can actually be selected at render time.
- */
-function buildEffectiveRefsBySection(
- sectionMetadata: SectionMetadata[],
- kind: 'header' | 'footer',
-): Map {
- const result = new Map();
- let inheritedRefs: HeaderFooterRefs = {};
-
- for (const section of sectionMetadata) {
- const explicitRefs = getRefsForKind(section, kind);
- const effectiveRefs: HeaderFooterRefs = { ...inheritedRefs };
-
- for (const variant of HEADER_FOOTER_VARIANTS) {
- const rId = explicitRefs?.[variant];
- if (rId) {
- effectiveRefs[variant] = rId;
- }
- }
-
- if (Object.keys(effectiveRefs).length > 0) {
- result.set(section.sectionIndex, effectiveRefs);
- }
-
- inheritedRefs = effectiveRefs;
- }
-
- return result;
-}
-
-function collectReferencedRIdsBySection(effectiveRefsBySection: Map): Set {
- const result = new Set();
-
- for (const refs of effectiveRefsBySection.values()) {
- for (const variant of HEADER_FOOTER_VARIANTS) {
- const rId = refs[variant];
- if (rId) {
- result.add(rId);
- }
- }
- }
-
- return result;
-}
-
-/**
- * Resolve the default header/footer rId for each section.
- *
- * Multi-section layout has historically measured only the default variant with
- * section-specific constraints. Preserve that behavior to avoid changing
- * established rendering for documents that use first/even/odd variants.
- */
-function resolveDefaultRIdPerSection(
- sectionMetadata: SectionMetadata[],
- kind: 'header' | 'footer',
-): Map {
- const result = new Map();
- let inheritedDefaultRId: string | undefined;
-
- for (const section of sectionMetadata) {
- const refs = getRefsForKind(section, kind);
- const explicitDefaultRId = refs?.default;
-
- if (explicitDefaultRId) {
- inheritedDefaultRId = explicitDefaultRId;
- }
-
- if (inheritedDefaultRId) {
- result.set(section.sectionIndex, inheritedDefaultRId);
- }
- }
-
- return result;
-}
/**
* Layout header/footer blocks per rId, respecting per-section margins.
@@ -276,12 +78,12 @@ export async function layoutPerRIdHeaderFooters(
);
} else {
// Single-section or uniform margins: use original single-constraint path
- const effectiveHeaderRefsBySection = buildEffectiveRefsBySection(sectionMetadata, 'header');
- const effectiveFooterRefsBySection = buildEffectiveRefsBySection(sectionMetadata, 'footer');
+ const effectiveHeaderRefsBySection = buildEffectiveHeaderFooterRefsBySection(sectionMetadata, 'header');
+ const effectiveFooterRefsBySection = buildEffectiveHeaderFooterRefsBySection(sectionMetadata, 'footer');
await layoutBlocksByRId(
'header',
headerBlocksByRId,
- collectReferencedRIdsBySection(effectiveHeaderRefsBySection),
+ collectReferencedHeaderFooterRIds(effectiveHeaderRefsBySection),
constraints,
pageResolver,
deps.headerLayoutsByRId,
@@ -289,7 +91,7 @@ export async function layoutPerRIdHeaderFooters(
await layoutBlocksByRId(
'footer',
footerBlocksByRId,
- collectReferencedRIdsBySection(effectiveFooterRefsBySection),
+ collectReferencedHeaderFooterRIds(effectiveFooterRefsBySection),
constraints,
pageResolver,
deps.footerLayoutsByRId,
@@ -410,59 +212,15 @@ async function layoutWithPerSectionConstraints(
layoutsByRId: Map,
): Promise {
if (!blocksByRId) return;
-
- const defaultRIdPerSection = resolveDefaultRIdPerSection(sectionMetadata, kind);
-
- // Extract table width specs per rId (SD-1837).
- // Word allows tables in headers/footers to extend beyond content margins.
- // For pct tables, the width is relative to the section's content width.
- // For auto-width tables, the grid columns define the minimum width.
- const tableWidthSpecByRId = new Map();
- for (const [rId, blocks] of blocksByRId) {
- const spec = getTableWidthSpec(blocks);
- if (spec) {
- tableWidthSpecByRId.set(rId, spec);
- }
- }
-
- // Group sections by (rId, effectiveWidth) to measure each unique pair only once
- // Key: `${rId}::w${effectiveWidth}`, Value: { constraints, sections[] }
- const groups = new Map<
- string,
- { sectionConstraints: Constraints; sectionIndices: number[]; rId: string; effectiveWidth: number }
- >();
-
- for (const section of sectionMetadata) {
- const rId = defaultRIdPerSection.get(section.sectionIndex);
- if (!rId || !blocksByRId.has(rId)) continue;
-
- // Resolve the minimum width needed for tables in this section.
- // For pct tables, this depends on the section's content width.
- const contentWidth = buildSectionContentWidth(section, fallbackConstraints);
- const tableWidthSpec = tableWidthSpecByRId.get(rId);
- const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth);
- const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined);
- const effectiveWidth = sectionConstraints.width;
- // Include vertical geometry in the key so sections with different page heights,
- // vertical margins, or header distance get separate layouts (page-relative anchors
- // and header band origin resolve differently).
- const groupKey = `${rId}::w${effectiveWidth}::ph${sectionConstraints.pageHeight ?? ''}::mt${sectionConstraints.margins?.top ?? ''}::mb${sectionConstraints.margins?.bottom ?? ''}::mh${sectionConstraints.margins?.header ?? ''}`;
-
- let group = groups.get(groupKey);
- if (!group) {
- group = {
- sectionConstraints,
- sectionIndices: [],
- rId,
- effectiveWidth,
- };
- groups.set(groupKey, group);
- }
- group.sectionIndices.push(section.sectionIndex);
- }
-
- // Measure and layout each unique (rId, effectiveWidth) group
- for (const [, group] of groups) {
+ const groups = buildSectionAwareHeaderFooterMeasurementGroups(
+ kind,
+ blocksByRId,
+ sectionMetadata,
+ fallbackConstraints,
+ );
+
+ // Measure and layout each unique (rId, effectiveWidth) group.
+ for (const group of groups) {
const blocks = blocksByRId.get(group.rId);
if (!blocks || blocks.length === 0) continue;
@@ -506,7 +264,7 @@ async function layoutWithPerSectionConstraints(
effectiveWidth: needsFrameAdjust ? group.effectiveWidth : undefined,
};
- layoutsByRId.set(`${group.rId}::s${sectionIndex}`, result);
+ layoutsByRId.set(buildSectionAwareHeaderFooterLayoutKey(group.rId, sectionIndex), result);
}
}
} catch (error) {
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts
index 6c6b6d372f..ac2958d450 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts
@@ -67,14 +67,23 @@ const { mockCreateHeaderFooterEditor, mockOnHeaderFooterDataUpdate, mockToFlowBl
return editorStub;
};
- const mockCreateHeaderFooterEditor = vi.fn(() => {
- const editor = createSectionEditor();
- editors.push({ editor, emit: editor.emit });
- queueMicrotask(() => {
- editor.emit('create');
- });
- return editor;
- });
+ const mockCreateHeaderFooterEditor = vi.fn(
+ (input?: { editorContainer?: HTMLElement; editorHost?: HTMLElement }) => {
+ const editor = createSectionEditor();
+ if (input?.editorContainer instanceof HTMLElement) {
+ if (input.editorHost instanceof HTMLElement) {
+ input.editorHost.appendChild(input.editorContainer);
+ } else {
+ document.body.appendChild(input.editorContainer);
+ }
+ }
+ editors.push({ editor, emit: editor.emit });
+ queueMicrotask(() => {
+ editor.emit('create');
+ });
+ return editor;
+ },
+ );
return {
mockCreateHeaderFooterEditor,
@@ -192,6 +201,39 @@ describe('HeaderFooterEditorManager', () => {
);
});
+ it('ensureEditorSync creates a reusable editor instance immediately for presentation activation', () => {
+ const editor = createMockEditor();
+ const manager = new HeaderFooterEditorManager(editor);
+ const descriptor = { id: 'rId-header-default', kind: 'header' } as const;
+ const host = document.createElement('div');
+
+ const first = manager.ensureEditorSync(descriptor, { editorHost: host });
+ const second = manager.ensureEditorSync(descriptor, { editorHost: host });
+
+ expect(first).toBeDefined();
+ expect(second).toBe(first);
+ expect(mockCreateHeaderFooterEditor).toHaveBeenCalledTimes(1);
+ expect(host.children).toHaveLength(1);
+ });
+
+ it('ensureEditorSync reattaches the cached editor container to a new host', () => {
+ const editor = createMockEditor();
+ const manager = new HeaderFooterEditorManager(editor);
+ const descriptor = { id: 'rId-header-default', kind: 'header' } as const;
+ const firstHost = document.createElement('div');
+ const secondHost = document.createElement('div');
+
+ const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: firstHost });
+ expect(sectionEditor).toBeDefined();
+ expect(firstHost.children).toHaveLength(1);
+
+ const sameEditor = manager.ensureEditorSync(descriptor, { editorHost: secondHost });
+
+ expect(sameEditor).toBe(sectionEditor);
+ expect(firstHost.children).toHaveLength(0);
+ expect(secondHost.children).toHaveLength(1);
+ });
+
it('emits contentChanged and syncs converter/Yjs data when section editor updates', async () => {
const editor = createMockEditor();
const manager = new HeaderFooterEditorManager(editor);
@@ -521,6 +563,86 @@ describe('HeaderFooterLayoutAdapter', () => {
expect(options?.mediaFiles).toEqual(manager.rootEditor.converter.media);
});
+ it('stamps header/footer FlowBlocks with the part story key', () => {
+ const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' };
+ const doc = { type: 'doc', content: [{ type: 'paragraph' }] };
+
+ const manager = {
+ rootEditor: {
+ converter: {
+ convertedXml: {},
+ numbering: {},
+ linkedStyles: {},
+ },
+ },
+ getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []),
+ getDocumentJson: vi.fn(() => doc),
+ } as unknown as HeaderFooterEditorManager;
+
+ const adapter = new HeaderFooterLayoutAdapter(manager);
+
+ mockToFlowBlocks.mockClear();
+ adapter.getBatch('header');
+
+ const [, options] = mockToFlowBlocks.mock.calls[0] || [];
+ expect(options?.storyKey).toBe('hf:part:rId-header-default');
+ });
+
+ it('passes tracked change render config through to header/footer flow blocks', () => {
+ const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' };
+ const doc = { type: 'doc', content: [{ type: 'paragraph' }] };
+
+ const manager = {
+ rootEditor: {
+ converter: {
+ convertedXml: {},
+ numbering: {},
+ linkedStyles: {},
+ },
+ },
+ getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []),
+ getDocumentJson: vi.fn(() => doc),
+ } as unknown as HeaderFooterEditorManager;
+
+ const adapter = new HeaderFooterLayoutAdapter(manager);
+ adapter.setTrackedChangesRenderConfig({ mode: 'final', enabled: false });
+
+ mockToFlowBlocks.mockClear();
+ adapter.getBatch('header');
+
+ const [, options] = mockToFlowBlocks.mock.calls[0] || [];
+ expect(options?.trackedChangesMode).toBe('final');
+ expect(options?.enableTrackedChanges).toBe(false);
+ });
+
+ it('invalidates cached header/footer flow blocks when tracked change render config changes', () => {
+ const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' };
+ const doc = { type: 'doc', content: [{ type: 'paragraph' }] };
+
+ const manager = {
+ rootEditor: {
+ converter: {
+ convertedXml: {},
+ numbering: {},
+ linkedStyles: {},
+ },
+ },
+ getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []),
+ getDocumentJson: vi.fn(() => doc),
+ } as unknown as HeaderFooterEditorManager;
+
+ const adapter = new HeaderFooterLayoutAdapter(manager);
+
+ mockToFlowBlocks.mockClear();
+ adapter.getBatch('header');
+ adapter.getBatch('header');
+ expect(mockToFlowBlocks).toHaveBeenCalledTimes(1);
+
+ adapter.setTrackedChangesRenderConfig({ mode: 'final', enabled: true });
+ adapter.getBatch('header');
+ expect(mockToFlowBlocks).toHaveBeenCalledTimes(2);
+ });
+
it('returns undefined when no descriptors have FlowBlocks', () => {
const manager = {
getDescriptors: () => [{ id: 'missing', kind: 'header', variant: 'default' }],
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts
index 2978355844..4ad46e389c 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts
@@ -1,11 +1,12 @@
import { toFlowBlocks } from '@superdoc/pm-adapter';
import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js';
-import type { FlowBlock } from '@superdoc/contracts';
+import type { FlowBlock, TrackedChangesMode } from '@superdoc/contracts';
import type { HeaderFooterBatch } from '@superdoc/layout-bridge';
import type { Editor } from '@core/Editor.js';
import { EventEmitter } from '@core/EventEmitter.js';
import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js';
import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js';
+import { buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js';
const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const;
const DEFAULT_HEADER_FOOTER_HEIGHT = 100;
@@ -78,9 +79,15 @@ export interface HeaderFooterDocument {
type HeaderFooterLayoutCacheEntry = {
docRef: unknown;
+ renderConfigKey: string;
blocks: FlowBlock[];
};
+export type HeaderFooterTrackedChangesRenderConfig = {
+ mode: TrackedChangesMode;
+ enabled: boolean;
+};
+
type HeaderFooterEditorEntry = {
descriptor: HeaderFooterDescriptor;
editor: Editor;
@@ -323,39 +330,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
console.error('[HeaderFooterEditorManager] Editor initialization failed:', error);
this.emit('error', { descriptor, error });
});
-
- // Move editor container to the new editorHost if provided
- // This is necessary because cached editors may have been appended elsewhere
- if (existing.container && options?.editorHost) {
- // Only move if not already in the target host
- if (existing.container.parentElement !== options.editorHost) {
- options.editorHost.appendChild(existing.container);
- }
- }
-
- // Update editor options if provided
- if (existing.editor && options) {
- const updateOptions: Record = {};
- if (options.currentPageNumber !== undefined) {
- updateOptions.currentPageNumber = options.currentPageNumber;
- }
- if (options.totalPageCount !== undefined) {
- updateOptions.totalPageCount = options.totalPageCount;
- }
- if (options.availableWidth !== undefined) {
- updateOptions.availableWidth = options.availableWidth;
- }
- if (options.availableHeight !== undefined) {
- updateOptions.availableHeight = options.availableHeight;
- }
- if (Object.keys(updateOptions).length > 0) {
- existing.editor.setOptions(updateOptions);
- // Refresh page number display after option changes.
- // NodeViews read editor.options but PM doesn't re-render them
- // when only options change (no document transaction).
- this.#refreshPageNumberDisplay(existing.editor);
- }
- }
+ this.#mountAndUpdateEntry(existing, options);
return existing.editor;
}
@@ -373,7 +348,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
// Start creation and track the promise
const creationPromise = (async () => {
try {
- const entry = await this.#createEditor(descriptor, options);
+ const entry = this.#createEditorEntry(descriptor, options);
if (!entry) return null;
this.#editorEntries.set(descriptor.id, entry);
@@ -399,6 +374,44 @@ export class HeaderFooterEditorManager extends EventEmitter {
return creationPromise;
}
+ /**
+ * Synchronously returns the cached editor for a descriptor, creating it on demand.
+ *
+ * Presentation-mode story activation needs a stable editor instance and DOM
+ * target immediately so input can be forwarded into the hidden host without
+ * waiting for the async `create` event. The normal lifecycle hooks still run
+ * through the returned entry's `ready` promise.
+ */
+ ensureEditorSync(
+ descriptor: HeaderFooterDescriptor,
+ options?: {
+ editorHost?: HTMLElement;
+ availableWidth?: number;
+ availableHeight?: number;
+ currentPageNumber?: number;
+ totalPageCount?: number;
+ },
+ ): Editor | null {
+ if (!descriptor?.id) return null;
+
+ const existing = this.#editorEntries.get(descriptor.id);
+ if (existing) {
+ this.#cacheHits += 1;
+ this.#updateAccessOrder(descriptor.id);
+ this.#mountAndUpdateEntry(existing, options);
+ return existing.editor;
+ }
+
+ const entry = this.#createEditorEntry(descriptor, options);
+ if (!entry) return null;
+
+ this.#cacheMisses += 1;
+ this.#editorEntries.set(descriptor.id, entry);
+ this.#updateAccessOrder(descriptor.id);
+ this.#enforceCacheSizeLimit();
+ return entry.editor;
+ }
+
/**
* Updates page number DOM elements to reflect current editor options.
* Called after setOptions to sync NodeViews that read editor.options.
@@ -664,7 +677,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
this.#editorEntries.clear();
}
- async #createEditor(
+ #createEditorEntry(
descriptor: HeaderFooterDescriptor,
options?: {
editorHost?: HTMLElement;
@@ -673,7 +686,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
currentPageNumber?: number;
totalPageCount?: number;
},
- ): Promise {
+ ): HeaderFooterEditorEntry | null {
const json = this.getDocumentJson(descriptor);
if (!json) return null;
@@ -792,6 +805,45 @@ export class HeaderFooterEditorManager extends EventEmitter {
};
}
+ #mountAndUpdateEntry(
+ entry: HeaderFooterEditorEntry,
+ options?: {
+ editorHost?: HTMLElement;
+ availableWidth?: number;
+ availableHeight?: number;
+ currentPageNumber?: number;
+ totalPageCount?: number;
+ },
+ ): void {
+ if (entry.container && options?.editorHost && entry.container.parentElement !== options.editorHost) {
+ options.editorHost.appendChild(entry.container);
+ }
+
+ if (!options) {
+ return;
+ }
+
+ const updateOptions: Record = {};
+ if (options.currentPageNumber !== undefined) {
+ updateOptions.currentPageNumber = options.currentPageNumber;
+ }
+ if (options.totalPageCount !== undefined) {
+ updateOptions.totalPageCount = options.totalPageCount;
+ }
+ if (options.availableWidth !== undefined) {
+ updateOptions.availableWidth = options.availableWidth;
+ }
+ if (options.availableHeight !== undefined) {
+ updateOptions.availableHeight = options.availableHeight;
+ }
+ if (Object.keys(updateOptions).length > 0) {
+ entry.editor.setOptions(updateOptions);
+ // NodeViews that render PAGE / NUMPAGES read editor.options, so refresh
+ // them when the presentation context changes without a document step.
+ this.#refreshPageNumberDisplay(entry.editor);
+ }
+ }
+
#createEditorContainer(): HTMLElement {
const doc =
(this.#editor.options?.element?.ownerDocument as Document | undefined) ?? globalThis.document ?? undefined;
@@ -1006,6 +1058,10 @@ export class HeaderFooterLayoutAdapter {
#manager: HeaderFooterEditorManager;
#mediaFiles?: Record;
#blockCache: Map = new Map();
+ #trackedChangesRenderConfig: HeaderFooterTrackedChangesRenderConfig = {
+ mode: 'review',
+ enabled: true,
+ };
/**
* Creates a new HeaderFooterLayoutAdapter.
@@ -1018,6 +1074,23 @@ export class HeaderFooterLayoutAdapter {
this.#mediaFiles = mediaFiles;
}
+ setTrackedChangesRenderConfig(config: HeaderFooterTrackedChangesRenderConfig): void {
+ const nextConfig: HeaderFooterTrackedChangesRenderConfig = {
+ mode: config.mode,
+ enabled: config.enabled,
+ };
+
+ if (
+ this.#trackedChangesRenderConfig.mode === nextConfig.mode &&
+ this.#trackedChangesRenderConfig.enabled === nextConfig.enabled
+ ) {
+ return;
+ }
+
+ this.#trackedChangesRenderConfig = nextConfig;
+ this.invalidateAll();
+ }
+
/**
* Retrieves FlowBlock batches for all variants of a given header/footer kind.
*
@@ -1159,8 +1232,9 @@ export class HeaderFooterLayoutAdapter {
const doc = this.#manager.getDocumentJson(descriptor);
if (!doc) return undefined;
+ const renderConfigKey = this.#serializeRenderConfig();
const cacheEntry = this.#blockCache.get(descriptor.id);
- if (cacheEntry?.docRef === doc) {
+ if (cacheEntry?.docRef === doc && cacheEntry.renderConfigKey === renderConfigKey) {
return cacheEntry.blocks;
}
@@ -1186,13 +1260,20 @@ export class HeaderFooterLayoutAdapter {
converterContext,
defaultFont,
defaultSize,
+ trackedChangesMode: this.#trackedChangesRenderConfig.mode,
+ enableTrackedChanges: this.#trackedChangesRenderConfig.enabled,
+ storyKey: buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId: descriptor.id }),
...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}),
});
const blocks = result.blocks;
- this.#blockCache.set(descriptor.id, { docRef: doc, blocks });
+ this.#blockCache.set(descriptor.id, { docRef: doc, renderConfigKey, blocks });
return blocks;
}
+
+ #serializeRenderConfig(): string {
+ return `${this.#trackedChangesRenderConfig.mode}|${this.#trackedChangesRenderConfig.enabled ? '1' : '0'}`;
+ }
/**
* Extracts converter context needed for FlowBlock conversion.
* Uses type guard for safe access to converter property.
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts
index 15d9fe71d7..55b04b0920 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts
@@ -6,12 +6,8 @@ import {
HeaderFooterLayoutAdapter,
type HeaderFooterDescriptor,
} from './HeaderFooterRegistry.js';
-import { EditorOverlayManager } from './EditorOverlayManager.js';
export type InitHeaderFooterRegistryDeps = {
- painterHost: HTMLElement;
- visibleHost: HTMLElement;
- selectionOverlay: HTMLElement | null;
editor: Editor;
converter: Parameters[0];
mediaFiles?: Record;
@@ -19,15 +15,12 @@ export type InitHeaderFooterRegistryDeps = {
initBudgetMs: number;
resetSession: () => void;
requestRerender: () => void;
- exitHeaderFooterMode: () => void;
previousCleanups: Array<() => void>;
previousAdapter: HeaderFooterLayoutAdapter | null;
previousManager: HeaderFooterEditorManager | null;
- previousOverlayManager: EditorOverlayManager | null;
};
export type InitHeaderFooterRegistryResult = {
- overlayManager: EditorOverlayManager;
headerFooterIdentifier: HeaderFooterIdentifier | null;
headerFooterManager: HeaderFooterEditorManager;
headerFooterAdapter: HeaderFooterLayoutAdapter;
@@ -35,9 +28,6 @@ export type InitHeaderFooterRegistryResult = {
};
export function initHeaderFooterRegistry({
- painterHost,
- visibleHost,
- selectionOverlay,
editor,
converter,
mediaFiles,
@@ -45,11 +35,9 @@ export function initHeaderFooterRegistry({
initBudgetMs,
resetSession,
requestRerender,
- exitHeaderFooterMode,
previousCleanups,
previousAdapter,
previousManager,
- previousOverlayManager,
}: InitHeaderFooterRegistryDeps): InitHeaderFooterRegistryResult {
const startTime = performance.now();
@@ -62,15 +50,9 @@ export function initHeaderFooterRegistry({
});
previousAdapter?.clear();
previousManager?.destroy();
- previousOverlayManager?.destroy();
resetSession();
- // Initialize EditorOverlayManager for in-place editing
- const overlayManager = new EditorOverlayManager(painterHost, visibleHost, selectionOverlay);
- // Set callback for when user clicks on dimming overlay to exit edit mode
- overlayManager.setOnDimmingClick(exitHeaderFooterMode);
-
const headerFooterIdentifier = extractIdentifierFromConverter(converter);
const headerFooterManager = new HeaderFooterEditorManager(editor);
const headerFooterAdapter = new HeaderFooterLayoutAdapter(
@@ -99,7 +81,6 @@ export function initHeaderFooterRegistry({
}
return {
- overlayManager,
headerFooterIdentifier,
headerFooterManager,
headerFooterAdapter,
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/types.ts b/packages/super-editor/src/editors/v1/core/header-footer/types.ts
index f60b0556fd..561cd6c02d 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/types.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/types.ts
@@ -2,7 +2,7 @@
* Shared header/footer types.
*
* Canonical definitions for header/footer region data used across
- * PresentationEditor, EditorOverlayManager, and HeaderFooterSessionManager.
+ * PresentationEditor and HeaderFooterSessionManager.
*/
/**
diff --git a/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js b/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js
index 25fbd51a9a..78894e1639 100644
--- a/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js
+++ b/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js
@@ -18,8 +18,12 @@ export function getEditorSurfaceElement(editor) {
return editor.element;
}
+ const parentEditor = editor.options?.parentEditor ?? null;
+ const presentationEditor =
+ editor.presentationEditor ?? parentEditor?.presentationEditor ?? parentEditor?._presentationEditor ?? null;
+
// For flow Editor: check for attached PresentationEditor, then fall back to view.dom or options.element
- return editor.presentationEditor?.element ?? editor.view?.dom ?? editor.options?.element ?? null;
+ return presentationEditor?.element ?? editor.view?.dom ?? editor.options?.element ?? null;
}
/**
diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts
index fdb1aaa81d..9e4bc83f3e 100644
--- a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts
+++ b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts
@@ -43,7 +43,17 @@ function getConverter(editor: Editor): ConverterForHeaderFooter | undefined {
// Part ID Parsing
// ---------------------------------------------------------------------------
-/** Mutation source tag for local header/footer sub-editor edits. */
+/**
+ * Mutation source tag for local header/footer sub-editor edits.
+ *
+ * @remarks
+ * This tag remains a coordination signal used to suppress redundant refresh
+ * fan-out when a local sub-editor has already propagated an edit. The
+ * refactor described in
+ * `plans/story-backed-parts-presentation-editing.md` (Phase 5) aims to stop
+ * relying on local UI code to pre-update converter caches; the tag stays, but
+ * the descriptor path should become authoritative for cache rebuilds.
+ */
export const SOURCE_HEADER_FOOTER_LOCAL = 'header-footer-sync:local';
const HEADER_PATTERN = /^word\/header\d+\.xml$/;
@@ -125,14 +135,17 @@ export function ensureHeaderFooterDescriptor(partId: PartId, sectionId: string):
const resolvedSectionId = ctx.sectionId ?? sectionId;
- // Local edits (header-footer-sync:local) already update the PM cache
- // and refresh other sub-editors in onHeaderFooterDataUpdate. Running
- // refreshActiveSubEditors here would re-replace the originating editor,
- // causing a redundant update cycle with cursor churn.
+ // Local edits still emit SOURCE_HEADER_FOOTER_LOCAL as a coordination
+ // signal so we can suppress redundant live-editor fan-out, but the
+ // descriptor path is authoritative for rebuilding the PM cache from the
+ // committed OOXML. This avoids depending on UI callers to pre-update
+ // converter state before mutatePart runs.
const isLocalSync = ctx.source === SOURCE_HEADER_FOOTER_LOCAL;
- // For remote applies, rebuild the PM JSON from the updated OOXML
- if (!isLocalSync && typeof converter.reimportHeaderFooterPart === 'function') {
+ // Rebuild the PM JSON cache from the updated OOXML for both local and
+ // remote applies. Local sync suppresses only the live-editor refresh
+ // fan-out below.
+ if (typeof converter.reimportHeaderFooterPart === 'function') {
try {
const pmJson = converter.reimportHeaderFooterPart(ctx.partId);
if (pmJson) {
@@ -222,14 +235,18 @@ function destroySubEditors(converter: ConverterForHeaderFooter, type: 'header' |
function registerHeaderFooterInvalidationHandler(partId: PartId): void {
registerInvalidationHandler(partId, (editor) => {
+ const view = (editor as unknown as { view?: { dispatch?: (tr: unknown) => void } }).view;
+ if (!view?.dispatch) {
+ return;
+ }
+
try {
const tr = (editor as unknown as { state: { tr: unknown } }).state.tr;
const setMeta = (tr as unknown as { setMeta: (key: string, value: boolean) => unknown }).setMeta;
setMeta.call(tr, 'forceUpdatePagination', true);
- const view = (editor as unknown as { view?: { dispatch?: (tr: unknown) => void } }).view;
- view?.dispatch?.(tr);
+ view.dispatch(tr);
} catch {
- // View may not be ready
+ // UI invalidation is best-effort only.
}
});
}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
index 2c9d1dab9d..19319e9114 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
@@ -19,6 +19,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model';
import type { Mapping } from 'prosemirror-transform';
import { Editor } from '../Editor.js';
import { EventEmitter } from '../EventEmitter.js';
+import type { ProseMirrorJSON } from '../types/EditorTypes.js';
import { EpochPositionMapper } from './layout/EpochPositionMapper.js';
import { DomPositionIndex } from '../../dom-observer/DomPositionIndex.js';
import { DomPositionIndexObserverManager } from '../../dom-observer/DomPositionIndexObserverManager.js';
@@ -26,6 +27,10 @@ import {
computeDomCaretPageLocal as computeDomCaretPageLocalFromDom,
computeSelectionRectsFromDom as computeSelectionRectsFromDomFromDom,
} from '../../dom-observer/DomSelectionGeometry.js';
+import {
+ readLayoutEpochFromDom as readLayoutEpochFromDomFromDom,
+ resolvePositionWithinFragmentDom as resolvePositionWithinFragmentDomFromDom,
+} from '../../dom-observer/index.js';
import {
convertPageLocalToOverlayCoords as convertPageLocalToOverlayCoordsFromTransform,
getPageOffsetX as getPageOffsetXFromTransform,
@@ -38,7 +43,7 @@ import {
import { getPageElementByIndex } from '../../dom-observer/PageDom.js';
import { inchesToPx, parseColumns } from './layout/LayoutOptionParsing.js';
import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/PresentationLayoutMetrics.js';
-import { buildFootnotesInput } from './layout/FootnotesBuilder.js';
+import { buildFootnotesInput, type NoteRenderOverride } from './layout/FootnotesBuilder.js';
import { safeCleanup } from './utils/SafeCleanup.js';
import { createHiddenHost } from './dom/HiddenHost.js';
import { RemoteCursorManager, type RenderDependencies } from './remote-cursors/RemoteCursorManager.js';
@@ -58,6 +63,11 @@ import { debugLog, updateSelectionDebugHud, type SelectionDebugHudState } from '
import { renderCellSelectionOverlay } from './selection/CellSelectionOverlay.js';
import { renderCaretOverlay, renderSelectionRects } from './selection/LocalSelectionOverlayRendering.js';
import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from './selection/CaretGeometry.js';
+import {
+ computeCaretRectFromVisibleTextOffset as computeCaretRectFromVisibleTextOffsetFromHelper,
+ computeSelectionRectsFromVisibleTextOffsets as computeSelectionRectsFromVisibleTextOffsetsFromHelper,
+ measureVisibleTextOffset as measureVisibleTextOffsetFromHelper,
+} from './selection/VisibleTextOffsetGeometry.js';
import { collectCommentPositions as collectCommentPositionsFromHelper } from './utils/CommentPositionCollection.js';
import { getCurrentSectionPageStyles as getCurrentSectionPageStylesFromHelper } from './layout/SectionPageStyles.js';
import {
@@ -73,6 +83,12 @@ import {
import { DragDropManager } from './input/DragDropManager.js';
import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.js';
import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js';
+import { StoryPresentationSessionManager } from './story-session/StoryPresentationSessionManager.js';
+import type { StoryPresentationSession } from './story-session/types.js';
+import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js';
+import { BODY_STORY_KEY, buildStoryKey, parseStoryKey } from '../../document-api-adapters/story-runtime/story-key.js';
+import { createStoryEditor } from '../story-editor-factory.js';
+import { buildEndnoteBlocks } from './layout/EndnotesBuilder.js';
import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter';
import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js';
import {
@@ -109,6 +125,7 @@ import type {
import { extractHeaderFooterSpace as _extractHeaderFooterSpace } from '@superdoc/contracts';
// TrackChangesBasePluginKey is used by #syncTrackedChangesPreferences and getTrackChangesPluginState.
import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js';
+import { runEditorRedo, runEditorUndo } from '@extensions/history/history.js';
// Collaboration cursor imports
import { ySyncPluginKey } from 'y-prosemirror';
@@ -123,6 +140,103 @@ type ThreadAnchorScrollPlan = {
achievedClientY: number;
applyScroll: (behavior: ScrollBehavior) => void;
};
+
+type RenderedNoteTarget = {
+ storyType: 'footnote' | 'endnote';
+ noteId: string;
+};
+
+type NoteStorySession = StoryPresentationSession & {
+ locator: Extract;
+};
+
+type BoundedCommentPositionEntry = {
+ threadId: string;
+ start?: number;
+ end?: number;
+ pos?: number;
+ key?: string;
+ storyKey?: string;
+ kind?: 'trackedChange' | 'comment';
+ bounds?: unknown;
+ rects?: unknown;
+ pageIndex?: number;
+};
+
+type NoteLayoutContext = {
+ target: RenderedNoteTarget;
+ blocks: FlowBlock[];
+ measures: Measure[];
+ firstPageIndex: number;
+ hostWidthPx: number;
+};
+
+const VOLATILE_HISTORY_ATTR_KEYS = new Set(['sdBlockId', 'sdBlockRev']);
+
+function stripVolatileHistoryAttrs(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map((item) => stripVolatileHistoryAttrs(item));
+ }
+
+ if (!value || typeof value !== 'object') {
+ return value;
+ }
+
+ const result: Record = {};
+ for (const [key, entryValue] of Object.entries(value as Record)) {
+ if (VOLATILE_HISTORY_ATTR_KEYS.has(key)) {
+ continue;
+ }
+ result[key] = stripVolatileHistoryAttrs(entryValue);
+ }
+ return result;
+}
+
+function docsEqualIgnoringVolatileHistoryAttrs(
+ before: ProseMirrorNode | null | undefined,
+ after: ProseMirrorNode | null | undefined,
+): boolean {
+ if (!before || !after) {
+ return false;
+ }
+
+ if (typeof before.eq === 'function' && before.eq(after)) {
+ return true;
+ }
+
+ const beforeJson = typeof before.toJSON === 'function' ? before.toJSON() : before;
+ const afterJson = typeof after.toJSON === 'function' ? after.toJSON() : after;
+
+ return JSON.stringify(stripVolatileHistoryAttrs(beforeJson)) === JSON.stringify(stripVolatileHistoryAttrs(afterJson));
+}
+
+type RenderedNoteFragmentHit = {
+ fragmentElement: HTMLElement;
+ pageIndex: number;
+};
+
+function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null {
+ if (typeof blockId !== 'string' || blockId.length === 0) {
+ return null;
+ }
+
+ if (blockId.startsWith('footnote-')) {
+ const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? '';
+ return noteId ? { storyType: 'footnote', noteId } : null;
+ }
+
+ if (blockId.startsWith('__sd_semantic_footnote-')) {
+ const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? '';
+ return noteId ? { storyType: 'footnote', noteId } : null;
+ }
+
+ if (blockId.startsWith('endnote-')) {
+ const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? '';
+ return noteId ? { storyType: 'endnote', noteId } : null;
+ }
+
+ return null;
+}
import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js';
import { DOM_CLASS_NAMES, buildSdtBlockSelector } from '@superdoc/dom-contract';
import {
@@ -130,10 +244,19 @@ import {
ensureEditorFieldAnnotationInteractionStyles,
} from './dom/EditorStyleInjector.js';
-import type { ResolveRangeOutput, DocumentApi, NavigableAddress, BlockNavigationAddress } from '@superdoc/document-api';
+import type {
+ ResolveRangeOutput,
+ DocumentApi,
+ NavigableAddress,
+ BlockNavigationAddress,
+ StoryLocator,
+} from '@superdoc/document-api';
+import { isStoryLocator } from '@superdoc/document-api';
import { getBlockIndex } from '../../document-api-adapters/helpers/index-cache.js';
import { findBlockByNodeIdOnly, findBlockById } from '../../document-api-adapters/helpers/node-address-resolver.js';
import { resolveTrackedChange } from '../../document-api-adapters/helpers/tracked-change-resolver.js';
+import { makeTrackedChangeAnchorKey } from '../../document-api-adapters/helpers/tracked-change-runtime-ref.js';
+import { getTrackedChangeIndex } from '../../document-api-adapters/tracked-changes/tracked-change-index.js';
import type { SelectionHandle } from '../selection-state.js';
const DOCUMENT_RELS_PART_ID = 'word/_rels/document.xml.rels';
@@ -313,6 +436,8 @@ export class PresentationEditor extends EventEmitter {
#hiddenHostWrapper: HTMLElement;
#layoutOptions: LayoutEngineOptions;
#layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() };
+ #layoutLookupBlocks: FlowBlock[] = [];
+ #layoutLookupMeasures: Measure[] = [];
/** Cache for incremental toFlowBlocks conversion */
#flowBlockCache: FlowBlockCache = new FlowBlockCache();
#footnoteNumberSignature: string | null = null;
@@ -333,6 +458,8 @@ export class PresentationEditor extends EventEmitter {
/**
* When true, the next selection render scrolls the caret/selection head into view.
* Only set for user-initiated actions (keyboard/mouse selection, image click, zoom).
+ * Not set on each `selectionUpdate` while a pointer drag is active โ edge auto-scroll
+ * owns the viewport then; `notifyDragSelectionEnded` restores one scroll after mouseup.
* Passive re-renders (virtualization remounts, layout completions, DOM rebuilds) leave
* this unset so they don't fight the user's scroll position.
*/
@@ -367,6 +494,14 @@ export class PresentationEditor extends EventEmitter {
#trackedChangesOverrides: TrackedChangesOverrides | undefined;
// Header/footer session management
#headerFooterSession: HeaderFooterSessionManager | null = null;
+ /**
+ * Generic story-backed presentation-session manager.
+ *
+ * Story-backed parts (headers, footers, footnotes, endnotes) all use this
+ * manager to keep ProseMirror off-screen while DomPainter remains the sole
+ * visible renderer.
+ */
+ #storySessionManager: StoryPresentationSessionManager | null = null;
#hoverOverlay: HTMLElement | null = null;
#hoverTooltip: HTMLElement | null = null;
#modeBanner: HTMLElement | null = null;
@@ -375,6 +510,15 @@ export class PresentationEditor extends EventEmitter {
#a11yLastAnnouncedSelectionKey: string | null = null;
#headerFooterSelectionHandler: ((...args: unknown[]) => void) | null = null;
#headerFooterEditor: Editor | null = null;
+ #storySessionSelectionHandler: ((...args: unknown[]) => void) | null = null;
+ #storySessionTransactionHandler: ((...args: unknown[]) => void) | null = null;
+ #storySessionEditor: Editor | null = null;
+ #persistentStorySessionEditors = new WeakSet();
+ #lastPersistentStoryHistoryEditor: Editor | null = null;
+ #activeSurfaceUiEventEditor: Editor | null = null;
+ #activeSurfaceUiUpdateHandler: ((...args: unknown[]) => void) | null = null;
+ #activeSurfaceUiContextMenuOpenHandler: ((...args: unknown[]) => void) | null = null;
+ #activeSurfaceUiContextMenuCloseHandler: ((...args: unknown[]) => void) | null = null;
#lastSelectedFieldAnnotation: {
element: HTMLElement;
pmStart: number;
@@ -468,6 +612,7 @@ export class PresentationEditor extends EventEmitter {
emitCommentPositionsInViewing: options.layoutEngineOptions?.emitCommentPositionsInViewing,
enableCommentsInViewing: options.layoutEngineOptions?.enableCommentsInViewing,
presence: validatedPresence,
+ showBookmarks: options.layoutEngineOptions?.showBookmarks ?? false,
};
this.#trackedChangesOverrides = options.layoutEngineOptions?.trackedChanges;
@@ -628,6 +773,10 @@ export class PresentationEditor extends EventEmitter {
modeBanner: this.#modeBanner,
});
this.#headerFooterSession.setDocumentMode(this.#documentMode);
+ this.#headerFooterSession.setTrackedChangesRenderConfig({
+ mode: this.#trackedChangesMode,
+ enabled: this.#trackedChangesEnabled,
+ });
this.#ariaLiveRegion = doc.createElement('div');
this.#ariaLiveRegion.className = 'presentation-editor__aria-live';
@@ -671,7 +820,7 @@ export class PresentationEditor extends EventEmitter {
editorProps: normalizedEditorProps,
documentMode: this.#documentMode,
});
- this.#wrapHiddenEditorFocus();
+ this.#wrapOffscreenEditorFocus(this.#editor);
// Set bidirectional reference for renderer-neutral helpers
// Type assertion is safe here as we control both Editor and PresentationEditor
(this.#editor as Editor & { presentationEditor?: PresentationEditor | null }).presentationEditor = this;
@@ -683,6 +832,7 @@ export class PresentationEditor extends EventEmitter {
}
this.#setupHeaderFooterSession();
+ this.#setupStorySessionManager();
this.#applyZoom();
this.#setupEditorListeners();
this.#initializeEditorInputManager();
@@ -690,6 +840,7 @@ export class PresentationEditor extends EventEmitter {
this.#setupDragHandlers();
this.#setupInputBridge();
this.#syncTrackedChangesPreferences();
+ this.#syncHeaderFooterTrackedChangesRenderConfig();
this.#setupSemanticResizeObserver();
this.#initializeProofing();
@@ -718,25 +869,33 @@ export class PresentationEditor extends EventEmitter {
}
/**
- * Wraps the hidden editor's focus method to prevent unwanted scrolling when it receives focus.
+ * Wraps an off-screen editor's focus method to preserve selection and avoid scroll jumps.
+ *
+ * PresentationEditor keeps the body editor and hidden-host story-session editors
+ * mounted off-screen. These editors must stay focusable for accessibility and
+ * input routing, but a raw focus call can do two harmful things:
+ *
+ * 1. Scroll the page toward the off-screen contenteditable.
+ * 2. Let the browser's stale DOM selection overwrite the ProseMirror selection
+ * before the active story has a chance to re-apply its real caret position.
*
- * The hidden ProseMirror editor is positioned off-screen but must remain focusable for
- * accessibility. When it receives focus, browsers may attempt to scroll it into view,
- * disrupting the user's viewport position. This method wraps the view's focus function
- * to prevent that scroll behavior using multiple fallback strategies.
+ * This wrapper installs the same focus contract on any off-screen editor we own:
+ * focus without scrolling, suppress transient selectionchange drift, then let
+ * ProseMirror re-synchronize its DOM selection.
*
* @remarks
* **Why this exists:**
- * - The hidden editor provides semantic document structure for screen readers
- * - It must be focusable, but is positioned off-screen with `left: -9999px`
+ * - Hidden editors provide semantic document structure for screen readers
+ * - They must be focusable, but are positioned off-screen with `left: -9999px`
* - Some browsers scroll to bring focused elements into view, breaking the user experience
- * - This wrapper prevents that scroll while maintaining focus behavior
+ * - Story sessions can temporarily lose native focus to the body editor or a UI surface
+ * - Restoring focus must preserve the active story selection, not restart at position 1
*
- * **Fallback strategies (in order):**
+ * **Focus strategies (in order):**
* 1. Try `view.dom.focus({ preventScroll: true })` - the standard approach
* 2. If that fails, try `view.dom.focus()` without options and restore scroll position
- * 3. If both fail, call the original ProseMirror focus method as last resort
- * 4. Always restore scroll position if it changed during any focus attempt
+ * 3. Always run the original ProseMirror focus logic so `selectionToDOM()` replays
+ * 4. Restore scroll position if any focus attempt changed it
*
* **Idempotency:**
* - Safe to call multiple times - checks `__sdPreventScrollFocus` flag to avoid re-wrapping
@@ -746,8 +905,8 @@ export class PresentationEditor extends EventEmitter {
* - Skips wrapping if the focus function has a `mock` property (Vitest/Jest mocks)
* - Prevents interference with test assertions and mock function tracking
*/
- #wrapHiddenEditorFocus(): void {
- const view = this.#editor?.view;
+ #wrapOffscreenEditorFocus(editor: Editor | null | undefined): void {
+ const view = editor?.view;
if (!view || !view.dom || typeof view.focus !== 'function') {
return;
}
@@ -784,54 +943,60 @@ export class PresentationEditor extends EventEmitter {
const beforeX = win.scrollX;
const beforeY = win.scrollY;
const alreadyFocused = view.hasFocus();
- let focused = false;
+
+ if (!alreadyFocused) {
+ // When focus jumps back into an off-screen editor, browsers can emit a
+ // transient DOM selection at the document start before ProseMirror has
+ // re-applied the current PM selection. Suppress that drift first.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (view as any).domObserver.suppressSelectionUpdates();
+ }
+
+ let domFocused = false;
// Strategy 1: Try focus with preventScroll option (modern browsers)
try {
view.dom.focus({ preventScroll: true });
- focused = true;
+ domFocused = true;
} catch (error) {
- debugLog('warn', 'Hidden editor focus: preventScroll failed', {
+ debugLog('warn', 'Off-screen editor focus: preventScroll failed', {
error: String(error),
strategy: 'preventScroll',
});
}
// Strategy 2: Fall back to focus without options
- if (!focused) {
+ if (!domFocused) {
try {
view.dom.focus();
- focused = true;
+ domFocused = true;
} catch (error) {
- debugLog('warn', 'Hidden editor focus: standard focus failed', {
+ debugLog('warn', 'Off-screen editor focus: standard focus failed', {
error: String(error),
strategy: 'standard',
});
}
}
- // Strategy 3: Last resort - call original ProseMirror focus
- if (!focused) {
- try {
- originalFocus();
- } catch (error) {
- debugLog('error', 'Hidden editor focus: all strategies failed', {
+ // Always let ProseMirror replay its own focus logic after the native DOM
+ // focus step. This is what writes the current PM selection back into the
+ // hidden contenteditable, which is critical for story-session carets.
+ try {
+ originalFocus();
+ } catch (error) {
+ if (!domFocused) {
+ debugLog('error', 'Off-screen editor focus: all strategies failed', {
+ error: String(error),
+ strategy: 'original',
+ });
+ } else {
+ debugLog('warn', 'Off-screen editor focus: ProseMirror selection sync failed', {
error: String(error),
strategy: 'original',
});
}
}
- // When the editor was not focused before, the browser places the DOM selection
- // at an arbitrary position inside the off-screen contenteditable. ProseMirror's
- // DOMObserver would read this stale position via a selectionchange event and
- // overwrite PM state, causing the cursor to jump. Suppress selection updates
- // for the next 50ms so PM re-applies its own selection to the DOM instead.
- if (!alreadyFocused) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (view as any).domObserver.suppressSelectionUpdates();
- }
-
// Restore scroll position if any focus attempt changed it
if (win.scrollX !== beforeX || win.scrollY !== beforeY) {
win.scrollTo(beforeX, beforeY);
@@ -1084,6 +1249,11 @@ export class PresentationEditor extends EventEmitter {
* ```
*/
getActiveEditor(): Editor {
+ // An active story session (header/footer in hidden-host mode, or a note
+ // session) always owns the editable surface.
+ const storySession = this.#storySessionManager?.getActiveSession();
+ if (storySession) return storySession.editor;
+
const session = this.#headerFooterSession?.session;
const activeHfEditor = this.#headerFooterSession?.activeEditor;
if (!session || session.mode === 'body' || !activeHfEditor) {
@@ -1092,6 +1262,105 @@ export class PresentationEditor extends EventEmitter {
return activeHfEditor;
}
+ #getActiveStorySession(): StoryPresentationSession | null {
+ return this.#storySessionManager?.getActiveSession() ?? null;
+ }
+
+ #getActiveNoteStorySession(): NoteStorySession | null {
+ const session = this.#getActiveStorySession();
+ if (!session || session.kind !== 'note') {
+ return null;
+ }
+ if (session.locator.storyType !== 'footnote' && session.locator.storyType !== 'endnote') {
+ return null;
+ }
+ return session as NoteStorySession;
+ }
+
+ #buildActiveNoteRenderOverride(storyType: 'footnote' | 'endnote'): NoteRenderOverride | null {
+ const session = this.#getActiveNoteStorySession();
+ if (!session || session.locator.storyType !== storyType) {
+ return null;
+ }
+
+ const storyEditor = session.editor as Editor & {
+ getJSON?: () => ProseMirrorJSON;
+ getUpdatedJson?: () => ProseMirrorJSON;
+ };
+ const docJson =
+ typeof storyEditor.getUpdatedJson === 'function'
+ ? storyEditor.getUpdatedJson()
+ : typeof storyEditor.getJSON === 'function'
+ ? storyEditor.getJSON()
+ : null;
+
+ if (!docJson || typeof docJson !== 'object') {
+ return null;
+ }
+
+ return {
+ noteId: session.locator.noteId,
+ docJson,
+ };
+ }
+
+ #getActiveTrackedChangeStorySurface(): { storyKey: string; editor: Editor } | null {
+ const storySession = this.#getActiveStorySession();
+ if (storySession) {
+ return {
+ storyKey: buildStoryKey(storySession.locator),
+ editor: storySession.editor,
+ };
+ }
+
+ const headerFooterSession = this.#headerFooterSession?.session;
+ const activeHeaderFooterEditor = this.#headerFooterSession?.activeEditor;
+ const headerFooterRefId =
+ headerFooterSession && headerFooterSession.mode !== 'body' ? headerFooterSession.headerFooterRefId : null;
+
+ if (!headerFooterRefId || !activeHeaderFooterEditor) {
+ return null;
+ }
+
+ return {
+ storyKey: buildStoryKey({
+ kind: 'story',
+ storyType: 'headerFooterPart',
+ refId: headerFooterRefId,
+ }),
+ editor: activeHeaderFooterEditor,
+ };
+ }
+
+ /**
+ * Access the generic story-session manager.
+ *
+ * PresentationEditor uses one story-session model for all story-backed
+ * surfaces. This getter exists so tests and other editor-internal helpers
+ * can inspect the active session.
+ */
+ getStorySessionManager(): StoryPresentationSessionManager | null {
+ return this.#storySessionManager;
+ }
+
+ /**
+ * Exit any active non-body editing surface and restore the body editor.
+ *
+ * This gives tests and editor-integrated helpers a single public entry point
+ * that does not need to know whether the current surface is managed by the
+ * generic story-session bridge, the header/footer session manager, or both.
+ */
+ exitActiveStorySurface(): void {
+ const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
+ if (sessionMode !== 'body') {
+ this.#exitHeaderFooterMode();
+ }
+
+ if (this.#getActiveStorySession()) {
+ this.#exitActiveStorySession();
+ }
+ }
+
// -------------------------------------------------------------------
// Selection bridge โ tracked handles + snapshot convenience
// -------------------------------------------------------------------
@@ -1215,13 +1484,119 @@ export class PresentationEditor extends EventEmitter {
};
}
+ #runEditorHistoryCommand(
+ editor: Editor | null,
+ command: 'undo' | 'redo',
+ ): { didRun: boolean; didChangeDoc: boolean } {
+ if (!editor) {
+ return { didRun: false, didChangeDoc: false };
+ }
+
+ const beforeDoc = editor.state?.doc ?? null;
+
+ try {
+ const didRun = command === 'undo' ? runEditorUndo(editor) : runEditorRedo(editor);
+ const rawDidChangeDoc =
+ beforeDoc && editor.state?.doc && typeof editor.state.doc.eq === 'function'
+ ? !editor.state.doc.eq(beforeDoc)
+ : didRun;
+ const didChangeDoc =
+ editor === this.#editor &&
+ rawDidChangeDoc &&
+ docsEqualIgnoringVolatileHistoryAttrs(beforeDoc, editor.state?.doc)
+ ? false
+ : rawDidChangeDoc;
+
+ if (didRun && this.#persistentStorySessionEditors.has(editor)) {
+ this.#lastPersistentStoryHistoryEditor = editor;
+ }
+
+ return { didRun, didChangeDoc };
+ } catch {
+ return { didRun: false, didChangeDoc: false };
+ }
+ }
+
+ #runPersistentStoryHistoryCommand(command: 'undo' | 'redo'): boolean {
+ const editor = this.#lastPersistentStoryHistoryEditor;
+ if (!editor || !this.#persistentStorySessionEditors.has(editor)) {
+ return false;
+ }
+
+ const handler = command === 'undo' ? editor.commands?.undo : editor.commands?.redo;
+ if (typeof handler !== 'function') {
+ return false;
+ }
+
+ try {
+ const didRun = Boolean(handler());
+ if (didRun) {
+ this.#lastPersistentStoryHistoryEditor = editor;
+ }
+ return didRun;
+ } catch {
+ return false;
+ }
+ }
+
+ #canRunEditorHistoryCommand(editor: Editor | null, command: 'undo' | 'redo'): boolean {
+ if (!editor) {
+ return false;
+ }
+
+ try {
+ return Boolean(
+ command === 'undo'
+ ? runEditorUndo(editor, { allowDispatch: false })
+ : runEditorRedo(editor, { allowDispatch: false }),
+ );
+ } catch {
+ return false;
+ }
+ }
+
+ #canRunPersistentStoryHistoryCommand(command: 'undo' | 'redo'): boolean {
+ const editor = this.#lastPersistentStoryHistoryEditor;
+ if (!editor || !this.#persistentStorySessionEditors.has(editor)) {
+ return false;
+ }
+
+ return this.#canRunEditorHistoryCommand(editor, command);
+ }
+
+ canUndo(): boolean {
+ const editor = this.getActiveEditor();
+ if (this.#canRunEditorHistoryCommand(editor, 'undo')) {
+ return true;
+ }
+ if (editor === this.#editor) {
+ return this.#canRunPersistentStoryHistoryCommand('undo');
+ }
+ return false;
+ }
+
+ canRedo(): boolean {
+ const editor = this.getActiveEditor();
+ if (this.#canRunEditorHistoryCommand(editor, 'redo')) {
+ return true;
+ }
+ if (editor === this.#editor) {
+ return this.#canRunPersistentStoryHistoryCommand('redo');
+ }
+ return false;
+ }
+
/**
* Undo the last action in the active editor.
*/
undo(): boolean {
const editor = this.getActiveEditor();
- if (editor?.commands?.undo) {
- return Boolean(editor.commands.undo());
+ const { didRun, didChangeDoc } = this.#runEditorHistoryCommand(editor, 'undo');
+ if (didRun && (editor !== this.#editor || didChangeDoc)) {
+ return true;
+ }
+ if (editor === this.#editor) {
+ return this.#runPersistentStoryHistoryCommand('undo');
}
return false;
}
@@ -1231,8 +1606,12 @@ export class PresentationEditor extends EventEmitter {
*/
redo(): boolean {
const editor = this.getActiveEditor();
- if (editor?.commands?.redo) {
- return Boolean(editor.commands.redo());
+ const { didRun, didChangeDoc } = this.#runEditorHistoryCommand(editor, 'redo');
+ if (didRun && (editor !== this.#editor || didChangeDoc)) {
+ return true;
+ }
+ if (editor === this.#editor) {
+ return this.#runPersistentStoryHistoryCommand('redo');
}
return false;
}
@@ -1372,9 +1751,11 @@ export class PresentationEditor extends EventEmitter {
this.#documentMode = mode;
this.#editor.setDocumentMode(mode);
this.#headerFooterSession?.setDocumentMode(mode);
+ this.#syncActiveStorySessionDocumentMode(this.#storySessionManager?.getActiveSession() ?? null);
this.#syncDocumentModeClass();
this.#syncHiddenEditorA11yAttributes();
const trackedChangesChanged = this.#syncTrackedChangesPreferences();
+ this.#syncHeaderFooterTrackedChangesRenderConfig();
// Re-render if mode changed OR tracked changes preferences changed.
// Mode change affects enableComments in toFlowBlocks even if tracked changes didn't change.
if (modeChanged || trackedChangesChanged) {
@@ -1421,6 +1802,7 @@ export class PresentationEditor extends EventEmitter {
this.#trackedChangesOverrides = overrides;
this.#layoutOptions.trackedChanges = overrides;
const trackedChangesChanged = this.#syncTrackedChangesPreferences();
+ this.#syncHeaderFooterTrackedChangesRenderConfig();
if (trackedChangesChanged) {
// Clear flow block cache since conversion-affecting settings changed
this.#flowBlockCache.clear();
@@ -1535,22 +1917,17 @@ export class PresentationEditor extends EventEmitter {
* Return layout-relative rects for the current document selection.
*/
getSelectionRects(relativeTo?: HTMLElement): RangeRect[] {
- const selection = this.#editor.state?.selection;
+ const selection = this.getActiveEditor().state?.selection;
if (!selection || selection.empty) return [];
return this.getRangeRects(selection.from, selection.to, relativeTo);
}
- /**
- * Convert an arbitrary document range into layout-based bounding rects.
- *
- * @param from - Start position in the ProseMirror document
- * @param to - End position in the ProseMirror document
- * @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates
- * relative to this element's bounding rect. If omitted, returns absolute viewport
- * coordinates relative to the selection overlay.
- * @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height)
- */
- getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] {
+ #computeRangeRects(
+ from: number,
+ to: number,
+ relativeTo?: HTMLElement,
+ options: { forceBodySurface?: boolean } = {},
+ ): RangeRect[] {
if (!this.#selectionOverlay) return [];
if (!Number.isFinite(from) || !Number.isFinite(to)) return [];
@@ -1567,10 +1944,16 @@ export class PresentationEditor extends EventEmitter {
let usedDomRects = false;
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
+ const activeNoteSession = this.#getActiveNoteStorySession();
+ const useHeaderFooterSurface = !options.forceBodySurface && sessionMode !== 'body';
+ const useNoteSurface = !options.forceBodySurface && activeNoteSession != null;
const layoutRectSource = () => {
- if (sessionMode !== 'body') {
+ if (useHeaderFooterSurface) {
return this.#computeHeaderFooterSelectionRects(start, end);
}
+ if (useNoteSurface) {
+ return this.#computeNoteSelectionRects(start, end) ?? [];
+ }
const domRects = this.#computeSelectionRectsFromDom(start, end);
if (domRects != null) {
usedDomRects = true;
@@ -1595,7 +1978,7 @@ export class PresentationEditor extends EventEmitter {
let domCaretStart: { pageIndex: number; x: number; y: number } | null = null;
let domCaretEnd: { pageIndex: number; x: number; y: number } | null = null;
const pageDelta: Record = {};
- if (!usedDomRects) {
+ if (!usedDomRects && !useNoteSurface) {
// Geometry fallback path: apply a small DOM-based delta to reduce drift.
try {
domCaretStart = this.#computeDomCaretPageLocal(start);
@@ -1615,12 +1998,9 @@ export class PresentationEditor extends EventEmitter {
}
}
- // Fix Issue #1: Get actual header/footer page height instead of hardcoded 1
- // When in header/footer mode, we need to use the real page height from the layout context
- // to correctly map coordinates for selection highlighting
- const pageHeight = sessionMode === 'body' ? this.#getBodyPageHeight() : this.#getHeaderFooterPageHeight();
- const pageGap = this.#layoutState.layout?.pageGap ?? 0;
- const finalRects = rawRects
+ const pageHeight = this.#getBodyPageHeight();
+ const pageGap = useHeaderFooterSurface || !this.#layoutState.layout ? 0 : (this.#layoutState.layout.pageGap ?? 0);
+ return rawRects
.map((rect: LayoutRect, idx: number, allRects: LayoutRect[]) => {
let adjustedX = rect.x;
let adjustedY = rect.y;
@@ -1664,8 +2044,20 @@ export class PresentationEditor extends EventEmitter {
};
})
.filter((rect: RangeRect | null): rect is RangeRect => Boolean(rect));
+ }
- return finalRects;
+ /**
+ * Convert an arbitrary document range into layout-based bounding rects.
+ *
+ * @param from - Start position in the ProseMirror document
+ * @param to - End position in the ProseMirror document
+ * @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates
+ * relative to this element's bounding rect. If omitted, returns absolute viewport
+ * coordinates relative to the selection overlay.
+ * @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height)
+ */
+ getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] {
+ return this.#computeRangeRects(from, to, relativeTo);
}
/**
@@ -1700,6 +2092,42 @@ export class PresentationEditor extends EventEmitter {
};
}
+ #getThreadSelectionBounds(
+ data: { storyKey?: unknown; start?: unknown; end?: unknown; pos?: unknown },
+ relativeTo: HTMLElement | undefined,
+ ): {
+ bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number };
+ rects: RangeRect[];
+ pageIndex: number;
+ } | null {
+ const start = Number.isFinite(data.start ?? data.pos) ? Number(data.start ?? data.pos) : undefined;
+ const end = Number.isFinite(data.end) ? Number(data.end) : start;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
+ return null;
+ }
+
+ const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null;
+ const rects =
+ storyKey === BODY_STORY_KEY
+ ? this.#computeRangeRects(start!, end!, relativeTo, { forceBodySurface: true })
+ : this.getRangeRects(start!, end!, relativeTo);
+
+ if (!rects.length) {
+ return null;
+ }
+
+ const bounds = this.#aggregateLayoutBounds(rects);
+ if (!bounds) {
+ return null;
+ }
+
+ return {
+ rects,
+ bounds,
+ pageIndex: rects[0]?.pageIndex ?? 0,
+ };
+ }
+
/**
* Remap comment positions to layout coordinates with bounds and rects.
* Takes a positions object with threadIds as keys and position data as values.
@@ -1752,6 +2180,19 @@ export class PresentationEditor extends EventEmitter {
remapped[threadId] = data;
return;
}
+
+ const storyTrackedBounds = this.#getStoryTrackedChangeBounds(data, relativeTo);
+ if (storyTrackedBounds) {
+ hasUpdates = true;
+ remapped[threadId] = {
+ ...data,
+ bounds: storyTrackedBounds.bounds,
+ rects: storyTrackedBounds.rects,
+ pageIndex: storyTrackedBounds.pageIndex,
+ };
+ return;
+ }
+
const start = data.start ?? data.pos;
const end = data.end ?? start;
if (!Number.isFinite(start) || !Number.isFinite(end)) {
@@ -1759,7 +2200,7 @@ export class PresentationEditor extends EventEmitter {
return;
}
- const layoutRange = this.getSelectionBounds(start!, end!, relativeTo);
+ const layoutRange = this.#getThreadSelectionBounds(data, relativeTo);
if (!layoutRange) {
remapped[threadId] = data;
return;
@@ -1777,6 +2218,23 @@ export class PresentationEditor extends EventEmitter {
return hasUpdates ? remapped : positions;
}
+ #shouldEmitCommentPositions(): boolean {
+ const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true;
+ return this.#documentMode !== 'viewing' || allowViewingCommentPositions;
+ }
+
+ #emitCommentPositions(relativeTo?: HTMLElement): void {
+ if (!this.#shouldEmitCommentPositions()) {
+ return;
+ }
+
+ const commentPositions = this.#collectCommentPositions();
+ const positionsWithBounds =
+ relativeTo != null ? this.getCommentBounds(commentPositions, relativeTo) : commentPositions;
+
+ this.emit('commentPositions', { positions: positionsWithBounds });
+ }
+
/**
* Collect all comment and tracked change positions from the PM document.
*
@@ -1788,20 +2246,372 @@ export class PresentationEditor extends EventEmitter {
*
* @returns Map of threadId -> { threadId, start, end }
*/
- #collectCommentPositions(): Record {
- return collectCommentPositionsFromHelper(this.#editor?.state?.doc ?? null, {
- commentMarkName: CommentMarkName,
- trackChangeMarkNames: [TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName],
- });
+ #collectCommentPositions(): Record<
+ string,
+ {
+ threadId: string;
+ start?: number;
+ end?: number;
+ key?: string;
+ storyKey?: string;
+ kind?: 'trackedChange' | 'comment';
+ }
+ > {
+ return {
+ ...collectCommentPositionsFromHelper(this.#editor?.state?.doc ?? null, {
+ commentMarkName: CommentMarkName,
+ trackChangeMarkNames: [TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName],
+ storyKey: BODY_STORY_KEY,
+ }),
+ ...this.#collectIndexedTrackedChangePositions(),
+ ...this.#collectRenderedTrackedChangePositions(),
+ };
}
- /**
- * Return a snapshot of the latest layout state.
- */
- getLayoutSnapshot(): {
- layout: Layout | null;
- blocks: FlowBlock[];
- measures: Measure[];
+ #collectIndexedTrackedChangePositions(): Record<
+ string,
+ {
+ threadId: string;
+ key: string;
+ storyKey: string;
+ kind: 'trackedChange';
+ start?: number;
+ end?: number;
+ }
+ > {
+ const positions: Record<
+ string,
+ {
+ threadId: string;
+ key: string;
+ storyKey: string;
+ kind: 'trackedChange';
+ start?: number;
+ end?: number;
+ }
+ > = {};
+
+ let snapshots: ReadonlyArray<{
+ anchorKey?: unknown;
+ runtimeRef?: { rawId?: unknown; storyKey?: unknown };
+ range?: { from?: unknown; to?: unknown };
+ }> = [];
+
+ try {
+ snapshots = getTrackedChangeIndex(this.#editor).getAll();
+ } catch {
+ return positions;
+ }
+
+ snapshots.forEach((snapshot) => {
+ const key = typeof snapshot?.anchorKey === 'string' ? snapshot.anchorKey : null;
+ const storyKey = typeof snapshot?.runtimeRef?.storyKey === 'string' ? snapshot.runtimeRef.storyKey : null;
+ const rawId = snapshot?.runtimeRef?.rawId;
+ const threadId = rawId == null ? null : String(rawId);
+
+ if (!key || !storyKey || !threadId || storyKey === BODY_STORY_KEY || positions[key]) {
+ return;
+ }
+
+ const start = Number.isFinite(snapshot?.range?.from) ? Number(snapshot.range.from) : undefined;
+ const end = Number.isFinite(snapshot?.range?.to) ? Number(snapshot.range.to) : undefined;
+
+ positions[key] = {
+ threadId,
+ key,
+ storyKey,
+ kind: 'trackedChange',
+ ...(start !== undefined ? { start } : {}),
+ ...(end !== undefined ? { end } : {}),
+ };
+ });
+
+ return positions;
+ }
+
+ #collectRenderedTrackedChangePositions(): Record<
+ string,
+ {
+ threadId: string;
+ key: string;
+ storyKey: string;
+ kind: 'trackedChange';
+ }
+ > {
+ const positions: Record<
+ string,
+ {
+ threadId: string;
+ key: string;
+ storyKey: string;
+ kind: 'trackedChange';
+ }
+ > = {};
+ const host = this.#visibleHost;
+
+ if (!host) {
+ return positions;
+ }
+
+ const elements = host.querySelectorAll('[data-track-change-id][data-story-key]');
+ elements.forEach((element) => {
+ const storyKey = element.dataset.storyKey?.trim();
+ const rawId = element.dataset.trackChangeId?.trim();
+ if (!storyKey || !rawId || storyKey === BODY_STORY_KEY) {
+ return;
+ }
+
+ const key = makeTrackedChangeAnchorKey({ storyKey, rawId });
+ if (positions[key]) {
+ return;
+ }
+
+ positions[key] = {
+ threadId: rawId,
+ key,
+ storyKey,
+ kind: 'trackedChange',
+ };
+ });
+
+ return positions;
+ }
+
+ #getStoryTrackedChangeBounds(
+ data: { threadId?: unknown; storyKey?: unknown; kind?: unknown; start?: unknown; end?: unknown },
+ relativeTo?: HTMLElement,
+ ): {
+ bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number };
+ rects: RangeRect[];
+ pageIndex: number;
+ } | null {
+ if (data?.kind !== 'trackedChange') {
+ return null;
+ }
+
+ const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null;
+ if (!storyKey || storyKey === BODY_STORY_KEY) {
+ return null;
+ }
+
+ const activeSurface = this.#getActiveTrackedChangeStorySurface();
+ if (!activeSurface || activeSurface.storyKey !== storyKey) {
+ return this.#getRenderedTrackedChangeBounds(data, relativeTo);
+ }
+
+ const start = Number.isFinite(data.start) ? Number(data.start) : undefined;
+ const end = Number.isFinite(data.end) ? Number(data.end) : start;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
+ return this.#getRenderedTrackedChangeBounds(data, relativeTo);
+ }
+
+ const rects = this.getRangeRects(start!, end!, relativeTo);
+ if (!rects.length) {
+ return this.#getRenderedTrackedChangeBounds(data, relativeTo);
+ }
+
+ const bounds = this.#aggregateLayoutBounds(rects);
+ if (!bounds) {
+ return this.#getRenderedTrackedChangeBounds(data, relativeTo);
+ }
+
+ return {
+ bounds,
+ rects,
+ pageIndex: rects[0]?.pageIndex ?? 0,
+ };
+ }
+
+ #getRenderedTrackedChangeBounds(
+ data: { threadId?: unknown; storyKey?: unknown; kind?: unknown },
+ relativeTo?: HTMLElement,
+ ): {
+ bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number };
+ rects: RangeRect[];
+ pageIndex: number;
+ } | null {
+ if (data?.kind !== 'trackedChange') {
+ return null;
+ }
+
+ const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null;
+ const rawId = typeof data.threadId === 'string' ? data.threadId : null;
+ if (!storyKey || !rawId || storyKey === BODY_STORY_KEY) {
+ return null;
+ }
+
+ const elements = this.#findRenderedTrackedChangeElements(rawId, storyKey);
+ if (!elements.length) {
+ return null;
+ }
+
+ const relativeRect = relativeTo?.getBoundingClientRect?.();
+ const rects = elements
+ .map((element) => {
+ const rect = element.getBoundingClientRect();
+ if (![rect.top, rect.left, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite)) {
+ return null;
+ }
+
+ const pageIndex = Number(element.closest('.superdoc-page')?.dataset?.pageIndex ?? 0);
+ return {
+ pageIndex: Number.isFinite(pageIndex) ? pageIndex : 0,
+ left: rect.left - (relativeRect?.left ?? 0),
+ top: rect.top - (relativeRect?.top ?? 0),
+ right: rect.right - (relativeRect?.left ?? 0),
+ bottom: rect.bottom - (relativeRect?.top ?? 0),
+ width: rect.width,
+ height: rect.height,
+ } satisfies RangeRect;
+ })
+ .filter((rect): rect is RangeRect => Boolean(rect));
+
+ if (!rects.length) {
+ return null;
+ }
+
+ const groupedRects = this.#groupRangeRectsByPage(rects);
+ const preferredPageIndex = this.#getPreferredRenderedTrackedChangePageIndex(storyKey, groupedRects, relativeTo);
+ const anchorRects = groupedRects.get(preferredPageIndex) ?? rects;
+ const bounds = this.#aggregateLayoutBounds(anchorRects);
+ if (!bounds) {
+ return null;
+ }
+
+ return {
+ bounds,
+ rects,
+ pageIndex: preferredPageIndex,
+ };
+ }
+
+ #findRenderedTrackedChangeElements(rawId: string, storyKey?: string): HTMLElement[] {
+ const host = this.#visibleHost;
+ if (!host) {
+ return [];
+ }
+
+ const baseSelector = `[data-track-change-id="${escapeAttrValue(rawId)}"]`;
+ if (!storyKey) {
+ return Array.from(host.querySelectorAll(baseSelector));
+ }
+
+ const storySelector = `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]`;
+ const exactMatches = Array.from(host.querySelectorAll(storySelector));
+ const allMatches = Array.from(host.querySelectorAll(baseSelector));
+
+ if (exactMatches.length > 1 || exactMatches.length === allMatches.length || allMatches.length === 0) {
+ return exactMatches;
+ }
+
+ return allMatches;
+ }
+
+ #groupRangeRectsByPage(rects: RangeRect[]): Map {
+ const grouped = new Map();
+
+ rects.forEach((rect) => {
+ const pageIndex = Number.isFinite(rect.pageIndex) ? rect.pageIndex : 0;
+ const pageRects = grouped.get(pageIndex);
+ if (pageRects) {
+ pageRects.push(rect);
+ return;
+ }
+ grouped.set(pageIndex, [rect]);
+ });
+
+ return grouped;
+ }
+
+ #getPreferredRenderedTrackedChangePageIndex(
+ storyKey: string,
+ groupedRects: Map,
+ relativeTo?: HTMLElement,
+ ): number {
+ const activeHeaderFooterSession = this.#headerFooterSession?.session;
+ const activeHeaderFooterStoryKey =
+ activeHeaderFooterSession?.mode !== 'body' && activeHeaderFooterSession?.headerFooterRefId
+ ? buildStoryKey({
+ kind: 'story',
+ storyType: 'headerFooterPart',
+ refId: activeHeaderFooterSession.headerFooterRefId,
+ })
+ : null;
+
+ const activePageIndex =
+ activeHeaderFooterStoryKey === storyKey && Number.isFinite(activeHeaderFooterSession?.pageIndex)
+ ? Number(activeHeaderFooterSession?.pageIndex)
+ : null;
+ if (activePageIndex != null && groupedRects.has(activePageIndex)) {
+ return activePageIndex;
+ }
+
+ const scrollViewport =
+ this.#scrollContainer instanceof Window
+ ? {
+ top: 0,
+ bottom: this.#scrollContainer.innerHeight,
+ }
+ : this.#scrollContainer instanceof Element
+ ? this.#scrollContainer.getBoundingClientRect()
+ : this.#visibleHost?.ownerDocument?.defaultView
+ ? {
+ top: 0,
+ bottom: this.#visibleHost.ownerDocument.defaultView.innerHeight,
+ }
+ : this.#visibleHost?.getBoundingClientRect?.();
+ const viewportRect = scrollViewport ?? null;
+ if (viewportRect) {
+ const relativeRect = relativeTo?.getBoundingClientRect?.();
+ const visibleTop = viewportRect.top - (relativeRect?.top ?? 0);
+ const visibleBottom = viewportRect.bottom - (relativeRect?.top ?? 0);
+ const viewportCenter = visibleTop + (visibleBottom - visibleTop) / 2;
+
+ let bestPageIndex: number | null = null;
+ let bestIntersection = -1;
+ let bestDistance = Number.POSITIVE_INFINITY;
+
+ groupedRects.forEach((pageRects, pageIndex) => {
+ const pageBounds = this.#aggregateLayoutBounds(pageRects);
+ if (!pageBounds) {
+ return;
+ }
+
+ const intersection = Math.max(
+ 0,
+ Math.min(pageBounds.bottom, visibleBottom) - Math.max(pageBounds.top, visibleTop),
+ );
+ const pageCenter = pageBounds.top + pageBounds.height / 2;
+ const distance = Math.abs(pageCenter - viewportCenter);
+
+ if (
+ intersection > bestIntersection ||
+ (intersection === bestIntersection && distance < bestDistance) ||
+ (intersection === bestIntersection &&
+ distance === bestDistance &&
+ (bestPageIndex == null || pageIndex < bestPageIndex))
+ ) {
+ bestPageIndex = pageIndex;
+ bestIntersection = intersection;
+ bestDistance = distance;
+ }
+ });
+
+ if (bestPageIndex != null) {
+ return bestPageIndex;
+ }
+ }
+
+ return [...groupedRects.keys()].sort((left, right) => left - right)[0] ?? 0;
+ }
+
+ /**
+ * Return a snapshot of the latest layout state.
+ */
+ getLayoutSnapshot(): {
+ layout: Layout | null;
+ blocks: FlowBlock[];
+ measures: Measure[];
sectionMetadata: SectionMetadata[];
} {
return {
@@ -2018,6 +2828,24 @@ export class PresentationEditor extends EventEmitter {
this.#scheduleRerender();
}
+ /**
+ * Toggle the SD-2454 "Show bookmarks" bracket indicators at runtime.
+ *
+ * When enabled, the pm-adapter emits visible gray `[` / `]` marker runs at
+ * bookmarkStart / bookmarkEnd positions (mirroring Word's opt-in behavior).
+ * Because markers are real characters that participate in text measurement
+ * and line breaking, toggling invalidates the flow-block cache and triggers
+ * a full re-layout.
+ */
+ setShowBookmarks(showBookmarks: boolean): void {
+ const next = !!showBookmarks;
+ if (this.#layoutOptions.showBookmarks === next) return;
+ this.#layoutOptions.showBookmarks = next;
+ this.#flowBlockCache?.clear();
+ this.#pendingDocChange = true;
+ this.#scheduleRerender();
+ }
+
/**
* Convert a viewport coordinate into a document hit using the current layout.
*/
@@ -2027,30 +2855,64 @@ export class PresentationEditor extends EventEmitter {
return null;
}
+ const noteContext = this.#buildActiveNoteLayoutContext();
+ if (noteContext) {
+ const rawHit =
+ this.#resolveNoteDomHit(noteContext, clientX, clientY) ??
+ clickToPositionGeometry(this.#layoutState.layout, noteContext.blocks, noteContext.measures, normalized, {
+ geometryHelper: this.#pageGeometryHelper ?? undefined,
+ });
+ if (!rawHit) {
+ return null;
+ }
+
+ const doc = this.getActiveEditor().state?.doc;
+ if (!doc) {
+ return rawHit;
+ }
+
+ return {
+ ...rawHit,
+ pos: Math.max(0, Math.min(rawHit.pos, doc.content.size)),
+ };
+ }
+
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
if (sessionMode !== 'body') {
const context = this.#getHeaderFooterContext();
if (!context) {
return null;
}
- const headerPageHeight = context.layout.pageSize?.h ?? context.region.height ?? 1;
+ const pageGap = this.#layoutState.layout?.pageGap ?? this.#getEffectivePageGap();
const bodyPageHeight = this.#getBodyPageHeight();
- const pageIndex = Math.max(0, Math.floor(normalized.y / bodyPageHeight));
+ const pageIndex = normalized.pageIndex ?? Math.max(0, Math.floor(normalized.y / (bodyPageHeight + pageGap)));
if (pageIndex !== context.region.pageIndex) {
return null;
}
const localX = normalized.x - context.region.localX;
- const localY = normalized.y - context.region.pageIndex * bodyPageHeight - context.region.localY;
+ const pageLocalY = normalized.pageLocalY ?? normalized.y - context.region.pageIndex * (bodyPageHeight + pageGap);
+ const localY = pageLocalY - context.region.localY;
if (localX < 0 || localY < 0 || localX > context.region.width || localY > context.region.height) {
return null;
}
- const headerPageIndex = Math.floor(localY / headerPageHeight);
const headerPoint = {
x: localX,
- y: headerPageIndex * headerPageHeight + (localY - headerPageIndex * headerPageHeight),
+ y: localY,
};
const hit = clickToPositionGeometry(context.layout, context.blocks, context.measures, headerPoint) ?? null;
- return hit;
+ if (!hit) {
+ return null;
+ }
+
+ const doc = this.getActiveEditor().state?.doc;
+ if (!doc) {
+ return hit;
+ }
+
+ return {
+ ...hit,
+ pos: Math.max(0, Math.min(hit.pos, doc.content.size)),
+ };
}
if (!this.#layoutState.layout) {
@@ -2286,11 +3148,14 @@ export class PresentationEditor extends EventEmitter {
// Get selection rects from the header/footer layout (already transformed to viewport)
const rects = this.#computeHeaderFooterSelectionRects(pos, pos);
- if (!rects || rects.length === 0) {
+ let rect = rects?.[0] ?? null;
+ if (!rect) {
+ rect = this.#computeHeaderFooterCaretRect(pos);
+ }
+ if (!rect) {
return null;
}
- const rect = rects[0];
const zoom = this.#layoutOptions.zoom ?? 1;
const containerRect = this.#visibleHost.getBoundingClientRect();
const scrollLeft = this.#visibleHost.scrollLeft ?? 0;
@@ -2311,6 +3176,36 @@ export class PresentationEditor extends EventEmitter {
};
}
+ if (this.#getActiveNoteStorySession()) {
+ const rects = this.#computeNoteSelectionRects(pos, pos) ?? [];
+ let rect = rects?.[0] ?? null;
+ if (!rect) {
+ rect = this.#computeNoteCaretRect(pos);
+ }
+ if (!rect) {
+ return null;
+ }
+
+ const zoom = this.#layoutOptions.zoom ?? 1;
+ const containerRect = this.#visibleHost.getBoundingClientRect();
+ const scrollLeft = this.#visibleHost.scrollLeft ?? 0;
+ const scrollTop = this.#visibleHost.scrollTop ?? 0;
+ const pageHeight = this.#getBodyPageHeight();
+ const pageGap = this.#layoutState.layout?.pageGap ?? 0;
+ const pageLocalY = rect.y - rect.pageIndex * (pageHeight + pageGap);
+ const coords = this.#convertPageLocalToOverlayCoords(rect.pageIndex, rect.x, pageLocalY);
+ if (!coords) return null;
+
+ return {
+ top: coords.y * zoom - scrollTop + containerRect.top,
+ bottom: coords.y * zoom - scrollTop + containerRect.top + rect.height * zoom,
+ left: coords.x * zoom - scrollLeft + containerRect.left,
+ right: coords.x * zoom - scrollLeft + containerRect.left + Math.max(1, rect.width) * zoom,
+ width: Math.max(1, rect.width) * zoom,
+ height: rect.height * zoom,
+ };
+ }
+
// In body mode, use main document layout
const rects = this.getRangeRects(pos, pos);
if (rects && rects.length > 0) {
@@ -2409,7 +3304,7 @@ export class PresentationEditor extends EventEmitter {
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
): boolean {
// Cancel any pending focus-scroll RAF so this intentional scroll is not undone
- // by the wrapHiddenEditorFocus safety net (e.g. search navigation after focus).
+ // by the wrapOffscreenEditorFocus safety net (e.g. search navigation after focus).
if (this.#focusScrollRafId != null) {
const win = this.#visibleHost.ownerDocument?.defaultView;
if (win) win.cancelAnimationFrame(this.#focusScrollRafId);
@@ -2499,12 +3394,17 @@ export class PresentationEditor extends EventEmitter {
#buildThreadAnchorScrollPlan(threadId: string, targetClientY: number): ThreadAnchorScrollPlan | null {
if (!threadId || !Number.isFinite(targetClientY)) return null;
- const threadPosition = this.#collectCommentPositions()[threadId];
+ const threadPosition = this.#resolveCommentPositionEntry(threadId);
if (!threadPosition) return null;
- const selectionBounds = this.getSelectionBounds(threadPosition.start, threadPosition.end);
- const currentTop = selectionBounds?.bounds?.top;
- if (!Number.isFinite(currentTop)) return null;
+ const boundedEntry = (this.getCommentBounds({ [threadId]: threadPosition })[threadId] ??
+ threadPosition) as BoundedCommentPositionEntry;
+ const currentTopValue =
+ typeof boundedEntry.bounds === 'object' && boundedEntry.bounds != null
+ ? (boundedEntry.bounds as { top?: unknown }).top
+ : undefined;
+ if (!Number.isFinite(currentTopValue)) return null;
+ const currentTop = Number(currentTopValue);
const requestedScrollDelta = currentTop - targetClientY;
const scrollTarget = this.#scrollContainer ?? this.#visibleHost;
@@ -2520,6 +3420,16 @@ export class PresentationEditor extends EventEmitter {
return null;
}
+ #resolveCommentPositionEntry(threadId: string): BoundedCommentPositionEntry | null {
+ const positions = this.#collectCommentPositions();
+ const directMatch = positions[threadId];
+ if (directMatch) {
+ return directMatch;
+ }
+
+ return Object.values(positions).find((entry) => entry?.key === threadId || entry?.threadId === threadId) ?? null;
+ }
+
#buildWindowThreadAnchorScrollPlan(
scrollTarget: Window,
currentTop: number,
@@ -2935,6 +3845,9 @@ export class PresentationEditor extends EventEmitter {
this.#a11ySelectionAnnounceTimeout = null;
}
+ this.#teardownStorySessionEventBridge();
+ this.#teardownActiveSurfaceUiEventBridge();
+
// Unregister from static registry
if (this.#registryKey) {
PresentationEditor.#instances.delete(this.#registryKey);
@@ -2947,8 +3860,16 @@ export class PresentationEditor extends EventEmitter {
this.#headerFooterSession = null;
}, 'Header/footer session manager');
+ // Clean up generic story-session manager (if the flag enabled it)
+ safeCleanup(() => {
+ this.#storySessionManager?.destroy();
+ this.#storySessionManager = null;
+ }, 'Story presentation session manager');
+
// Clear flow block cache to free memory
this.#flowBlockCache.clear();
+ this.#layoutLookupBlocks = [];
+ this.#layoutLookupMeasures = [];
this.#painterAdapter.reset();
this.#pageGeometryHelper = null;
@@ -3197,6 +4118,7 @@ export class PresentationEditor extends EventEmitter {
#setupEditorListeners() {
const handleUpdate = ({ transaction }: { transaction?: Transaction }) => {
const trackedChangesChanged = this.#syncTrackedChangesPreferences();
+ this.#syncHeaderFooterTrackedChangesRenderConfig();
if (transaction) {
this.#epochMapper.recordTransaction(transaction);
this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch());
@@ -3245,8 +4167,11 @@ export class PresentationEditor extends EventEmitter {
}
};
const handleSelection = () => {
- // User-initiated selection change (keyboard, mouse) โ scroll caret into view.
- this.#shouldScrollSelectionIntoView = true;
+ // User-initiated selection change โ scroll caret/head into view once, except during
+ // pointer drag: EditorInputManager edge auto-scroll must not fight #scrollActiveEndIntoView.
+ if (!this.#editorInputManager?.isDragging) {
+ this.#shouldScrollSelectionIntoView = true;
+ }
// Use immediate rendering for selection-only changes (clicks, arrow keys).
// Without immediate, the render is RAF-deferred โ leaving a window where
// a remote collaborator's edit can cancel the pending render via
@@ -3334,11 +4259,13 @@ export class PresentationEditor extends EventEmitter {
event: 'stylesDefaultsChanged',
handler: handleStylesDefaultsChanged as (...args: unknown[]) => void,
});
+ this.#syncActiveSurfaceUiEventBridge(this.#editor);
// Listen for footnote/endnote part mutations (e.g., insert via document API).
// These modify the OOXML part and derived cache but don't change the PM document,
// so the normal 'update' event won't trigger a layout refresh.
const handleNotesPartChanged = () => {
+ this.#flowBlockCache.setHasExternalChanges(true);
this.#pendingDocChange = true;
this.#selectionSync.onLayoutStart();
this.#scheduleRerender();
@@ -3532,6 +4459,7 @@ export class PresentationEditor extends EventEmitter {
getDocumentMode: () => this.#documentMode,
getPageElement: (pageIndex: number) => this.#getPageElement(pageIndex),
isSelectionAwareVirtualizationEnabled: () => this.#isSelectionAwareVirtualizationEnabled(),
+ getActiveStorySession: () => this.#getActiveStorySession(),
});
// Set callbacks - functions that the manager calls to interact with PresentationEditor
@@ -3549,7 +4477,7 @@ export class PresentationEditor extends EventEmitter {
hitTestHeaderFooterRegion: (x: number, y: number, pageIndex?: number, pageLocalY?: number) =>
this.#hitTestHeaderFooterRegion(x, y, pageIndex, pageLocalY),
exitHeaderFooterMode: () => this.#exitHeaderFooterMode(),
- activateHeaderFooterRegion: (region) => this.#activateHeaderFooterRegion(region),
+ activateHeaderFooterRegion: (region, options) => this.#activateHeaderFooterRegion(region, options),
emitHeaderFooterEditBlocked: (reason: string) => this.#emitHeaderFooterEditBlocked(reason),
findRegionForPage: (kind, pageIndex) => this.#findRegionForPage(kind, pageIndex),
getCurrentPageIndex: () => this.#getCurrentPageIndex(),
@@ -3557,6 +4485,7 @@ export class PresentationEditor extends EventEmitter {
updateSelectionDebugHud: () => this.#updateSelectionDebugHud(),
clearHoverRegion: () => this.#clearHoverRegion(),
renderHoverRegion: (region) => this.#renderHoverRegion(region),
+ hitTest: (clientX: number, clientY: number) => this.hitTest(clientX, clientY),
focusEditorAfterImageSelection: () => this.#focusEditorAfterImageSelection(),
resolveInlineImageElementByPmStart: (pmStart) => this.#painterAdapter.getInlineImageElementByPmStart(pmStart),
resolveImageFragmentElementByPmStart: (pmStart) => this.#painterAdapter.getImageFragmentElementByPmStart(pmStart),
@@ -3566,7 +4495,13 @@ export class PresentationEditor extends EventEmitter {
selectParagraphAt: (pos: number) => this.#selectParagraphAt(pos),
finalizeDragSelectionWithDom: (pointer, dragAnchor, dragMode) =>
this.#finalizeDragSelectionWithDom(pointer, dragAnchor, dragMode),
+ notifyDragSelectionEnded: () => {
+ this.#shouldScrollSelectionIntoView = true;
+ this.#scheduleSelectionUpdate({ immediate: true });
+ },
hitTestTable: (x: number, y: number) => this.#hitTestTable(x, y),
+ activateRenderedNoteSession: (target, options) => this.#activateRenderedNoteSession(target, options),
+ exitActiveStorySession: () => this.#exitActiveStorySession(),
});
}
@@ -3766,6 +4701,11 @@ export class PresentationEditor extends EventEmitter {
this.#visibleHost,
() => this.#getActiveDomTarget(),
() => !this.#isViewLocked(),
+ () => this.#editorInputManager?.notifyTargetChanged(),
+ {
+ useWindowFallback: true,
+ getTargetEditor: () => this.getActiveEditor(),
+ },
);
this.#inputBridge.bind();
}
@@ -3794,6 +4734,7 @@ export class PresentationEditor extends EventEmitter {
this.#pendingDocChange = true;
},
getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1,
+ getStorySessionManager: () => this.#ensureStorySessionManager(),
});
// Set up callbacks
@@ -3846,6 +4787,8 @@ export class PresentationEditor extends EventEmitter {
this.#scheduleSelectionUpdate({ immediate: true });
this.#scheduleA11ySelectionAnnouncement({ immediate: true });
}
+
+ this.#syncActiveSurfaceUiEventBridge();
},
onEditBlocked: (reason) => {
this.emit('headerFooterEditBlocked', { reason });
@@ -3869,6 +4812,21 @@ export class PresentationEditor extends EventEmitter {
});
},
onSurfaceTransaction: ({ sourceEditor, surface, headerId, sectionType, transaction, duration }) => {
+ const documentTransaction =
+ transaction && typeof transaction === 'object' ? (transaction as { docChanged?: boolean }) : null;
+ if (documentTransaction?.docChanged && headerId) {
+ this.#invalidateTrackedChangesForStory({
+ kind: 'story',
+ storyType: 'headerFooterPart',
+ refId: headerId,
+ });
+ this.#headerFooterSession?.invalidateLayoutForRefs([headerId]);
+ this.#flowBlockCache.setHasExternalChanges(true);
+ this.#pendingDocChange = true;
+ this.#selectionSync.onLayoutStart();
+ this.#scheduleRerender();
+ this.#emitCommentPositions();
+ }
this.emit('headerFooterTransaction', {
editor: this.#editor,
sourceEditor,
@@ -3885,39 +4843,246 @@ export class PresentationEditor extends EventEmitter {
this.#headerFooterSession.initialize();
}
- /**
- * Attempts to perform a table hit test for the given normalized coordinates.
- *
- * @param normalizedX - X coordinate in layout space
- * @param normalizedY - Y coordinate in layout space
- * @returns TableHitResult if the point is inside a table cell, null otherwise
- * @private
- */
- #hitTestTable(normalizedX: number, normalizedY: number): TableHitResult | null {
- const configuredPageHeight = (this.#layoutOptions.pageSize ?? DEFAULT_PAGE_SIZE).h;
- return hitTestTableFromHelper(
- this.#layoutState.layout,
- this.#layoutState.blocks,
- this.#layoutState.measures,
- normalizedX,
- normalizedY,
- configuredPageHeight,
- this.#getEffectivePageGap(),
- this.#pageGeometryHelper,
- );
+ #teardownStorySessionEventBridge(): void {
+ if (this.#storySessionEditor) {
+ if (this.#storySessionSelectionHandler) {
+ this.#storySessionEditor.off?.('selectionUpdate', this.#storySessionSelectionHandler);
+ }
+ if (this.#storySessionTransactionHandler) {
+ this.#storySessionEditor.off?.('transaction', this.#storySessionTransactionHandler);
+ }
+ }
+ this.#storySessionEditor = null;
+ this.#storySessionSelectionHandler = null;
+ this.#storySessionTransactionHandler = null;
}
- /**
- * Selects the word at the given document position.
- *
- * This method traverses up the document tree to find the nearest textblock ancestor,
- * then expands the selection to word boundaries using Unicode-aware word character
- * detection. This handles cases where the position is within nested structures like
- * list items or table cells.
- *
- * Algorithm:
- * 1. Traverse ancestors until a textblock is found (paragraphs, headings, list items)
- * 2. From the click position, expand backward while characters match word regex
+ #teardownActiveSurfaceUiEventBridge(): void {
+ if (this.#activeSurfaceUiEventEditor) {
+ if (this.#activeSurfaceUiUpdateHandler) {
+ this.#activeSurfaceUiEventEditor.off?.('update', this.#activeSurfaceUiUpdateHandler);
+ }
+ if (this.#activeSurfaceUiContextMenuOpenHandler) {
+ this.#activeSurfaceUiEventEditor.off?.('contextMenu:open', this.#activeSurfaceUiContextMenuOpenHandler);
+ }
+ if (this.#activeSurfaceUiContextMenuCloseHandler) {
+ this.#activeSurfaceUiEventEditor.off?.('contextMenu:close', this.#activeSurfaceUiContextMenuCloseHandler);
+ }
+ }
+
+ this.#activeSurfaceUiEventEditor = null;
+ this.#activeSurfaceUiUpdateHandler = null;
+ this.#activeSurfaceUiContextMenuOpenHandler = null;
+ this.#activeSurfaceUiContextMenuCloseHandler = null;
+ }
+
+ #syncActiveSurfaceUiEventBridge(editor: Editor | null = this.getActiveEditor()): void {
+ const nextEditor = editor ?? null;
+ if (nextEditor === this.#activeSurfaceUiEventEditor) {
+ return;
+ }
+
+ this.#teardownActiveSurfaceUiEventBridge();
+ if (!nextEditor) {
+ return;
+ }
+
+ const updateHandler = (event?: { transaction?: Transaction }) => {
+ this.emit('update', {
+ ...(event ?? {}),
+ editor: this,
+ });
+ };
+ const contextMenuOpenHandler = (event?: { menuPosition?: { left?: string; top?: string } }) => {
+ this.emit('contextMenu:open', event ?? {});
+ };
+ const contextMenuCloseHandler = () => {
+ this.emit('contextMenu:close');
+ };
+
+ nextEditor.on?.('update', updateHandler);
+ nextEditor.on?.('contextMenu:open', contextMenuOpenHandler);
+ nextEditor.on?.('contextMenu:close', contextMenuCloseHandler);
+ this.#activeSurfaceUiEventEditor = nextEditor;
+ this.#activeSurfaceUiUpdateHandler = updateHandler;
+ this.#activeSurfaceUiContextMenuOpenHandler = contextMenuOpenHandler;
+ this.#activeSurfaceUiContextMenuCloseHandler = contextMenuCloseHandler;
+ }
+
+ #syncStorySessionEventBridge(session: StoryPresentationSession | null): void {
+ this.#teardownStorySessionEventBridge();
+
+ if (!session) {
+ this.#scheduleSelectionUpdate({ immediate: true });
+ return;
+ }
+
+ const handler = () => {
+ this.#scheduleSelectionUpdate();
+ this.#scheduleA11ySelectionAnnouncement();
+ };
+ const transactionHandler = ({ transaction }: { transaction?: { docChanged?: boolean } }) => {
+ if (!transaction?.docChanged) {
+ return;
+ }
+
+ if (this.#persistentStorySessionEditors.has(session.editor)) {
+ this.#lastPersistentStoryHistoryEditor = session.editor;
+ }
+
+ if (session.kind === 'note') {
+ this.#invalidateTrackedChangesForStory(session.locator);
+ this.#pendingDocChange = true;
+ this.#selectionSync.onLayoutStart();
+ this.#scheduleRerender();
+ }
+ };
+
+ session.editor.on?.('selectionUpdate', handler);
+ session.editor.on?.('transaction', transactionHandler);
+ this.#storySessionEditor = session.editor;
+ this.#storySessionSelectionHandler = handler;
+ this.#storySessionTransactionHandler = transactionHandler;
+ this.#scheduleSelectionUpdate({ immediate: true });
+ this.#scheduleA11ySelectionAnnouncement({ immediate: true });
+ this.#syncActiveSurfaceUiEventBridge();
+ }
+
+ #syncActiveStorySessionDocumentMode(session: StoryPresentationSession | null): void {
+ if (!session || session.kind !== 'note') {
+ return;
+ }
+
+ // Story editors default to viewing mode at construction time. When a note
+ // session becomes the active presentation surface, it must inherit the
+ // current document mode so double-clicking produces an actually editable
+ // footnote/endnote surface.
+ if (typeof session.editor.setDocumentMode === 'function') {
+ session.editor.setDocumentMode(this.#documentMode);
+ return;
+ }
+
+ session.editor.setEditable?.(this.#documentMode !== 'viewing');
+ session.editor.setOptions?.({ documentMode: this.#documentMode });
+ }
+
+ #invalidateTrackedChangesForStory(locator: StoryLocator): void {
+ try {
+ getTrackedChangeIndex(this.#editor).invalidate(locator);
+ } catch {
+ // Tracked-change sync is best-effort while a live story session is typing.
+ }
+ }
+
+ #ensureStorySessionManager(): StoryPresentationSessionManager {
+ if (this.#storySessionManager) {
+ return this.#storySessionManager;
+ }
+
+ this.#storySessionManager = new StoryPresentationSessionManager({
+ resolveRuntime: (locator) => resolveStoryRuntime(this.#editor, locator, { intent: 'write' }),
+ getMountContainer: () => {
+ const doc = this.#visibleHost?.ownerDocument;
+ return doc?.body ?? this.#visibleHost ?? null;
+ },
+ editorFactory: ({ runtime, hostElement, activationOptions }) => {
+ const editorContext = activationOptions.editorContext ?? {};
+
+ if (runtime.kind === 'headerFooter' && runtime.locator.storyType === 'headerFooterPart') {
+ const descriptor = this.#headerFooterSession?.manager?.getDescriptorById(runtime.locator.refId) ?? null;
+ const persisted = descriptor
+ ? (this.#headerFooterSession?.manager?.ensureEditorSync(descriptor, {
+ editorHost: hostElement,
+ availableWidth: editorContext.availableWidth,
+ availableHeight: editorContext.availableHeight,
+ currentPageNumber: editorContext.currentPageNumber,
+ totalPageCount: editorContext.totalPageCount,
+ }) ?? null)
+ : null;
+
+ if (persisted) {
+ this.#persistentStorySessionEditors.add(persisted);
+ return { editor: persisted };
+ }
+ }
+
+ const existing = runtime.editor;
+ const pmJson = existing.getJSON() as unknown as Record;
+ const fresh = createStoryEditor(this.#editor, pmJson, {
+ documentId: runtime.storyKey,
+ isHeaderOrFooter: runtime.kind === 'headerFooter',
+ headless: false,
+ element: hostElement,
+ currentPageNumber: editorContext.currentPageNumber,
+ totalPageCount: editorContext.totalPageCount,
+ });
+
+ return {
+ editor: fresh,
+ dispose: () => {
+ try {
+ fresh.destroy();
+ } catch {
+ // best-effort teardown
+ }
+ },
+ };
+ },
+ onActiveSessionChanged: () => {
+ const activeSession = this.#storySessionManager?.getActiveSession() ?? null;
+ if (activeSession?.hostWrapper) {
+ this.#wrapOffscreenEditorFocus(activeSession.editor);
+ }
+ this.#syncActiveStorySessionDocumentMode(activeSession);
+ this.#syncStorySessionEventBridge(activeSession);
+ this.#syncActiveSurfaceUiEventBridge();
+ this.#inputBridge?.notifyTargetChanged();
+ },
+ });
+
+ return this.#storySessionManager;
+ }
+
+ /**
+ * Set up the generic story-session manager.
+ */
+ #setupStorySessionManager() {
+ this.#ensureStorySessionManager();
+ }
+
+ /**
+ * Attempts to perform a table hit test for the given normalized coordinates.
+ *
+ * @param normalizedX - X coordinate in layout space
+ * @param normalizedY - Y coordinate in layout space
+ * @returns TableHitResult if the point is inside a table cell, null otherwise
+ * @private
+ */
+ #hitTestTable(normalizedX: number, normalizedY: number): TableHitResult | null {
+ const configuredPageHeight = (this.#layoutOptions.pageSize ?? DEFAULT_PAGE_SIZE).h;
+ return hitTestTableFromHelper(
+ this.#layoutState.layout,
+ this.#layoutState.blocks,
+ this.#layoutState.measures,
+ normalizedX,
+ normalizedY,
+ configuredPageHeight,
+ this.#getEffectivePageGap(),
+ this.#pageGeometryHelper,
+ );
+ }
+
+ /**
+ * Selects the word at the given document position.
+ *
+ * This method traverses up the document tree to find the nearest textblock ancestor,
+ * then expands the selection to word boundaries using Unicode-aware word character
+ * detection. This handles cases where the position is within nested structures like
+ * list items or table cells.
+ *
+ * Algorithm:
+ * 1. Traverse ancestors until a textblock is found (paragraphs, headings, list items)
+ * 2. From the click position, expand backward while characters match word regex
* 3. Expand forward while characters match word regex
* 4. Create a text selection spanning the word boundaries
*
@@ -3926,7 +5091,8 @@ export class PresentationEditor extends EventEmitter {
* @private
*/
#selectWordAt(pos: number): boolean {
- const state = this.#editor.state;
+ const activeEditor = this.getActiveEditor();
+ const state = activeEditor.state;
if (!state?.doc) {
return false;
}
@@ -3938,7 +5104,7 @@ export class PresentationEditor extends EventEmitter {
const tr = state.tr.setSelection(TextSelection.create(state.doc, range.from, range.to));
try {
- this.#editor.view?.dispatch(tr);
+ activeEditor.view?.dispatch(tr);
return true;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
@@ -3964,7 +5130,8 @@ export class PresentationEditor extends EventEmitter {
* @private
*/
#selectParagraphAt(pos: number): boolean {
- const state = this.#editor.state;
+ const activeEditor = this.getActiveEditor();
+ const state = activeEditor.state;
if (!state?.doc) {
return false;
}
@@ -3974,7 +5141,7 @@ export class PresentationEditor extends EventEmitter {
}
const tr = state.tr.setSelection(TextSelection.create(state.doc, range.from, range.to));
try {
- this.#editor.view?.dispatch(tr);
+ activeEditor.view?.dispatch(tr);
return true;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
@@ -4194,6 +5361,7 @@ export class PresentationEditor extends EventEmitter {
themeColors: this.#editor?.converter?.themeColors ?? undefined,
converterContext,
flowBlockCache: this.#flowBlockCache,
+ showBookmarks: this.#layoutOptions.showBookmarks ?? false,
...(positionMap ? { positions: positionMap } : {}),
...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}),
});
@@ -4228,16 +5396,29 @@ export class PresentationEditor extends EventEmitter {
const isSemanticFlow = this.#isSemanticFlowMode();
const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata);
+ const activeFootnoteOverride = this.#buildActiveNoteRenderOverride('footnote');
const footnotesLayoutInput = buildFootnotesInput(
this.#editor?.state,
(this.#editor as EditorWithConverter)?.converter,
converterContext,
this.#editor?.converter?.themeColors ?? undefined,
+ activeFootnoteOverride,
);
const semanticFootnoteBlocks = isSemanticFlow
? buildSemanticFootnoteBlocks(footnotesLayoutInput, this.#layoutOptions.semanticOptions?.footnotesMode)
: [];
- const blocksForLayout = semanticFootnoteBlocks.length > 0 ? [...blocks, ...semanticFootnoteBlocks] : blocks;
+ const activeEndnoteOverride = this.#buildActiveNoteRenderOverride('endnote');
+ const endnoteBlocks = buildEndnoteBlocks(
+ this.#editor?.state,
+ (this.#editor as EditorWithConverter)?.converter,
+ converterContext,
+ this.#editor?.converter?.themeColors ?? undefined,
+ activeEndnoteOverride,
+ );
+ const blocksForLayout =
+ semanticFootnoteBlocks.length > 0 || endnoteBlocks.length > 0
+ ? [...blocks, ...semanticFootnoteBlocks, ...endnoteBlocks]
+ : blocks;
const layoutOptions =
!isSemanticFlow && footnotesLayoutInput
? { ...baseLayoutOptions, footnotes: footnotesLayoutInput }
@@ -4249,10 +5430,14 @@ export class PresentationEditor extends EventEmitter {
let layout: Layout;
let measures: Measure[];
let resolvedLayout: ReturnType;
+ let bodyBlocksForPaint: FlowBlock[] = blocksForLayout;
+ let bodyMeasuresForPaint: Measure[] = [];
let headerLayouts: HeaderFooterLayoutResult[] | undefined;
let footerLayouts: HeaderFooterLayoutResult[] | undefined;
let extraBlocks: FlowBlock[] | undefined;
let extraMeasures: Measure[] | undefined;
+ let resolveBlocks: FlowBlock[] = blocksForLayout;
+ let resolveMeasures: Measure[] = previousMeasures;
const headerFooterInput = this.#buildHeaderFooterInput();
try {
const incrementalLayoutStart = perfNow();
@@ -4291,15 +5476,18 @@ export class PresentationEditor extends EventEmitter {
(layout as Layout & { layoutEpoch?: number }).layoutEpoch = layoutEpoch;
// Include footnote-injected blocks (separators, footnote paragraphs) so
- // resolveLayout can find them when resolving page fragments.
- const resolveBlocks = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout;
- const resolveMeasures = extraMeasures ? [...measures, ...extraMeasures] : measures;
+ // resolveLayout, painter lookups, and note/story navigation all operate
+ // on the same block/measure set.
+ bodyBlocksForPaint = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout;
+ bodyMeasuresForPaint = extraMeasures ? [...measures, ...extraMeasures] : measures;
+ resolveBlocks = bodyBlocksForPaint;
+ resolveMeasures = bodyMeasuresForPaint;
resolvedLayout = resolveLayout({
layout,
flowMode: this.#layoutOptions.flowMode ?? 'paginated',
- blocks: resolveBlocks,
- measures: resolveMeasures,
+ blocks: bodyBlocksForPaint,
+ measures: bodyMeasuresForPaint,
});
headerLayouts = result.headers;
@@ -4322,6 +5510,8 @@ export class PresentationEditor extends EventEmitter {
}
const anchorMap = computeAnchorMapFromHelper(bookmarks, layout, blocksForLayout);
this.#layoutState = { blocks: blocksForLayout, measures, layout, bookmarks, anchorMap };
+ this.#layoutLookupBlocks = resolveBlocks;
+ this.#layoutLookupMeasures = resolveMeasures;
// Build blockId โ pageNumber map for TOC page-number resolution.
// Stored on editor.storage so the document-api adapter layer can read it
@@ -4376,47 +5566,6 @@ export class PresentationEditor extends EventEmitter {
);
}
- // Extract header/footer blocks and measures from layout results
- const headerBlocks: FlowBlock[] = [];
- const headerMeasures: Measure[] = [];
- if (headerLayouts) {
- for (const headerResult of headerLayouts) {
- headerBlocks.push(...headerResult.blocks);
- headerMeasures.push(...headerResult.measures);
- }
- }
- // Also include per-rId header blocks for multi-section support
- const headerLayoutsByRId = this.#headerFooterSession?.headerLayoutsByRId;
- if (headerLayoutsByRId) {
- for (const rIdResult of headerLayoutsByRId.values()) {
- headerBlocks.push(...rIdResult.blocks);
- headerMeasures.push(...rIdResult.measures);
- }
- }
-
- const footerBlocks: FlowBlock[] = [];
- const footerMeasures: Measure[] = [];
- if (footerLayouts) {
- for (const footerResult of footerLayouts) {
- footerBlocks.push(...footerResult.blocks);
- footerMeasures.push(...footerResult.measures);
- }
- }
- // Also include per-rId footer blocks for multi-section support
- const footerLayoutsByRId = this.#headerFooterSession?.footerLayoutsByRId;
- if (footerLayoutsByRId) {
- for (const rIdResult of footerLayoutsByRId.values()) {
- footerBlocks.push(...rIdResult.blocks);
- footerMeasures.push(...rIdResult.measures);
- }
- }
-
- // Merge any extra lookup blocks (e.g., footnotes injected into page fragments)
- if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) {
- footerBlocks.push(...extraBlocks);
- footerMeasures.push(...extraMeasures);
- }
-
// Avoid MutationObserver overhead while repainting large DOM trees.
this.#domIndexObserverManager?.pause();
// Pass the transaction mapping for efficient position attribute updates.
@@ -4427,12 +5576,6 @@ export class PresentationEditor extends EventEmitter {
const paintInput: DomPainterInput = {
resolvedLayout,
sourceLayout: layout,
- blocks: blocksForLayout,
- measures,
- headerBlocks: headerBlocks.length > 0 ? headerBlocks : undefined,
- headerMeasures: headerMeasures.length > 0 ? headerMeasures : undefined,
- footerBlocks: footerBlocks.length > 0 ? footerBlocks : undefined,
- footerMeasures: footerMeasures.length > 0 ? footerMeasures : undefined,
};
this.#painterAdapter.paint(paintInput, this.#painterHost, mapping ?? undefined);
const painterPaintEnd = perfNow();
@@ -4469,11 +5612,7 @@ export class PresentationEditor extends EventEmitter {
// Emit fresh comment positions after layout completes.
// Always emit โ even when empty โ so the store can clear stale positions
// (e.g. when undo removes the last tracked-change mark).
- const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true;
- if (this.#documentMode !== 'viewing' || allowViewingCommentPositions) {
- const commentPositions = this.#collectCommentPositions();
- this.emit('commentPositions', { positions: commentPositions });
- }
+ this.#emitCommentPositions();
this.#selectionSync.requestRender({ immediate: true });
@@ -4990,12 +6129,23 @@ export class PresentationEditor extends EventEmitter {
// (virtualization remounts, layout completions) never set this flag, so
// they won't scroll the viewport to the caret โ only real user-initiated
// selection changes (keyboard, mouse, image click, zoom) will.
- const shouldScrollIntoView = this.#shouldScrollSelectionIntoView;
+ // Belt-and-suspenders: never scroll from this path while pointer-drag is active.
+ const shouldScrollIntoView = this.#shouldScrollSelectionIntoView && !this.#editorInputManager?.isDragging;
this.#shouldScrollSelectionIntoView = false;
+ const activeStorySession = this.#getActiveStorySession();
+ if (activeStorySession?.kind === 'headerFooter') {
+ this.#updateHeaderFooterSelection(shouldScrollIntoView);
+ return;
+ }
+ if (activeStorySession?.kind === 'note') {
+ this.#updateNoteSelection(shouldScrollIntoView);
+ return;
+ }
+
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
if (sessionMode !== 'body') {
- this.#updateHeaderFooterSelection();
+ this.#updateHeaderFooterSelection(shouldScrollIntoView);
return;
}
@@ -5596,9 +6746,42 @@ export class PresentationEditor extends EventEmitter {
return this.#headerFooterSession?.hitTestRegion(x, y, this.#layoutState.layout, pageIndex, pageLocalY) ?? null;
}
- #activateHeaderFooterRegion(region: HeaderFooterRegion) {
- // Delegate to session manager
- this.#headerFooterSession?.activateRegion(region);
+ #activateHeaderFooterRegion(
+ region: HeaderFooterRegion,
+ options?: { clientX: number; clientY: number; pageIndex?: number; source?: 'pointerDoubleClick' | 'programmatic' },
+ ) {
+ void this.#activateHeaderFooterRegionAtPoint(region, options);
+ }
+
+ async #activateHeaderFooterRegionAtPoint(
+ region: HeaderFooterRegion,
+ options?: { clientX: number; clientY: number; pageIndex?: number; source?: 'pointerDoubleClick' | 'programmatic' },
+ ): Promise {
+ const editor =
+ (await this.#headerFooterSession?.activateRegion(region, {
+ initialSelection: options ? 'defer' : 'end',
+ })) ?? null;
+
+ if (!editor || !options) {
+ return;
+ }
+
+ const doc = editor.state?.doc;
+ const hit = this.hitTest(options.clientX, options.clientY);
+ if (!doc || !hit) {
+ return;
+ }
+
+ try {
+ const selection = this.#createCollapsedSelectionNearInlineContent(doc, hit.pos);
+ const tr = editor.state.tr.setSelection(selection);
+ editor.view?.dispatch(tr);
+ editor.view?.focus?.();
+ this.#shouldScrollSelectionIntoView = true;
+ this.#scheduleSelectionUpdate({ immediate: true });
+ } catch {
+ // Ignore stale activation hits during rerender races.
+ }
}
#exitHeaderFooterMode() {
@@ -5610,101 +6793,379 @@ export class PresentationEditor extends EventEmitter {
this.#editor.view?.focus();
}
- #getActiveDomTarget(): HTMLElement | null {
- const session = this.#headerFooterSession?.session;
- if (session && session.mode !== 'body') {
- const activeEditor = this.#headerFooterSession?.activeEditor;
- return activeEditor?.view?.dom ?? this.#editor.view?.dom ?? null;
+ #buildNoteLayoutContext(target: RenderedNoteTarget | null | undefined): NoteLayoutContext | null {
+ const layout = this.#layoutState.layout;
+ if (!target || !layout) {
+ return null;
}
- return this.#editor.view?.dom ?? null;
- }
- #updateAwarenessSession() {
- const provider = this.#options.collaborationProvider;
- const awareness = provider?.awareness;
+ const blocks: FlowBlock[] = [];
+ const measures: Measure[] = [];
+ const noteBlockIds = new Set();
- // Runtime validation: ensure setLocalStateField method exists
- if (!awareness || typeof awareness.setLocalStateField !== 'function') {
- return;
- }
+ this.#layoutLookupBlocks.forEach((block, index) => {
+ const blockId = typeof block?.id === 'string' ? block.id : '';
+ const parsed = parseRenderedNoteTarget(blockId);
+ if (!parsed) {
+ return;
+ }
+ if (parsed.storyType !== target.storyType || parsed.noteId !== target.noteId) {
+ return;
+ }
+ blocks.push(block);
+ measures.push(this.#layoutLookupMeasures[index]);
+ noteBlockIds.add(blockId);
+ });
- const session = this.#headerFooterSession?.session;
- if (!session || session.mode === 'body') {
- awareness.setLocalStateField('layoutSession', null);
- return;
+ if (blocks.length === 0 || measures.length !== blocks.length) {
+ return null;
}
- awareness.setLocalStateField('layoutSession', {
- kind: session.kind,
- headerId: session.headerFooterRefId ?? null,
- pageNumber: session.pageNumber ?? null,
+
+ let firstPageIndex = -1;
+ let hostWidthPx = 0;
+
+ layout.pages.forEach((page, pageIndex) => {
+ page.fragments.forEach((fragment) => {
+ if (!noteBlockIds.has(fragment.blockId)) {
+ return;
+ }
+ if (firstPageIndex < 0) {
+ firstPageIndex = pageIndex;
+ }
+ const fragmentWidth = typeof fragment.width === 'number' ? fragment.width : 0;
+ hostWidthPx = Math.max(hostWidthPx, fragmentWidth);
+ });
});
- }
- #announce(message: string) {
- if (!this.#ariaLiveRegion) return;
- this.#ariaLiveRegion.textContent = message;
+ if (firstPageIndex < 0) {
+ firstPageIndex = 0;
+ }
+
+ if (!(hostWidthPx > 0)) {
+ const page = layout.pages[firstPageIndex];
+ const pageWidth = page?.size?.w ?? layout.pageSize.w ?? DEFAULT_PAGE_SIZE.w;
+ const margins = page?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS;
+ const marginLeft = margins.left ?? DEFAULT_MARGINS.left ?? 0;
+ const marginRight = margins.right ?? DEFAULT_MARGINS.right ?? 0;
+ hostWidthPx = Math.max(1, pageWidth - marginLeft - marginRight);
+ }
+
+ return {
+ target,
+ blocks,
+ measures,
+ firstPageIndex,
+ hostWidthPx: Math.max(1, hostWidthPx),
+ };
}
- #syncHiddenEditorA11yAttributes(): void {
- // Keep the hidden ProseMirror surface focusable and well-described for assistive technology.
- syncHiddenEditorA11yAttributesFromHelper(this.#editor?.view?.dom as unknown, this.#documentMode);
+ #buildActiveNoteLayoutContext(): NoteLayoutContext | null {
+ const session = this.#getActiveNoteStorySession();
+ if (!session) {
+ return null;
+ }
+ return this.#buildNoteLayoutContext({
+ storyType: session.locator.storyType,
+ noteId: session.locator.noteId,
+ });
}
- #scheduleA11ySelectionAnnouncement(options?: { immediate?: boolean }) {
- const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
- this.#a11ySelectionAnnounceTimeout = scheduleA11ySelectionAnnouncementFromHelper(
- {
- ariaLiveRegion: this.#ariaLiveRegion,
- sessionMode,
- isDragging: this.#editorInputManager?.isDragging ?? false,
- visibleHost: this.#visibleHost,
- currentTimeout: this.#a11ySelectionAnnounceTimeout,
- announceNow: () => {
- this.#a11ySelectionAnnounceTimeout = null;
- this.#announceSelectionNow();
- },
- },
- options,
+ #collectNoteBlockIds(context: NoteLayoutContext): Set {
+ return new Set(
+ context.blocks
+ .map((block) => (typeof block?.id === 'string' ? block.id : null))
+ .filter((blockId): blockId is string => !!blockId),
);
}
- #announceSelectionNow(): void {
- if (!this.#ariaLiveRegion) return;
- const announcement = computeA11ySelectionAnnouncementFromHelper(this.getActiveEditor().state);
- if (!announcement) return;
+ #resolveRenderedPageIndexForElement(element: HTMLElement): number {
+ const pageElement = element.closest('[data-page-index]');
+ const pageIndex = Number(pageElement?.dataset.pageIndex ?? 'NaN');
+ if (Number.isFinite(pageIndex) && pageIndex >= 0) {
+ return pageIndex;
+ }
- if (announcement.key === this.#a11yLastAnnouncedSelectionKey) {
- return;
+ const blockId = element.getAttribute('data-block-id') ?? '';
+ const layout = this.#layoutState.layout;
+ if (!blockId || !layout) {
+ return 0;
}
- this.#a11yLastAnnouncedSelectionKey = announcement.key;
- this.#announce(announcement.message);
- }
- #emitHeaderFooterEditBlocked(reason: string) {
- this.emit('headerFooterEditBlocked', { reason });
- }
+ for (let index = 0; index < layout.pages.length; index += 1) {
+ if (layout.pages[index]?.fragments?.some((fragment) => fragment.blockId === blockId)) {
+ return index;
+ }
+ }
- #resolveDescriptorForRegion(region: HeaderFooterRegion): HeaderFooterDescriptor | null {
- return this.#headerFooterSession?.resolveDescriptorForRegion(region) ?? null;
+ return 0;
}
- /**
- * Gets the DOM element for a specific page index.
- *
- * @param pageIndex - Zero-based page index
- * @returns The page element or null if not mounted
- */
- #getPageElement(pageIndex: number): HTMLElement | null {
- return getPageElementByIndex(this.#painterHost, pageIndex);
- }
+ #getRenderedNoteFragmentElements(noteBlockIds: ReadonlySet): HTMLElement[] {
+ if (!this.#viewportHost || noteBlockIds.size === 0) {
+ return [];
+ }
- #isSelectionAwareVirtualizationEnabled(): boolean {
- return Boolean(this.#layoutOptions.virtualization?.enabled && this.#layoutOptions.layoutMode === 'vertical');
+ return Array.from(this.#viewportHost.querySelectorAll('[data-block-id]')).filter((element) =>
+ noteBlockIds.has(element.getAttribute('data-block-id') ?? ''),
+ );
}
- #updateSelectionVirtualizationPins(options?: { includeDragBuffer?: boolean; extraPages?: number[] }): void {
- if (!this.#isSelectionAwareVirtualizationEnabled()) {
- return;
+ #findRenderedNoteFragmentAtPoint(
+ noteBlockIds: ReadonlySet,
+ clientX: number,
+ clientY: number,
+ ): RenderedNoteFragmentHit | null {
+ const doc = this.#viewportHost.ownerDocument ?? document;
+ const elementsFromPoint = typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint.bind(doc) : null;
+
+ const toFragmentHit = (element: Element | null): RenderedNoteFragmentHit | null => {
+ const fragmentElement = element instanceof HTMLElement ? element.closest('[data-block-id]') : null;
+ const blockId = fragmentElement?.getAttribute('data-block-id') ?? '';
+ if (!fragmentElement || !noteBlockIds.has(blockId)) {
+ return null;
+ }
+
+ return {
+ fragmentElement,
+ pageIndex: this.#resolveRenderedPageIndexForElement(fragmentElement),
+ };
+ };
+
+ if (elementsFromPoint) {
+ for (const element of elementsFromPoint(clientX, clientY)) {
+ const fragmentHit = toFragmentHit(element);
+ if (fragmentHit) {
+ return fragmentHit;
+ }
+ }
+ }
+
+ for (const fragmentElement of this.#getRenderedNoteFragmentElements(noteBlockIds)) {
+ const rect = fragmentElement.getBoundingClientRect();
+ if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
+ continue;
+ }
+
+ return {
+ fragmentElement,
+ pageIndex: this.#resolveRenderedPageIndexForElement(fragmentElement),
+ };
+ }
+
+ return null;
+ }
+
+ #resolveNoteDomHit(context: NoteLayoutContext, clientX: number, clientY: number): PositionHit | null {
+ const layout = this.#layoutState.layout;
+ if (!layout) {
+ return null;
+ }
+
+ const noteBlockIds = this.#collectNoteBlockIds(context);
+ if (noteBlockIds.size === 0) {
+ return null;
+ }
+
+ const fragmentHit = this.#findRenderedNoteFragmentAtPoint(noteBlockIds, clientX, clientY);
+ if (!fragmentHit) {
+ return null;
+ }
+
+ const pos = resolvePositionWithinFragmentDomFromDom(fragmentHit.fragmentElement, clientX, clientY);
+ if (pos == null) {
+ return null;
+ }
+
+ return {
+ pos,
+ layoutEpoch:
+ readLayoutEpochFromDomFromDom(fragmentHit.fragmentElement, clientX, clientY) ?? layout.layoutEpoch ?? 0,
+ blockId: fragmentHit.fragmentElement.getAttribute('data-block-id') ?? '',
+ pageIndex: fragmentHit.pageIndex,
+ column: 0,
+ lineIndex: -1,
+ };
+ }
+
+ #createCollapsedSelectionNearInlineContent(doc: ProseMirrorNode, pos: number): Selection {
+ const clampedPos = Math.max(0, Math.min(pos, doc.content.size));
+ const directSelection = TextSelection.create(doc, clampedPos);
+ if (directSelection.$from.parent.inlineContent) {
+ return directSelection;
+ }
+
+ const bias = clampedPos >= doc.content.size ? -1 : 1;
+ return Selection.near(doc.resolve(clampedPos), bias);
+ }
+
+ #activateRenderedNoteSession(
+ target: RenderedNoteTarget,
+ options: { clientX: number; clientY: number; pageIndex?: number },
+ ): boolean {
+ if ((this.#headerFooterSession?.session?.mode ?? 'body') !== 'body') {
+ this.#headerFooterSession?.exitMode();
+ }
+
+ const storySessionManager = this.#ensureStorySessionManager();
+
+ if (target.storyType !== 'footnote' && target.storyType !== 'endnote') {
+ return false;
+ }
+
+ const targetContext = this.#buildNoteLayoutContext(target);
+ const totalPageCount = this.#layoutState.layout?.pages?.length ?? 1;
+ const pageNumber = Math.max(1, (options.pageIndex ?? targetContext?.firstPageIndex ?? 0) + 1);
+
+ const session = storySessionManager.activate(
+ {
+ kind: 'story',
+ storyType: target.storyType,
+ noteId: target.noteId,
+ },
+ {
+ // Render from the active note session locally while typing, then persist
+ // the canonical notes part once when the session exits.
+ commitPolicy: 'onExit',
+ preferHiddenHost: true,
+ hostWidthPx: targetContext?.hostWidthPx ?? this.#visibleHost.clientWidth ?? 1,
+ editorContext: {
+ currentPageNumber: pageNumber,
+ totalPageCount: Math.max(1, totalPageCount),
+ surfaceKind: target.storyType === 'endnote' ? 'endnote' : 'note',
+ },
+ },
+ );
+
+ const hit = this.hitTest(options.clientX, options.clientY);
+ const doc = session.editor.state?.doc;
+ if (hit && doc) {
+ try {
+ const selection = this.#createCollapsedSelectionNearInlineContent(doc, hit.pos);
+ const tr = session.editor.state.tr.setSelection(selection);
+ session.editor.view?.dispatch(tr);
+ } catch {
+ // Ignore stale pointer hits during activation races.
+ }
+ }
+
+ session.editor.view?.focus();
+ this.#shouldScrollSelectionIntoView = true;
+ this.#scheduleSelectionUpdate({ immediate: true });
+ return true;
+ }
+
+ #exitActiveStorySession(): void {
+ const session = this.#getActiveStorySession();
+ if (!session) {
+ return;
+ }
+
+ this.#storySessionManager?.exit();
+ this.#pendingDocChange = true;
+ this.#scheduleRerender();
+ this.#editor.view?.focus();
+ }
+
+ #getActiveDomTarget(): HTMLElement | null {
+ // While a story session is active, forwarded input targets the session
+ // editor's DOM rather than the body's hidden editor DOM.
+ const storyTarget = this.#storySessionManager?.getActiveEditorDomTarget();
+ if (storyTarget) return storyTarget;
+
+ const session = this.#headerFooterSession?.session;
+ if (session && session.mode !== 'body') {
+ const activeEditor = this.#headerFooterSession?.activeEditor;
+ return activeEditor?.view?.dom ?? this.#editor.view?.dom ?? null;
+ }
+ return this.#editor.view?.dom ?? null;
+ }
+
+ #updateAwarenessSession() {
+ const provider = this.#options.collaborationProvider;
+ const awareness = provider?.awareness;
+
+ // Runtime validation: ensure setLocalStateField method exists
+ if (!awareness || typeof awareness.setLocalStateField !== 'function') {
+ return;
+ }
+
+ const session = this.#headerFooterSession?.session;
+ if (!session || session.mode === 'body') {
+ awareness.setLocalStateField('layoutSession', null);
+ return;
+ }
+ awareness.setLocalStateField('layoutSession', {
+ kind: session.kind,
+ headerId: session.headerFooterRefId ?? null,
+ pageNumber: session.pageNumber ?? null,
+ });
+ }
+
+ #announce(message: string) {
+ if (!this.#ariaLiveRegion) return;
+ this.#ariaLiveRegion.textContent = message;
+ }
+
+ #syncHiddenEditorA11yAttributes(): void {
+ // Keep the hidden ProseMirror surface focusable and well-described for assistive technology.
+ syncHiddenEditorA11yAttributesFromHelper(this.#editor?.view?.dom as unknown, this.#documentMode);
+ }
+
+ #scheduleA11ySelectionAnnouncement(options?: { immediate?: boolean }) {
+ const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
+ this.#a11ySelectionAnnounceTimeout = scheduleA11ySelectionAnnouncementFromHelper(
+ {
+ ariaLiveRegion: this.#ariaLiveRegion,
+ sessionMode,
+ isDragging: this.#editorInputManager?.isDragging ?? false,
+ visibleHost: this.#visibleHost,
+ currentTimeout: this.#a11ySelectionAnnounceTimeout,
+ announceNow: () => {
+ this.#a11ySelectionAnnounceTimeout = null;
+ this.#announceSelectionNow();
+ },
+ },
+ options,
+ );
+ }
+
+ #announceSelectionNow(): void {
+ if (!this.#ariaLiveRegion) return;
+ const announcement = computeA11ySelectionAnnouncementFromHelper(this.getActiveEditor().state);
+ if (!announcement) return;
+
+ if (announcement.key === this.#a11yLastAnnouncedSelectionKey) {
+ return;
+ }
+ this.#a11yLastAnnouncedSelectionKey = announcement.key;
+ this.#announce(announcement.message);
+ }
+
+ #emitHeaderFooterEditBlocked(reason: string) {
+ this.emit('headerFooterEditBlocked', { reason });
+ }
+
+ #resolveDescriptorForRegion(region: HeaderFooterRegion): HeaderFooterDescriptor | null {
+ return this.#headerFooterSession?.resolveDescriptorForRegion(region) ?? null;
+ }
+
+ /**
+ * Gets the DOM element for a specific page index.
+ *
+ * @param pageIndex - Zero-based page index
+ * @returns The page element or null if not mounted
+ */
+ #getPageElement(pageIndex: number): HTMLElement | null {
+ return getPageElementByIndex(this.#painterHost, pageIndex);
+ }
+
+ #isSelectionAwareVirtualizationEnabled(): boolean {
+ return Boolean(this.#layoutOptions.virtualization?.enabled && this.#layoutOptions.layoutMode === 'vertical');
+ }
+
+ #updateSelectionVirtualizationPins(options?: { includeDragBuffer?: boolean; extraPages?: number[] }): void {
+ if (!this.#isSelectionAwareVirtualizationEnabled()) {
+ return;
}
if (!this.#painterAdapter.hasPainter) {
return;
@@ -5839,8 +7300,24 @@ export class PresentationEditor extends EventEmitter {
yPosition += pageHeight + virtualGap;
}
- // Scroll viewport to the calculated position
- if (this.#visibleHost) {
+ // Scroll viewport to the calculated position.
+ //
+ // The authoritative scrollable ancestor is `#scrollContainer` โ setting
+ // scrollTop on the visible host alone is a no-op when the host is
+ // `overflow: visible` (the standard layout). Without this, anchor
+ // navigation (TOC clicks, cross-reference click-to-navigate under
+ // SD-2495) silently does nothing whenever the target page is outside
+ // the current viewport.
+ //
+ // We also write to `#visibleHost` for backwards compatibility: legacy
+ // layouts may make the visible host itself scrollable, and tests mock
+ // scrollTop on the host element.
+ if (this.#scrollContainer instanceof Window) {
+ this.#scrollContainer.scrollTo({ top: yPosition });
+ } else if (this.#scrollContainer) {
+ this.#scrollContainer.scrollTop = yPosition;
+ }
+ if (this.#visibleHost && this.#visibleHost !== this.#scrollContainer) {
this.#visibleHost.scrollTop = yPosition;
}
}
@@ -5898,7 +7375,11 @@ export class PresentationEditor extends EventEmitter {
return await this.#navigateToComment(target.entityId);
}
if (target.entityType === 'trackedChange') {
- return await this.#navigateToTrackedChange(target.entityId);
+ return await this.#navigateToTrackedChange(
+ target.entityId,
+ resolveStoryKeyFromAddress(target.story),
+ target.pageIndex,
+ );
}
}
@@ -5980,10 +7461,24 @@ export class PresentationEditor extends EventEmitter {
return true;
}
- async #navigateToTrackedChange(entityId: string): Promise {
+ async #navigateToTrackedChange(entityId: string, storyKey?: string, preferredPageIndex?: number): Promise {
const editor = this.#editor;
if (!editor) return false;
+ if (storyKey && storyKey !== BODY_STORY_KEY) {
+ if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) {
+ return true;
+ }
+
+ if (await this.#activateTrackedChangeStorySurface(entityId, storyKey, preferredPageIndex)) {
+ if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) {
+ return true;
+ }
+ }
+
+ return this.#scrollToRenderedTrackedChange(entityId, storyKey, preferredPageIndex);
+ }
+
const setCursorById = editor.commands?.setCursorById;
// Try direct cursor placement, then scroll to the new selection.
@@ -5994,7 +7489,9 @@ export class PresentationEditor extends EventEmitter {
// Fall back to resolving the tracked change position and scrolling.
const resolved = resolveTrackedChange(editor, entityId);
- if (!resolved) return false;
+ if (!resolved) {
+ return this.#scrollToRenderedTrackedChange(entityId, undefined, preferredPageIndex);
+ }
// Try with the raw ID (tracked changes may use a different internal ID).
if (typeof setCursorById === 'function' && resolved.rawId !== entityId) {
@@ -6016,6 +7513,162 @@ export class PresentationEditor extends EventEmitter {
return true;
}
+ async #activateTrackedChangeStorySurface(
+ entityId: string,
+ storyKey: string,
+ preferredPageIndex?: number,
+ ): Promise {
+ let locator: StoryLocator | null = null;
+ try {
+ locator = parseStoryKey(storyKey);
+ } catch {
+ return false;
+ }
+
+ if (!locator || locator.storyType === 'body') {
+ return false;
+ }
+
+ const candidate = this.#findRenderedTrackedChangeElement(entityId, storyKey, preferredPageIndex);
+ if (!candidate) {
+ return false;
+ }
+
+ const rect = candidate.getBoundingClientRect();
+ const clientX = rect.left + Math.max(rect.width / 2, 1);
+ const clientY = rect.top + Math.max(rect.height / 2, 1);
+ const pageIndex = this.#resolveRenderedPageIndexForElement(candidate);
+
+ if (locator.storyType === 'footnote' || locator.storyType === 'endnote') {
+ try {
+ if (
+ !this.#activateRenderedNoteSession(
+ {
+ storyType: locator.storyType,
+ noteId: locator.noteId,
+ },
+ { clientX, clientY, pageIndex },
+ )
+ ) {
+ return false;
+ }
+ } catch {
+ return false;
+ }
+
+ return this.#waitForTrackedChangeStorySurface(storyKey);
+ }
+
+ if (locator.storyType !== 'headerFooterPart') {
+ return false;
+ }
+
+ const pageElement = candidate.closest('.superdoc-page');
+ const pageRect = pageElement?.getBoundingClientRect();
+ const pageLocalY = pageRect ? clientY - pageRect.top : undefined;
+ const region = this.#hitTestHeaderFooterRegion(clientX, clientY, pageIndex, pageLocalY);
+ if (!region) {
+ return false;
+ }
+
+ this.#activateHeaderFooterRegion(region, {
+ clientX,
+ clientY,
+ pageIndex,
+ source: 'programmatic',
+ });
+ return this.#waitForTrackedChangeStorySurface(storyKey);
+ }
+
+ async #waitForTrackedChangeStorySurface(storyKey: string, timeoutMs = 500): Promise {
+ const deadline = Date.now() + timeoutMs;
+
+ while (Date.now() < deadline) {
+ if (this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey) {
+ return true;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 16));
+ }
+
+ return this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey;
+ }
+
+ #navigateToActiveStoryTrackedChange(entityId: string, storyKey: string): boolean {
+ const activeSurface = this.#getActiveTrackedChangeStorySurface();
+ if (!activeSurface || activeSurface.storyKey !== storyKey) {
+ return false;
+ }
+
+ const sessionEditor = activeSurface.editor;
+ const setCursorById = sessionEditor.commands?.setCursorById;
+
+ if (typeof setCursorById === 'function' && setCursorById(entityId, { preferredActiveThreadId: entityId })) {
+ this.#focusAndRevealActiveStorySelection(sessionEditor);
+ return true;
+ }
+
+ const resolved = resolveTrackedChange(sessionEditor, entityId);
+ if (!resolved) {
+ return false;
+ }
+
+ if (typeof setCursorById === 'function' && resolved.rawId !== entityId) {
+ if (setCursorById(resolved.rawId, { preferredActiveThreadId: resolved.rawId })) {
+ this.#focusAndRevealActiveStorySelection(sessionEditor);
+ return true;
+ }
+ }
+
+ sessionEditor.commands?.setTextSelection?.({ from: resolved.from, to: resolved.from });
+ this.#focusAndRevealActiveStorySelection(sessionEditor);
+ return true;
+ }
+
+ #focusAndRevealActiveStorySelection(editor: Editor): void {
+ editor.view?.focus?.();
+ this.#shouldScrollSelectionIntoView = true;
+ this.#scheduleSelectionUpdate({ immediate: true });
+ }
+
+ #findRenderedTrackedChangeElement(
+ entityId: string,
+ storyKey?: string,
+ preferredPageIndex?: number,
+ ): HTMLElement | null {
+ const candidates = this.#findRenderedTrackedChangeElements(entityId, storyKey);
+ if (!candidates.length) {
+ return null;
+ }
+
+ if (!Number.isFinite(preferredPageIndex)) {
+ return candidates[0] ?? null;
+ }
+
+ return (
+ candidates.find((candidate) => this.#resolveRenderedPageIndexForElement(candidate) === preferredPageIndex) ??
+ candidates[0] ??
+ null
+ );
+ }
+
+ async #scrollToRenderedTrackedChange(
+ entityId: string,
+ storyKey?: string,
+ preferredPageIndex?: number,
+ ): Promise {
+ const candidate = this.#findRenderedTrackedChangeElement(entityId, storyKey, preferredPageIndex);
+ if (!candidate) {
+ return false;
+ }
+
+ try {
+ candidate.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
/**
* Navigate to a bookmark/anchor in the current document (e.g., TOC links).
*
@@ -6213,6 +7866,155 @@ export class PresentationEditor extends EventEmitter {
return this.#headerFooterSession?.computeSelectionRects(from, to) ?? [];
}
+ #computeHeaderFooterCaretRect(pos: number): LayoutRect | null {
+ return this.#headerFooterSession?.computeCaretRect(pos) ?? null;
+ }
+
+ /**
+ * Translate an active hidden-editor position into a visible-text offset.
+ *
+ * `domAtPos()` gives the correct DOM boundary inside the hidden note editor,
+ * even when the PM position sits inside tracked-change wrapper structure. We
+ * then measure that boundary as visible text so it can be projected onto the
+ * painted note surface without relying on raw PM ranges.
+ */
+ #measureActiveEditorVisibleTextOffset(pos: number): number | null {
+ if (!Number.isFinite(pos)) {
+ return null;
+ }
+
+ const activeEditor = this.getActiveEditor();
+ const view = activeEditor?.view;
+ const root = view?.dom as HTMLElement | null;
+ if (!view || !root) {
+ return null;
+ }
+
+ try {
+ const domPoint = view.domAtPos(pos);
+ if (!domPoint?.node) {
+ return null;
+ }
+
+ return measureVisibleTextOffsetFromHelper(root, domPoint.node, domPoint.offset);
+ } catch (error) {
+ if (process.env.NODE_ENV === 'development') {
+ console.warn('[PresentationEditor] Failed to measure active editor visible text offset:', error);
+ }
+ return null;
+ }
+ }
+
+ #computeNoteSelectionRectsFromDom(context: NoteLayoutContext, from: number, to: number): LayoutRect[] | null {
+ const layout = this.#layoutState.layout;
+ if (!layout) {
+ return null;
+ }
+
+ const startOffset = this.#measureActiveEditorVisibleTextOffset(Math.min(from, to));
+ const endOffset = this.#measureActiveEditorVisibleTextOffset(Math.max(from, to));
+ if (startOffset == null || endOffset == null) {
+ return null;
+ }
+
+ const noteFragments = this.#getRenderedNoteFragmentElements(this.#collectNoteBlockIds(context));
+ if (!noteFragments.length) {
+ return null;
+ }
+
+ return computeSelectionRectsFromVisibleTextOffsetsFromHelper(
+ {
+ containers: noteFragments,
+ zoom: this.#layoutOptions.zoom ?? 1,
+ pageHeight: this.#getBodyPageHeight(),
+ pageGap: layout.pageGap ?? this.#getEffectivePageGap(),
+ },
+ startOffset,
+ endOffset,
+ );
+ }
+
+ #computeNoteSelectionRects(from: number, to: number): LayoutRect[] | null {
+ const context = this.#buildActiveNoteLayoutContext();
+ const layout = this.#layoutState.layout;
+ if (!context || !layout) {
+ return null;
+ }
+
+ const domRects = this.#computeNoteSelectionRectsFromDom(context, from, to);
+ if (domRects != null) {
+ return domRects;
+ }
+
+ return selectionToRects(layout, context.blocks, context.measures, from, to, this.#pageGeometryHelper ?? undefined);
+ }
+
+ #computeNoteDomCaretRect(context: NoteLayoutContext, pos: number): LayoutRect | null {
+ const layout = this.#layoutState.layout;
+ if (!layout) {
+ return null;
+ }
+
+ const noteBlockIds = this.#collectNoteBlockIds(context);
+ if (noteBlockIds.size === 0) {
+ return null;
+ }
+
+ const textOffset = this.#measureActiveEditorVisibleTextOffset(pos);
+ if (textOffset == null) {
+ return null;
+ }
+
+ return computeCaretRectFromVisibleTextOffsetFromHelper(
+ {
+ containers: this.#getRenderedNoteFragmentElements(noteBlockIds),
+ zoom: this.#layoutOptions.zoom ?? 1,
+ pageHeight: this.#getBodyPageHeight(),
+ pageGap: layout.pageGap ?? this.#getEffectivePageGap(),
+ },
+ textOffset,
+ );
+ }
+
+ #computeNoteCaretRect(pos: number): LayoutRect | null {
+ const context = this.#buildActiveNoteLayoutContext();
+ const layout = this.#layoutState.layout;
+ if (!context || !layout) {
+ return null;
+ }
+
+ const domRect = this.#computeNoteDomCaretRect(context, pos);
+ if (domRect) {
+ return domRect;
+ }
+
+ const geometry = computeCaretLayoutRectGeometryFromHelper(
+ {
+ layout,
+ blocks: context.blocks,
+ measures: context.measures,
+ painterHost: this.#painterHost,
+ viewportHost: this.#viewportHost,
+ visibleHost: this.#visibleHost,
+ zoom: this.#layoutOptions.zoom ?? 1,
+ },
+ pos,
+ false,
+ );
+ if (!geometry) {
+ return null;
+ }
+
+ const pageStride = this.#getBodyPageHeight() + (layout.pageGap ?? 0);
+ return {
+ pageIndex: geometry.pageIndex,
+ x: geometry.x,
+ y: geometry.pageIndex * pageStride + geometry.y,
+ width: 1,
+ height: geometry.height,
+ };
+ }
+
#syncTrackedChangesPreferences(): boolean {
const mode = this.#deriveTrackedChangesMode();
const enabled = this.#deriveTrackedChangesEnabled();
@@ -6224,6 +8026,13 @@ export class PresentationEditor extends EventEmitter {
return hasChanged;
}
+ #syncHeaderFooterTrackedChangesRenderConfig(): void {
+ this.#headerFooterSession?.setTrackedChangesRenderConfig({
+ mode: this.#trackedChangesMode,
+ enabled: this.#trackedChangesEnabled,
+ });
+ }
+
#deriveTrackedChangesMode(): TrackedChangesMode {
const overrideMode = this.#trackedChangesOverrides?.mode;
if (overrideMode) {
@@ -6704,6 +8513,21 @@ export class PresentationEditor extends EventEmitter {
if (session && session.mode !== 'body') {
return session.pageIndex ?? 0;
}
+ if (this.#getActiveNoteStorySession()) {
+ const selection = this.getActiveEditor().state?.selection;
+ if (!selection) {
+ return this.#buildActiveNoteLayoutContext()?.firstPageIndex ?? 0;
+ }
+ const rects = this.#computeNoteSelectionRects(selection.from, selection.to) ?? [];
+ if (rects.length > 0) {
+ return rects[0]?.pageIndex ?? 0;
+ }
+ return (
+ this.#computeNoteCaretRect(selection.from)?.pageIndex ??
+ this.#buildActiveNoteLayoutContext()?.firstPageIndex ??
+ 0
+ );
+ }
const layout = this.#layoutState.layout;
const selection = this.#editor.state?.selection;
if (!layout || !selection) {
@@ -6828,10 +8652,10 @@ export class PresentationEditor extends EventEmitter {
* selection rectangles in layout space, then renders them into the shared
* selection overlay so selection behaves consistently with body content.
*
- * Caret rendering is left to the ProseMirror header/footer editor; this
- * overlay only mirrors non-collapsed selections.
+ * In hidden-host mode this also renders the caret from the active story
+ * editor's hidden DOM geometry.
*/
- #updateHeaderFooterSelection() {
+ #updateHeaderFooterSelection(shouldScrollIntoView = false) {
this.#clearSelectedFieldAnnotationClass();
if (!this.#localSelectionLayer) {
@@ -6849,11 +8673,35 @@ export class PresentationEditor extends EventEmitter {
const { from, to } = selection;
- // Let the header/footer ProseMirror editor handle caret rendering.
if (from === to) {
+ const caretRect = this.#computeHeaderFooterCaretRect(from);
+ if (!caretRect) {
+ try {
+ this.#localSelectionLayer.innerHTML = '';
+ } catch {}
+ return;
+ }
+
try {
this.#localSelectionLayer.innerHTML = '';
- } catch {}
+ renderCaretOverlay({
+ localSelectionLayer: this.#localSelectionLayer,
+ caretLayout: {
+ pageIndex: caretRect.pageIndex,
+ x: caretRect.x,
+ y: caretRect.y - caretRect.pageIndex * this.#getBodyPageHeight(),
+ height: caretRect.height,
+ },
+ convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y),
+ });
+ } catch (error) {
+ if (process.env.NODE_ENV === 'development') {
+ console.warn('[PresentationEditor] Failed to render header/footer caret:', error);
+ }
+ }
+ if (shouldScrollIntoView) {
+ this.#scrollActiveEndIntoView(caretRect.pageIndex);
+ }
return;
}
@@ -6883,6 +8731,94 @@ export class PresentationEditor extends EventEmitter {
console.warn('[PresentationEditor] Failed to render header/footer selection rects:', error);
}
}
+
+ if (shouldScrollIntoView) {
+ const selectionHead = activeEditor?.state?.selection?.head ?? to;
+ const headCaretRect = this.#computeHeaderFooterCaretRect(selectionHead);
+ const headPageIndex = headCaretRect?.pageIndex ?? rects.at(-1)?.pageIndex ?? rects[0]?.pageIndex;
+ if (Number.isFinite(headPageIndex)) {
+ this.#scrollActiveEndIntoView(headPageIndex!);
+ }
+ }
+ }
+
+ #updateNoteSelection(shouldScrollIntoView = false) {
+ this.#clearSelectedFieldAnnotationClass();
+
+ if (!this.#localSelectionLayer) {
+ return;
+ }
+
+ const activeEditor = this.getActiveEditor();
+ const selection = activeEditor?.state?.selection;
+ if (!selection) {
+ try {
+ this.#localSelectionLayer.innerHTML = '';
+ } catch {}
+ return;
+ }
+
+ const { from, to } = selection;
+
+ if (from === to) {
+ const caretRect = this.#computeNoteCaretRect(from);
+ if (!caretRect) {
+ return;
+ }
+
+ try {
+ this.#localSelectionLayer.innerHTML = '';
+ renderCaretOverlay({
+ localSelectionLayer: this.#localSelectionLayer,
+ caretLayout: {
+ pageIndex: caretRect.pageIndex,
+ x: caretRect.x,
+ y:
+ caretRect.y -
+ caretRect.pageIndex * (this.#getBodyPageHeight() + (this.#layoutState.layout?.pageGap ?? 0)),
+ height: caretRect.height,
+ },
+ convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y),
+ });
+ } catch (error) {
+ if (process.env.NODE_ENV === 'development') {
+ console.warn('[PresentationEditor] Failed to render note caret:', error);
+ }
+ }
+ if (shouldScrollIntoView) {
+ this.#scrollActiveEndIntoView(caretRect.pageIndex);
+ }
+ return;
+ }
+
+ const rects = this.#computeNoteSelectionRects(from, to);
+ if (rects == null || !rects.length) {
+ return;
+ }
+
+ try {
+ this.#localSelectionLayer.innerHTML = '';
+ renderSelectionRects({
+ localSelectionLayer: this.#localSelectionLayer,
+ rects,
+ pageHeight: this.#getBodyPageHeight(),
+ pageGap: this.#layoutState.layout?.pageGap ?? 0,
+ convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y),
+ });
+ } catch (error) {
+ if (process.env.NODE_ENV === 'development') {
+ console.warn('[PresentationEditor] Failed to render note selection rects:', error);
+ }
+ }
+
+ if (shouldScrollIntoView) {
+ const selectionHead = activeEditor?.state?.selection?.head ?? to;
+ const headCaretRect = this.#computeNoteCaretRect(selectionHead);
+ const headPageIndex = headCaretRect?.pageIndex ?? rects.at(-1)?.pageIndex ?? rects[0]?.pageIndex;
+ if (Number.isFinite(headPageIndex)) {
+ this.#scrollActiveEndIntoView(headPageIndex!);
+ }
+ }
}
#dismissErrorBanner() {
@@ -6920,3 +8856,28 @@ export class PresentationEditor extends EventEmitter {
return this.#documentMode === 'viewing';
}
}
+
+function escapeAttrValue(value: string): string {
+ const cssApi =
+ typeof globalThis === 'object' && globalThis && 'CSS' in globalThis
+ ? (globalThis.CSS as { escape?: (input: string) => string } | undefined)
+ : undefined;
+
+ if (typeof cssApi?.escape === 'function') {
+ return cssApi.escape(value);
+ }
+
+ return value.replace(/["\\]/g, (char) => `\\${char}`);
+}
+
+function resolveStoryKeyFromAddress(story: StoryLocator | unknown): string | undefined {
+ if (!isStoryLocator(story)) {
+ return undefined;
+ }
+
+ try {
+ return buildStoryKey(story);
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts
index 48826151b1..fd1ae02e1e 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts
@@ -43,9 +43,6 @@ describe('ensureEditorNativeSelectionStyles', () => {
expect(css).toContain('.superdoc-layout *::selection');
expect(css).toContain('.superdoc-layout *::-moz-selection');
expect(css).toContain('background: transparent');
- expect(css).toContain('.superdoc-layout .superdoc-header-editor-host *::selection');
- expect(css).toContain('.superdoc-layout .superdoc-footer-editor-host *::selection');
- expect(css).toContain('color: HighlightText');
});
});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts
index 05c07bcb33..308be64218 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts
@@ -31,22 +31,6 @@ const NATIVE_SELECTION_STYLES = `
background: transparent;
}
-/* Keep native selection visible inside live header/footer editors.
- * Unlike the main document surface, header/footer editing uses a visible
- * ProseMirror host. If we suppress native selection there, users can end up
- * with no obvious selection feedback when the custom overlay is subtle or
- * still syncing to the current drag gesture. */
-.superdoc-layout .superdoc-header-editor-host *::selection,
-.superdoc-layout .superdoc-footer-editor-host *::selection {
- background: Highlight;
- color: HighlightText;
-}
-
-.superdoc-layout .superdoc-header-editor-host *::-moz-selection,
-.superdoc-layout .superdoc-footer-editor-host *::-moz-selection {
- background: Highlight;
- color: HighlightText;
-}
`;
let nativeSelectionStylesInjected = false;
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
index 698f155d34..f5adc628c6 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
@@ -4,15 +4,27 @@
* This class encapsulates all the state and logic for:
* - Header/footer region tracking and hit testing
* - Session state machine (body/header/footer modes)
- * - Editor overlay management for H/F editing
+ * - Hidden-host story-session coordination for H/F editing
* - Decoration providers for rendering
* - Hover UI for edit affordances
*
* @module presentation-editor/header-footer/HeaderFooterSessionManager
*/
-import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts';
+import type {
+ Layout,
+ FlowBlock,
+ Measure,
+ Page,
+ SectionMetadata,
+ Fragment,
+ ResolvedHeaderFooterLayout,
+ ResolvedPaintItem,
+} from '@superdoc/contracts';
import type { PageDecorationProvider } from '@superdoc/painter-dom';
+import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved';
+import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api';
+import { DOM_CLASS_NAMES } from '@superdoc/dom-contract';
import type { Editor } from '../../Editor.js';
import type {
@@ -27,8 +39,8 @@ import {
HeaderFooterEditorManager,
HeaderFooterLayoutAdapter,
type HeaderFooterDescriptor,
+ type HeaderFooterTrackedChangesRenderConfig,
} from '../../header-footer/HeaderFooterRegistry.js';
-import { EditorOverlayManager } from '../../header-footer/EditorOverlayManager.js';
import { initHeaderFooterRegistry } from '../../header-footer/HeaderFooterRegistryInit.js';
import { layoutPerRIdHeaderFooters } from '../../header-footer/HeaderFooterPerRidLayout.js';
import {
@@ -37,13 +49,16 @@ import {
getHeaderFooterTypeForSection,
getBucketForPageNumber,
getBucketRepresentative,
+ buildSectionAwareHeaderFooterLayoutKey,
type HeaderFooterIdentifier,
type HeaderFooterLayoutResult,
type MultiSectionHeaderFooterIdentifier,
type HeaderFooterConstraints,
} from '@superdoc/layout-bridge';
+import { selectionToRects } from '@superdoc/layout-bridge';
import { deduplicateOverlappingRects } from '../../../dom-observer/DomSelectionGeometry.js';
import { resolveSectionProjections } from '../../../document-api-adapters/helpers/sections-resolver.js';
+import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from '../selection/CaretGeometry.js';
import {
ensureExplicitHeaderFooterSlot,
normalizeVariant,
@@ -53,6 +68,155 @@ import {
// Types
// =============================================================================
+type SurfacePmEntry = {
+ pmStart: number;
+ pmEnd: number;
+ el: HTMLElement;
+};
+
+function buildSurfacePmEntries(surface: HTMLElement): SurfacePmEntry[] {
+ const nodes = Array.from(surface.querySelectorAll('[data-pm-start][data-pm-end]'));
+ const nonLeaf = new WeakSet();
+ const nodeSet = new WeakSet();
+ nodes.forEach((node) => nodeSet.add(node));
+
+ for (const node of nodes) {
+ let parent = node.parentElement;
+ while (parent && parent !== surface) {
+ if (nodeSet.has(parent)) {
+ nonLeaf.add(parent);
+ }
+ parent = parent.parentElement;
+ }
+ }
+
+ const entries: SurfacePmEntry[] = [];
+ for (const node of nodes) {
+ if (node.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER)) {
+ continue;
+ }
+ if (nonLeaf.has(node)) {
+ continue;
+ }
+
+ const pmStart = Number(node.dataset.pmStart ?? 'NaN');
+ const pmEnd = Number(node.dataset.pmEnd ?? 'NaN');
+ if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd) || pmEnd < pmStart) {
+ continue;
+ }
+
+ entries.push({ pmStart, pmEnd, el: node });
+ }
+
+ entries.sort((a, b) => (a.pmStart - b.pmStart !== 0 ? a.pmStart - b.pmStart : a.pmEnd - b.pmEnd));
+ return entries;
+}
+
+function findSurfaceEntriesInRange(
+ entries: SurfacePmEntry[],
+ from: number,
+ to: number,
+ options?: { boundaryInclusive?: boolean },
+): SurfacePmEntry[] {
+ if (!Number.isFinite(from) || !Number.isFinite(to) || entries.length === 0) {
+ return [];
+ }
+
+ const start = Math.min(from, to);
+ const end = Math.max(from, to);
+ if (start === end) {
+ return [];
+ }
+
+ const boundaryInclusive = options?.boundaryInclusive === true;
+ return entries.filter((entry) =>
+ boundaryInclusive ? entry.pmStart <= end && entry.pmEnd >= start : entry.pmStart < end && entry.pmEnd > start,
+ );
+}
+
+function findSurfaceEntryAtPos(entries: SurfacePmEntry[], pos: number): SurfacePmEntry | null {
+ if (!Number.isFinite(pos) || entries.length === 0) {
+ return null;
+ }
+
+ const exactEntry = entries.find((entry) => pos >= entry.pmStart && pos <= entry.pmEnd);
+ if (exactEntry) {
+ return exactEntry;
+ }
+
+ const nextEntry = entries.find((entry) => pos < entry.pmStart);
+ if (nextEntry) {
+ return nextEntry;
+ }
+
+ return entries[entries.length - 1] ?? null;
+}
+
+function mapPmPosToTextOffset(pos: number, pmStart: number, pmEnd: number, textLength: number): number {
+ if (!Number.isFinite(pos) || !Number.isFinite(pmStart) || !Number.isFinite(pmEnd) || textLength <= 0) {
+ return 0;
+ }
+
+ const pmRange = pmEnd - pmStart;
+ if (!Number.isFinite(pmRange) || pmRange <= 0) {
+ return 0;
+ }
+
+ if (pmRange === textLength) {
+ return Math.min(textLength, Math.max(0, pos - pmStart));
+ }
+
+ if (pos <= pmStart) {
+ return 0;
+ }
+ if (pos >= pmEnd) {
+ return textLength;
+ }
+
+ const midpoint = pmStart + pmRange / 2;
+ return pos <= midpoint ? 0 : textLength;
+}
+
+function setSurfaceRangeStart(range: Range, entry: SurfacePmEntry, pos: number): boolean {
+ const textNode = entry.el.firstChild;
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
+ range.setStart(textNode, mapPmPosToTextOffset(pos, entry.pmStart, entry.pmEnd, (textNode as Text).length));
+ return true;
+ }
+
+ if (!entry.el.isConnected || !entry.el.parentNode) {
+ return false;
+ }
+
+ if (pos <= entry.pmStart) {
+ range.setStartBefore(entry.el);
+ return true;
+ }
+
+ range.setStartAfter(entry.el);
+ return true;
+}
+
+function setSurfaceRangeEnd(range: Range, entry: SurfacePmEntry, pos: number): boolean {
+ const textNode = entry.el.firstChild;
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
+ range.setEnd(textNode, mapPmPosToTextOffset(pos, entry.pmStart, entry.pmEnd, (textNode as Text).length));
+ return true;
+ }
+
+ if (!entry.el.isConnected || !entry.el.parentNode) {
+ return false;
+ }
+
+ if (pos <= entry.pmStart) {
+ range.setEndBefore(entry.el);
+ return true;
+ }
+
+ range.setEndAfter(entry.el);
+ return true;
+}
+
/**
* Options for initializing the HeaderFooterSessionManager.
*/
@@ -135,6 +299,11 @@ export type SessionManagerDependencies = {
setPendingDocChange: () => void;
/** Get total page count from body layout */
getBodyPageCount: () => number;
+ /** Get the generic story-session manager when enabled */
+ getStorySessionManager?: () => {
+ activate: (locator: HeaderFooterPartStoryLocator, options?: Record) => { editor: Editor };
+ exit: () => void;
+ } | null;
};
/**
@@ -176,6 +345,59 @@ export type SessionManagerCallbacks = {
}) => void;
};
+type HeaderFooterActivationOptions = {
+ initialSelection?: 'end' | 'defer';
+};
+
+// =============================================================================
+// Helpers
+// =============================================================================
+
+/**
+ * Resolve a `HeaderFooterLayoutResult` into a `ResolvedHeaderFooterLayout`.
+ * Paired with the originals so the decoration provider can deliver aligned
+ * `items` alongside `fragments`.
+ */
+function resolveResult(result: HeaderFooterLayoutResult): ResolvedHeaderFooterLayout {
+ return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures);
+}
+
+function shiftResolvedPaintItemY(item: ResolvedPaintItem, yOffset: number): ResolvedPaintItem {
+ if (item.kind === 'group') {
+ return {
+ ...item,
+ y: item.y + yOffset,
+ children: item.children.map((child) => shiftResolvedPaintItemY(child, yOffset)),
+ };
+ }
+
+ return {
+ ...item,
+ y: item.y + yOffset,
+ };
+}
+
+function normalizeDecorationFragments(fragments: Fragment[], layoutMinY: number): Fragment[] {
+ if (layoutMinY >= 0) {
+ return fragments;
+ }
+
+ const yOffset = -layoutMinY;
+ return fragments.map((fragment) => ({ ...fragment, y: fragment.y + yOffset }));
+}
+
+function normalizeDecorationItems(
+ items: ResolvedPaintItem[] | undefined,
+ layoutMinY: number,
+): ResolvedPaintItem[] | undefined {
+ if (!items || layoutMinY >= 0) {
+ return items;
+ }
+
+ const yOffset = -layoutMinY;
+ return items.map((item) => shiftResolvedPaintItemY(item, yOffset));
+}
+
// =============================================================================
// HeaderFooterSessionManager
// =============================================================================
@@ -194,7 +416,6 @@ export class HeaderFooterSessionManager {
#headerFooterAdapter: HeaderFooterLayoutAdapter | null = null;
#headerFooterIdentifier: HeaderFooterIdentifier | null = null;
#multiSectionIdentifier: MultiSectionHeaderFooterIdentifier | null = null;
- #overlayManager: EditorOverlayManager | null = null;
#managerCleanups: Array<() => void> = [];
// Layout results
@@ -203,6 +424,12 @@ export class HeaderFooterSessionManager {
#headerLayoutsByRId: Map = new Map();
#footerLayoutsByRId: Map = new Map();
+ // Resolved layouts (aligned 1:1 with the results above)
+ #resolvedHeaderLayouts: ResolvedHeaderFooterLayout[] | null = null;
+ #resolvedFooterLayouts: ResolvedHeaderFooterLayout[] | null = null;
+ #resolvedHeaderByRId: Map = new Map();
+ #resolvedFooterByRId: Map = new Map();
+
// Decoration providers
#headerDecorationProvider: PageDecorationProvider | undefined;
#footerDecorationProvider: PageDecorationProvider | undefined;
@@ -220,10 +447,15 @@ export class HeaderFooterSessionManager {
#hoverOverlay: HTMLElement | null = null;
#hoverTooltip: HTMLElement | null = null;
#modeBanner: HTMLElement | null = null;
+ #activeBorderLine: HTMLElement | null = null;
#hoverRegion: HeaderFooterRegion | null = null;
// Document mode
#documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing';
+ #trackedChangesRenderConfig: HeaderFooterTrackedChangesRenderConfig = {
+ mode: 'review',
+ enabled: true,
+ };
constructor(options: HeaderFooterSessionManagerOptions) {
this.#options = options;
@@ -318,11 +550,6 @@ export class HeaderFooterSessionManager {
});
}
- /** Editor overlay manager */
- get overlayManager(): EditorOverlayManager | null {
- return this.#overlayManager;
- }
-
/** Header layout results */
get headerLayoutResults(): HeaderFooterLayoutResult[] | null {
return this.#headerLayoutResults;
@@ -331,6 +558,7 @@ export class HeaderFooterSessionManager {
/** Set header layout results */
set headerLayoutResults(results: HeaderFooterLayoutResult[] | null) {
this.#headerLayoutResults = results;
+ this.#resolvedHeaderLayouts = results ? results.map(resolveResult) : null;
}
/** Footer layout results */
@@ -341,6 +569,7 @@ export class HeaderFooterSessionManager {
/** Set footer layout results */
set footerLayoutResults(results: HeaderFooterLayoutResult[] | null) {
this.#footerLayoutResults = results;
+ this.#resolvedFooterLayouts = results ? results.map(resolveResult) : null;
}
/** Header layouts by rId */
@@ -420,6 +649,26 @@ export class HeaderFooterSessionManager {
*/
setDocumentMode(mode: 'editing' | 'viewing' | 'suggesting'): void {
this.#documentMode = mode;
+ if (this.#activeEditor) {
+ this.#applyChildEditorDocumentMode(this.#activeEditor, mode);
+ }
+ }
+
+ setTrackedChangesRenderConfig(config: HeaderFooterTrackedChangesRenderConfig): void {
+ const nextConfig: HeaderFooterTrackedChangesRenderConfig = {
+ mode: config.mode,
+ enabled: config.enabled,
+ };
+
+ if (
+ this.#trackedChangesRenderConfig.mode === nextConfig.mode &&
+ this.#trackedChangesRenderConfig.enabled === nextConfig.enabled
+ ) {
+ return;
+ }
+
+ this.#trackedChangesRenderConfig = nextConfig;
+ this.#headerFooterAdapter?.setTrackedChangesRenderConfig(nextConfig);
}
/**
@@ -431,6 +680,8 @@ export class HeaderFooterSessionManager {
): void {
this.#headerLayoutResults = headerResults;
this.#footerLayoutResults = footerResults;
+ this.#resolvedHeaderLayouts = headerResults ? headerResults.map(resolveResult) : null;
+ this.#resolvedFooterLayouts = footerResults ? footerResults.map(resolveResult) : null;
}
/**
@@ -451,9 +702,6 @@ export class HeaderFooterSessionManager {
const mediaFiles = optionsMedia ?? storageMedia;
const result = initHeaderFooterRegistry({
- painterHost: this.#options.painterHost,
- visibleHost: this.#options.visibleHost,
- selectionOverlay: this.#options.selectionOverlay,
editor: this.#options.editor,
converter,
mediaFiles,
@@ -470,19 +718,15 @@ export class HeaderFooterSessionManager {
this.#deps?.setPendingDocChange();
this.#deps?.scheduleRerender();
},
- exitHeaderFooterMode: () => {
- this.exitMode();
- },
previousCleanups: this.#managerCleanups,
previousAdapter: this.#headerFooterAdapter,
previousManager: this.#headerFooterManager,
- previousOverlayManager: this.#overlayManager,
});
- this.#overlayManager = result.overlayManager;
this.#headerFooterIdentifier = result.headerFooterIdentifier;
this.#headerFooterManager = result.headerFooterManager;
this.#headerFooterAdapter = result.headerFooterAdapter;
+ this.#headerFooterAdapter?.setTrackedChangesRenderConfig(this.#trackedChangesRenderConfig);
this.#managerCleanups = result.cleanups;
}
@@ -574,6 +818,8 @@ export class HeaderFooterSessionManager {
if (!region.sectionId) console.error('[HeaderFooterSessionManager] Footer region missing sectionId', region);
}
}
+
+ this.#syncActiveBorder();
}
/**
@@ -708,13 +954,13 @@ export class HeaderFooterSessionManager {
/**
* Activate a header/footer region for editing.
*/
- activateRegion(region: HeaderFooterRegion): void {
+ activateRegion(region: HeaderFooterRegion, options?: HeaderFooterActivationOptions): Promise {
const permission = this.#validateEditPermission();
if (!permission.allowed) {
this.#callbacks.onEditBlocked?.(permission.reason ?? 'restricted');
- return;
+ return Promise.resolve(null);
}
- void this.#enterMode(region);
+ return this.#enterMode(region, options);
}
/**
@@ -725,15 +971,11 @@ export class HeaderFooterSessionManager {
// Capture headerFooterRefId before clearing session - needed for cache invalidation
const editedHeaderId = this.#session.headerFooterRefId;
-
if (this.#activeEditor) {
- this.#activeEditor.setEditable(false);
- this.#activeEditor.setOptions({ documentMode: 'viewing' });
+ this.#applyChildEditorDocumentMode(this.#activeEditor, 'viewing');
}
this.#teardownActiveEditorEventBridge();
-
- this.#overlayManager?.hideEditingOverlay();
- this.#overlayManager?.showSelectionOverlay();
+ this.#deps?.getStorySessionManager?.()?.exit();
this.#activeEditor = null;
this.#session = { mode: 'body' };
@@ -765,21 +1007,53 @@ export class HeaderFooterSessionManager {
this.activateRegion(region);
}
- async #enterMode(region: HeaderFooterRegion): Promise {
+ #activateStorySessionForRegion(region: HeaderFooterRegion, descriptor: HeaderFooterDescriptor): Editor | null {
+ const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null;
+ if (!storySessionManager) {
+ return null;
+ }
+
+ const locator: HeaderFooterPartStoryLocator = {
+ kind: 'story',
+ storyType: 'headerFooterPart',
+ refId: descriptor.id,
+ };
+
+ const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1;
+ const session = storySessionManager.activate(locator, {
+ // Presentation-mode header/footer sessions now reuse the manager-backed
+ // per-refId editor, which already exports on update. Commit once on exit
+ // to avoid double-syncing every keystroke while still flushing the final
+ // state if the session closes mid-batch.
+ commitPolicy: 'onExit',
+ preferHiddenHost: true,
+ hostWidthPx: Math.max(1, region.width),
+ editorContext: {
+ availableWidth: Math.max(1, region.width),
+ availableHeight: Math.max(1, region.height),
+ currentPageNumber: Math.max(1, region.pageNumber ?? 1),
+ totalPageCount: Math.max(1, bodyPageCount),
+ surfaceKind: region.kind,
+ },
+ });
+
+ return session?.editor ?? null;
+ }
+
+ async #enterMode(region: HeaderFooterRegion, options?: HeaderFooterActivationOptions): Promise {
try {
- if (!this.#headerFooterManager || !this.#overlayManager) {
+ if (!this.#headerFooterManager) {
this.clearHover();
- return;
+ return null;
}
// Clean up previous session if switching between pages while in editing mode
if (this.#session.mode !== 'body') {
if (this.#activeEditor) {
- this.#activeEditor.setEditable(false);
- this.#activeEditor.setOptions({ documentMode: 'viewing' });
+ this.#applyChildEditorDocumentMode(this.#activeEditor, 'viewing');
}
this.#teardownActiveEditorEventBridge();
- this.#overlayManager.hideEditingOverlay();
+ this.#deps?.getStorySessionManager?.()?.exit();
this.#activeEditor = null;
this.#session = { mode: 'body' };
}
@@ -793,6 +1067,7 @@ export class HeaderFooterSessionManager {
sectionId: region.sectionId,
kind: region.kind,
variant: normalizeVariant(region.sectionType ?? 'default'),
+ addToHistory: false,
});
if (materializationResult) {
// Refresh registry so the new refId is discoverable
@@ -809,12 +1084,12 @@ export class HeaderFooterSessionManager {
region,
);
this.clearHover();
- return;
+ return null;
}
if (!descriptor.id) {
console.warn('[HeaderFooterSessionManager] Descriptor missing id:', descriptor);
this.clearHover();
- return;
+ return null;
}
// Virtualized pages may not be mounted - scroll into view if needed
@@ -830,7 +1105,7 @@ export class HeaderFooterSessionManager {
error: new Error('Failed to mount page for editing'),
context: 'enterMode',
});
- return;
+ return null;
}
pageElement = this.#deps?.getPageElement(region.pageIndex) ?? null;
} catch (scrollError) {
@@ -840,7 +1115,7 @@ export class HeaderFooterSessionManager {
error: scrollError,
context: 'enterMode.pageMount',
});
- return;
+ return null;
}
}
@@ -851,115 +1126,58 @@ export class HeaderFooterSessionManager {
error: new Error('Page element not found after mount'),
context: 'enterMode',
});
- return;
+ return null;
}
- const layoutOptions = this.#deps?.getLayoutOptions() ?? {};
- const { success, editorHost, reason } = this.#overlayManager.showEditingOverlay(
- pageElement,
- region,
- layoutOptions.zoom ?? 1,
- );
- if (!success || !editorHost) {
- console.error('[HeaderFooterSessionManager] Failed to create editor host:', reason);
+ let editor;
+ const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null;
+ if (!storySessionManager) {
this.clearHover();
this.#callbacks.onError?.({
- error: new Error(`Failed to create editor host: ${reason}`),
- context: 'enterMode.showOverlay',
+ error: new Error('Story session manager unavailable'),
+ context: 'enterMode.storySessionUnavailable',
});
- return;
+ return null;
}
- const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1;
- let editor;
try {
- editor = await this.#headerFooterManager.ensureEditor(descriptor, {
- editorHost,
- availableWidth: region.width,
- availableHeight: region.height,
- currentPageNumber: region.pageNumber,
- totalPageCount: bodyPageCount,
- });
+ editor = this.#activateStorySessionForRegion(region, descriptor);
} catch (editorError) {
- console.error('[HeaderFooterSessionManager] Error creating editor:', editorError);
- this.#overlayManager.hideEditingOverlay();
+ console.error('[HeaderFooterSessionManager] Error creating story session:', editorError);
this.clearHover();
this.#callbacks.onError?.({
error: editorError,
- context: 'enterMode.ensureEditor',
+ context: 'enterMode.storySession',
});
- return;
+ return null;
}
if (!editor) {
console.warn('[HeaderFooterSessionManager] Failed to ensure editor for descriptor:', descriptor);
- this.#overlayManager.hideEditingOverlay();
this.clearHover();
this.#callbacks.onError?.({
error: new Error('Failed to create editor instance'),
context: 'enterMode.ensureEditor',
});
- return;
+ return null;
}
- // For footers, apply positioning adjustments
- if (region.kind === 'footer') {
- const editorContainer = editorHost.firstElementChild;
- if (editorContainer instanceof HTMLElement) {
- editorContainer.style.overflow = 'visible';
- if (region.minY != null && region.minY < 0) {
- const shiftDown = Math.abs(region.minY);
- editorContainer.style.transform = `translateY(${shiftDown}px)`;
- } else {
- editorContainer.style.transform = '';
- }
- }
- }
+ const shouldRestoreInitialSelection = options?.initialSelection !== 'defer';
try {
- editor.setEditable(true);
- editor.setOptions({ documentMode: 'editing' });
-
- // Ensure the header/footer editor receives focus on user interaction.
- // Without this, subsequent clicks in newly-activated editors may not
- // update ProseMirror selection because the view never regains focus.
- try {
- const editorView = editor.view;
- if (editorView && editorHost) {
- const focusHandler = () => {
- try {
- editorView.focus();
- } catch {
- // Ignore focus errors; selection updates will still work when possible.
- }
- };
- editorHost.addEventListener('mousedown', focusHandler);
- this.#managerCleanups.push(() => editorHost.removeEventListener('mousedown', focusHandler));
- }
- } catch {
- // Best-effort: if we can't wire the focus handler, continue without it.
- }
+ this.#applyChildEditorDocumentMode(editor, this.#documentMode);
- // Move caret to end of content
- try {
- const doc = editor.state?.doc;
- if (doc) {
- const endPos = doc.content.size - 1;
- const pos = Math.max(1, endPos);
- editor.commands?.setTextSelection?.({ from: pos, to: pos });
- }
- } catch (cursorError) {
- console.warn('[HeaderFooterSessionManager] Could not set cursor to end:', cursorError);
+ if (shouldRestoreInitialSelection) {
+ this.#applyDefaultSelectionAtStoryEnd(editor, 'Could not set cursor to end');
}
} catch (editableError) {
console.error('[HeaderFooterSessionManager] Error setting editor editable:', editableError);
- this.#overlayManager.hideEditingOverlay();
this.clearHover();
this.#callbacks.onError?.({
error: editableError,
context: 'enterMode.setEditable',
});
- return;
+ return null;
}
this.#activeEditor = editor;
@@ -981,16 +1199,29 @@ export class HeaderFooterSessionManager {
console.warn('[HeaderFooterSessionManager] Could not focus editor:', focusError);
}
+ if (shouldRestoreInitialSelection) {
+ // WebKit can keep a stale DOM selection when the hidden story editor
+ // receives focus. Re-applying the PM selection after focus keeps the
+ // first keyboard event aligned with the intended caret position.
+ this.#applyDefaultSelectionAtStoryEnd(editor, 'Could not restore cursor after focus');
+ try {
+ editor.view?.focus();
+ } catch (focusError) {
+ console.warn('[HeaderFooterSessionManager] Could not refocus editor after restoring selection:', focusError);
+ }
+ this.#scheduleSelectionRestoreAfterFocus(editor);
+ }
+
this.#emitModeChanged();
this.#emitEditingContext(editor);
this.#deps?.notifyInputBridgeTargetChanged();
+ return editor;
} catch (error) {
console.error('[HeaderFooterSessionManager] Unexpected error in enterMode:', error);
// Attempt cleanup
try {
- this.#overlayManager?.hideEditingOverlay();
- this.#overlayManager?.showSelectionOverlay();
+ this.#deps?.getStorySessionManager?.()?.exit();
this.clearHover();
this.#teardownActiveEditorEventBridge();
this.#activeEditor = null;
@@ -1003,9 +1234,77 @@ export class HeaderFooterSessionManager {
error,
context: 'enterMode',
});
+ return null;
}
}
+ #applyChildEditorDocumentMode(editor: Editor, mode: 'editing' | 'viewing' | 'suggesting'): void {
+ const pm = editor.view?.dom ?? null;
+
+ if (mode === 'viewing') {
+ editor.commands?.enableTrackChangesShowOriginal?.();
+ editor.setOptions?.({ documentMode: 'viewing' });
+ editor.setEditable?.(false);
+ } else if (mode === 'suggesting') {
+ editor.commands?.disableTrackChangesShowOriginal?.();
+ editor.commands?.enableTrackChanges?.();
+ editor.setOptions?.({ documentMode: 'suggesting' });
+ editor.setEditable?.(true);
+ } else {
+ editor.commands?.disableTrackChangesShowOriginal?.();
+ editor.commands?.disableTrackChanges?.();
+ editor.setOptions?.({ documentMode: 'editing' });
+ editor.setEditable?.(true);
+ }
+
+ if (pm instanceof HTMLElement) {
+ pm.setAttribute('aria-readonly', mode === 'viewing' ? 'true' : 'false');
+ pm.setAttribute('documentmode', mode);
+ pm.classList.toggle('view-mode', mode === 'viewing');
+ }
+ }
+
+ #getDefaultSelectionAtStoryEnd(editor: Editor): { from: number; to: number } | null {
+ const doc = editor.state?.doc;
+ if (!doc) return null;
+
+ const endPos = doc.content.size - 1;
+ const pos = Math.max(1, endPos);
+ return { from: pos, to: pos };
+ }
+
+ #applyEditorTextSelection(editor: Editor, selection: { from: number; to: number }, warningMessage: string): void {
+ try {
+ editor.commands?.setTextSelection?.(selection);
+ } catch (error) {
+ console.warn(`[HeaderFooterSessionManager] ${warningMessage}:`, error);
+ }
+ }
+
+ #applyDefaultSelectionAtStoryEnd(editor: Editor, warningMessage: string): void {
+ const selection = this.#getDefaultSelectionAtStoryEnd(editor);
+ if (!selection) return;
+ this.#applyEditorTextSelection(editor, selection, warningMessage);
+ }
+
+ #scheduleSelectionRestoreAfterFocus(editor: Editor): void {
+ const win = editor.view?.dom?.ownerDocument?.defaultView;
+ if (!win) return;
+
+ win.requestAnimationFrame(() => {
+ if (this.#activeEditor !== editor || this.#session.mode === 'body') {
+ return;
+ }
+
+ this.#applyDefaultSelectionAtStoryEnd(editor, 'Could not restore cursor on the next frame');
+ try {
+ editor.view?.focus();
+ } catch (focusError) {
+ console.warn('[HeaderFooterSessionManager] Could not refocus editor on the next frame:', focusError);
+ }
+ });
+ }
+
#validateEditPermission(): { allowed: boolean; reason?: string } {
if (this.#deps?.isViewLocked()) {
return { allowed: false, reason: 'documentMode' };
@@ -1042,6 +1341,7 @@ export class HeaderFooterSessionManager {
this.#callbacks.onModeChanged?.(this.#session);
this.#callbacks.onUpdateAwarenessSession?.(this.#session);
this.#updateModeBanner();
+ this.#syncActiveBorder();
}
#emitEditingContext(editor: Editor): void {
@@ -1175,6 +1475,55 @@ export class HeaderFooterSessionManager {
return this.#hoverRegion;
}
+ #getActiveRegion(): HeaderFooterRegion | null {
+ if (this.#session.mode === 'header') {
+ return this.#headerRegions.get(this.#session.pageIndex ?? -1) ?? null;
+ }
+
+ if (this.#session.mode === 'footer') {
+ return this.#footerRegions.get(this.#session.pageIndex ?? -1) ?? null;
+ }
+
+ return null;
+ }
+
+ #hideActiveBorder(): void {
+ if (this.#activeBorderLine) {
+ this.#activeBorderLine.remove();
+ this.#activeBorderLine = null;
+ }
+ }
+
+ #syncActiveBorder(): void {
+ this.#hideActiveBorder();
+
+ const region = this.#getActiveRegion();
+ if (!region || this.#session.mode === 'body') {
+ return;
+ }
+
+ const pageElement = this.#deps?.getPageElement(region.pageIndex);
+ if (!pageElement) {
+ return;
+ }
+
+ const borderLine = pageElement.ownerDocument.createElement('div');
+ borderLine.className = 'superdoc-header-footer-border';
+ Object.assign(borderLine.style, {
+ position: 'absolute',
+ left: '0',
+ right: '0',
+ top: `${region.kind === 'header' ? region.localY + region.height : region.localY}px`,
+ height: '1px',
+ backgroundColor: '#4472c4',
+ pointerEvents: 'none',
+ zIndex: '8',
+ });
+
+ pageElement.appendChild(borderLine);
+ this.#activeBorderLine = borderLine;
+ }
+
// ===========================================================================
// Layout
// ===========================================================================
@@ -1274,10 +1623,20 @@ export class HeaderFooterSessionManager {
layout: Layout,
sectionMetadata: SectionMetadata[],
): Promise {
- return await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, {
+ await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, {
headerLayoutsByRId: this.#headerLayoutsByRId,
footerLayoutsByRId: this.#footerLayoutsByRId,
});
+
+ // Rebuild resolved maps aligned 1:1 with the raw rId maps.
+ this.#resolvedHeaderByRId.clear();
+ for (const [key, result] of this.#headerLayoutsByRId) {
+ this.#resolvedHeaderByRId.set(key, resolveResult(result));
+ }
+ this.#resolvedFooterByRId.clear();
+ for (const [key, result] of this.#footerLayoutsByRId) {
+ this.#resolvedFooterByRId.set(key, resolveResult(result));
+ }
}
#computeMetrics(
@@ -1377,15 +1736,9 @@ export class HeaderFooterSessionManager {
/**
* Compute selection rectangles in header/footer mode.
*
- * This method intentionally does NOT use layout-engine geometry. Header/footer
- * editing is driven by a dedicated ProseMirror editor instance mounted inside
- * an overlay host. For selection, we rely on the browser's native DOM selection
- * rectangles from that editor and then remap them into layout coordinates using
- * the current region and body page height.
- *
- * Selection rectangles are therefore derived from:
- * - Native ProseMirror selection โ DOM Range โ client rects
- * - Header/footer region โ pageIndex / local offset
+ * Header/footer editing uses a hidden off-screen ProseMirror host, so the
+ * visible selection overlay must be derived from the rendered header/footer
+ * layout rather than from the editor DOM.
*/
computeSelectionRects(from: number, to: number): LayoutRect[] {
// Guard: must be in header/footer mode with an active editor and region context.
@@ -1411,30 +1764,15 @@ export class HeaderFooterSessionManager {
const region = context.region;
const pageIndex = region.pageIndex;
+ const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h;
- // Compute DOM-based rectangles local to the editor host. We intentionally
- // ignore the numeric from/to arguments and any cached ProseMirror
- // selection, and instead rely solely on the live DOM selection inside the
- // active header/footer editor. This avoids stale selection state when
- // switching between multiple header/footer editors.
- const domSelection = view.dom.ownerDocument?.getSelection?.();
- let domRectList: DOMRect[] = [];
-
- if (domSelection && domSelection.rangeCount > 0) {
- for (let i = 0; i < domSelection.rangeCount; i += 1) {
- const range = domSelection.getRangeAt(i);
- if (!range) continue;
- const rangeRects = Array.from(range.getClientRects()) as unknown as DOMRect[];
- domRectList.push(...rangeRects);
- }
-
- // Normalize to a minimal set of rects. Browsers often return both a
- // line-box rect and a text-content rect on the same line; without
- // deduplication this produces overlapping highlights that look like
- // intersecting selections.
- domRectList = deduplicateOverlappingRects(domRectList);
+ const hiddenHostRects = this.#computeHiddenHostSelectionRects(context, from, to, bodyPageHeight);
+ if (hiddenHostRects) {
+ return hiddenHostRects;
}
+ const domRectList = this.#computeEditorRangeClientRects(view, from, to);
+
if (!domRectList.length) {
return [];
}
@@ -1447,7 +1785,6 @@ export class HeaderFooterSessionManager {
// deltas and sizes must be converted back out of zoom space here.
const editorDom = view.dom as HTMLElement;
const editorHostRect = editorDom.getBoundingClientRect();
- const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h;
const layoutOptions = this.#deps?.getLayoutOptions() ?? {};
const zoom =
typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0
@@ -1487,6 +1824,356 @@ export class HeaderFooterSessionManager {
return layoutRects;
}
+ #computeHiddenHostSelectionRects(
+ context: HeaderFooterLayoutContext,
+ from: number,
+ to: number,
+ bodyPageHeight: number,
+ ): LayoutRect[] | null {
+ const activeEditor = this.#activeEditor;
+ const editorDom = activeEditor?.view?.dom as HTMLElement | null;
+ if (!editorDom?.closest?.('.presentation-editor__story-hidden-host')) {
+ return null;
+ }
+
+ const visibleSurfaceRects = this.#computeVisibleSurfaceSelectionRects(context, from, to, bodyPageHeight);
+ if (visibleSurfaceRects?.length) {
+ return visibleSurfaceRects;
+ }
+
+ const localRects = selectionToRects(context.layout, context.blocks, context.measures, from, to) ?? [];
+ if (localRects.length) {
+ return localRects.map((rect) => ({
+ pageIndex: context.region.pageIndex,
+ x: context.region.localX + rect.x,
+ y: context.region.pageIndex * bodyPageHeight + context.region.localY + rect.y,
+ width: rect.width,
+ height: rect.height,
+ }));
+ }
+
+ const liveRect = activeEditor
+ ? this.#computeHiddenHostLiveRangeRect(activeEditor, from, to, context, bodyPageHeight)
+ : null;
+ return liveRect ? [liveRect] : [];
+ }
+
+ #computeVisibleSurfaceSelectionRects(
+ context: HeaderFooterLayoutContext,
+ from: number,
+ to: number,
+ bodyPageHeight: number,
+ ): LayoutRect[] | null {
+ const pageElement = this.#deps?.getPageElement(context.region.pageIndex);
+ if (!pageElement) {
+ return null;
+ }
+
+ const surfaceSelector = this.#session.mode === 'header' ? '.superdoc-page-header' : '.superdoc-page-footer';
+ const surfaceElement = pageElement.querySelector(surfaceSelector);
+ if (!surfaceElement) {
+ return null;
+ }
+
+ const entries = buildSurfacePmEntries(surfaceElement);
+ const surfaceEntries = findSurfaceEntriesInRange(entries, from, to, { boundaryInclusive: true });
+ if (!surfaceEntries.length) {
+ return null;
+ }
+
+ const start = Math.min(from, to);
+ const end = Math.max(from, to);
+ const startEntry =
+ surfaceEntries.find((entry) => start >= entry.pmStart && start <= entry.pmEnd) ?? surfaceEntries[0] ?? null;
+ const endEntry =
+ surfaceEntries.find((entry) => end >= entry.pmStart && end <= entry.pmEnd) ??
+ surfaceEntries[surfaceEntries.length - 1] ??
+ null;
+ if (!startEntry || !endEntry) {
+ return null;
+ }
+
+ const doc = pageElement.ownerDocument;
+ if (!doc?.createRange) {
+ return null;
+ }
+
+ const range = doc.createRange();
+ try {
+ if (!setSurfaceRangeStart(range, startEntry, start)) {
+ return null;
+ }
+ if (!setSurfaceRangeEnd(range, endEntry, end)) {
+ return null;
+ }
+ } catch {
+ return null;
+ }
+
+ let clientRects: DOMRect[] = [];
+ try {
+ clientRects = deduplicateOverlappingRects(Array.from(range.getClientRects()) as unknown as DOMRect[]);
+ } catch {
+ return null;
+ }
+
+ if (!clientRects.length) {
+ return null;
+ }
+
+ const layoutOptions = this.#deps?.getLayoutOptions() ?? {};
+ const zoom =
+ typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0
+ ? layoutOptions.zoom
+ : 1;
+ const pageRect = pageElement.getBoundingClientRect();
+
+ const layoutRects: LayoutRect[] = [];
+ for (const clientRect of clientRects) {
+ const width = clientRect.width / zoom;
+ const height = clientRect.height / zoom;
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ continue;
+ }
+
+ const localX = (clientRect.left - pageRect.left) / zoom;
+ const localY = (clientRect.top - pageRect.top) / zoom;
+ if (!Number.isFinite(localX) || !Number.isFinite(localY)) {
+ continue;
+ }
+
+ layoutRects.push({
+ pageIndex: context.region.pageIndex,
+ x: localX,
+ y: context.region.pageIndex * bodyPageHeight + localY,
+ width: Math.max(1, width),
+ height: Math.max(1, height),
+ });
+ }
+
+ return layoutRects.length ? layoutRects : null;
+ }
+
+ #computeVisibleSurfaceCaretRect(
+ context: HeaderFooterLayoutContext,
+ pos: number,
+ bodyPageHeight: number,
+ ): LayoutRect | null {
+ const pageElement = this.#deps?.getPageElement(context.region.pageIndex);
+ if (!pageElement) {
+ return null;
+ }
+
+ const surfaceSelector = this.#session.mode === 'header' ? '.superdoc-page-header' : '.superdoc-page-footer';
+ const surfaceElement = pageElement.querySelector(surfaceSelector);
+ if (!surfaceElement) {
+ return null;
+ }
+
+ const entries = buildSurfacePmEntries(surfaceElement);
+ const entry = findSurfaceEntryAtPos(entries, pos);
+ if (!entry) {
+ return null;
+ }
+
+ const pageRect = pageElement.getBoundingClientRect();
+ const zoom =
+ typeof this.#deps?.getLayoutOptions()?.zoom === 'number' &&
+ Number.isFinite(this.#deps?.getLayoutOptions()?.zoom) &&
+ (this.#deps?.getLayoutOptions()?.zoom ?? 0) > 0
+ ? (this.#deps?.getLayoutOptions()?.zoom as number)
+ : 1;
+
+ const textNode = Array.from(entry.el.childNodes).find((node): node is Text => node.nodeType === Node.TEXT_NODE);
+ if (textNode) {
+ const range = entry.el.ownerDocument?.createRange();
+ if (!range) {
+ return null;
+ }
+
+ const charIndex = mapPmPosToTextOffset(pos, entry.pmStart, entry.pmEnd, textNode.length);
+ range.setStart(textNode, charIndex);
+ range.setEnd(textNode, charIndex);
+
+ const rangeRect = range.getBoundingClientRect();
+ if (!Number.isFinite(rangeRect.left) || !Number.isFinite(rangeRect.top) || rangeRect.height <= 0) {
+ return null;
+ }
+
+ return {
+ pageIndex: context.region.pageIndex,
+ x: (rangeRect.left - pageRect.left) / zoom,
+ y: context.region.pageIndex * bodyPageHeight + (rangeRect.top - pageRect.top) / zoom,
+ width: 1,
+ height: Math.max(1, rangeRect.height / zoom),
+ };
+ }
+
+ const elementRect = entry.el.getBoundingClientRect();
+ if (!Number.isFinite(elementRect.left) || !Number.isFinite(elementRect.top) || elementRect.height <= 0) {
+ return null;
+ }
+
+ const localX = (pos <= entry.pmStart ? elementRect.left : elementRect.right) - pageRect.left;
+ return {
+ pageIndex: context.region.pageIndex,
+ x: localX / zoom,
+ y: context.region.pageIndex * bodyPageHeight + (elementRect.top - pageRect.top) / zoom,
+ width: 1,
+ height: Math.max(1, elementRect.height / zoom),
+ };
+ }
+
+ #computeHiddenHostLiveRangeRect(
+ editor: Editor,
+ from: number,
+ to: number,
+ context: HeaderFooterLayoutContext,
+ bodyPageHeight: number,
+ ): LayoutRect | null {
+ const view = editor.view as
+ | (Editor['view'] & {
+ coordsAtPos?: (pos: number, side?: number) => { left: number; right: number; top: number; bottom: number };
+ })
+ | null
+ | undefined;
+
+ if (!view || typeof view.coordsAtPos !== 'function') {
+ return null;
+ }
+
+ const docSize = editor.state?.doc?.content?.size ?? 0;
+ const start = Math.max(0, Math.min(Math.min(from, to), docSize));
+ const end = Math.max(0, Math.min(Math.max(from, to), docSize));
+
+ const layoutOptions = this.#deps?.getLayoutOptions() ?? {};
+ const zoom =
+ typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0
+ ? layoutOptions.zoom
+ : 1;
+ const editorHostRect = view.dom.getBoundingClientRect();
+
+ try {
+ const startCoords = view.coordsAtPos(start);
+ const endCoords = start === end ? startCoords : view.coordsAtPos(end, -1);
+ const left = Math.min(startCoords.left, endCoords.left);
+ const right = Math.max(startCoords.right, endCoords.right);
+ const top = Math.min(startCoords.top, endCoords.top);
+ const bottom = Math.max(startCoords.bottom, endCoords.bottom);
+ const width = Math.max(1, (right - left) / zoom);
+ const height = Math.max(1, (bottom - top) / zoom);
+ const localX = (left - editorHostRect.left) / zoom;
+ const localY = (top - editorHostRect.top) / zoom;
+
+ if (!Number.isFinite(localX) || !Number.isFinite(localY)) {
+ return null;
+ }
+
+ return {
+ pageIndex: context.region.pageIndex,
+ x: context.region.localX + localX,
+ y: context.region.pageIndex * bodyPageHeight + context.region.localY + localY,
+ width,
+ height,
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ #computeEditorRangeClientRects(view: Editor['view'], from: number, to: number): DOMRect[] {
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
+ return [];
+ }
+
+ const docSize = view.state?.doc?.content?.size ?? 0;
+ const start = Math.max(0, Math.min(Math.min(from, to), docSize));
+ const end = Math.max(0, Math.min(Math.max(from, to), docSize));
+ if (start === end || typeof view.domAtPos !== 'function') {
+ return [];
+ }
+
+ const doc = view.dom.ownerDocument;
+ const range = doc?.createRange?.();
+ if (!range) {
+ return [];
+ }
+
+ try {
+ const startBoundary = view.domAtPos(start);
+ const endBoundary = view.domAtPos(end);
+ range.setStart(startBoundary.node, startBoundary.offset);
+ range.setEnd(endBoundary.node, endBoundary.offset);
+ } catch {
+ return [];
+ }
+
+ try {
+ const clientRects = Array.from(range.getClientRects()) as unknown as DOMRect[];
+ return deduplicateOverlappingRects(clientRects);
+ } catch {
+ return [];
+ }
+ }
+
+ computeCaretRect(pos: number): LayoutRect | null {
+ if (this.#session.mode === 'body') {
+ return null;
+ }
+
+ const context = this.getContext();
+ if (!context) {
+ return null;
+ }
+
+ const region = context.region;
+ const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h;
+ const visibleSurfaceCaretRect = this.#computeVisibleSurfaceCaretRect(context, pos, bodyPageHeight);
+ if (visibleSurfaceCaretRect) {
+ return visibleSurfaceCaretRect;
+ }
+
+ const layoutOptions = this.#deps?.getLayoutOptions() ?? {};
+ const geometry = computeCaretLayoutRectGeometryFromHelper(
+ {
+ layout: context.layout,
+ blocks: context.blocks,
+ measures: context.measures,
+ painterHost: null,
+ viewportHost: this.#options.visibleHost,
+ visibleHost: this.#options.visibleHost,
+ zoom: layoutOptions.zoom ?? 1,
+ },
+ pos,
+ false,
+ );
+
+ if (geometry) {
+ return {
+ pageIndex: region.pageIndex,
+ x: region.localX + geometry.x,
+ y: region.pageIndex * bodyPageHeight + region.localY + geometry.y,
+ width: 1,
+ height: geometry.height,
+ };
+ }
+
+ const liveRect = this.#activeEditor
+ ? this.#computeHiddenHostLiveRangeRect(this.#activeEditor, pos, pos, context, bodyPageHeight)
+ : null;
+ if (liveRect) {
+ return {
+ pageIndex: liveRect.pageIndex,
+ x: liveRect.x,
+ y: liveRect.y,
+ width: 1,
+ height: liveRect.height,
+ };
+ }
+
+ return null;
+ }
+
/**
* Get the current header/footer layout context.
*/
@@ -1504,27 +2191,18 @@ export class HeaderFooterSessionManager {
return null;
}
- const results = this.#session.mode === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults;
- if (!results || results.length === 0) {
+ const activeLayoutResult = this.#resolveActiveLayoutResult(region);
+ if (!activeLayoutResult) {
console.warn('[HeaderFooterSessionManager] Header/footer layout results not available');
return null;
}
- const variant = results.find((entry) => entry.type === this.#session.sectionType) ?? results[0] ?? null;
- if (!variant) {
- console.warn(
- '[HeaderFooterSessionManager] Header/footer variant not found for sectionType:',
- this.#session.sectionType,
- );
- return null;
- }
-
const pageWidth = Math.max(1, region.width);
- const pageHeight = Math.max(1, variant.layout.height ?? region.height ?? 1);
+ const pageHeight = Math.max(1, activeLayoutResult.layout.height ?? region.height ?? 1);
const layoutLike: Layout = {
pageSize: { w: pageWidth, h: pageHeight },
- pages: variant.layout.pages.map((page: Page) => ({
+ pages: activeLayoutResult.layout.pages.map((page: Page) => ({
number: page.number,
numberText: page.numberText,
fragments: page.fragments,
@@ -1533,12 +2211,32 @@ export class HeaderFooterSessionManager {
return {
layout: layoutLike,
- blocks: variant.blocks,
- measures: variant.measures,
+ blocks: activeLayoutResult.blocks,
+ measures: activeLayoutResult.measures,
region,
};
}
+ #resolveActiveLayoutResult(region: HeaderFooterRegion): HeaderFooterLayoutResult | null {
+ const layoutsByRId = this.#session.mode === 'header' ? this.#headerLayoutsByRId : this.#footerLayoutsByRId;
+ const concreteRefId = this.#session.headerFooterRefId ?? region.headerFooterRefId ?? null;
+
+ if (concreteRefId && layoutsByRId.size > 0) {
+ const compositeKey = buildSectionAwareHeaderFooterLayoutKey(concreteRefId, region.sectionIndex ?? 0);
+ const layoutByRef = layoutsByRId.get(compositeKey) ?? layoutsByRId.get(concreteRefId) ?? null;
+ if (layoutByRef) {
+ return layoutByRef;
+ }
+ }
+
+ const results = this.#session.mode === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults;
+ if (!results || results.length === 0) {
+ return null;
+ }
+
+ return results.find((entry) => entry.type === this.#session.sectionType) ?? results[0] ?? null;
+ }
+
/**
* Get the page height for header/footer mode.
*/
@@ -1578,6 +2276,8 @@ export class HeaderFooterSessionManager {
createDecorationProvider(kind: 'header' | 'footer', layout: Layout): PageDecorationProvider | undefined {
const results = kind === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults;
const layoutsByRId = kind === 'header' ? this.#headerLayoutsByRId : this.#footerLayoutsByRId;
+ const resolvedResults = kind === 'header' ? this.#resolvedHeaderLayouts : this.#resolvedFooterLayouts;
+ const resolvedByRId = kind === 'header' ? this.#resolvedHeaderByRId : this.#resolvedFooterByRId;
if ((!results || results.length === 0) && (!layoutsByRId || layoutsByRId.size === 0)) {
return undefined;
@@ -1652,6 +2352,15 @@ export class HeaderFooterSessionManager {
const slotPage = this.#findPageForNumber(rIdLayout.layout.pages, pageNumber);
if (slotPage) {
const fragments = slotPage.fragments ?? [];
+ const resolvedLayout = resolvedByRId.get(rIdLayoutKey);
+ const resolvedSlotPage = resolvedLayout?.pages.find((p) => p.number === slotPage.number);
+ const resolvedItems = resolvedSlotPage?.items;
+ if (resolvedItems && resolvedItems.length !== fragments.length) {
+ console.warn(
+ `[HeaderFooterSessionManager] Resolved items length (${resolvedItems.length}) does not match fragments length (${fragments.length}) for rId '${rIdLayoutKey}' page ${pageNumber}. Dropping items.`,
+ );
+ }
+ const alignedItems = resolvedItems && resolvedItems.length === fragments.length ? resolvedItems : undefined;
const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? defaultPageSize.h;
const margins = pageMargins ?? layout.pages[0]?.margins ?? layoutOptions.margins ?? defaultMargins;
const decorationMargins =
@@ -1666,11 +2375,12 @@ export class HeaderFooterSessionManager {
const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0);
const layoutMinY = rIdLayout.layout.minY ?? 0;
- const normalizedFragments =
- layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments;
+ const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY);
+ const normalizedItems = normalizeDecorationItems(alignedItems, layoutMinY);
return {
fragments: normalizedFragments,
+ items: normalizedItems,
height: metrics.containerHeight,
contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight,
offset: metrics.offset,
@@ -1691,7 +2401,8 @@ export class HeaderFooterSessionManager {
return null;
}
- const variant = results.find((entry) => entry.type === headerFooterType);
+ const variantIndex = results.findIndex((entry) => entry.type === headerFooterType);
+ const variant = variantIndex >= 0 ? results[variantIndex] : undefined;
if (!variant || !variant.layout?.pages?.length) {
return null;
}
@@ -1702,6 +2413,17 @@ export class HeaderFooterSessionManager {
}
const fragments = slotPage.fragments ?? [];
+ const resolvedVariant = resolvedResults?.[variantIndex];
+ const resolvedVariantPage = resolvedVariant?.pages.find((p) => p.number === slotPage.number);
+ const resolvedVariantItems = resolvedVariantPage?.items;
+ if (resolvedVariantItems && resolvedVariantItems.length !== fragments.length) {
+ console.warn(
+ `[HeaderFooterSessionManager] Resolved items length (${resolvedVariantItems.length}) does not match fragments length (${fragments.length}) for variant '${headerFooterType}' page ${pageNumber}. Dropping items.`,
+ );
+ }
+ const alignedVariantItems =
+ resolvedVariantItems && resolvedVariantItems.length === fragments.length ? resolvedVariantItems : undefined;
+
const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? defaultPageSize.h;
const margins = pageMargins ?? layout.pages[0]?.margins ?? layoutOptions.margins ?? defaultMargins;
const decorationMargins =
@@ -1714,10 +2436,12 @@ export class HeaderFooterSessionManager {
const finalHeaderId = sectionRId ?? fallbackId ?? undefined;
const layoutMinY = variant.layout.minY ?? 0;
- const normalizedFragments = layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments;
+ const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY);
+ const normalizedItems = normalizeDecorationItems(alignedVariantItems, layoutMinY);
return {
fragments: normalizedFragments,
+ items: normalizedItems,
height: metrics.containerHeight,
contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight,
offset: metrics.offset,
@@ -1798,6 +2522,10 @@ export class HeaderFooterSessionManager {
this.#footerLayoutResults = null;
this.#headerLayoutsByRId.clear();
this.#footerLayoutsByRId.clear();
+ this.#resolvedHeaderLayouts = null;
+ this.#resolvedFooterLayouts = null;
+ this.#resolvedHeaderByRId.clear();
+ this.#resolvedFooterByRId.clear();
// Clear decoration providers
this.#headerDecorationProvider = undefined;
@@ -1812,12 +2540,10 @@ export class HeaderFooterSessionManager {
this.#activeEditor = null;
// Clear UI references
+ this.#hideActiveBorder();
this.#hoverOverlay = null;
this.#hoverTooltip = null;
this.#modeBanner = null;
this.#hoverRegion = null;
-
- // Clear overlay manager
- this.#overlayManager = null;
}
}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts
index f7d6f7e8be..711900a7ef 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts
@@ -1,10 +1,18 @@
import { isInRegisteredSurface } from '../utils/uiSurfaceRegistry.js';
import { CONTEXT_MENU_HANDLED_FLAG } from '../../../components/context-menu/event-flags.js';
+const BRIDGE_FORWARDED_FLAG = Symbol('presentation-input-bridge-forwarded');
+
export class PresentationInputBridge {
#windowRoot: Window;
#layoutSurfaces: Set;
#getTargetDom: () => HTMLElement | null;
+ #getTargetEditor?: () => {
+ focus?: () => void;
+ view?: {
+ dom?: HTMLElement | null;
+ };
+ } | null;
/** Callback that returns whether the editor is in an editable mode (editing/suggesting vs viewing) */
#isEditable: () => boolean;
#onTargetChanged?: (target: HTMLElement | null) => void;
@@ -27,6 +35,8 @@ export class PresentationInputBridge {
* @param onTargetChanged - Optional callback invoked when the target editor DOM element changes
* @param options - Optional configuration including:
* - useWindowFallback: Whether to attach window-level event listeners as fallback
+ * - getTargetEditor: Returns the active editor so focus restoration can
+ * use editor-aware focus logic instead of raw DOM focus
*/
constructor(
windowRoot: Window,
@@ -34,11 +44,20 @@ export class PresentationInputBridge {
getTargetDom: () => HTMLElement | null,
isEditable: () => boolean,
onTargetChanged?: (target: HTMLElement | null) => void,
- options?: { useWindowFallback?: boolean },
+ options?: {
+ useWindowFallback?: boolean;
+ getTargetEditor?: () => {
+ focus?: () => void;
+ view?: {
+ dom?: HTMLElement | null;
+ };
+ } | null;
+ },
) {
this.#windowRoot = windowRoot;
this.#layoutSurfaces = new Set([layoutSurface]);
this.#getTargetDom = getTargetDom;
+ this.#getTargetEditor = options?.getTargetEditor;
this.#isEditable = isEditable;
this.#onTargetChanged = onTargetChanged;
this.#listeners = [];
@@ -46,6 +65,15 @@ export class PresentationInputBridge {
}
bind() {
+ if (this.#useWindowFallback) {
+ this.#addListener('keydown', this.#captureStaleKeyboardEvent, this.#windowRoot, true);
+ this.#addListener('beforeinput', this.#captureStaleTextEvent, this.#windowRoot, true);
+ this.#addListener('input', this.#captureStaleTextEvent, this.#windowRoot, true);
+ this.#addListener('compositionstart', this.#captureStaleCompositionEvent, this.#windowRoot, true);
+ this.#addListener('compositionupdate', this.#captureStaleCompositionEvent, this.#windowRoot, true);
+ this.#addListener('compositionend', this.#captureStaleCompositionEvent, this.#windowRoot, true);
+ }
+
const keyboardTargets = this.#getListenerTargets();
keyboardTargets.forEach((target) => {
this.#addListener('keydown', this.#forwardKeyboardEvent, target);
@@ -120,12 +148,30 @@ export class PresentationInputBridge {
}
#dispatchToTarget(originalEvent: Event, synthetic: Event) {
- if (this.#destroyed) return;
- const target = this.#getTargetDom();
- this.#currentTarget = target;
+ const target = this.#resolveDispatchTarget();
if (!target) return;
+ this.#dispatchToResolvedTarget(originalEvent, synthetic, target);
+ }
+
+ #dispatchToResolvedTarget(
+ originalEvent: Event,
+ synthetic: Event,
+ target: HTMLElement,
+ options?: { focusTarget?: boolean; suppressOriginal?: boolean },
+ ) {
+ if (this.#destroyed) return;
const isConnected = (target as { isConnected?: boolean }).isConnected;
if (isConnected === false) return;
+
+ if (options?.suppressOriginal) {
+ this.#suppressOriginalEvent(originalEvent);
+ }
+
+ if (options?.focusTarget) {
+ this.#focusTargetDom(target);
+ }
+
+ this.#currentTarget = target;
try {
const canceled = !target.dispatchEvent(synthetic) || synthetic.defaultPrevented;
if (canceled) {
@@ -138,6 +184,91 @@ export class PresentationInputBridge {
}
}
+ #resolveDispatchTarget(): HTMLElement | null {
+ const target = this.#getTargetDom();
+ this.#currentTarget = target;
+ if (!target) return null;
+ const isConnected = (target as { isConnected?: boolean }).isConnected;
+ if (isConnected === false) return null;
+ return target;
+ }
+
+ #focusTargetDom(target: HTMLElement) {
+ const targetEditor = this.#getTargetEditor?.() ?? null;
+ const targetEditorDom = targetEditor?.view?.dom ?? null;
+ if (targetEditorDom === target && typeof targetEditor?.focus === 'function') {
+ targetEditor.focus();
+ return;
+ }
+
+ const doc = target.ownerDocument ?? document;
+ const active = doc.activeElement as HTMLElement | null;
+ const activeIsTarget = active === target || (!!active && target.contains(active));
+ if (activeIsTarget) {
+ return;
+ }
+
+ try {
+ target.focus({ preventScroll: true });
+ } catch {
+ target.focus();
+ }
+ }
+
+ #suppressOriginalEvent(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (typeof event.stopImmediatePropagation === 'function') {
+ event.stopImmediatePropagation();
+ }
+ }
+
+ /**
+ * Resolve a hidden editor DOM that still owns native focus even though a
+ * different editor surface is currently active.
+ *
+ * This happens when body focus survives or is restored while a footnote /
+ * header / footer session is visually active. Native input then targets the
+ * stale hidden editor directly, bypassing the visible-surface bridge unless we
+ * intercept and reroute it.
+ */
+ #resolveStaleEditorOrigin(event: Event): { activeTarget: HTMLElement; staleEditorTarget: HTMLElement } | null {
+ const activeTarget = this.#resolveDispatchTarget();
+ if (!activeTarget) {
+ return null;
+ }
+
+ if (this.#isEventOnActiveTarget(event)) {
+ return null;
+ }
+
+ if (this.#isInLayoutSurface(event)) {
+ return null;
+ }
+
+ if (isInRegisteredSurface(event)) {
+ return null;
+ }
+
+ const originNode = event.target as Node | null;
+ const originElement =
+ originNode instanceof HTMLElement
+ ? originNode
+ : originNode?.parentElement instanceof HTMLElement
+ ? originNode.parentElement
+ : null;
+ const staleEditorTarget = originElement?.closest?.('.ProseMirror[contenteditable="true"]') as HTMLElement | null;
+
+ if (!staleEditorTarget || staleEditorTarget === activeTarget) {
+ return null;
+ }
+
+ return {
+ activeTarget,
+ staleEditorTarget,
+ };
+ }
+
/**
* Forwards keyboard events to the hidden editor, skipping IME composition events
* and plain character keys (which are handled by beforeinput instead).
@@ -146,6 +277,9 @@ export class PresentationInputBridge {
* @param event - The keyboard event from the layout surface
*/
#forwardKeyboardEvent(event: KeyboardEvent) {
+ if (this.#wasForwardedByBridge(event)) {
+ return;
+ }
if (!this.#isEditable()) {
return;
}
@@ -161,6 +295,7 @@ export class PresentationInputBridge {
if (this.#isPlainCharacterKey(event)) {
return;
}
+ this.#markForwardedByBridge(event);
// Dispatch synchronously so browser defaults can still be prevented
const synthetic = new KeyboardEvent(event.type, {
@@ -178,6 +313,47 @@ export class PresentationInputBridge {
this.#dispatchToTarget(event, synthetic);
}
+ #captureStaleKeyboardEvent(event: KeyboardEvent) {
+ if (!this.#isEditable()) {
+ return;
+ }
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ const staleOrigin = this.#resolveStaleEditorOrigin(event);
+ if (!staleOrigin) {
+ return;
+ }
+
+ // Plain text and IME composition complete through beforeinput/input.
+ // Restore the active editor view first so the browser routes the follow-up
+ // text events into the current story surface instead of the stale body DOM.
+ // Non-text commands (Backspace, Enter, arrows, shortcuts) must also be
+ // rerouted here because there may be no beforeinput.
+ this.#focusTargetDom(staleOrigin.activeTarget);
+ if (this.#isCompositionKeyboardEvent(event) || this.#isPlainCharacterKey(event)) {
+ return;
+ }
+
+ const synthetic = new KeyboardEvent(event.type, {
+ key: event.key,
+ code: event.code,
+ location: event.location,
+ repeat: event.repeat,
+ ctrlKey: event.ctrlKey,
+ shiftKey: event.shiftKey,
+ altKey: event.altKey,
+ metaKey: event.metaKey,
+ bubbles: true,
+ cancelable: true,
+ });
+ this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, {
+ focusTarget: true,
+ suppressOriginal: true,
+ });
+ }
+
/**
* Forwards text input events (beforeinput) to the hidden editor.
* Uses microtask deferral for cooperative handling.
@@ -185,6 +361,9 @@ export class PresentationInputBridge {
* @param event - The input event from the layout surface
*/
#forwardTextEvent(event: InputEvent | TextEvent) {
+ if (this.#wasForwardedByBridge(event)) {
+ return;
+ }
if (!this.#isEditable()) {
return;
}
@@ -194,6 +373,7 @@ export class PresentationInputBridge {
if (event.defaultPrevented) {
return;
}
+ this.#markForwardedByBridge(event);
const dispatchSyntheticEvent = () => {
// Only re-check mutable state - surface check was already done
@@ -225,6 +405,39 @@ export class PresentationInputBridge {
queueMicrotask(dispatchSyntheticEvent);
}
+ #captureStaleTextEvent(event: InputEvent | TextEvent) {
+ if (!this.#isEditable()) {
+ return;
+ }
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ const staleOrigin = this.#resolveStaleEditorOrigin(event);
+ if (!staleOrigin) {
+ return;
+ }
+
+ let synthetic: Event;
+ if (typeof InputEvent !== 'undefined') {
+ synthetic = new InputEvent(event.type, {
+ data: (event as InputEvent).data ?? (event as TextEvent).data ?? null,
+ inputType: (event as InputEvent).inputType ?? 'insertText',
+ dataTransfer: (event as InputEvent).dataTransfer ?? null,
+ isComposing: (event as InputEvent).isComposing ?? false,
+ bubbles: true,
+ cancelable: true,
+ });
+ } else {
+ synthetic = new Event(event.type, { bubbles: true, cancelable: true });
+ }
+
+ this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, {
+ focusTarget: true,
+ suppressOriginal: true,
+ });
+ }
+
/**
* Forwards composition events (compositionstart, compositionupdate, compositionend)
* to the hidden editor for IME input handling.
@@ -232,6 +445,9 @@ export class PresentationInputBridge {
* @param event - The composition event from the layout surface
*/
#forwardCompositionEvent(event: CompositionEvent) {
+ if (this.#wasForwardedByBridge(event)) {
+ return;
+ }
if (!this.#isEditable()) {
return;
}
@@ -241,6 +457,7 @@ export class PresentationInputBridge {
if (event.defaultPrevented) {
return;
}
+ this.#markForwardedByBridge(event);
let synthetic: Event;
if (typeof CompositionEvent !== 'undefined') {
@@ -255,6 +472,36 @@ export class PresentationInputBridge {
this.#dispatchToTarget(event, synthetic);
}
+ #captureStaleCompositionEvent(event: CompositionEvent) {
+ if (!this.#isEditable()) {
+ return;
+ }
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ const staleOrigin = this.#resolveStaleEditorOrigin(event);
+ if (!staleOrigin) {
+ return;
+ }
+
+ let synthetic: Event;
+ if (typeof CompositionEvent !== 'undefined') {
+ synthetic = new CompositionEvent(event.type, {
+ data: event.data ?? '',
+ bubbles: true,
+ cancelable: true,
+ });
+ } else {
+ synthetic = new Event(event.type, { bubbles: true, cancelable: true });
+ }
+
+ this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, {
+ focusTarget: true,
+ suppressOriginal: true,
+ });
+ }
+
/**
* Forwards context menu events to the hidden editor.
*
@@ -272,6 +519,9 @@ export class PresentationInputBridge {
if (handledByContextMenu) {
return;
}
+ if (this.#wasForwardedByBridge(event)) {
+ return;
+ }
if (!this.#isEditable()) {
return;
}
@@ -281,6 +531,7 @@ export class PresentationInputBridge {
if (event.defaultPrevented) {
return;
}
+ this.#markForwardedByBridge(event);
const synthetic = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
@@ -359,6 +610,14 @@ export class PresentationInputBridge {
return origin ? this.#layoutSurfaces.has(origin) : false;
}
+ #wasForwardedByBridge(event: Event): boolean {
+ return Boolean((event as Event & { [BRIDGE_FORWARDED_FLAG]?: boolean })[BRIDGE_FORWARDED_FLAG]);
+ }
+
+ #markForwardedByBridge(event: Event) {
+ (event as Event & { [BRIDGE_FORWARDED_FLAG]?: boolean })[BRIDGE_FORWARDED_FLAG] = true;
+ }
+
/**
* Returns the set of event targets to attach listeners to.
* Includes registered layout surfaces and optionally the window for fallback.
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts
new file mode 100644
index 0000000000..4927353733
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts
@@ -0,0 +1,196 @@
+import type { EditorState } from 'prosemirror-state';
+import type { FlowBlock, Run as LayoutRun, TextRun } from '@superdoc/contracts';
+import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter';
+import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js';
+
+import type { ProseMirrorJSON } from '../../types/EditorTypes.js';
+import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js';
+import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js';
+import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js';
+import type { NoteRenderOverride } from './FootnotesBuilder.js';
+
+export type EndnoteConverterLike = {
+ endnotes?: Array<{ id?: unknown; content?: unknown[] }>;
+};
+
+type ParagraphBlock = FlowBlock & {
+ kind: 'paragraph';
+ runs?: LayoutRun[];
+};
+
+const ENDNOTE_MARKER_DATA_ATTR = 'data-sd-endnote-number';
+const DEFAULT_MARKER_FONT_FAMILY = 'Arial';
+const DEFAULT_MARKER_FONT_SIZE = 12;
+
+export function buildEndnoteBlocks(
+ editorState: EditorState | null | undefined,
+ converter: EndnoteConverterLike | null | undefined,
+ converterContext: ConverterContext | undefined,
+ themeColors: unknown,
+ renderOverride: NoteRenderOverride | null = null,
+): FlowBlock[] {
+ if (!editorState) return [];
+
+ const endnoteNumberById = converterContext?.endnoteNumberById;
+ const importedEndnotes = Array.isArray(converter?.endnotes) ? converter.endnotes : [];
+ if (importedEndnotes.length === 0) return [];
+
+ const orderedEndnoteIds: string[] = [];
+ const seen = new Set();
+
+ editorState.doc.descendants((node) => {
+ if (node.type?.name !== 'endnoteReference') return;
+ const id = node.attrs?.id;
+ if (id == null) return;
+ const key = String(id);
+ if (!key || seen.has(key)) return;
+ seen.add(key);
+ orderedEndnoteIds.push(key);
+ });
+
+ if (orderedEndnoteIds.length === 0) return [];
+
+ const blocks: FlowBlock[] = [];
+
+ orderedEndnoteIds.forEach((id) => {
+ try {
+ const endnoteDoc = resolveEndnoteDocJson(id, importedEndnotes, renderOverride);
+ if (!endnoteDoc) return;
+
+ const result = toFlowBlocks(endnoteDoc, {
+ blockIdPrefix: `endnote-${id}-`,
+ storyKey: buildStoryKey({ kind: 'story', storyType: 'endnote', noteId: id }),
+ enableRichHyperlinks: true,
+ themeColors: themeColors as never,
+ converterContext: converterContext as never,
+ });
+
+ if (result?.blocks?.length) {
+ ensureEndnoteMarker(result.blocks, id, endnoteNumberById);
+ blocks.push(...result.blocks);
+ }
+ } catch {}
+ });
+
+ return blocks;
+}
+
+function isTextRun(run: LayoutRun): run is TextRun {
+ return (run.kind === 'text' || run.kind == null) && typeof (run as { text?: unknown }).text === 'string';
+}
+
+function isEndnoteMarker(run: LayoutRun): boolean {
+ return isTextRun(run) && Boolean(run.dataAttrs?.[ENDNOTE_MARKER_DATA_ATTR]);
+}
+
+function resolveDisplayNumber(id: string, endnoteNumberById: Record | undefined): number {
+ if (!endnoteNumberById || typeof endnoteNumberById !== 'object') return 1;
+ const num = endnoteNumberById[id];
+ if (typeof num === 'number' && Number.isFinite(num) && num > 0) return num;
+ return 1;
+}
+
+function resolveMarkerFontFamily(firstTextRun: TextRun | undefined): string {
+ return typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : DEFAULT_MARKER_FONT_FAMILY;
+}
+
+function resolveMarkerBaseFontSize(firstTextRun: TextRun | undefined): number {
+ if (
+ typeof firstTextRun?.fontSize === 'number' &&
+ Number.isFinite(firstTextRun.fontSize) &&
+ firstTextRun.fontSize > 0
+ ) {
+ return firstTextRun.fontSize;
+ }
+
+ return DEFAULT_MARKER_FONT_SIZE;
+}
+
+function buildMarkerRun(markerText: string, firstTextRun: TextRun | undefined): TextRun {
+ const markerRun: TextRun = {
+ kind: 'text',
+ text: markerText,
+ dataAttrs: { [ENDNOTE_MARKER_DATA_ATTR]: 'true' },
+ fontFamily: resolveMarkerFontFamily(firstTextRun),
+ fontSize: resolveMarkerBaseFontSize(firstTextRun) * SUBSCRIPT_SUPERSCRIPT_SCALE,
+ vertAlign: 'superscript',
+ };
+
+ if (typeof firstTextRun?.bold === 'boolean') markerRun.bold = firstTextRun.bold;
+ if (typeof firstTextRun?.italic === 'boolean') markerRun.italic = firstTextRun.italic;
+ if (typeof firstTextRun?.letterSpacing === 'number' && Number.isFinite(firstTextRun.letterSpacing)) {
+ markerRun.letterSpacing = firstTextRun.letterSpacing;
+ }
+ if (firstTextRun?.color != null) markerRun.color = firstTextRun.color;
+
+ return markerRun;
+}
+
+function syncMarkerRun(target: TextRun, source: TextRun): void {
+ target.kind = source.kind;
+ target.text = source.text;
+ target.dataAttrs = source.dataAttrs;
+ target.fontFamily = source.fontFamily;
+ target.fontSize = source.fontSize;
+ target.bold = source.bold;
+ target.italic = source.italic;
+ target.letterSpacing = source.letterSpacing;
+ target.color = source.color;
+ target.vertAlign = source.vertAlign;
+ target.baselineShift = source.baselineShift;
+ delete target.pmStart;
+ delete target.pmEnd;
+}
+
+function cloneJsonValue(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
+function cloneNoteContentJson(content: unknown[]): ProseMirrorJSON[] {
+ return cloneJsonValue(content) as ProseMirrorJSON[];
+}
+
+function resolveEndnoteDocJson(
+ id: string,
+ importedEndnotes: Array<{ id?: unknown; content?: unknown[] }>,
+ renderOverride: NoteRenderOverride | null,
+): ProseMirrorJSON | null {
+ if (renderOverride && renderOverride.noteId === id) {
+ return normalizeNotePmJson(cloneJsonValue(renderOverride.docJson));
+ }
+
+ const entry = findNoteEntryById(importedEndnotes, id);
+ const content = entry?.content;
+ if (!Array.isArray(content) || content.length === 0) {
+ return null;
+ }
+
+ return normalizeNotePmJson({
+ type: 'doc',
+ content: cloneNoteContentJson(content),
+ });
+}
+
+function ensureEndnoteMarker(
+ blocks: FlowBlock[],
+ id: string,
+ endnoteNumberById: Record | undefined,
+): void {
+ const firstParagraph = blocks.find((block): block is ParagraphBlock => block.kind === 'paragraph');
+ if (!firstParagraph) return;
+
+ const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : [];
+ firstParagraph.runs = runs;
+
+ const firstTextRun = runs.find(
+ (run): run is TextRun => isTextRun(run) && !isEndnoteMarker(run) && run.text.length > 0,
+ );
+ const markerRun = buildMarkerRun(String(resolveDisplayNumber(id, endnoteNumberById)), firstTextRun);
+
+ if (runs[0] && isTextRun(runs[0]) && isEndnoteMarker(runs[0])) {
+ syncMarkerRun(runs[0], markerRun);
+ return;
+ }
+
+ runs.unshift(markerRun);
+}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts
index 9a73cadd3b..07171fec78 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts
@@ -6,14 +6,15 @@
*
* ## Key Concepts
*
- * - `pmStart`/`pmEnd`: ProseMirror document positions that map layout elements
- * back to their source positions in the editor. Used for selection, cursor
- * placement, and click-to-position functionality.
- *
* - `data-sd-footnote-number`: A data attribute marking the superscript number
* run (e.g., "1") at the start of footnote content. Used to distinguish the
* marker from actual footnote text during rendering and selection.
*
+ * The synthetic marker is visual chrome, not part of the editable note story.
+ * It must not carry `pmStart`/`pmEnd`, otherwise the rendered marker consumes
+ * horizontal space that the hidden story editor does not own. That creates
+ * caret drift and inaccurate click-to-position at the start of the note.
+ *
* @module presentation-editor/layout/FootnotesBuilder
*/
@@ -22,8 +23,11 @@ import type { FlowBlock } from '@superdoc/contracts';
import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter';
import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js';
+import type { ProseMirrorJSON } from '../../types/EditorTypes.js';
import type { FootnoteReference, FootnotesLayoutInput } from '../types.js';
import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js';
+import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js';
+import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js';
// Re-export types for consumers
export type { FootnoteReference, FootnotesLayoutInput };
@@ -37,6 +41,11 @@ export type ConverterLike = {
footnotes?: Array<{ id?: unknown; content?: unknown[] }>;
};
+export type NoteRenderOverride = {
+ noteId: string;
+ docJson: ProseMirrorJSON;
+};
+
/** A text run within a paragraph block. */
type Run = {
kind?: string;
@@ -88,6 +97,7 @@ export function buildFootnotesInput(
converter: ConverterLike | null | undefined,
converterContext: ConverterContext | undefined,
themeColors: unknown,
+ renderOverride: NoteRenderOverride | null = null,
): FootnotesLayoutInput | null {
if (!editorState) return null;
@@ -118,16 +128,13 @@ export function buildFootnotesInput(
const blocksById = new Map();
idsInUse.forEach((id) => {
- const entry = findNoteEntryById(importedFootnotes, id);
- const content = entry?.content;
- if (!Array.isArray(content) || content.length === 0) return;
-
try {
- // Deep clone to prevent mutation of the original converter data
- const clonedContent = JSON.parse(JSON.stringify(content));
- const footnoteDoc = { type: 'doc', content: clonedContent };
+ const footnoteDoc = resolveNoteDocJson(id, importedFootnotes, renderOverride);
+ if (!footnoteDoc) return;
+
const result = toFlowBlocks(footnoteDoc, {
blockIdPrefix: `footnote-${id}-`,
+ storyKey: buildStoryKey({ kind: 'story', storyType: 'footnote', noteId: id }),
enableRichHyperlinks: true,
themeColors: themeColors as never,
converterContext: converterContext as never,
@@ -167,25 +174,6 @@ function isFootnoteMarker(run: Run): boolean {
return Boolean(run.dataAttrs?.[FOOTNOTE_MARKER_DATA_ATTR]);
}
-/**
- * Finds the first run with valid ProseMirror position data.
- * Used to inherit position info for the marker run.
- *
- * @param runs - Array of runs to search
- * @returns The first run with pmStart/pmEnd, or undefined
- */
-function findRunWithPositions(runs: Run[]): Run | undefined {
- return runs.find((r) => {
- if (isFootnoteMarker(r)) return false;
- return (
- typeof r.pmStart === 'number' &&
- Number.isFinite(r.pmStart) &&
- typeof r.pmEnd === 'number' &&
- Number.isFinite(r.pmEnd)
- );
- });
-}
-
/**
* Resolves the display number for a footnote.
* Falls back to 1 if the footnote ID is not in the mapping or invalid.
@@ -211,33 +199,6 @@ function resolveMarkerText(value: unknown): string {
return String(value ?? '');
}
-/**
- * Computes the PM position range for the marker run.
- *
- * The marker inherits position info from an existing run so that clicking
- * on the footnote number positions the cursor correctly. The end position
- * is clamped to not exceed the original run's range.
- *
- * @param baseRun - The run to inherit positions from
- * @param markerLength - Length of the marker text
- * @returns Object with pmStart and pmEnd, or nulls if no base run
- */
-function computeMarkerPositions(
- baseRun: Run | undefined,
- markerLength: number,
-): { pmStart: number | null; pmEnd: number | null } {
- if (baseRun?.pmStart == null) {
- return { pmStart: null, pmEnd: null };
- }
-
- const pmStart = baseRun.pmStart;
- // Clamp pmEnd to not exceed the base run's end position
- const pmEnd =
- baseRun.pmEnd != null ? Math.max(pmStart, Math.min(baseRun.pmEnd, pmStart + markerLength)) : pmStart + markerLength;
-
- return { pmStart, pmEnd };
-}
-
function resolveMarkerFontFamily(firstTextRun: Run | undefined): string {
return typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : DEFAULT_MARKER_FONT_FAMILY;
}
@@ -254,11 +215,7 @@ function resolveMarkerBaseFontSize(firstTextRun: Run | undefined): number {
return DEFAULT_MARKER_FONT_SIZE;
}
-function buildMarkerRun(
- markerText: string,
- firstTextRun: Run | undefined,
- positions: { pmStart: number | null; pmEnd: number | null },
-): Run {
+function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run {
const markerRun: Run = {
kind: 'text',
text: markerText,
@@ -274,12 +231,39 @@ function buildMarkerRun(
markerRun.letterSpacing = firstTextRun.letterSpacing;
}
if (firstTextRun?.color != null) markerRun.color = firstTextRun.color;
- if (positions.pmStart != null) markerRun.pmStart = positions.pmStart;
- if (positions.pmEnd != null) markerRun.pmEnd = positions.pmEnd;
return markerRun;
}
+function cloneJsonValue(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
+function cloneNoteContentJson(content: unknown[]): ProseMirrorJSON[] {
+ return cloneJsonValue(content) as ProseMirrorJSON[];
+}
+
+function resolveNoteDocJson(
+ id: string,
+ importedFootnotes: Array<{ id?: unknown; content?: unknown[] }>,
+ renderOverride: NoteRenderOverride | null,
+): ProseMirrorJSON | null {
+ if (renderOverride && renderOverride.noteId === id) {
+ return normalizeNotePmJson(cloneJsonValue(renderOverride.docJson));
+ }
+
+ const entry = findNoteEntryById(importedFootnotes, id);
+ const content = entry?.content;
+ if (!Array.isArray(content) || content.length === 0) {
+ return null;
+ }
+
+ return normalizeNotePmJson({
+ type: 'doc',
+ content: cloneNoteContentJson(content),
+ });
+}
+
function syncMarkerRun(target: Run, source: Run): void {
target.kind = source.kind;
target.text = source.text;
@@ -292,8 +276,8 @@ function syncMarkerRun(target: Run, source: Run): void {
target.color = source.color;
target.vertAlign = source.vertAlign;
target.baselineShift = source.baselineShift;
- target.pmStart = source.pmStart ?? target.pmStart;
- target.pmEnd = source.pmEnd ?? target.pmEnd;
+ delete target.pmStart;
+ delete target.pmEnd;
}
/**
@@ -303,7 +287,8 @@ function syncMarkerRun(target: Run, source: Run): void {
* number rendered as a normal digit with superscript styling. This function
* prepends that marker to the first paragraph's runs.
*
- * If a marker already exists, updates its PM positions if missing.
+ * If a marker already exists, normalizes it back to the synthetic visual-only
+ * shape so stale PM ranges do not leak into the active editing surface.
* Modifies the blocks array in place.
*
* @param blocks - Array of FlowBlocks to modify
@@ -321,11 +306,8 @@ function ensureFootnoteMarker(
const runs: Run[] = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : [];
const displayNumber = resolveDisplayNumber(id, footnoteNumberById);
const markerText = resolveMarkerText(displayNumber);
-
- const baseRun = findRunWithPositions(runs);
- const positions = computeMarkerPositions(baseRun, markerText.length);
const firstTextRun = runs.find((run) => typeof run.text === 'string' && !isFootnoteMarker(run));
- const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun, positions);
+ const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun);
// Check if marker already exists
const existingMarker = runs.find(isFootnoteMarker);
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts
index 2cc0e7a524..1a207a2eb9 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts
@@ -23,6 +23,7 @@ import type { PositionHit, PageGeometryHelper, TableHitResult } from '@superdoc/
import type { SelectionDebugHudState } from '../selection/SelectionDebug.js';
import type { EpochPositionMapper } from '../layout/EpochPositionMapper.js';
import type { HeaderFooterSessionManager } from '../header-footer/HeaderFooterSessionManager.js';
+import type { StoryPresentationSession } from '../story-session/types.js';
import { getFragmentAtPosition } from '@superdoc/layout-bridge';
import { resolvePointerPositionHit } from '../input/PositionHitResolver.js';
@@ -55,6 +56,9 @@ const AUTO_SCROLL_MAX_SPEED_PX = 24;
const SCROLL_DETECTION_TOLERANCE_PX = 1;
const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight';
const TRACK_CHANGE_SELECTOR = '[data-track-change-id]';
+const PM_TRACK_CHANGE_SELECTOR = '.track-insert[data-id], .track-delete[data-id], .track-format[data-id]';
+const VISIBLE_HEADER_FOOTER_SELECTOR = '.superdoc-page-header, .superdoc-page-footer';
+const VISIBLE_BODY_CONTENT_SELECTOR = '.superdoc-line, .superdoc-fragment, [data-block-id]';
const COMMENT_THREAD_HIT_TOLERANCE_PX = 3;
const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray = [
[0, 0],
@@ -71,12 +75,54 @@ type CommentThreadHit = {
};
/**
- * Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder).
+ * Block IDs for note content use `footnote-{id}-` / `endnote-{id}-` prefixes.
* Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from
* shared constants โ it matches both heading and body footnote block IDs.
*/
-function isFootnoteBlockId(blockId: string): boolean {
- return typeof blockId === 'string' && (blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId));
+function isRenderedNoteBlockId(blockId: string): boolean {
+ return (
+ typeof blockId === 'string' &&
+ (blockId.startsWith('footnote-') || blockId.startsWith('endnote-') || isSemanticFootnoteBlockId(blockId))
+ );
+}
+
+type RenderedNoteTarget = {
+ storyType: 'footnote' | 'endnote';
+ noteId: string;
+};
+
+function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null {
+ if (typeof blockId !== 'string' || blockId.length === 0) {
+ return null;
+ }
+
+ if (blockId.startsWith('footnote-')) {
+ const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? '';
+ return noteId ? { storyType: 'footnote', noteId } : null;
+ }
+
+ if (blockId.startsWith('__sd_semantic_footnote-')) {
+ const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? '';
+ return noteId ? { storyType: 'footnote', noteId } : null;
+ }
+
+ if (blockId.startsWith('endnote-')) {
+ const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? '';
+ return noteId ? { storyType: 'endnote', noteId } : null;
+ }
+
+ return null;
+}
+
+function isSameRenderedNoteTarget(
+ left: RenderedNoteTarget | null | undefined,
+ right: RenderedNoteTarget | null | undefined,
+): boolean {
+ if (!left || !right) {
+ return false;
+ }
+
+ return left.storyType === right.storyType && left.noteId === right.noteId;
}
function getCommentHighlightThreadIds(target: EventTarget | null): string[] {
@@ -111,8 +157,10 @@ function resolveTrackChangeThreadId(target: EventTarget | null): string | null {
return null;
}
- const trackedChangeElement = target.closest(TRACK_CHANGE_SELECTOR);
- const threadId = trackedChangeElement?.getAttribute('data-track-change-id')?.trim();
+ const trackedChangeElement = target.closest(`${TRACK_CHANGE_SELECTOR}, ${PM_TRACK_CHANGE_SELECTOR}`);
+ const threadId =
+ trackedChangeElement?.getAttribute('data-track-change-id')?.trim() ??
+ trackedChangeElement?.getAttribute('data-id')?.trim();
return threadId ? threadId : null;
}
@@ -177,6 +225,54 @@ function collectElementsNearPointerTarget(target: EventTarget | null, clientX: n
return candidates;
}
+function elementContainsPointerSample(element: Element, clientX: number, clientY: number): boolean {
+ const rect = element.getBoundingClientRect();
+ if (![rect.left, rect.top, rect.right, rect.bottom].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0) {
+ return false;
+ }
+
+ for (const [offsetX, offsetY] of COMMENT_THREAD_HIT_SAMPLE_OFFSETS) {
+ const sampleX = clientX + offsetX;
+ const sampleY = clientY + offsetY;
+ if (sampleX >= rect.left && sampleX <= rect.right && sampleY >= rect.top && sampleY <= rect.bottom) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function resolveCommentThreadIdFromGeometry(
+ elements: Iterable,
+ clientX: number,
+ clientY: number,
+): string | null {
+ let resolvedThreadId: string | null = null;
+
+ for (const element of elements) {
+ if (!elementContainsPointerSample(element, clientX, clientY)) {
+ continue;
+ }
+
+ const hit = resolveCommentThreadHit(element);
+ if (hit.isAmbiguous) {
+ return null;
+ }
+
+ if (!hit.threadId) {
+ continue;
+ }
+
+ if (resolvedThreadId && resolvedThreadId !== hit.threadId) {
+ return null;
+ }
+
+ resolvedThreadId = hit.threadId;
+ }
+
+ return resolvedThreadId;
+}
+
function resolveCommentThreadIdNearPointer(
target: EventTarget | null,
clientX: number,
@@ -204,6 +300,45 @@ function resolveCommentThreadIdNearPointer(
return null;
}
+type VisiblePointerSurfaceHit = { kind: 'headerFooter'; surface: HTMLElement } | { kind: 'bodyContent' };
+
+function resolveVisibleSurfaceAtPointer(
+ target: EventTarget | null,
+ clientX: number,
+ clientY: number,
+): VisiblePointerSurfaceHit | null {
+ const ownerDocument = target instanceof Element ? target.ownerDocument : document;
+ const ownerWindow = ownerDocument.defaultView;
+
+ if (typeof ownerDocument.elementFromPoint !== 'function' || !ownerWindow) {
+ return null;
+ }
+
+ const sampleX = clamp(clientX, 0, Math.max(ownerWindow.innerWidth - 1, 0));
+ const sampleY = clamp(clientY, 0, Math.max(ownerWindow.innerHeight - 1, 0));
+ const sampledElements =
+ typeof ownerDocument.elementsFromPoint === 'function'
+ ? ownerDocument.elementsFromPoint(sampleX, sampleY)
+ : [ownerDocument.elementFromPoint(sampleX, sampleY)];
+
+ for (const element of sampledElements) {
+ if (!(element instanceof HTMLElement)) {
+ continue;
+ }
+
+ const visibleHeaderFooterSurface = element.closest(VISIBLE_HEADER_FOOTER_SELECTOR) as HTMLElement | null;
+ if (visibleHeaderFooterSurface) {
+ return { kind: 'headerFooter', surface: visibleHeaderFooterSurface };
+ }
+
+ if (element.closest(VISIBLE_BODY_CONTENT_SELECTOR)) {
+ return { kind: 'bodyContent' };
+ }
+ }
+
+ return null;
+}
+
function getActiveCommentThreadId(editor: Editor): string | null {
const pluginState = CommentsPluginKey.getState(editor.state) as { activeThreadId?: unknown } | null;
const activeThreadId = pluginState?.activeThreadId;
@@ -288,6 +423,8 @@ export type EditorInputDependencies = {
getPageElement: (pageIndex: number) => HTMLElement | null;
/** Check if selection-aware virtualization is enabled */
isSelectionAwareVirtualizationEnabled: () => boolean;
+ /** Get the currently active non-body story session, if any */
+ getActiveStorySession?: () => StoryPresentationSession | null;
};
/**
@@ -324,7 +461,10 @@ export type EditorInputCallbacks = {
/** Exit header/footer mode */
exitHeaderFooterMode?: () => void;
/** Activate header/footer region */
- activateHeaderFooterRegion?: (region: HeaderFooterRegion) => void;
+ activateHeaderFooterRegion?: (
+ region: HeaderFooterRegion,
+ options?: { clientX: number; clientY: number; pageIndex?: number; source?: 'pointerDoubleClick' | 'programmatic' },
+ ) => void;
/** Emit header/footer edit blocked */
emitHeaderFooterEditBlocked?: (reason: string) => void;
/** Find region for page */
@@ -359,8 +499,23 @@ export type EditorInputCallbacks = {
dragAnchor: number,
dragMode: 'char' | 'word' | 'para',
) => void;
+ /**
+ * Called when a pointer text-drag selection ends.
+ * Used to scroll the selection into view once after auto-scroll stops; during drag,
+ * selection-driven scroll is suppressed to avoid fighting edge auto-scroll.
+ */
+ notifyDragSelectionEnded?: () => void;
/** Hit test table at coordinates */
hitTestTable?: (x: number, y: number) => TableHitResult | null;
+ /** Hit test the currently active editing surface */
+ hitTest?: (clientX: number, clientY: number) => PositionHit | null;
+ /** Activate a rendered note session from a visible note block click */
+ activateRenderedNoteSession?: (
+ target: RenderedNoteTarget,
+ options: { clientX: number; clientY: number; pageIndex?: number },
+ ) => boolean;
+ /** Exit the active generic story session */
+ exitActiveStorySession?: () => void;
};
// =============================================================================
@@ -599,6 +754,18 @@ export class EditorInputManager {
return this.#lastSelectedImageBlockId;
}
+ /**
+ * Resets click-derived interaction state when the active editing surface
+ * changes (for example body -> footnote or footnote -> header).
+ *
+ * Without this, a single click in the previous surface can be mistaken for
+ * the first click of a double/triple click in the next surface.
+ */
+ notifyTargetChanged(): void {
+ this.#resetMultiClickTracking();
+ this.#pendingMarginClick = null;
+ }
+
/** Drag anchor page index */
get dragAnchorPageIndex(): number | null {
return this.#dragAnchorPageIndex;
@@ -653,6 +820,12 @@ export class EditorInputManager {
this.#cellDragMode = 'none';
}
+ #resetMultiClickTracking(): void {
+ this.#clickCount = 0;
+ this.#lastClickTime = 0;
+ this.#lastClickPosition = null;
+ }
+
#registerPointerClick(event: MouseEvent): number {
const nextState = registerPointerClickFromHelper(
event,
@@ -676,10 +849,86 @@ export class EditorInputManager {
}
#getFirstTextPosition(): number {
- const editor = this.#deps?.getEditor();
+ const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor();
return getFirstTextPositionFromHelper(editor?.state?.doc ?? null);
}
+ #resolveBodyPointerHit(
+ layoutState: ReturnType,
+ normalized: { x: number; y: number },
+ clientX: number,
+ clientY: number,
+ ): PositionHit | null {
+ const viewportHost = this.#deps?.getViewportHost();
+ const pageGeometryHelper = this.#deps?.getPageGeometryHelper();
+ if (!viewportHost) {
+ return null;
+ }
+
+ return (
+ resolvePointerPositionHit({
+ layout: layoutState.layout,
+ blocks: layoutState.blocks,
+ measures: layoutState.measures,
+ containerPoint: normalized,
+ domContainer: viewportHost,
+ clientX,
+ clientY,
+ geometryHelper: pageGeometryHelper ?? undefined,
+ }) ?? null
+ );
+ }
+
+ #resolveSelectionPointerHit(options: {
+ layoutState: ReturnType;
+ normalized: { x: number; y: number };
+ clientX: number;
+ clientY: number;
+ editor: Editor;
+ useActiveSurfaceHitTest: boolean;
+ }): { rawHit: PositionHit | null; hit: PositionHit | null } {
+ const { layoutState, normalized, clientX, clientY, editor, useActiveSurfaceHitTest } = options;
+ const doc = editor.state?.doc;
+ const rawHit =
+ useActiveSurfaceHitTest && this.#callbacks.hitTest
+ ? this.#callbacks.hitTest(clientX, clientY)
+ : this.#resolveBodyPointerHit(layoutState, normalized, clientX, clientY);
+
+ if (!rawHit || !doc) {
+ return { rawHit, hit: null };
+ }
+
+ if (useActiveSurfaceHitTest) {
+ return {
+ rawHit,
+ hit: {
+ ...rawHit,
+ pos: clamp(rawHit.pos, 0, doc.content.size),
+ },
+ };
+ }
+
+ const epochMapper = this.#deps?.getEpochMapper();
+ if (!epochMapper) {
+ return { rawHit, hit: null };
+ }
+
+ const mapped = epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1);
+ if (!mapped.ok) {
+ debugLog('warn', 'pointer mapping failed', mapped);
+ return { rawHit, hit: null };
+ }
+
+ return {
+ rawHit,
+ hit: {
+ ...rawHit,
+ pos: clamp(mapped.pos, 0, doc.content.size),
+ layoutEpoch: mapped.toEpoch,
+ },
+ };
+ }
+
#calculateExtendedSelection(
anchor: number,
head: number,
@@ -1055,17 +1304,50 @@ export class EditorInputManager {
return;
}
- const editor = this.#deps.getEditor();
- if (this.#handleSingleCommentHighlightClick(event, target, editor)) {
- return;
- }
+ const bodyEditor = this.#deps.getEditor();
+ const layoutState = this.#deps.getLayoutState();
+ const clickedNoteTarget = this.#resolveRenderedNoteTargetAtPointer(target, event.clientX, event.clientY);
- if (this.#handleRepeatClickOnActiveComment(event, target, editor)) {
- return;
- }
+ // Check header/footer session state
+ const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
+ let activeStorySession = this.#deps.getActiveStorySession?.() ?? null;
+ let activeNoteSession = activeStorySession?.kind === 'note' ? activeStorySession : null;
+ const activeNoteTarget = this.#getActiveRenderedNoteTarget();
- const layoutState = this.#deps.getLayoutState();
if (!layoutState.layout) {
+ if (clickedNoteTarget && !isSameRenderedNoteTarget(activeNoteTarget, clickedNoteTarget)) {
+ if (!isDraggableAnnotation) {
+ event.preventDefault();
+ }
+ const activated = this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (activated) {
+ this.#syncNonBodyCommentActivation(event, target, bodyEditor);
+ return;
+ }
+ this.#focusEditor();
+ return;
+ }
+
+ if (!clickedNoteTarget && activeNoteSession) {
+ this.#callbacks.exitActiveStorySession?.();
+ }
+
+ const isActiveStorySurface = sessionMode !== 'body' || activeNoteSession != null;
+ if (!isActiveStorySurface) {
+ if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) {
+ return;
+ }
+
+ if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) {
+ return;
+ }
+ } else {
+ this.#syncNonBodyCommentActivation(event, target, bodyEditor);
+ }
+
this.#handleClickWithoutLayout(event, isDraggableAnnotation);
return;
}
@@ -1076,17 +1358,44 @@ export class EditorInputManager {
const { x, y } = normalizedPoint;
this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y };
- // Disallow cursor placement in footnote lines: keep current selection and only focus editor.
- const fragmentEl = target?.closest?.('[data-block-id]') as HTMLElement | null;
- const clickedBlockId = fragmentEl?.getAttribute?.('data-block-id') ?? '';
- if (isFootnoteBlockId(clickedBlockId)) {
- if (!isDraggableAnnotation) event.preventDefault();
- this.#focusEditor();
- return;
+ if (clickedNoteTarget) {
+ const isSameActiveNote = isSameRenderedNoteTarget(activeNoteTarget, clickedNoteTarget);
+ if (!isSameActiveNote) {
+ if (!isDraggableAnnotation) event.preventDefault();
+ const activated = this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pageIndex: normalizedPoint.pageIndex,
+ });
+ if (activated) {
+ this.#syncNonBodyCommentActivation(event, target, bodyEditor);
+ return;
+ }
+ this.#focusEditor();
+ return;
+ }
+ } else if (activeNoteSession) {
+ this.#callbacks.exitActiveStorySession?.();
+ activeStorySession = null;
+ activeNoteSession = null;
}
- // Check header/footer session state
- const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
+ const isActiveStorySurface = sessionMode !== 'body' || activeStorySession != null;
+ if (!isActiveStorySurface) {
+ if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) {
+ return;
+ }
+
+ if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) {
+ return;
+ }
+ } else {
+ this.#syncNonBodyCommentActivation(event, target, bodyEditor);
+ }
+
+ const isNoteEditing = activeNoteSession != null;
+ const useActiveSurfaceHitTest = sessionMode !== 'body' || activeStorySession != null;
+ const editor = sessionMode === 'body' && !isNoteEditing ? bodyEditor : this.#deps.getActiveEditor();
if (sessionMode !== 'body') {
if (this.#handleClickInHeaderFooterMode(event, x, y, normalizedPoint.pageIndex, normalizedPoint.pageLocalY))
return;
@@ -1100,37 +1409,21 @@ export class EditorInputManager {
normalizedPoint.pageLocalY,
);
if (headerFooterRegion) {
- event.preventDefault(); // Prevent native selection before double-click handles it
- return; // Will be handled by double-click
+ if (sessionMode === 'body') {
+ event.preventDefault(); // Prevent native selection before double-click handles it
+ return; // Will be handled by double-click
+ }
}
- // Get hit position
- const viewportHost = this.#deps.getViewportHost();
- const pageGeometryHelper = this.#deps.getPageGeometryHelper();
- const rawHit = resolvePointerPositionHit({
- layout: layoutState.layout,
- blocks: layoutState.blocks,
- measures: layoutState.measures,
- containerPoint: { x, y },
- domContainer: viewportHost,
+ const { rawHit, hit } = this.#resolveSelectionPointerHit({
+ layoutState,
+ normalized: { x, y },
clientX: event.clientX,
clientY: event.clientY,
- geometryHelper: pageGeometryHelper ?? undefined,
+ editor,
+ useActiveSurfaceHitTest,
});
-
const doc = editor.state?.doc;
- const epochMapper = this.#deps.getEpochMapper();
- const mapped =
- rawHit && doc ? epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1) : null;
-
- if (mapped && !mapped.ok) {
- debugLog('warn', 'pointerdown mapping failed', mapped);
- }
-
- const hit =
- rawHit && doc && mapped?.ok
- ? { ...rawHit, pos: Math.max(0, Math.min(mapped.pos, doc.content.size)), layoutEpoch: mapped.toEpoch }
- : null;
this.#debugLastHit = hit
? { source: 'dom', pos: rawHit?.pos ?? null, layoutEpoch: rawHit?.layoutEpoch ?? null, mappedPos: hit.pos }
@@ -1185,9 +1478,19 @@ export class EditorInputManager {
return;
}
- // Disallow cursor placement in footnote lines (footnote content is read-only in the layout).
- // Keep the current selection unchanged instead of moving caret to document start.
- if (isFootnoteBlockId(rawHit.blockId)) {
+ // Guard against stale note hits after a session switch or partial rerender.
+ if (
+ isNoteEditing &&
+ activeNoteTarget &&
+ parseRenderedNoteTarget(rawHit.blockId)?.noteId !== activeNoteTarget.noteId
+ ) {
+ this.#callbacks.exitActiveStorySession?.();
+ this.#focusEditor();
+ return;
+ }
+
+ // Disallow entering read-only note content unless it has been activated into a story session.
+ if (isRenderedNoteBlockId(rawHit.blockId) && !isNoteEditing) {
this.#focusEditor();
return;
}
@@ -1199,11 +1502,16 @@ export class EditorInputManager {
}
// Check for image/fragment hit
- const fragmentHit = getFragmentAtPosition(layoutState.layout, layoutState.blocks, layoutState.measures, rawHit.pos);
+ const fragmentHit = useActiveSurfaceHitTest
+ ? null
+ : getFragmentAtPosition(layoutState.layout, layoutState.blocks, layoutState.measures, rawHit.pos);
// Handle inline image click
const targetImg = (event.target as HTMLElement | null)?.closest?.('img') as HTMLImageElement | null;
- if (this.#handleInlineImageClick(event, targetImg, rawHit, doc, epochMapper)) return;
+ if (!useActiveSurfaceHitTest) {
+ const epochMapper = this.#deps.getEpochMapper();
+ if (this.#handleInlineImageClick(event, targetImg, rawHit, doc, epochMapper)) return;
+ }
// Handle atomic fragment (image/drawing) click
if (this.#handleFragmentClick(event, fragmentHit, hit, doc)) return;
@@ -1269,21 +1577,19 @@ export class EditorInputManager {
}
// Capture pointer for reliable drag tracking
+ const viewportHost = this.#deps.getViewportHost();
if (typeof viewportHost.setPointerCapture === 'function') {
viewportHost.setPointerCapture(event.pointerId);
}
// Handle double/triple click selection
let handledByDepth = false;
- const sessionModeForDepth = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
- if (sessionModeForDepth === 'body') {
- const selectionPos = clickDepth >= 2 && this.#dragAnchor !== null ? this.#dragAnchor : hit.pos;
-
- if (clickDepth >= 3) {
- handledByDepth = this.#callbacks.selectParagraphAt?.(selectionPos) ?? false;
- } else if (clickDepth === 2) {
- handledByDepth = this.#callbacks.selectWordAt?.(selectionPos) ?? false;
- }
+ const selectionPos = clickDepth >= 2 && this.#dragAnchor !== null ? this.#dragAnchor : hit.pos;
+
+ if (clickDepth >= 3) {
+ handledByDepth = this.#callbacks.selectParagraphAt?.(selectionPos) ?? false;
+ } else if (clickDepth === 2) {
+ handledByDepth = this.#callbacks.selectWordAt?.(selectionPos) ?? false;
}
const hasFocus = editor.view?.hasFocus?.() ?? false;
@@ -1358,6 +1664,10 @@ export class EditorInputManager {
// Handle header/footer hover
const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY);
if (!normalized) return;
+ if (this.#deps.getActiveStorySession?.()?.kind === 'note') {
+ this.#callbacks.clearHoverRegion?.();
+ return;
+ }
this.#handleHover(normalized);
}
@@ -1406,6 +1716,8 @@ export class EditorInputManager {
this.#callbacks.finalizeDragSelectionWithDom?.(pointer, dragAnchor, dragMode);
}
+ this.#callbacks.notifyDragSelectionEnded?.();
+
this.#callbacks.scheduleA11ySelectionAnnouncement?.({ immediate: true });
this.#dragLastPointer = null;
@@ -1440,19 +1752,7 @@ export class EditorInputManager {
return;
}
- // When editing a header/footer, let the ProseMirror editor inside the
- // overlay handle double-click word/paragraph selection. Do not re-run
- // header/footer hit-testing for double-clicks that occur inside the
- // active editor host.
const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
- if (sessionMode !== 'body') {
- const activeEditorHost = this.#deps.getHeaderFooterSession()?.overlayManager?.getActiveEditorHost?.();
- const clickedInsideEditorHost =
- activeEditorHost && (activeEditorHost.contains(target as Node) || activeEditorHost === target);
- if (clickedInsideEditorHost) {
- return;
- }
- }
const layoutState = this.#deps.getLayoutState();
if (!layoutState.layout) return;
@@ -1460,6 +1760,33 @@ export class EditorInputManager {
const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY);
if (!normalized) return;
+ const clickedNoteTarget = this.#resolveRenderedNoteTargetAtPointer(target, event.clientX, event.clientY);
+ if (clickedNoteTarget) {
+ if (isSameRenderedNoteTarget(this.#getActiveRenderedNoteTarget(), clickedNoteTarget)) {
+ // Pointerdown already updated selection inside the live note session.
+ // Re-activating the same note here would remount the hidden editor and
+ // wipe out the word/paragraph selection that the multi-click logic just set.
+ //
+ // The activation gesture itself only registers one click inside the live
+ // note, so its trailing dblclick can leave a stale single-click marker
+ // behind. Clear only that activation residue and preserve genuine active
+ // multi-click state for triple-click paragraph selection.
+ if (this.#clickCount <= 1) {
+ this.#resetMultiClickTracking();
+ }
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pageIndex: normalized.pageIndex,
+ });
+ return;
+ }
+
const region = this.#callbacks.hitTestHeaderFooterRegion?.(
normalized.x,
normalized.y,
@@ -1467,13 +1794,20 @@ export class EditorInputManager {
normalized.pageLocalY,
);
if (region) {
- event.preventDefault();
- event.stopPropagation();
-
- // Materialization (if needed) now happens inside #enterMode via
- // ensureExplicitHeaderFooterSlot. The pointer handler only triggers
- // activation โ it is not responsible for slot creation.
- this.#callbacks.activateHeaderFooterRegion?.(region);
+ if (sessionMode === 'body' || this.#isDifferentHeaderFooterRegionFromActiveSession(region)) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ // Materialization (if needed) now happens inside #enterMode via
+ // ensureExplicitHeaderFooterSlot. The pointer handler only triggers
+ // activation โ it is not responsible for slot creation.
+ this.#callbacks.activateHeaderFooterRegion?.(region, {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pageIndex: normalized.pageIndex,
+ source: 'pointerDoubleClick',
+ });
+ }
} else if ((this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body') !== 'body') {
this.#callbacks.exitHeaderFooterMode?.();
}
@@ -1504,11 +1838,17 @@ export class EditorInputManager {
if (!this.#deps) return;
const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
+ const activeStorySession = this.#deps.getActiveStorySession?.() ?? null;
if (event.key === 'Escape' && sessionMode !== 'body') {
event.preventDefault();
this.#callbacks.exitHeaderFooterMode?.();
return;
}
+ if (event.key === 'Escape' && activeStorySession?.kind === 'note') {
+ event.preventDefault();
+ this.#callbacks.exitActiveStorySession?.();
+ return;
+ }
// Ctrl+Alt+H/F shortcuts
if (event.ctrlKey && event.altKey && !event.shiftKey) {
@@ -1555,9 +1895,12 @@ export class EditorInputManager {
#handleLinkClick(event: MouseEvent, linkEl: HTMLAnchorElement): void {
const href = linkEl.getAttribute('href') ?? '';
const isAnchorLink = href.startsWith('#') && href.length > 1;
- const isTocLink = linkEl.closest('.superdoc-toc-entry') !== null;
- if (isAnchorLink && isTocLink) {
+ // SD-2495: route any internal-anchor click (`#`) to in-document
+ // navigation. Covers TOC entries, heading/bookmark cross-references
+ // (REF fields with `\h`), and any other internal-hyperlink case โ they all
+ // should scroll to the bookmark target instead of navigating the browser.
+ if (isAnchorLink) {
event.preventDefault();
event.stopPropagation();
this.#callbacks.goToAnchor?.(href);
@@ -1796,12 +2139,16 @@ export class EditorInputManager {
pageLocalY?: number,
): boolean {
const session = this.#deps?.getHeaderFooterSession();
- const activeEditorHost = session?.overlayManager?.getActiveEditorHost?.();
- const clickedInsideEditorHost =
- activeEditorHost && (activeEditorHost.contains(event.target as Node) || activeEditorHost === event.target);
-
- if (clickedInsideEditorHost) {
- return true; // Let editor handle it
+ const activeSurfaceSelector =
+ session?.session?.mode === 'footer' ? '.superdoc-page-footer' : '.superdoc-page-header';
+ const visiblePointerSurface = resolveVisibleSurfaceAtPointer(event.target, event.clientX, event.clientY);
+ const clickedInsideVisibleActiveSurface =
+ visiblePointerSurface?.kind === 'headerFooter' &&
+ visiblePointerSurface.surface.closest(activeSurfaceSelector) != null;
+
+ if (visiblePointerSurface?.kind === 'bodyContent') {
+ this.#callbacks.exitHeaderFooterMode?.();
+ return false; // Continue to body click handling after exiting the active H/F session
}
const headerFooterRegion = this.#callbacks.hitTestHeaderFooterRegion?.(x, y, pageIndex, pageLocalY);
@@ -1810,12 +2157,84 @@ export class EditorInputManager {
return false; // Continue to body click handling
}
- // Click is in a H/F region on a different page โ don't consume the event.
- // Let it fall through to the existing footer region check in #handlePointerDown
- // which properly calls event.preventDefault() before the dblclick handler activates it.
+ if (visiblePointerSurface?.kind === 'headerFooter' && !clickedInsideVisibleActiveSurface) {
+ if (this.#isDifferentHeaderFooterRegionFromActiveSession(headerFooterRegion)) {
+ event.preventDefault();
+ return true;
+ }
+
+ this.#callbacks.exitHeaderFooterMode?.();
+ return false; // Continue to body click handling
+ }
+
+ this.#syncNonBodyCommentSelection(event, event.target as HTMLElement | null, this.#deps.getEditor(), {
+ clearOnMiss: true,
+ });
+
+ // Click is in the active rendered header/footer surface. Keep the story
+ // session active, update any tracked-change/comment bubble state, and let
+ // the normal rendered-surface hit testing place the selection/caret.
return false;
}
+ #isDifferentHeaderFooterRegionFromActiveSession(region: HeaderFooterRegion): boolean {
+ const session = this.#deps?.getHeaderFooterSession()?.session;
+ if (!session || session.mode === 'body') {
+ return true;
+ }
+
+ if (session.mode !== region.kind) {
+ return true;
+ }
+
+ if (
+ session.headerFooterRefId &&
+ region.headerFooterRefId &&
+ session.headerFooterRefId !== region.headerFooterRefId
+ ) {
+ return true;
+ }
+
+ if (
+ Number.isFinite(session.pageIndex) &&
+ Number.isFinite(region.pageIndex) &&
+ session.pageIndex !== region.pageIndex
+ ) {
+ return true;
+ }
+
+ return (session.sectionType ?? null) !== (region.sectionType ?? null);
+ }
+
+ #isSameHeaderFooterRegion(
+ left: HeaderFooterRegion | null | undefined,
+ right: HeaderFooterRegion | null | undefined,
+ ): boolean {
+ if (!left || !right) {
+ return false;
+ }
+
+ if (left.kind !== right.kind || left.pageIndex !== right.pageIndex) {
+ return false;
+ }
+
+ if ((left.sectionId ?? null) !== (right.sectionId ?? null)) {
+ return false;
+ }
+
+ if ((left.sectionType ?? null) !== (right.sectionType ?? null)) {
+ return false;
+ }
+
+ const leftRefId = left.headerFooterRefId ?? null;
+ const rightRefId = right.headerFooterRefId ?? null;
+ if (leftRefId && rightRefId && leftRefId !== rightRefId) {
+ return false;
+ }
+
+ return true;
+ }
+
#handleInlineImageClick(
event: PointerEvent,
targetImg: HTMLImageElement | null,
@@ -1927,7 +2346,7 @@ export class EditorInputManager {
}
#handleShiftClick(event: PointerEvent, headPos: number): void {
- const editor = this.#deps?.getEditor();
+ const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor();
if (!editor) return;
const anchor = editor.state.selection.anchor;
@@ -1956,26 +2375,26 @@ export class EditorInputManager {
this.#pendingMarginClick = null;
this.#dragLastPointer = { clientX, clientY, x: normalized.x, y: normalized.y };
- const viewportHost = this.#deps.getViewportHost();
- const pageGeometryHelper = this.#deps.getPageGeometryHelper();
-
- const rawHit = resolvePointerPositionHit({
- layout: layoutState.layout,
- blocks: layoutState.blocks,
- measures: layoutState.measures,
- containerPoint: { x: normalized.x, y: normalized.y },
- domContainer: viewportHost,
+ const activeStorySession = this.#deps.getActiveStorySession?.() ?? null;
+ const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
+ const useActiveSurfaceHitTest = sessionMode !== 'body' || activeStorySession != null;
+ const editor = useActiveSurfaceHitTest
+ ? this.#deps.getActiveEditor()
+ : (this.#deps.getEditor() as ReturnType);
+ const { rawHit, hit } = this.#resolveSelectionPointerHit({
+ layoutState,
+ normalized: { x: normalized.x, y: normalized.y },
clientX,
clientY,
- geometryHelper: pageGeometryHelper ?? undefined,
+ editor,
+ useActiveSurfaceHitTest,
});
- if (!rawHit) return;
+ if (!rawHit || !hit) return;
- // Don't extend selection into footnote lines
- if (isFootnoteBlockId(rawHit.blockId)) return;
+ // Don't extend a body selection into read-only footnote content.
+ if (!useActiveSurfaceHitTest && isRenderedNoteBlockId(rawHit.blockId)) return;
- const editor = this.#deps.getEditor();
const doc = editor.state?.doc;
if (!doc) return;
@@ -1988,21 +2407,8 @@ export class EditorInputManager {
this.#callbacks.updateSelectionVirtualizationPins?.({ includeDragBuffer: true, extraPages: [rawHit.pageIndex] });
- const epochMapper = this.#deps.getEpochMapper();
- const mappedHead = epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1);
- if (!mappedHead.ok) {
- debugLog('warn', 'drag mapping failed', mappedHead);
- return;
- }
-
- const hit = {
- ...rawHit,
- pos: Math.max(0, Math.min(mappedHead.pos, doc.content.size)),
- layoutEpoch: mappedHead.toEpoch,
- };
-
this.#debugLastHit = {
- source: pageMounted ? 'dom' : 'geometry',
+ source: useActiveSurfaceHitTest || pageMounted ? 'dom' : 'geometry',
pos: rawHit.pos,
layoutEpoch: rawHit.layoutEpoch,
mappedPos: hit.pos,
@@ -2010,7 +2416,7 @@ export class EditorInputManager {
this.#callbacks.updateSelectionDebugHud?.();
// Check for cell selection
- const currentTableHit = this.#hitTestTable(normalized.x, normalized.y);
+ const currentTableHit = useActiveSurfaceHitTest ? null : this.#hitTestTable(normalized.x, normalized.y);
const shouldUseCellSel = this.#shouldUseCellSelection(currentTableHit);
if (shouldUseCellSel && this.#cellAnchor) {
@@ -2079,17 +2485,12 @@ export class EditorInputManager {
#handleHover(normalized: { x: number; y: number; pageIndex?: number; pageLocalY?: number }): void {
if (!this.#deps) return;
- const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
- if (sessionMode !== 'body') {
- this.#callbacks.clearHoverRegion?.();
- return;
- }
-
if (this.#deps.getDocumentMode() === 'viewing') {
this.#callbacks.clearHoverRegion?.();
return;
}
+ const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
const region = this.#callbacks.hitTestHeaderFooterRegion?.(
normalized.x,
normalized.y,
@@ -2101,13 +2502,13 @@ export class EditorInputManager {
return;
}
+ if (sessionMode !== 'body' && !this.#isDifferentHeaderFooterRegionFromActiveSession(region)) {
+ this.#callbacks.clearHoverRegion?.();
+ return;
+ }
+
const currentHover = this.#deps.getHeaderFooterSession()?.hoverRegion;
- if (
- currentHover &&
- currentHover.kind === region.kind &&
- currentHover.pageIndex === region.pageIndex &&
- currentHover.sectionType === region.sectionType
- ) {
+ if (this.#isSameHeaderFooterRegion(currentHover, region)) {
return;
}
@@ -2231,8 +2632,56 @@ export class EditorInputManager {
this.#callbacks.activateHeaderFooterRegion?.(region);
}
+ #getActiveRenderedNoteTarget(): RenderedNoteTarget | null {
+ const activeStorySession = this.#deps?.getActiveStorySession?.() ?? null;
+ if (activeStorySession?.kind !== 'note') {
+ return null;
+ }
+
+ const locator = activeStorySession.locator;
+ if (locator.storyType !== 'footnote' && locator.storyType !== 'endnote') {
+ return null;
+ }
+
+ return {
+ storyType: locator.storyType,
+ noteId: locator.noteId,
+ };
+ }
+
+ #resolveRenderedNoteTargetAtPointer(
+ target: HTMLElement | null,
+ clientX: number,
+ clientY: number,
+ ): RenderedNoteTarget | null {
+ const blockIdFromTarget = target?.closest?.('[data-block-id]')?.getAttribute?.('data-block-id') ?? '';
+ const parsedFromTarget = parseRenderedNoteTarget(blockIdFromTarget);
+ if (parsedFromTarget) {
+ return parsedFromTarget;
+ }
+
+ const doc = this.#deps?.getViewportHost()?.ownerDocument ?? document;
+ if (typeof doc.elementsFromPoint !== 'function') {
+ return null;
+ }
+
+ for (const element of doc.elementsFromPoint(clientX, clientY)) {
+ if (!(element instanceof HTMLElement)) {
+ continue;
+ }
+
+ const blockId = element.closest('[data-block-id]')?.getAttribute('data-block-id') ?? '';
+ const parsed = parseRenderedNoteTarget(blockId);
+ if (parsed) {
+ return parsed;
+ }
+ }
+
+ return null;
+ }
+
#focusEditorAtFirstPosition(): void {
- const editor = this.#deps?.getEditor();
+ const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor();
const editorDom = editor?.view?.dom as HTMLElement | undefined;
if (!editorDom) return;
@@ -2259,7 +2708,7 @@ export class EditorInputManager {
* operations with tracked changes.
*/
#focusEditor(): void {
- const editor = this.#deps?.getEditor();
+ const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor();
const view = editor?.view;
const editorDom = view?.dom as HTMLElement | undefined;
if (!editorDom) return;
@@ -2296,6 +2745,65 @@ export class EditorInputManager {
return true;
}
+ #syncNonBodyCommentActivation(event: PointerEvent, target: HTMLElement | null, editor: Editor): void {
+ this.#syncNonBodyCommentSelection(event, target, editor);
+ }
+
+ #resolveHeaderFooterCommentThreadIdFromGeometry(clientX: number, clientY: number): string | null {
+ const sessionMode = this.#deps?.getHeaderFooterSession()?.session?.mode ?? 'body';
+ if (sessionMode !== 'header' && sessionMode !== 'footer') {
+ return null;
+ }
+
+ const viewportHost = this.#deps?.getViewportHost();
+ if (!viewportHost) {
+ return null;
+ }
+
+ const activeSurfaceSelector = sessionMode === 'footer' ? '.superdoc-page-footer' : '.superdoc-page-header';
+ const annotationSelector = [
+ `${activeSurfaceSelector} ${COMMENT_HIGHLIGHT_SELECTOR}`,
+ `${activeSurfaceSelector} ${TRACK_CHANGE_SELECTOR}`,
+ `${activeSurfaceSelector} ${PM_TRACK_CHANGE_SELECTOR}`,
+ ].join(', ');
+ const annotationElements = Array.from(viewportHost.querySelectorAll(annotationSelector));
+
+ return resolveCommentThreadIdFromGeometry(annotationElements, clientX, clientY);
+ }
+
+ #syncNonBodyCommentSelection(
+ event: PointerEvent,
+ target: HTMLElement | null,
+ editor: Editor,
+ { clearOnMiss = false }: { clearOnMiss?: boolean } = {},
+ ): void {
+ const clickedThreadId =
+ resolveCommentThreadIdNearPointer(target, event.clientX, event.clientY) ??
+ this.#resolveHeaderFooterCommentThreadIdFromGeometry(event.clientX, event.clientY);
+ const activeThreadId = getActiveCommentThreadId(editor);
+
+ if (!clickedThreadId) {
+ if (!clearOnMiss || !activeThreadId) {
+ return;
+ }
+
+ editor.emit?.('commentsUpdate', {
+ type: comments_module_events.SELECTED,
+ activeCommentId: null,
+ });
+ return;
+ }
+
+ if (clickedThreadId === activeThreadId) {
+ return;
+ }
+
+ editor.emit?.('commentsUpdate', {
+ type: comments_module_events.SELECTED,
+ activeCommentId: clickedThreadId,
+ });
+ }
+
#handleSingleCommentHighlightClick(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean {
// Direct hits on inline annotated text should not be intercepted here.
// Let generic click-to-position place the caret at the clicked pixel.
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts
index b92868328f..1af5bf21ea 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts
@@ -106,8 +106,8 @@ export type RenderCaretOverlayDeps = {
*
* @remarks
* This function creates a single div element representing the text cursor with:
- * - 2px width and height from caretLayout
- * - Black color (#000000)
+ * - Black 2px caret with the global blink animation
+ * - Subtle white halo for contrast against dark glyphs
* - 1px border radius for visual polish
* - Absolute positioning in overlay coordinates
* - Pointer-events: none to allow interaction with underlying content
@@ -144,6 +144,7 @@ export function renderCaretOverlay({
caretEl.style.height = `${finalHeight}px`;
caretEl.style.backgroundColor = '#000000';
caretEl.style.borderRadius = '1px';
+ caretEl.style.boxShadow = '0 0 0 1px rgba(255, 255, 255, 0.92)';
caretEl.style.pointerEvents = 'none';
localSelectionLayer.appendChild(caretEl);
}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts
new file mode 100644
index 0000000000..89c5f1f63a
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts
@@ -0,0 +1,470 @@
+import { DOM_CLASS_NAMES } from '@superdoc/dom-contract';
+
+import { deduplicateOverlappingRects, type LayoutRect } from '../../../dom-observer/DomSelectionGeometry.js';
+
+type VisibleTextSegment = {
+ node: Text;
+ startOffset: number;
+ endOffset: number;
+ pageElement: HTMLElement;
+ lineElement: HTMLElement | null;
+};
+
+type VisibleTextModel = {
+ segments: VisibleTextSegment[];
+ totalLength: number;
+};
+
+type ResolvedTextPoint = {
+ node: Text;
+ offset: number;
+ pageElement: HTMLElement;
+ lineElement: HTMLElement | null;
+};
+
+export type VisibleTextOffsetGeometryOptions = {
+ containers: HTMLElement[];
+ zoom: number;
+ pageHeight: number;
+ pageGap: number;
+};
+
+/**
+ * Measures a visible-text offset within a DOM root from a concrete DOM boundary.
+ *
+ * This is used for note overlays because `EditorView.domAtPos()` can resolve the
+ * active note selection to the correct hidden-editor DOM boundary even when the
+ * ProseMirror position lands inside tracked-change wrapper structure. Measuring the
+ * boundary as visible text gives us a stable bridge from the hidden editor DOM to
+ * the painted note DOM.
+ */
+export function measureVisibleTextOffset(root: HTMLElement, boundaryNode: Node, boundaryOffset: number): number | null {
+ if (!root || !boundaryNode) {
+ return null;
+ }
+ if (boundaryNode !== root && !root.contains(boundaryNode)) {
+ return null;
+ }
+
+ const doc = root.ownerDocument ?? document;
+ const boundary = doc.createRange();
+
+ try {
+ boundary.setStart(boundaryNode, boundaryOffset);
+ boundary.setEnd(boundaryNode, boundaryOffset);
+ } catch {
+ return null;
+ }
+
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let total = 0;
+ let currentNode = walker.nextNode();
+
+ while (currentNode) {
+ const textNode = currentNode as Text;
+ const textLength = textNode.textContent?.length ?? 0;
+ if (textLength === 0) {
+ currentNode = walker.nextNode();
+ continue;
+ }
+
+ const textRange = doc.createRange();
+ textRange.selectNodeContents(textNode);
+
+ if (textRange.compareBoundaryPoints(Range.END_TO_END, boundary) <= 0) {
+ total += textLength;
+ currentNode = walker.nextNode();
+ continue;
+ }
+
+ if (textNode === boundaryNode) {
+ return total + Math.max(0, Math.min(boundaryOffset, textLength));
+ }
+
+ if (textRange.compareBoundaryPoints(Range.START_TO_START, boundary) >= 0) {
+ return total;
+ }
+
+ return total;
+ }
+
+ return total;
+}
+
+export function computeCaretRectFromVisibleTextOffset(
+ options: VisibleTextOffsetGeometryOptions,
+ textOffset: number,
+): LayoutRect | null {
+ const model = collectVisibleTextModel(options.containers);
+ if (!model.segments.length) {
+ return null;
+ }
+
+ const point = resolveTextPoint(model, textOffset, 'forward');
+ if (!point) {
+ return null;
+ }
+
+ const doc = point.node.ownerDocument ?? document;
+ const range = doc.createRange();
+ range.setStart(point.node, point.offset);
+ range.setEnd(point.node, point.offset);
+
+ const rangeRect = range.getBoundingClientRect();
+ const lineRect = point.lineElement?.getBoundingClientRect() ?? rangeRect;
+ const pageRect = point.pageElement.getBoundingClientRect();
+ const pageIndex = Number(point.pageElement.dataset.pageIndex ?? 'NaN');
+
+ if (!Number.isFinite(pageIndex)) {
+ return null;
+ }
+
+ const localX = (rangeRect.left - pageRect.left) / options.zoom;
+ const localY = (lineRect.top - pageRect.top) / options.zoom;
+ if (!Number.isFinite(localX) || !Number.isFinite(localY)) {
+ return null;
+ }
+
+ return {
+ pageIndex,
+ x: localX,
+ y: pageIndex * (options.pageHeight + options.pageGap) + localY,
+ width: 1,
+ height: Math.max(1, lineRect.height / options.zoom),
+ };
+}
+
+export function computeSelectionRectsFromVisibleTextOffsets(
+ options: VisibleTextOffsetGeometryOptions,
+ fromOffset: number,
+ toOffset: number,
+): LayoutRect[] | null {
+ if (!Number.isFinite(fromOffset) || !Number.isFinite(toOffset)) {
+ return null;
+ }
+
+ const startOffset = Math.min(fromOffset, toOffset);
+ const endOffset = Math.max(fromOffset, toOffset);
+ if (startOffset === endOffset) {
+ return [];
+ }
+
+ const model = collectVisibleTextModel(options.containers);
+ if (!model.segments.length) {
+ return null;
+ }
+
+ const startPoint = resolveTextPoint(model, startOffset, 'forward');
+ const endPoint = resolveTextPoint(model, endOffset, 'backward');
+ if (!startPoint || !endPoint) {
+ return null;
+ }
+
+ const doc = startPoint.node.ownerDocument ?? document;
+ const range = doc.createRange();
+
+ try {
+ range.setStart(startPoint.node, startPoint.offset);
+ range.setEnd(endPoint.node, endPoint.offset);
+ } catch {
+ return null;
+ }
+
+ const rawRects = Array.from(range.getClientRects()) as unknown as DOMRect[];
+ const pageElements = collectUniquePageElements(model.segments);
+ const rects = deduplicateOverlappingRects(rawRects);
+ const layoutRects: LayoutRect[] = [];
+
+ for (const rect of rects) {
+ if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height) || rect.width <= 0 || rect.height <= 0) {
+ continue;
+ }
+
+ const pageElement = findPageElementForRect(rect, pageElements);
+ if (!pageElement) {
+ continue;
+ }
+
+ const pageRect = pageElement.getBoundingClientRect();
+ const pageIndex = Number(pageElement.dataset.pageIndex ?? 'NaN');
+ if (!Number.isFinite(pageIndex)) {
+ continue;
+ }
+
+ const localX = (rect.left - pageRect.left) / options.zoom;
+ const localY = (rect.top - pageRect.top) / options.zoom;
+ if (!Number.isFinite(localX) || !Number.isFinite(localY)) {
+ continue;
+ }
+
+ layoutRects.push({
+ pageIndex,
+ x: localX,
+ y: pageIndex * (options.pageHeight + options.pageGap) + localY,
+ width: Math.max(1, rect.width / options.zoom),
+ height: Math.max(1, rect.height / options.zoom),
+ });
+ }
+
+ return layoutRects;
+}
+
+function collectVisibleTextModel(containers: readonly HTMLElement[]): VisibleTextModel {
+ const lines = collectRenderedLineElements(containers);
+ if (!lines.length) {
+ return {
+ segments: [],
+ totalLength: 0,
+ };
+ }
+
+ const segments: VisibleTextSegment[] = [];
+ let totalLength = 0;
+
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
+ const lineElement = lines[lineIndex]!;
+ const leafElements = collectLeafPmElements(lineElement);
+ let lineVisibleLength = 0;
+
+ for (const leafElement of leafElements) {
+ const pageElement = leafElement.closest(`.${DOM_CLASS_NAMES.PAGE}[data-page-index]`);
+ if (!pageElement) {
+ continue;
+ }
+
+ const doc = leafElement.ownerDocument ?? document;
+ const walker = doc.createTreeWalker(leafElement, NodeFilter.SHOW_TEXT);
+ let currentNode = walker.nextNode();
+
+ while (currentNode) {
+ const textNode = currentNode as Text;
+ const textLength = textNode.textContent?.length ?? 0;
+ if (textLength > 0) {
+ segments.push({
+ node: textNode,
+ startOffset: totalLength + lineVisibleLength,
+ endOffset: totalLength + lineVisibleLength + textLength,
+ pageElement,
+ lineElement,
+ });
+ lineVisibleLength += textLength;
+ }
+
+ currentNode = walker.nextNode();
+ }
+ }
+
+ const lineTrailingGap = computeLineTrailingGap(lineElement, leafElements);
+ const nextLineGap = computeGapToNextLine(lineElement, lines[lineIndex + 1] ?? null);
+ totalLength += lineVisibleLength + lineTrailingGap + nextLineGap;
+ }
+
+ return {
+ segments,
+ totalLength,
+ };
+}
+
+function collectRenderedLineElements(containers: readonly HTMLElement[]): HTMLElement[] {
+ const lines: HTMLElement[] = [];
+
+ for (const container of containers) {
+ lines.push(...Array.from(container.querySelectorAll('.superdoc-line[data-pm-start][data-pm-end]')));
+ }
+
+ return lines;
+}
+
+function computeLineTrailingGap(lineElement: HTMLElement, leafElements: readonly HTMLElement[]): number {
+ const linePmEnd = getPmEnd(lineElement);
+ const lastLeafElement = leafElements[leafElements.length - 1];
+ const lastLeafPmEnd = lastLeafElement ? getPmEnd(lastLeafElement) : null;
+
+ if (linePmEnd == null || lastLeafPmEnd == null) {
+ return 0;
+ }
+
+ return Math.max(0, linePmEnd - lastLeafPmEnd);
+}
+
+function computeGapToNextLine(currentLine: HTMLElement, nextLine: HTMLElement | null): number {
+ if (!nextLine) {
+ return 0;
+ }
+
+ const currentLinePmEnd = getPmEnd(currentLine);
+ const nextLinePmStart = getPmStart(nextLine);
+ if (currentLinePmEnd == null || nextLinePmStart == null) {
+ return 0;
+ }
+
+ return Math.max(0, nextLinePmStart - currentLinePmEnd);
+}
+
+function collectLeafPmElements(container: HTMLElement): HTMLElement[] {
+ const pmElements: HTMLElement[] = [];
+ if (container.matches('[data-pm-start][data-pm-end]')) {
+ pmElements.push(container);
+ }
+ pmElements.push(...Array.from(container.querySelectorAll('[data-pm-start][data-pm-end]')));
+
+ const pmElementSet = new WeakSet(pmElements);
+ const nonLeaf = new WeakSet();
+
+ for (const element of pmElements) {
+ if (element.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER)) {
+ continue;
+ }
+
+ let parent = element.parentElement;
+ while (parent) {
+ if (pmElementSet.has(parent)) {
+ nonLeaf.add(parent);
+ }
+ if (parent === container) {
+ break;
+ }
+ parent = parent.parentElement;
+ }
+ }
+
+ return pmElements.filter(
+ (element) => !element.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER) && !nonLeaf.has(element),
+ );
+}
+
+function resolveTextPoint(
+ model: VisibleTextModel,
+ targetOffset: number,
+ affinity: 'forward' | 'backward',
+): ResolvedTextPoint | null {
+ const { segments, totalLength } = model;
+ if (!segments.length || !Number.isFinite(targetOffset)) {
+ return null;
+ }
+
+ if (targetOffset < 0 || targetOffset > totalLength) {
+ return null;
+ }
+
+ let previousSegment: VisibleTextSegment | null = null;
+
+ for (let index = 0; index < segments.length; index += 1) {
+ const segment = segments[index]!;
+ if (targetOffset < segment.startOffset) {
+ if (affinity === 'forward') {
+ return {
+ node: segment.node,
+ offset: 0,
+ pageElement: segment.pageElement,
+ lineElement: segment.lineElement,
+ };
+ }
+
+ if (previousSegment) {
+ return {
+ node: previousSegment.node,
+ offset: previousSegment.node.textContent?.length ?? 0,
+ pageElement: previousSegment.pageElement,
+ lineElement: previousSegment.lineElement,
+ };
+ }
+
+ return {
+ node: segment.node,
+ offset: 0,
+ pageElement: segment.pageElement,
+ lineElement: segment.lineElement,
+ };
+ }
+
+ if (targetOffset >= segment.startOffset && targetOffset < segment.endOffset) {
+ return {
+ node: segment.node,
+ offset: targetOffset - segment.startOffset,
+ pageElement: segment.pageElement,
+ lineElement: segment.lineElement,
+ };
+ }
+
+ if (targetOffset !== segment.endOffset) {
+ previousSegment = segment;
+ continue;
+ }
+
+ if (affinity === 'forward' && index + 1 < segments.length) {
+ previousSegment = segment;
+ continue;
+ }
+
+ return {
+ node: segment.node,
+ offset: segment.node.textContent?.length ?? 0,
+ pageElement: segment.pageElement,
+ lineElement: segment.lineElement,
+ };
+ }
+
+ const lastSegment = segments[segments.length - 1];
+ if (!lastSegment) {
+ return null;
+ }
+
+ return {
+ node: lastSegment.node,
+ offset: lastSegment.node.textContent?.length ?? 0,
+ pageElement: lastSegment.pageElement,
+ lineElement: lastSegment.lineElement,
+ };
+}
+
+function getPmStart(element: HTMLElement): number | null {
+ return parsePmValue(element.dataset.pmStart);
+}
+
+function getPmEnd(element: HTMLElement): number | null {
+ return parsePmValue(element.dataset.pmEnd);
+}
+
+function parsePmValue(value: string | undefined): number | null {
+ if (!value) {
+ return null;
+ }
+
+ const parsedValue = Number(value);
+ return Number.isFinite(parsedValue) ? parsedValue : null;
+}
+
+function collectUniquePageElements(segments: readonly VisibleTextSegment[]): HTMLElement[] {
+ const seen = new Set();
+ const pages: HTMLElement[] = [];
+
+ for (const segment of segments) {
+ if (seen.has(segment.pageElement)) {
+ continue;
+ }
+ seen.add(segment.pageElement);
+ pages.push(segment.pageElement);
+ }
+
+ return pages;
+}
+
+function findPageElementForRect(rect: DOMRect, pageElements: readonly HTMLElement[]): HTMLElement | null {
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+
+ for (const pageElement of pageElements) {
+ const pageRect = pageElement.getBoundingClientRect();
+ if (
+ centerX >= pageRect.left &&
+ centerX <= pageRect.right &&
+ centerY >= pageRect.top &&
+ centerY <= pageRect.bottom
+ ) {
+ return pageElement;
+ }
+ }
+
+ return null;
+}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts
new file mode 100644
index 0000000000..c0aaaf3d1c
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts
@@ -0,0 +1,354 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { StoryPresentationSessionManager } from './StoryPresentationSessionManager.js';
+import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js';
+import type { Editor } from '../../Editor.js';
+import type { StoryLocator } from '@superdoc/document-api';
+import {
+ getLiveStorySessionCount,
+ resolveLiveStorySessionRuntime,
+} from '../../../document-api-adapters/story-runtime/live-story-session-runtime-registry.js';
+
+// ---------------------------------------------------------------------------
+// Test doubles
+// ---------------------------------------------------------------------------
+//
+// The session manager only interacts with the runtime's commit / dispose
+// hooks and with `editor.view.dom` when a DOM target is needed. Everything
+// else is delegated to caller-supplied callbacks, so a bare-minimum
+// Editor-shaped stub is sufficient.
+
+type StubEditor = Pick & {
+ options?: { parentEditor?: StubEditor };
+ emitTransaction?: (docChanged?: boolean) => void;
+};
+
+function makeStubEditor(dom: HTMLElement | null): StubEditor {
+ const transactionListeners = new Set<(payload: { transaction: { docChanged: boolean } }) => void>();
+ return {
+ view: dom ? ({ dom } as unknown as Editor['view']) : undefined,
+ on(event, handler) {
+ if (event === 'transaction') {
+ transactionListeners.add(handler as (payload: { transaction: { docChanged: boolean } }) => void);
+ }
+ },
+ off(event, handler) {
+ if (event === 'transaction' && handler) {
+ transactionListeners.delete(handler as (payload: { transaction: { docChanged: boolean } }) => void);
+ }
+ },
+ emitTransaction(docChanged = true) {
+ transactionListeners.forEach((listener) => listener({ transaction: { docChanged } }));
+ },
+ } as StubEditor;
+}
+
+function makeStubLocator(): StoryLocator {
+ return { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' };
+}
+
+function makeStubRuntime(editor: StubEditor, overrides: Partial = {}): StoryRuntime {
+ return {
+ locator: makeStubLocator(),
+ storyKey: 'story:headerFooterPart:rId7',
+ editor: editor as unknown as Editor,
+ kind: 'headerFooter',
+ ...overrides,
+ };
+}
+
+function makeHostEditor(): Editor {
+ return { state: { doc: { content: { size: 10 } } } } as unknown as Editor;
+}
+
+describe('StoryPresentationSessionManager', () => {
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ it('refuses to host a body runtime', () => {
+ const editor = makeStubEditor(document.createElement('div'));
+ const runtime: StoryRuntime = {
+ locator: { kind: 'story', storyType: 'body' },
+ storyKey: 'story:body',
+ editor: editor as unknown as Editor,
+ kind: 'body',
+ };
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ expect(() => manager.activate({ kind: 'story', storyType: 'body' })).toThrow(/cannot host a body runtime/);
+ });
+
+ it('activates a session, tracks its editor DOM, and exits cleanly', () => {
+ const dom = document.createElement('div');
+ const editor = makeStubEditor(dom);
+ const runtime = makeStubRuntime(editor);
+
+ const onChange = vi.fn();
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ onActiveSessionChanged: onChange,
+ });
+
+ expect(manager.getActiveSession()).toBeNull();
+ expect(manager.getActiveEditorDomTarget()).toBeNull();
+
+ const session = manager.activate(makeStubLocator());
+ expect(session.kind).toBe('headerFooter');
+ expect(session.locator.storyType).toBe('headerFooterPart');
+ expect(manager.getActiveSession()).toBe(session);
+ expect(manager.getActiveEditorDomTarget()).toBe(dom);
+ expect(onChange).toHaveBeenLastCalledWith(session);
+
+ manager.exit();
+ expect(manager.getActiveSession()).toBeNull();
+ expect(manager.getActiveEditorDomTarget()).toBeNull();
+ expect(session.isDisposed).toBe(true);
+ expect(onChange).toHaveBeenLastCalledWith(null);
+ });
+
+ it('disposes the previous session when a new session activates over it', () => {
+ const first = makeStubRuntime(makeStubEditor(document.createElement('div')), {
+ dispose: vi.fn(),
+ cacheable: false,
+ });
+ const second = makeStubRuntime(makeStubEditor(document.createElement('div')), {
+ dispose: vi.fn(),
+ cacheable: false,
+ });
+
+ const runtimes = [first, second];
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtimes.shift()!,
+ getMountContainer: () => container,
+ });
+
+ const s1 = manager.activate(makeStubLocator());
+ expect(s1.isDisposed).toBe(false);
+
+ manager.activate(makeStubLocator());
+ expect(s1.isDisposed).toBe(true);
+ expect(first.dispose).toHaveBeenCalledTimes(1);
+ });
+
+ it('commits on exit when commitPolicy is onExit (default)', () => {
+ const commit = vi.fn();
+ const editor = makeStubEditor(document.createElement('div'));
+ const runtime = makeStubRuntime(editor, { commit });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ manager.activate(makeStubLocator());
+ expect(commit).not.toHaveBeenCalled();
+
+ manager.exit();
+ expect(commit).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not commit on exit when commitPolicy is manual', () => {
+ const commit = vi.fn();
+ const editor = makeStubEditor(document.createElement('div'));
+ const runtime = makeStubRuntime(editor, { commit });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ manager.activate(makeStubLocator(), { commitPolicy: 'manual' });
+ manager.exit();
+ expect(commit).not.toHaveBeenCalled();
+ });
+
+ it('manual commit() invokes the runtime.commit callback', () => {
+ const commit = vi.fn();
+ const editor = makeStubEditor(document.createElement('div'));
+ const runtime = makeStubRuntime(editor, { commit });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ const session = manager.activate(makeStubLocator(), { commitPolicy: 'manual' });
+ session.commit();
+ expect(commit).toHaveBeenCalledTimes(1);
+ });
+
+ it('manual commit() prefers runtime.commitEditor with the session editor', () => {
+ const runtimeEditor = makeStubEditor(document.createElement('div'));
+ const sessionEditor = makeStubEditor(document.createElement('div'));
+ const commitEditor = vi.fn();
+ const runtime = makeStubRuntime(runtimeEditor, { commitEditor });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ editorFactory: () => ({ editor: sessionEditor as unknown as Editor }),
+ });
+
+ const session = manager.activate(makeStubLocator(), { commitPolicy: 'manual' });
+ session.commit();
+ expect(commitEditor).toHaveBeenCalledWith(expect.anything(), sessionEditor);
+ });
+
+ it('does not dispose cacheable runtimes on exit', () => {
+ const editor = makeStubEditor(document.createElement('div'));
+ const dispose = vi.fn();
+ const runtime = makeStubRuntime(editor, { dispose, cacheable: true });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ manager.activate(makeStubLocator());
+ manager.exit();
+ expect(dispose).not.toHaveBeenCalled();
+ });
+
+ it('disposes non-cacheable runtimes on exit', () => {
+ const editor = makeStubEditor(document.createElement('div'));
+ const dispose = vi.fn();
+ const runtime = makeStubRuntime(editor, { dispose, cacheable: false });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ manager.activate(makeStubLocator());
+ manager.exit();
+ expect(dispose).toHaveBeenCalledTimes(1);
+ });
+
+ it('commits on doc-changing transactions when commitPolicy is continuous', () => {
+ const commit = vi.fn();
+ const editor = makeStubEditor(document.createElement('div'));
+ const runtime = makeStubRuntime(editor, { commit });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ const session = manager.activate(makeStubLocator(), { commitPolicy: 'continuous' });
+ editor.emitTransaction?.(true);
+ editor.emitTransaction?.(false);
+
+ expect(commit).toHaveBeenCalledTimes(1);
+ manager.exit();
+ expect(session.isDisposed).toBe(true);
+ });
+
+ it('appends a hidden-host wrapper and tears it down on exit when an editorFactory is supplied', () => {
+ const dom = document.createElement('div');
+ const freshEditor = makeStubEditor(dom);
+ const runtime = makeStubRuntime(makeStubEditor(null));
+
+ const factory = vi.fn((input) => {
+ // The factory should be handed a hidden host element to mount into.
+ expect(input.hostElement).toBeInstanceOf(HTMLElement);
+ expect(input.hostElement.classList.contains('presentation-editor__story-hidden-host')).toBe(true);
+ return { editor: freshEditor as unknown as Editor };
+ });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ editorFactory: factory,
+ });
+
+ const session = manager.activate(makeStubLocator());
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(session.hostWrapper).not.toBeNull();
+ expect(session.hostWrapper?.parentNode).toBe(container);
+ expect(session.domTarget).toBe(dom);
+
+ manager.exit();
+ expect(session.hostWrapper?.parentNode).toBeNull();
+ });
+
+ it('destroy() deactivates any active session', () => {
+ const editor = makeStubEditor(document.createElement('div'));
+ const runtime = makeStubRuntime(editor);
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ });
+
+ const session = manager.activate(makeStubLocator());
+ manager.destroy();
+ expect(session.isDisposed).toBe(true);
+ expect(manager.getActiveSession()).toBeNull();
+ });
+
+ it('throws a clear error when hidden-host activation has no mount container', () => {
+ const runtime = makeStubRuntime(makeStubEditor(document.createElement('div')));
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => null,
+ editorFactory: () => ({ editor: makeStubEditor(document.createElement('div')) as unknown as Editor }),
+ });
+ expect(() => manager.activate(makeStubLocator())).toThrow(/no mount container/);
+ });
+
+ it('allows runtime reuse without a mount container when preferHiddenHost is false', () => {
+ const dom = document.createElement('div');
+ const runtime = makeStubRuntime(makeStubEditor(dom));
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => null,
+ });
+
+ const session = manager.activate(makeStubLocator(), { preferHiddenHost: false });
+ expect(session.editor).toBe(runtime.editor);
+ expect(session.hostWrapper).toBeNull();
+ expect(session.domTarget).toBe(dom);
+ });
+
+ it('registers the active session editor as the live story runtime and unregisters it on exit', () => {
+ const hostEditor = makeHostEditor();
+ const runtimeEditor = makeStubEditor(document.createElement('div'));
+ runtimeEditor.options = { parentEditor: hostEditor as unknown as StubEditor };
+
+ const sessionEditor = makeStubEditor(document.createElement('div'));
+ sessionEditor.options = { parentEditor: hostEditor as unknown as StubEditor };
+
+ const runtime = makeStubRuntime(runtimeEditor, {
+ locator: { kind: 'story', storyType: 'footnote', noteId: '8' },
+ storyKey: 'fn:8',
+ kind: 'note',
+ });
+
+ const manager = new StoryPresentationSessionManager({
+ resolveRuntime: () => runtime,
+ getMountContainer: () => container,
+ editorFactory: () => ({ editor: sessionEditor as unknown as Editor }),
+ });
+
+ manager.activate(runtime.locator);
+
+ const liveRuntime = resolveLiveStorySessionRuntime(hostEditor, 'fn:8');
+ expect(liveRuntime?.editor).toBe(sessionEditor);
+ expect(getLiveStorySessionCount(hostEditor)).toBe(1);
+
+ manager.exit();
+
+ expect(resolveLiveStorySessionRuntime(hostEditor, 'fn:8')).toBeNull();
+ expect(getLiveStorySessionCount(hostEditor)).toBe(0);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts
new file mode 100644
index 0000000000..6548c5eeae
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts
@@ -0,0 +1,337 @@
+/**
+ * StoryPresentationSessionManager
+ *
+ * Owns the active interactive editing session for a story-backed part
+ * (header, footer, or future note/endnote). This is the generalization of
+ * `HeaderFooterSessionManager`'s session-lifecycle responsibilities, split
+ * out from the header/footer region/layout code so future story kinds can
+ * reuse it.
+ *
+ * Responsibilities:
+ * - Resolve a {@link StoryLocator} to a {@link StoryRuntime} through the
+ * caller-supplied resolver (so the manager doesn't reach across the
+ * document-api-adapters package boundary directly).
+ * - Create a hidden off-screen host and mount a story editor into it when
+ * the runtime does not already have a visible editor we can reuse.
+ * - Expose the active editor's DOM as the target for
+ * `PresentationInputBridge`.
+ * - Commit and dispose on exit.
+ *
+ * What it deliberately does NOT do (left to callers / future phases):
+ * - Region discovery or section-aware slot materialization (lives in the
+ * header/footer-specific adapter).
+ * - Caret/selection rendering (Phase 3 of the plan).
+ * - Pointer hit-testing (lives in EditorInputManager / region providers).
+ *
+ * See `plans/story-backed-parts-presentation-editing.md`.
+ */
+
+import type { StoryLocator } from '@superdoc/document-api';
+import type { Editor } from '../../Editor.js';
+import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js';
+import type { StoryPresentationSession, ActivateStorySessionOptions, StoryCommitPolicy } from './types.js';
+import { createStoryHiddenHost } from './createStoryHiddenHost.js';
+import { registerLiveStorySessionRuntime } from '../../../document-api-adapters/story-runtime/live-story-session-runtime-registry.js';
+
+/**
+ * Creates (or returns) the ProseMirror editor that should back an active
+ * session for a given runtime. May return a fresh editor mounted into a
+ * freshly-created hidden host, or the runtime's existing editor.
+ */
+export interface StorySessionEditorFactoryInput {
+ /** The resolved story runtime. */
+ runtime: StoryRuntime;
+ /** The element the story editor should be mounted into, if headless. */
+ hostElement: HTMLElement;
+ /** Activation-time options for the session being created. */
+ activationOptions: ActivateStorySessionOptions;
+}
+
+export interface StorySessionEditorFactoryResult {
+ /** The editor that should be used for the session. */
+ editor: Editor;
+ /**
+ * Optional teardown to run when the session is disposed. Only set when
+ * the factory created a fresh editor; reused editors are owned elsewhere.
+ */
+ dispose?: () => void;
+}
+
+/** Factory used by the manager to obtain a mountable story editor. */
+export type StorySessionEditorFactory = (input: StorySessionEditorFactoryInput) => StorySessionEditorFactoryResult;
+
+/**
+ * Constructor options for {@link StoryPresentationSessionManager}.
+ */
+export interface StoryPresentationSessionManagerOptions {
+ /**
+ * Resolve a locator to a {@link StoryRuntime}. In production this wraps
+ * `resolveStoryRuntime(hostEditor, locator, { intent: 'write' })`; in
+ * tests it can be any mock.
+ */
+ resolveRuntime: (locator: StoryLocator) => StoryRuntime;
+
+ /**
+ * Returns the host element the session will mount into. Defaults to the
+ * container the session manager was given on construction, but may be
+ * overridden per session (e.g., a page-local overlay).
+ */
+ getMountContainer: () => HTMLElement | null;
+
+ /**
+ * Optional factory for creating the session editor. When omitted the
+ * manager uses the runtime's existing editor (appending the hidden host
+ * is still performed, but ProseMirror's DOM lives wherever the runtime
+ * originally placed it). Most callers will pass a factory that invokes
+ * `createStoryEditor` to mount a fresh editor into the hidden host.
+ */
+ editorFactory?: StorySessionEditorFactory;
+
+ /**
+ * Called after the active session changes (activate, exit, dispose).
+ * Consumers use this to notify `PresentationInputBridge`.
+ */
+ onActiveSessionChanged?: (session: StoryPresentationSession | null) => void;
+}
+
+/**
+ * Manages the lifecycle of a single active story-backed editing session.
+ *
+ * The first rollout assumes only one session is active at a time; if two
+ * activations overlap, the current session is disposed before the new one
+ * is activated.
+ */
+export class StoryPresentationSessionManager {
+ #options: StoryPresentationSessionManagerOptions;
+ #active: MutableStorySession | null = null;
+
+ constructor(options: StoryPresentationSessionManagerOptions) {
+ this.#options = options;
+ }
+
+ /** Returns the active session, or `null` if none is active. */
+ getActiveSession(): StoryPresentationSession | null {
+ return this.#active;
+ }
+
+ /**
+ * Returns the DOM element that should receive forwarded input events
+ * while a session is active, or `null` if there is no active session.
+ */
+ getActiveEditorDomTarget(): HTMLElement | null {
+ return this.#active?.domTarget ?? null;
+ }
+
+ /**
+ * Activate a session for the given locator. If a session is already
+ * active, it is disposed first.
+ */
+ activate(locator: StoryLocator, options: ActivateStorySessionOptions = {}): StoryPresentationSession {
+ if (this.#active) this.exit();
+
+ const runtime = this.#options.resolveRuntime(locator);
+ if (runtime.kind === 'body') {
+ throw new Error('StoryPresentationSessionManager cannot host a body runtime.');
+ }
+
+ const preferHiddenHost = options.preferHiddenHost !== false;
+ const commitPolicy: StoryCommitPolicy = options.commitPolicy ?? 'onExit';
+
+ let hostWrapper: HTMLElement | null = null;
+ let editor = runtime.editor;
+ let factoryDispose: (() => void) | undefined;
+ let sessionBeforeDispose: (() => void) | undefined;
+
+ if (preferHiddenHost && this.#options.editorFactory) {
+ const mountContainer = this.#options.getMountContainer();
+ if (!mountContainer) {
+ throw new Error('StoryPresentationSessionManager: no mount container available for hidden host.');
+ }
+ const doc = mountContainer.ownerDocument ?? document;
+ const width = options.hostWidthPx ?? mountContainer.clientWidth ?? 1;
+ const hidden = createStoryHiddenHost(doc, width, {
+ storyKey: runtime.storyKey,
+ storyKind: runtime.kind,
+ });
+ mountContainer.appendChild(hidden.wrapper);
+ const factoryResult = this.#options.editorFactory({
+ runtime,
+ hostElement: hidden.host,
+ activationOptions: options,
+ });
+ editor = factoryResult.editor;
+ factoryDispose = factoryResult.dispose;
+ hostWrapper = hidden.wrapper;
+ }
+
+ if (commitPolicy === 'continuous' && typeof editor.on === 'function') {
+ const handleTransaction = ({ transaction }: { transaction?: { docChanged?: boolean } }) => {
+ if (transaction?.docChanged) {
+ session.commit();
+ }
+ };
+ editor.on('transaction', handleTransaction);
+ sessionBeforeDispose = () => {
+ editor.off?.('transaction', handleTransaction);
+ };
+ }
+
+ const domTarget = (editor.view?.dom as HTMLElement | undefined) ?? hostWrapper ?? null;
+ const hostEditor = resolveSessionHostEditor(editor, runtime);
+ const unregisterRuntime = registerLiveStorySessionRuntime(hostEditor, runtime, editor);
+
+ const session = new MutableStorySession({
+ locator,
+ runtime,
+ editor,
+ kind: runtime.kind as Exclude,
+ hostWrapper,
+ domTarget,
+ commitPolicy,
+ shouldDisposeRuntime: runtime.cacheable === false,
+ beforeDispose: sessionBeforeDispose,
+ unregisterRuntime,
+ teardown: () => {
+ try {
+ factoryDispose?.();
+ } finally {
+ if (hostWrapper && hostWrapper.parentNode) {
+ hostWrapper.parentNode.removeChild(hostWrapper);
+ }
+ }
+ },
+ });
+
+ this.#active = session;
+ this.#options.onActiveSessionChanged?.(session);
+ return session;
+ }
+
+ /**
+ * Deactivate the current session. Safe to call when no session is active.
+ * Commits (if policy says so) and disposes the hidden host.
+ */
+ exit(): void {
+ const active = this.#active;
+ if (!active) return;
+ this.#active = null;
+ try {
+ active.dispose();
+ } finally {
+ this.#options.onActiveSessionChanged?.(null);
+ }
+ }
+
+ /**
+ * Dispose the manager and any active session.
+ */
+ destroy(): void {
+ this.exit();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Mutable session record โ the concrete object that implements the
+// StoryPresentationSession contract exposed to callers.
+// ---------------------------------------------------------------------------
+
+interface MutableStorySessionInit {
+ locator: StoryLocator;
+ runtime: StoryRuntime;
+ editor: Editor;
+ kind: Exclude;
+ hostWrapper: HTMLElement | null;
+ domTarget: HTMLElement | null;
+ commitPolicy: StoryCommitPolicy;
+ shouldDisposeRuntime: boolean;
+ afterActivate?: () => void;
+ beforeDispose?: () => void;
+ unregisterRuntime: () => void;
+ teardown: () => void;
+}
+
+class MutableStorySession implements StoryPresentationSession {
+ readonly locator: StoryLocator;
+ readonly runtime: StoryRuntime;
+ readonly editor: Editor;
+ readonly kind: Exclude;
+ readonly hostWrapper: HTMLElement | null;
+ readonly domTarget: HTMLElement | null;
+ readonly commitPolicy: StoryCommitPolicy;
+
+ #disposed = false;
+ #shouldDisposeRuntime: boolean;
+ #beforeDispose?: () => void;
+ #unregisterRuntime: () => void;
+ #teardown: () => void;
+
+ constructor(init: MutableStorySessionInit) {
+ this.locator = init.locator;
+ this.runtime = init.runtime;
+ this.editor = init.editor;
+ this.kind = init.kind;
+ this.hostWrapper = init.hostWrapper;
+ this.domTarget = init.domTarget;
+ this.commitPolicy = init.commitPolicy;
+ this.#shouldDisposeRuntime = init.shouldDisposeRuntime;
+ this.#beforeDispose = init.beforeDispose;
+ this.#unregisterRuntime = init.unregisterRuntime;
+ this.#teardown = init.teardown;
+ init.afterActivate?.();
+ }
+
+ get isDisposed(): boolean {
+ return this.#disposed;
+ }
+
+ commit(): void {
+ if (this.#disposed) return;
+ const hostEditor = getHostEditor(this.editor) ?? getHostEditor(this.runtime.editor) ?? this.runtime.editor;
+ if (this.runtime.commitEditor) {
+ this.runtime.commitEditor(hostEditor, this.editor);
+ return;
+ }
+ this.runtime.commit?.(hostEditor);
+ }
+
+ dispose(): void {
+ if (this.#disposed) return;
+ try {
+ if (this.commitPolicy === 'onExit') this.commit();
+ } finally {
+ this.#disposed = true;
+ try {
+ this.#beforeDispose?.();
+ } finally {
+ try {
+ this.#unregisterRuntime();
+ } finally {
+ try {
+ if (this.#shouldDisposeRuntime) {
+ this.runtime.dispose?.();
+ }
+ } finally {
+ this.#teardown();
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Retrieve the parent/host editor from a story editor when present.
+ *
+ * `createStoryEditor` stores the parent editor as a non-enumerable
+ * `parentEditor` getter on `options`. When present we prefer it so the
+ * commit callback runs against the body editor the runtime was resolved
+ * for.
+ */
+function getHostEditor(editor: Editor): Editor | null {
+ const options = editor.options as Partial<{ parentEditor: Editor }>;
+ return options?.parentEditor ?? null;
+}
+
+function resolveSessionHostEditor(editor: Editor, runtime: StoryRuntime): Editor {
+ return getHostEditor(editor) ?? getHostEditor(runtime.editor) ?? runtime.editor;
+}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts
new file mode 100644
index 0000000000..ff52c55c61
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ createStoryHiddenHost,
+ STORY_HIDDEN_HOST_CLASS,
+ STORY_HIDDEN_HOST_WRAPPER_CLASS,
+} from './createStoryHiddenHost.js';
+
+describe('createStoryHiddenHost', () => {
+ let doc: Document;
+
+ beforeEach(() => {
+ doc = document.implementation.createHTMLDocument('test');
+ });
+
+ it('returns wrapper + host with body-hidden-host invariants', () => {
+ const { wrapper, host } = createStoryHiddenHost(doc, 800);
+
+ // Wrapper keeps scroll-isolation invariants from createHiddenHost
+ expect(wrapper.style.position).toBe('fixed');
+ expect(wrapper.style.overflow).toBe('hidden');
+ expect(wrapper.style.width).toBe('1px');
+ expect(wrapper.style.height).toBe('1px');
+
+ // Host must remain focusable + in the a11y tree
+ expect(host.style.visibility).not.toBe('hidden');
+ expect(host.hasAttribute('aria-hidden')).toBe(false);
+ });
+
+ it('adds the story-specific class markers', () => {
+ const { wrapper, host } = createStoryHiddenHost(doc, 800);
+ expect(wrapper.classList.contains(STORY_HIDDEN_HOST_WRAPPER_CLASS)).toBe(true);
+ expect(host.classList.contains(STORY_HIDDEN_HOST_CLASS)).toBe(true);
+ });
+
+ it('propagates storyKey/storyKind as data attributes when provided', () => {
+ const { host } = createStoryHiddenHost(doc, 800, {
+ storyKey: 'story:headerFooterPart:rId7',
+ storyKind: 'headerFooter',
+ });
+ expect(host.getAttribute('data-story-key')).toBe('story:headerFooterPart:rId7');
+ expect(host.getAttribute('data-story-kind')).toBe('headerFooter');
+ });
+
+ it('omits data attributes when options are not supplied', () => {
+ const { host } = createStoryHiddenHost(doc, 800);
+ expect(host.hasAttribute('data-story-key')).toBe(false);
+ expect(host.hasAttribute('data-story-kind')).toBe(false);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts
new file mode 100644
index 0000000000..d733ff815e
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts
@@ -0,0 +1,55 @@
+/**
+ * Hidden-host factory for story-backed presentation editing sessions.
+ *
+ * Story editors need the same scroll-isolated, off-screen, focusable host
+ * as the body editor. Rather than re-implementing that contract, this helper
+ * delegates to {@link createHiddenHost} and adds a story-specific className
+ * so the two hosts are easy to tell apart in DevTools and in tests.
+ *
+ * The returned wrapper must be appended to the DOM before the story editor
+ * is created, and removed (or left for disposal) when the session exits.
+ */
+
+import { createHiddenHost, type HiddenHostElements } from '../dom/HiddenHost.js';
+
+/** Class name added to the story hidden host for introspection/testing. */
+export const STORY_HIDDEN_HOST_CLASS = 'presentation-editor__story-hidden-host';
+
+/** Class name added to the story wrapper for introspection/testing. */
+export const STORY_HIDDEN_HOST_WRAPPER_CLASS = 'presentation-editor__story-hidden-host-wrapper';
+
+/**
+ * Options for creating a story hidden host.
+ */
+export interface CreateStoryHiddenHostOptions {
+ /**
+ * Identifier used as `data-story-key` on the host. Purely informational โ
+ * makes it trivial to see in DevTools which story a hidden host belongs to.
+ */
+ storyKey?: string;
+ /**
+ * Identifier used as `data-story-kind` on the host (e.g., `"headerFooter"`,
+ * `"note"`).
+ */
+ storyKind?: string;
+}
+
+/**
+ * Creates an off-screen hidden host for a story editor.
+ *
+ * The host preserves the same accessibility invariants as the body hidden
+ * host (focusable, present in a11y tree, not `aria-hidden`,
+ * not `visibility: hidden`).
+ */
+export function createStoryHiddenHost(
+ doc: Document,
+ widthPx: number,
+ options: CreateStoryHiddenHostOptions = {},
+): HiddenHostElements {
+ const { wrapper, host } = createHiddenHost(doc, widthPx);
+ wrapper.classList.add(STORY_HIDDEN_HOST_WRAPPER_CLASS);
+ host.classList.add(STORY_HIDDEN_HOST_CLASS);
+ if (options.storyKey) host.setAttribute('data-story-key', options.storyKey);
+ if (options.storyKind) host.setAttribute('data-story-kind', options.storyKind);
+ return { wrapper, host };
+}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts
new file mode 100644
index 0000000000..b9a5164604
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Public entry point for the story-session module.
+ *
+ * See `plans/story-backed-parts-presentation-editing.md`.
+ */
+
+export type { StoryPresentationSession, ActivateStorySessionOptions, StoryCommitPolicy } from './types.js';
+
+export {
+ StoryPresentationSessionManager,
+ type StoryPresentationSessionManagerOptions,
+ type StorySessionEditorFactory,
+ type StorySessionEditorFactoryInput,
+ type StorySessionEditorFactoryResult,
+} from './StoryPresentationSessionManager.js';
+
+export {
+ createStoryHiddenHost,
+ STORY_HIDDEN_HOST_CLASS,
+ STORY_HIDDEN_HOST_WRAPPER_CLASS,
+ type CreateStoryHiddenHostOptions,
+} from './createStoryHiddenHost.js';
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts
new file mode 100644
index 0000000000..37f7ad0ab6
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts
@@ -0,0 +1,137 @@
+/**
+ * Types for story-backed presentation editing sessions.
+ *
+ * A "story presentation session" is an interactive layout-mode editing
+ * context for a non-body story (header, footer, footnote, endnote, or a
+ * future content part). It holds:
+ *
+ * - the resolved {@link StoryLocator} + {@link StoryRuntime} for the story
+ * - the hidden off-screen DOM host that backs the story's ProseMirror editor
+ * - the presentation-editor side metadata needed to render caret/selection
+ * overlays and commit back through the parts system on exit
+ *
+ * This is the generalization of what `HeaderFooterSessionManager` does today
+ * for headers/footers, but intentionally story-kind agnostic so future
+ * callers (e.g. notes) can reuse the same lifecycle.
+ *
+ * See `plans/story-backed-parts-presentation-editing.md`.
+ */
+
+import type { Editor } from '../../Editor.js';
+import type { StoryLocator } from '@superdoc/document-api';
+import type { StoryRuntime, StoryKind } from '../../../document-api-adapters/story-runtime/story-types.js';
+
+/**
+ * How the session's edits should be persisted back to the canonical part.
+ *
+ * - `'onExit'` โ commit once when the session ends (default).
+ * - `'continuous'` โ commit on every PM transaction. Reserved for future
+ * collaborative or autosave-style behaviors; not required for the initial
+ * header/footer rollout.
+ * - `'manual'` โ caller invokes {@link StoryPresentationSession.commit}.
+ */
+export type StoryCommitPolicy = 'onExit' | 'continuous' | 'manual';
+
+/**
+ * A single active interactive editing session for a story-backed part.
+ *
+ * Sessions are created by {@link StoryPresentationSessionManager.activate}
+ * and disposed by {@link StoryPresentationSessionManager.exit}. While active,
+ * the session's editor DOM is the target of `PresentationInputBridge` and
+ * rendered content is still painted by the layout engine.
+ */
+export interface StoryPresentationSession {
+ /** The locator that was resolved to produce this session. */
+ readonly locator: StoryLocator;
+
+ /** The resolved story runtime (owns the editor, commit callback, dispose). */
+ readonly runtime: StoryRuntime;
+
+ /**
+ * The ProseMirror editor that backs this story while the session is
+ * active. For most non-body stories this is a freshly-created headless
+ * editor; for live PresentationEditor sub-editors it may be reused.
+ */
+ readonly editor: Editor;
+
+ /** Broad category of the story (headerFooter, note, body is not valid here). */
+ readonly kind: Exclude;
+
+ /**
+ * Off-screen wrapper element appended to the DOM. Removed on exit.
+ * May be `null` if the session reuses a pre-existing mounted editor
+ * whose DOM lifecycle is managed elsewhere.
+ */
+ readonly hostWrapper: HTMLElement | null;
+
+ /**
+ * The element that ProseMirror writes its visible DOM into โ this is what
+ * `PresentationInputBridge` forwards input events to. For sessions that
+ * own a hidden host, this is the inner host element. For reused live
+ * sub-editors, it is `editor.view.dom` at activation time.
+ */
+ readonly domTarget: HTMLElement | null;
+
+ /** Commit policy โ how changes persist back to the canonical part. */
+ readonly commitPolicy: StoryCommitPolicy;
+
+ /** Whether the session has been deactivated. Set to `true` by the manager on exit. */
+ readonly isDisposed: boolean;
+
+ /**
+ * Commit the session's changes back through the story runtime's commit
+ * callback. No-op if the runtime has no commit hook (e.g., body runtime).
+ */
+ commit(): void;
+
+ /**
+ * Tear down the session: commit if policy says so, dispose the hidden
+ * host (if owned), and invoke {@link StoryRuntime.dispose} when present.
+ * After calling this, the session's `isDisposed` is `true` and no further
+ * commits are performed.
+ */
+ dispose(): void;
+}
+
+/**
+ * Options passed when activating a session.
+ */
+export interface ActivateStorySessionOptions {
+ /** Override commit policy. Defaults to `'onExit'`. */
+ commitPolicy?: StoryCommitPolicy;
+
+ /**
+ * Explicit hidden-host width in layout pixels.
+ *
+ * When omitted, the session manager falls back to the mount container width.
+ */
+ hostWidthPx?: number;
+
+ /**
+ * Optional session-scoped editor context consumed by the editor factory.
+ *
+ * This is how visible story context such as page number, visible region size,
+ * and surface kind flows into a hidden-host editor instance without baking it
+ * into the runtime cache key.
+ */
+ editorContext?: {
+ availableWidth?: number;
+ availableHeight?: number;
+ currentPageNumber?: number;
+ totalPageCount?: number;
+ surfaceKind?: 'header' | 'footer' | 'note' | 'endnote';
+ };
+
+ /**
+ * When `true`, the manager must create its own hidden host and story
+ * editor instead of reusing any live sub-editor that the runtime might
+ * already have mounted visibly. PresentationEditor uses this as the
+ * canonical editing mode for all story-backed parts.
+ *
+ * When `false`, the manager may reuse whatever editor the runtime
+ * resolves (legacy behavior).
+ *
+ * @default true
+ */
+ preferHiddenHost?: boolean;
+}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts
index 13ffa0ac7c..95ff73bc77 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts
@@ -88,6 +88,26 @@ describe('DomPositionIndex', () => {
expect(index.findElementAtPosition(10)).toBe(null);
});
+ it('skips footnote descendants when building the body DOM index', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+
+ Simple
+
+
+ This
+
+
+ `;
+
+ const index = new DomPositionIndex();
+ index.rebuild(container);
+
+ expect(index.size).toBe(1);
+ expect(index.findElementAtPosition(1)?.textContent).toBe('Simple');
+ });
+
it('correctly distributes elements across header, body, and footer sections', () => {
const container = document.createElement('div');
container.innerHTML = `
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts
new file mode 100644
index 0000000000..1ee225aa10
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts
@@ -0,0 +1,150 @@
+/**
+ * SD-2495 / SD-2537 regression guard for cross-reference click-to-navigate.
+ *
+ * The existing behavior before this PR only routed TOC-entry clicks through
+ * `goToAnchor`. Cross-reference rendered anchors (``) were
+ * dispatched as generic `superdoc-link-click` custom events โ host apps had
+ * to handle navigation themselves, and most didn't, so clicks silently
+ * opened nothing. The fix generalized the internal-anchor branch in
+ * `#handleLinkClick` to cover every `#โฆ` href.
+ *
+ * This test pins the new behavior:
+ * - clicks on `` invoke `goToAnchor('#someBookmark')`
+ * - the browser's default navigation is prevented
+ * so a future refactor that narrows the branch back to TOC-only breaks this.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
+
+import {
+ EditorInputManager,
+ type EditorInputDependencies,
+ type EditorInputCallbacks,
+} from '../pointer-events/EditorInputManager.js';
+
+describe('EditorInputManager โ anchor-href click routing (SD-2537)', () => {
+ let manager: EditorInputManager;
+ let viewportHost: HTMLElement;
+ let visibleHost: HTMLElement;
+ let goToAnchor: Mock;
+ let mockEditor: {
+ isEditable: boolean;
+ state: { doc: { content: { size: number } }; selection: { $anchor: null } };
+ view: { dispatch: Mock; dom: HTMLElement; focus: Mock; hasFocus: Mock };
+ on: Mock;
+ off: Mock;
+ emit: Mock;
+ };
+
+ beforeEach(() => {
+ viewportHost = document.createElement('div');
+ viewportHost.className = 'presentation-editor__viewport';
+ visibleHost = document.createElement('div');
+ visibleHost.className = 'presentation-editor__visible';
+ visibleHost.appendChild(viewportHost);
+ document.body.appendChild(visibleHost);
+
+ mockEditor = {
+ isEditable: true,
+ state: { doc: { content: { size: 100 } }, selection: { $anchor: null } },
+ view: { dispatch: vi.fn(), dom: document.createElement('div'), focus: vi.fn(), hasFocus: vi.fn(() => false) },
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ };
+
+ const deps: EditorInputDependencies = {
+ getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType),
+ getEditor: vi.fn(() => mockEditor as unknown as ReturnType),
+ getLayoutState: vi.fn(() => ({ layout: {} as never, blocks: [], measures: [] })),
+ getEpochMapper: vi.fn(() => ({
+ mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })),
+ })) as unknown as EditorInputDependencies['getEpochMapper'],
+ getViewportHost: vi.fn(() => viewportHost),
+ getVisibleHost: vi.fn(() => visibleHost),
+ getLayoutMode: vi.fn(() => 'vertical' as const),
+ getHeaderFooterSession: vi.fn(() => null),
+ getPageGeometryHelper: vi.fn(() => null),
+ getZoom: vi.fn(() => 1),
+ isViewLocked: vi.fn(() => false),
+ getDocumentMode: vi.fn(() => 'editing' as const),
+ getPageElement: vi.fn(() => null),
+ isSelectionAwareVirtualizationEnabled: vi.fn(() => false),
+ };
+
+ goToAnchor = vi.fn();
+ const callbacks: EditorInputCallbacks = {
+ normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })),
+ scheduleSelectionUpdate: vi.fn(),
+ updateSelectionDebugHud: vi.fn(),
+ goToAnchor,
+ };
+
+ manager = new EditorInputManager();
+ manager.setDependencies(deps);
+ manager.setCallbacks(callbacks);
+ manager.bind();
+ });
+
+ afterEach(() => {
+ manager.destroy();
+ document.body.innerHTML = '';
+ vi.clearAllMocks();
+ });
+
+ const makeAnchor = (href: string): HTMLAnchorElement => {
+ const a = document.createElement('a');
+ a.className = 'superdoc-link';
+ a.setAttribute('href', href);
+ a.textContent = '15';
+ viewportHost.appendChild(a);
+ return a;
+ };
+
+ const firePointerDown = (el: HTMLElement) => {
+ const PointerEventImpl =
+ (globalThis as unknown as { PointerEvent?: typeof PointerEvent }).PointerEvent ?? globalThis.MouseEvent;
+ el.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 10,
+ clientY: 10,
+ } as PointerEventInit),
+ );
+ };
+
+ it('routes `#` anchor clicks through goToAnchor', () => {
+ const a = makeAnchor('#_Ref506192326');
+ firePointerDown(a);
+ expect(goToAnchor).toHaveBeenCalledWith('#_Ref506192326');
+ });
+
+ it('routes TOC-inside `#โฆ` anchor clicks through goToAnchor (backward compat)', () => {
+ // The pre-PR behavior was TOC-only. Make sure generalizing the branch
+ // didn't accidentally exclude TOC entries.
+ const tocWrapper = document.createElement('span');
+ tocWrapper.className = 'superdoc-toc-entry';
+ const a = document.createElement('a');
+ a.className = 'superdoc-link';
+ a.setAttribute('href', '#_Toc123');
+ tocWrapper.appendChild(a);
+ viewportHost.appendChild(tocWrapper);
+
+ firePointerDown(a);
+ expect(goToAnchor).toHaveBeenCalledWith('#_Toc123');
+ });
+
+ it('does not route external hrefs through goToAnchor', () => {
+ const a = makeAnchor('https://example.com/page');
+ firePointerDown(a);
+ expect(goToAnchor).not.toHaveBeenCalled();
+ });
+
+ it('does not route bare `#` (empty fragment) to goToAnchor', () => {
+ const a = makeAnchor('#');
+ firePointerDown(a);
+ expect(goToAnchor).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts
index adfe5bfd04..41d1beff84 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts
@@ -135,6 +135,7 @@ describe('EditorInputManager - Drag Auto Scroll', () => {
normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })),
updateSelectionVirtualizationPins: vi.fn(),
scheduleSelectionUpdate: vi.fn(),
+ notifyDragSelectionEnded: vi.fn(),
};
manager = new EditorInputManager();
@@ -254,6 +255,8 @@ describe('EditorInputManager - Drag Auto Scroll', () => {
// Auto-scroll should be stopped
expect(rafCallback).toBeNull();
+ // one post-drag hook so PresentationEditor can scroll selection into view after auto-scroll stops
+ expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1);
});
it('does not auto-scroll in header/footer mode', () => {
@@ -328,4 +331,41 @@ describe('EditorInputManager - Drag Auto Scroll', () => {
expect(scrollContainer.scrollLeft).toBe(0);
});
});
+
+ describe('notifyDragSelectionEnded (selection scroll after drag)', () => {
+ it('invokes notifyDragSelectionEnded exactly once when a text drag ends after movement', () => {
+ startDrag(10, 10);
+ moveDrag(40, 25);
+ endDrag(40, 25);
+
+ expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1);
+ });
+
+ it('invokes notifyDragSelectionEnded when pointer goes down and up without move (click-hold-release)', () => {
+ startDrag(10, 10);
+ endDrag(10, 10);
+
+ expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not invoke notifyDragSelectionEnded on pointer up if no drag was started', () => {
+ endDrag(10, 10);
+
+ expect(mockCallbacks.notifyDragSelectionEnded).not.toHaveBeenCalled();
+ });
+
+ it('invokes notifyDragSelectionEnded once per completed drag gesture', () => {
+ startDrag(10, 10);
+ moveDrag(20, 15);
+ endDrag(20, 15);
+ expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1);
+
+ (mockCallbacks.notifyDragSelectionEnded as ReturnType).mockClear();
+
+ startDrag(50, 50);
+ moveDrag(60, 55);
+ endDrag(60, 55);
+ expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts
index 57183e6c67..0cceb3def7 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts
@@ -4,6 +4,10 @@ import { clickToPosition } from '@superdoc/layout-bridge';
import { resolvePointerPositionHit } from '../input/PositionHitResolver.js';
import { TextSelection } from 'prosemirror-state';
+const { mockCommentsPluginState } = vi.hoisted(() => ({
+ mockCommentsPluginState: { activeThreadId: null as string | null },
+}));
+
import {
EditorInputManager,
type EditorInputDependencies,
@@ -28,22 +32,28 @@ vi.mock('@superdoc/layout-bridge', () => ({
vi.mock('prosemirror-state', async (importOriginal) => {
const original = await importOriginal();
+ class MockTextSelection {
+ empty = true;
+ $from = { parent: { inlineContent: true } };
+ static create = vi.fn(() => new MockTextSelection());
+ }
return {
...original,
- TextSelection: {
- ...original.TextSelection,
- create: vi.fn(() => ({
- empty: true,
- $from: { parent: { inlineContent: true } },
- })),
- },
+ TextSelection: MockTextSelection,
};
});
+vi.mock('@extensions/comment/comments-plugin.js', () => ({
+ CommentsPluginKey: {
+ getState: vi.fn(() => mockCommentsPluginState),
+ },
+}));
+
describe('EditorInputManager - Footnote click selection behavior', () => {
let manager: EditorInputManager;
let viewportHost: HTMLElement;
let visibleHost: HTMLElement;
+ let originalElementFromPoint: typeof document.elementFromPoint | undefined;
let mockEditor: {
isEditable: boolean;
state: {
@@ -64,8 +74,11 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
};
let mockDeps: EditorInputDependencies;
let mockCallbacks: EditorInputCallbacks;
+ let activateRenderedNoteSession: Mock;
beforeEach(() => {
+ originalElementFromPoint = document.elementFromPoint?.bind(document);
+ mockCommentsPluginState.activeThreadId = null;
viewportHost = document.createElement('div');
viewportHost.className = 'presentation-editor__viewport';
visibleHost = document.createElement('div');
@@ -92,6 +105,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
},
selection: { $anchor: null },
storedMarks: null,
+ comments$: { activeThreadId: null },
},
view: {
dispatch: vi.fn(),
@@ -106,6 +120,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
mockDeps = {
getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType),
+ getActiveStorySession: vi.fn(() => null),
getEditor: vi.fn(() => mockEditor as unknown as ReturnType),
getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })),
getEpochMapper: vi.fn(() => ({
@@ -124,10 +139,12 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
};
mockCallbacks = {
+ activateRenderedNoteSession: vi.fn(() => true),
normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })),
scheduleSelectionUpdate: vi.fn(),
updateSelectionDebugHud: vi.fn(),
};
+ activateRenderedNoteSession = mockCallbacks.activateRenderedNoteSession as Mock;
manager = new EditorInputManager();
manager.setDependencies(mockDeps);
@@ -138,6 +155,14 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
afterEach(() => {
manager.destroy();
document.body.innerHTML = '';
+ if (originalElementFromPoint) {
+ Object.defineProperty(document, 'elementFromPoint', {
+ configurable: true,
+ value: originalElementFromPoint,
+ });
+ } else {
+ Reflect.deleteProperty(document, 'elementFromPoint');
+ }
vi.clearAllMocks();
});
@@ -148,7 +173,70 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
);
}
- it('does not change editor selection on direct footnote fragment click', () => {
+ function createActiveSessionEditor(docSize = 50) {
+ return {
+ ...mockEditor,
+ state: {
+ ...mockEditor.state,
+ doc: { ...mockEditor.state.doc, content: { size: docSize } },
+ tr: {
+ setSelection: vi.fn().mockReturnThis(),
+ setStoredMarks: vi.fn().mockReturnThis(),
+ },
+ },
+ view: {
+ ...mockEditor.view,
+ dispatch: vi.fn(),
+ },
+ };
+ }
+
+ function stubElementFromPoint(element: Element | null): Mock {
+ const elementFromPoint = vi.fn(() => element);
+ Object.defineProperty(document, 'elementFromPoint', {
+ configurable: true,
+ value: elementFromPoint,
+ });
+ return elementFromPoint;
+ }
+
+ function stubElementsFromPoint(elements: Array): Mock {
+ const elementsFromPoint = vi.fn(() => elements.filter((element): element is Element => !!element));
+ Object.defineProperty(document, 'elementsFromPoint', {
+ configurable: true,
+ value: elementsFromPoint,
+ });
+ return elementsFromPoint;
+ }
+
+ function stubBoundingRect(
+ element: Element,
+ {
+ left,
+ top,
+ width,
+ height,
+ }: {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ },
+ ) {
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
+ x: left,
+ y: top,
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ toJSON: () => ({}),
+ } as DOMRect);
+ }
+
+ it('activates a note session on direct footnote fragment click', () => {
const fragmentEl = document.createElement('span');
fragmentEl.setAttribute('data-block-id', 'footnote-1-0');
const nestedEl = document.createElement('span');
@@ -167,12 +255,75 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
} as PointerEventInit),
);
- // Expected behavior: footnote click should not relocate caret to start of the document.
+ expect(activateRenderedNoteSession).toHaveBeenCalledWith(
+ { storyType: 'footnote', noteId: '1' },
+ expect.objectContaining({ clientX: 10, clientY: 10 }),
+ );
+ expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
+ expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
+ });
+
+ it('activates a note session on direct endnote fragment click', () => {
+ const fragmentEl = document.createElement('span');
+ fragmentEl.setAttribute('data-block-id', 'endnote-1-0');
+ const nestedEl = document.createElement('span');
+ fragmentEl.appendChild(nestedEl);
+ viewportHost.appendChild(fragmentEl);
+
+ const PointerEventImpl = getPointerEventImpl();
+ nestedEl.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 16,
+ clientY: 12,
+ } as PointerEventInit),
+ );
+
+ expect(activateRenderedNoteSession).toHaveBeenCalledWith(
+ { storyType: 'endnote', noteId: '1' },
+ expect.objectContaining({ clientX: 16, clientY: 12 }),
+ );
expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
});
- it('does not change editor selection when hit-test resolves to a footnote block', () => {
+ it('activates the note session and syncs the tracked-change bubble on footnote clicks', () => {
+ const fragmentEl = document.createElement('span');
+ fragmentEl.setAttribute('data-block-id', 'footnote-1-0');
+
+ const trackedChangeEl = document.createElement('span');
+ trackedChangeEl.setAttribute('data-track-change-id', 'tc-1');
+ fragmentEl.appendChild(trackedChangeEl);
+ viewportHost.appendChild(fragmentEl);
+
+ const PointerEventImpl = getPointerEventImpl();
+ trackedChangeEl.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 12,
+ clientY: 10,
+ } as PointerEventInit),
+ );
+
+ expect(activateRenderedNoteSession).toHaveBeenCalledWith(
+ { storyType: 'footnote', noteId: '1' },
+ expect.objectContaining({ clientX: 12, clientY: 10 }),
+ );
+ expect(mockEditor.emit).toHaveBeenCalledWith(
+ 'commentsUpdate',
+ expect.objectContaining({
+ activeCommentId: 'tc-1',
+ }),
+ );
+ });
+
+ it('keeps legacy read-only behavior for stale footnote hits without a rendered footnote target', () => {
(resolvePointerPositionHit as unknown as Mock).mockReturnValue({
pos: 22,
layoutEpoch: 1,
@@ -197,26 +348,47 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
} as PointerEventInit),
);
- // Expected behavior: block edits in footnotes without resetting user selection.
+ expect(activateRenderedNoteSession).not.toHaveBeenCalled();
expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
});
- it('does not change editor selection when hit-test resolves to a semantic footnote block', () => {
+ it('does not reactivate the same note session when clicking inside the active note', () => {
(resolvePointerPositionHit as unknown as Mock).mockReturnValue({
pos: 22,
layoutEpoch: 1,
pageIndex: 0,
- blockId: '__sd_semantic_footnote-1-1',
+ blockId: 'footnote-1-1',
column: 0,
lineIndex: -1,
});
- const target = document.createElement('span');
- viewportHost.appendChild(target);
+ const activeNoteEditor = {
+ ...mockEditor,
+ state: {
+ ...mockEditor.state,
+ doc: { ...mockEditor.state.doc, content: { size: 50 } },
+ },
+ view: {
+ ...mockEditor.view,
+ dispatch: vi.fn(),
+ },
+ };
+ (mockDeps.getActiveStorySession as Mock).mockReturnValue({
+ kind: 'note',
+ locator: { kind: 'story', storyType: 'footnote', noteId: '1' },
+ editor: activeNoteEditor,
+ });
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor);
+
+ const fragmentEl = document.createElement('span');
+ fragmentEl.setAttribute('data-block-id', 'footnote-1-0');
+ const nestedEl = document.createElement('span');
+ fragmentEl.appendChild(nestedEl);
+ viewportHost.appendChild(fragmentEl);
const PointerEventImpl = getPointerEventImpl();
- target.dispatchEvent(
+ nestedEl.dispatchEvent(
new PointerEventImpl('pointerdown', {
bubbles: true,
cancelable: true,
@@ -227,11 +399,37 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
} as PointerEventInit),
);
- expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
- expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
+ expect(activateRenderedNoteSession).not.toHaveBeenCalled();
+ expect(mockEditor.view.focus).toHaveBeenCalled();
+ });
+
+ it('does not reactivate the same note session on double-click inside the active note', () => {
+ (mockDeps.getActiveStorySession as Mock).mockReturnValue({
+ kind: 'note',
+ locator: { kind: 'story', storyType: 'footnote', noteId: '1' },
+ editor: createActiveSessionEditor(),
+ });
+
+ const fragmentEl = document.createElement('span');
+ fragmentEl.setAttribute('data-block-id', 'footnote-1-0');
+ const nestedEl = document.createElement('span');
+ fragmentEl.appendChild(nestedEl);
+ viewportHost.appendChild(fragmentEl);
+
+ nestedEl.dispatchEvent(
+ new MouseEvent('dblclick', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ clientX: 12,
+ clientY: 14,
+ }),
+ );
+
+ expect(activateRenderedNoteSession).not.toHaveBeenCalled();
});
- it('does not change editor selection on semantic footnotes heading click', () => {
+ it('does not activate a note session on semantic footnotes heading click', () => {
(resolvePointerPositionHit as unknown as Mock).mockReturnValue(null);
const headingEl = document.createElement('div');
@@ -252,7 +450,646 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
} as PointerEventInit),
);
- expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
- expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
+ expect(activateRenderedNoteSession).not.toHaveBeenCalled();
+ expect(mockEditor.view.focus).toHaveBeenCalled();
+ });
+
+ it('uses story-surface hit testing for active note clicks', () => {
+ const activeNoteEditor = createActiveSessionEditor();
+ (mockDeps.getActiveStorySession as Mock).mockReturnValue({
+ kind: 'note',
+ locator: { kind: 'story', storyType: 'footnote', noteId: '1' },
+ editor: activeNoteEditor,
+ });
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor);
+ mockCallbacks.hitTest = vi.fn(() => ({
+ pos: 41,
+ layoutEpoch: 7,
+ pageIndex: 0,
+ blockId: 'footnote-1-0',
+ column: 0,
+ lineIndex: -1,
+ }));
+
+ const fragmentEl = document.createElement('span');
+ fragmentEl.setAttribute('data-block-id', 'footnote-1-0');
+ const nestedEl = document.createElement('span');
+ fragmentEl.appendChild(nestedEl);
+ viewportHost.appendChild(fragmentEl);
+
+ const PointerEventImpl = getPointerEventImpl();
+ nestedEl.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 18,
+ clientY: 16,
+ } as PointerEventInit),
+ );
+
+ expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(18, 16);
+ expect(resolvePointerPositionHit).not.toHaveBeenCalled();
+ expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled();
+ expect(activeNoteEditor.view.focus).toHaveBeenCalled();
+ });
+
+ it('keeps note hit testing while syncing the tracked-change bubble during active note editing', () => {
+ const activeNoteEditor = createActiveSessionEditor();
+ (mockDeps.getActiveStorySession as Mock).mockReturnValue({
+ kind: 'note',
+ locator: { kind: 'story', storyType: 'footnote', noteId: '1' },
+ editor: activeNoteEditor,
+ });
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor);
+ mockCallbacks.hitTest = vi.fn(() => ({
+ pos: 21,
+ layoutEpoch: 7,
+ pageIndex: 0,
+ blockId: 'footnote-1-0',
+ column: 0,
+ lineIndex: -1,
+ }));
+
+ const fragmentEl = document.createElement('span');
+ fragmentEl.setAttribute('data-block-id', 'footnote-1-0');
+ const trackedChangeEl = document.createElement('span');
+ trackedChangeEl.setAttribute('data-track-change-id', 'tc-1');
+ fragmentEl.appendChild(trackedChangeEl);
+ viewportHost.appendChild(fragmentEl);
+
+ const PointerEventImpl = getPointerEventImpl();
+ trackedChangeEl.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 18,
+ clientY: 16,
+ } as PointerEventInit),
+ );
+
+ expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(18, 16);
+ expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled();
+ expect(mockEditor.emit).toHaveBeenCalledWith(
+ 'commentsUpdate',
+ expect.objectContaining({
+ activeCommentId: 'tc-1',
+ }),
+ );
+ });
+
+ it('uses story-surface hit testing for active header clicks', () => {
+ const activeHeaderEditor = createActiveSessionEditor();
+ const pageContainer = document.createElement('div');
+ pageContainer.className = 'superdoc-page';
+ viewportHost.appendChild(pageContainer);
+
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor);
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: { mode: 'header' },
+ });
+ mockCallbacks.hitTest = vi.fn(() => ({
+ pos: 18,
+ layoutEpoch: 3,
+ pageIndex: 0,
+ blockId: 'header-1',
+ column: 0,
+ lineIndex: -1,
+ }));
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'header',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ localX: 0,
+ localY: 0,
+ width: 200,
+ height: 40,
+ }));
+ stubElementsFromPoint([pageContainer]);
+
+ const target = document.createElement('span');
+ viewportHost.appendChild(target);
+
+ const PointerEventImpl = getPointerEventImpl();
+ target.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 24,
+ clientY: 12,
+ } as PointerEventInit),
+ );
+
+ expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(24, 12);
+ expect(resolvePointerPositionHit).not.toHaveBeenCalled();
+ expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled();
+ expect(activeHeaderEditor.view.focus).toHaveBeenCalled();
+ });
+
+ it('keeps active header editing when the pointer stack only exposes the page container', () => {
+ const activeHeaderEditor = createActiveSessionEditor();
+ const exitHeaderFooterMode = vi.fn();
+
+ const pageContainer = document.createElement('div');
+ pageContainer.className = 'superdoc-page';
+ viewportHost.appendChild(pageContainer);
+
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor);
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: { mode: 'header' },
+ });
+ mockCallbacks.exitHeaderFooterMode = exitHeaderFooterMode;
+ mockCallbacks.hitTest = vi.fn(() => ({
+ pos: 18,
+ layoutEpoch: 3,
+ pageIndex: 0,
+ blockId: 'header-1',
+ column: 0,
+ lineIndex: -1,
+ }));
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'header',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ localX: 0,
+ localY: 0,
+ width: 200,
+ height: 40,
+ }));
+ manager.setCallbacks(mockCallbacks);
+ stubElementsFromPoint([pageContainer]);
+
+ const PointerEventImpl = getPointerEventImpl();
+ pageContainer.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 24,
+ clientY: 12,
+ } as PointerEventInit),
+ );
+
+ expect(exitHeaderFooterMode).not.toHaveBeenCalled();
+ expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(24, 12);
+ expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled();
+ });
+
+ it('exits active header editing when the topmost visible target is body content even if region hit-testing still says header', () => {
+ const activeHeaderEditor = createActiveSessionEditor();
+ const exitHeaderFooterMode = vi.fn();
+
+ const visibleHeader = document.createElement('div');
+ visibleHeader.className = 'superdoc-page-header';
+ viewportHost.appendChild(visibleHeader);
+
+ const bodyLine = document.createElement('div');
+ bodyLine.className = 'superdoc-line';
+ const bodyText = document.createElement('span');
+ bodyText.textContent = 'Visible body text';
+ bodyLine.appendChild(bodyText);
+ viewportHost.appendChild(bodyLine);
+ stubElementFromPoint(bodyText);
+ stubElementsFromPoint([bodyText, bodyLine, visibleHeader]);
+
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor);
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: { mode: 'header' },
+ });
+ mockCallbacks.exitHeaderFooterMode = exitHeaderFooterMode;
+ mockCallbacks.hitTest = vi.fn(() => ({
+ pos: 24,
+ layoutEpoch: 3,
+ pageIndex: 0,
+ blockId: 'body-1',
+ column: 0,
+ lineIndex: -1,
+ }));
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'header',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ localX: 0,
+ localY: 0,
+ width: 300,
+ height: 220,
+ }));
+ manager.setCallbacks(mockCallbacks);
+
+ const PointerEventImpl = getPointerEventImpl();
+ bodyText.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 30,
+ clientY: 220,
+ } as PointerEventInit),
+ );
+
+ expect(exitHeaderFooterMode).toHaveBeenCalledTimes(1);
+ expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(30, 220);
+ expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled();
+ });
+
+ it('keeps the current session alive on the first click into a different header/footer surface', () => {
+ const activeHeaderEditor = createActiveSessionEditor();
+ const exitHeaderFooterMode = vi.fn();
+
+ const pageEl = document.createElement('div');
+ pageEl.className = 'superdoc-page';
+ const footerSurface = document.createElement('div');
+ footerSurface.className = 'superdoc-page-footer';
+ const footerText = document.createElement('span');
+ footerText.textContent = 'Footer';
+ footerSurface.appendChild(footerText);
+ pageEl.appendChild(footerSurface);
+ viewportHost.appendChild(pageEl);
+
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor);
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: {
+ mode: 'header',
+ headerFooterRefId: 'rId6',
+ sectionType: 'default',
+ pageIndex: 0,
+ },
+ });
+ mockCallbacks.exitHeaderFooterMode = exitHeaderFooterMode;
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'footer',
+ headerFooterRefId: 'rId7',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ sectionId: 'section-0',
+ sectionIndex: 0,
+ localX: 0,
+ localY: 180,
+ width: 300,
+ height: 40,
+ }));
+ manager.setCallbacks(mockCallbacks);
+ stubElementFromPoint(footerText);
+ stubElementsFromPoint([footerText, footerSurface, pageEl]);
+
+ const PointerEventImpl = getPointerEventImpl();
+ footerText.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 30,
+ clientY: 210,
+ } as PointerEventInit),
+ );
+
+ expect(exitHeaderFooterMode).not.toHaveBeenCalled();
+ });
+
+ it('activates a different header/footer region on double-click without requiring a body round-trip', () => {
+ const activateHeaderFooterRegion = vi.fn();
+ const footerSurface = document.createElement('div');
+ footerSurface.className = 'superdoc-page-footer';
+ viewportHost.appendChild(footerSurface);
+
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: {
+ mode: 'header',
+ headerFooterRefId: 'rId6',
+ sectionType: 'default',
+ pageIndex: 0,
+ },
+ });
+ mockCallbacks.normalizeClientPoint = vi.fn((clientX: number, clientY: number) => ({
+ x: clientX,
+ y: clientY,
+ pageIndex: 0,
+ pageLocalY: clientY,
+ }));
+ mockCallbacks.activateHeaderFooterRegion = activateHeaderFooterRegion;
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'footer',
+ headerFooterRefId: 'rId7',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ sectionId: 'section-0',
+ sectionIndex: 0,
+ localX: 0,
+ localY: 180,
+ width: 300,
+ height: 40,
+ }));
+ manager.setCallbacks(mockCallbacks);
+ stubElementFromPoint(footerSurface);
+ stubElementsFromPoint([footerSurface]);
+
+ footerSurface.dispatchEvent(
+ new MouseEvent('dblclick', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ clientX: 30,
+ clientY: 210,
+ }),
+ );
+
+ expect(activateHeaderFooterRegion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'footer',
+ headerFooterRefId: 'rId7',
+ }),
+ expect.objectContaining({
+ clientX: 30,
+ clientY: 210,
+ pageIndex: 0,
+ source: 'pointerDoubleClick',
+ }),
+ );
+ });
+
+ it('renders the hover affordance for a different header/footer region while another region is active', () => {
+ const renderHover = vi.fn();
+ const renderHoverRegion = vi.fn();
+ const clearHoverRegion = vi.fn();
+ const footerSurface = document.createElement('div');
+ footerSurface.className = 'superdoc-page-footer';
+ viewportHost.appendChild(footerSurface);
+
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: {
+ mode: 'header',
+ headerFooterRefId: 'rId6',
+ sectionType: 'default',
+ pageIndex: 0,
+ },
+ hoverRegion: null,
+ renderHover,
+ });
+ mockCallbacks.renderHoverRegion = renderHoverRegion;
+ mockCallbacks.clearHoverRegion = clearHoverRegion;
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'footer',
+ headerFooterRefId: 'rId7',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ sectionId: 'section-0',
+ sectionIndex: 0,
+ localX: 0,
+ localY: 180,
+ width: 300,
+ height: 40,
+ }));
+ manager.setCallbacks(mockCallbacks);
+
+ const PointerEventImpl = getPointerEventImpl();
+ footerSurface.dispatchEvent(
+ new PointerEventImpl('pointermove', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 30,
+ clientY: 210,
+ } as PointerEventInit),
+ );
+
+ expect(renderHover).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'footer',
+ headerFooterRefId: 'rId7',
+ }),
+ );
+ expect(renderHoverRegion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'footer',
+ headerFooterRefId: 'rId7',
+ }),
+ );
+ expect(clearHoverRegion).not.toHaveBeenCalled();
+ });
+
+ it('keeps the hover affordance hidden for the currently active header/footer region', () => {
+ const renderHover = vi.fn();
+ const renderHoverRegion = vi.fn();
+ const clearHoverRegion = vi.fn();
+ const headerSurface = document.createElement('div');
+ headerSurface.className = 'superdoc-page-header';
+ viewportHost.appendChild(headerSurface);
+
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: {
+ mode: 'header',
+ headerFooterRefId: 'rId6',
+ sectionType: 'default',
+ pageIndex: 0,
+ },
+ hoverRegion: {
+ kind: 'footer',
+ headerFooterRefId: 'rId7',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ sectionId: 'section-0',
+ sectionIndex: 0,
+ localX: 0,
+ localY: 180,
+ width: 300,
+ height: 40,
+ },
+ renderHover,
+ });
+ mockCallbacks.renderHoverRegion = renderHoverRegion;
+ mockCallbacks.clearHoverRegion = clearHoverRegion;
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'header',
+ headerFooterRefId: 'rId6',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ sectionId: 'section-0',
+ sectionIndex: 0,
+ localX: 0,
+ localY: 0,
+ width: 300,
+ height: 40,
+ }));
+ manager.setCallbacks(mockCallbacks);
+
+ const PointerEventImpl = getPointerEventImpl();
+ headerSurface.dispatchEvent(
+ new PointerEventImpl('pointermove', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 30,
+ clientY: 20,
+ } as PointerEventInit),
+ );
+
+ expect(clearHoverRegion).toHaveBeenCalledTimes(1);
+ expect(renderHover).not.toHaveBeenCalled();
+ expect(renderHoverRegion).not.toHaveBeenCalled();
+ });
+
+ it('syncs the tracked-change bubble for real clicks inside the active rendered header surface', () => {
+ const activeHeaderEditor = createActiveSessionEditor();
+ const pageEl = document.createElement('div');
+ pageEl.className = 'superdoc-page';
+ const activeHeaderSurface = document.createElement('div');
+ activeHeaderSurface.className = 'superdoc-page-header';
+ const trackedChangeEl = document.createElement('span');
+ trackedChangeEl.className = 'track-insert';
+ trackedChangeEl.setAttribute('data-id', 'tc-header-1');
+ activeHeaderSurface.appendChild(trackedChangeEl);
+ pageEl.appendChild(activeHeaderSurface);
+ viewportHost.appendChild(pageEl);
+
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor);
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: { mode: 'header' },
+ });
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'header',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ localX: 0,
+ localY: 0,
+ width: 300,
+ height: 220,
+ }));
+ stubElementFromPoint(pageEl);
+ stubElementsFromPoint([pageEl]);
+ stubBoundingRect(trackedChangeEl, { left: 16, top: 8, width: 52, height: 18 });
+
+ const PointerEventImpl = getPointerEventImpl();
+ pageEl.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 20,
+ clientY: 12,
+ } as PointerEventInit),
+ );
+
+ expect(mockEditor.emit).toHaveBeenCalledWith(
+ 'commentsUpdate',
+ expect.objectContaining({
+ activeCommentId: 'tc-header-1',
+ }),
+ );
+ expect(resolvePointerPositionHit).toHaveBeenCalled();
+ });
+
+ it('clears the active tracked-change bubble for plain clicks inside the active rendered header surface', () => {
+ const activeHeaderEditor = createActiveSessionEditor();
+ const activeHeaderSurface = document.createElement('div');
+ activeHeaderSurface.className = 'superdoc-page-header';
+ const plainTextEl = document.createElement('span');
+ plainTextEl.textContent = 'Generic content header';
+ activeHeaderSurface.appendChild(plainTextEl);
+ viewportHost.appendChild(activeHeaderSurface);
+
+ mockCommentsPluginState.activeThreadId = 'tc-header-1';
+
+ (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor);
+ (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({
+ session: { mode: 'header' },
+ });
+ mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({
+ kind: 'header',
+ pageIndex: 0,
+ pageNumber: 1,
+ sectionType: 'default',
+ localX: 0,
+ localY: 0,
+ width: 300,
+ height: 220,
+ }));
+ stubElementFromPoint(plainTextEl);
+ stubElementsFromPoint([activeHeaderSurface]);
+
+ const PointerEventImpl = getPointerEventImpl();
+ plainTextEl.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 28,
+ clientY: 12,
+ } as PointerEventInit),
+ );
+
+ expect(mockEditor.emit).toHaveBeenCalledWith(
+ 'commentsUpdate',
+ expect.objectContaining({
+ activeCommentId: null,
+ }),
+ );
+ expect(resolvePointerPositionHit).toHaveBeenCalled();
+ });
+
+ it('resets multi-click state when the active editing target changes', () => {
+ const target = document.createElement('span');
+ viewportHost.appendChild(target);
+
+ const selectWordAt = vi.fn(() => true);
+ mockCallbacks.selectWordAt = selectWordAt;
+ manager.setCallbacks(mockCallbacks);
+
+ const PointerEventImpl = getPointerEventImpl();
+ target.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 18,
+ clientY: 22,
+ pointerId: 1,
+ } as PointerEventInit),
+ );
+ viewportHost.dispatchEvent(
+ new PointerEventImpl('pointerup', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 0,
+ clientX: 18,
+ clientY: 22,
+ pointerId: 1,
+ } as PointerEventInit),
+ );
+
+ manager.notifyTargetChanged();
+
+ target.dispatchEvent(
+ new PointerEventImpl('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ clientX: 18,
+ clientY: 22,
+ pointerId: 2,
+ } as PointerEventInit),
+ );
+
+ expect(selectWordAt).not.toHaveBeenCalled();
+ expect(TextSelection.create as unknown as Mock).toHaveBeenCalledTimes(2);
});
});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts
index d4d0a7da6c..572e3fb8f6 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts
@@ -3,6 +3,7 @@ import type { EditorState } from 'prosemirror-state';
import { buildFootnotesInput, type ConverterLike } from '../layout/FootnotesBuilder.js';
import type { ConverterContext } from '@superdoc/pm-adapter';
import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js';
+import { toFlowBlocks } from '@superdoc/pm-adapter';
// Mock toFlowBlocks
vi.mock('@superdoc/pm-adapter', async (importOriginal) => {
@@ -147,6 +148,44 @@ describe('buildFootnotesInput', () => {
expect(result?.dividerHeight).toBe(1);
});
+ it('stamps converted footnote blocks with the footnote story key', () => {
+ const editorState = createMockEditorState([{ id: '1', pos: 10 }]);
+ const converter = createMockConverter([
+ { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] },
+ ]);
+
+ buildFootnotesInput(editorState, converter, undefined, undefined);
+
+ const [, options] =
+ (toFlowBlocks as unknown as { mock: { calls: Array<[unknown, Record]> } }).mock.calls.at(-1) ??
+ [];
+ expect(options?.storyKey).toBe('fn:1');
+ });
+
+ it('prefers the active note render override over stale converter content', () => {
+ const editorState = createMockEditorState([{ id: '1', pos: 10 }]);
+ const converter = createMockConverter([
+ {
+ id: '1',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Stale note' }] }],
+ },
+ ]);
+
+ buildFootnotesInput(editorState, converter, undefined, undefined, {
+ noteId: '1',
+ docJson: {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Live note' }] }],
+ },
+ });
+
+ const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0];
+ expect(docArg).toEqual({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Live note' }] }],
+ });
+ });
+
it('only includes footnotes that are referenced in the document', () => {
const editorState = createMockEditorState([{ id: '1', pos: 10 }]); // Only ref 1 in doc
const converter = createMockConverter([
@@ -182,6 +221,112 @@ describe('buildFootnotesInput', () => {
?.runs?.[0];
expect(firstRun?.text).toBe('1');
expect(firstRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true');
+ expect(firstRun).not.toHaveProperty('pmStart');
+ expect(firstRun).not.toHaveProperty('pmEnd');
+ });
+
+ it('normalizes away empty note reference runs before layout conversion', () => {
+ const editorState = createMockEditorState([{ id: '1', pos: 10 }]);
+ const converter = createMockConverter([
+ {
+ id: '1',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } },
+ {
+ type: 'run',
+ content: [{ type: 'text', text: 'Note' }],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ buildFootnotesInput(editorState, converter, undefined, undefined);
+
+ const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0];
+ expect(docArg?.content?.[0]?.content).toEqual([
+ {
+ type: 'run',
+ content: [{ type: 'text', text: 'Note' }],
+ },
+ ]);
+ });
+
+ it('normalizes away note separator tabs before layout conversion', () => {
+ const editorState = createMockEditorState([{ id: '1', pos: 10 }]);
+ const converter = createMockConverter([
+ {
+ id: '1',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } },
+ {
+ type: 'run',
+ content: [{ type: 'tab' }, { type: 'text', text: 'Note' }],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ buildFootnotesInput(editorState, converter, undefined, undefined);
+
+ const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0];
+ expect(docArg?.content?.[0]?.content).toEqual([
+ {
+ type: 'run',
+ content: [{ type: 'text', text: 'Note' }],
+ },
+ ]);
+ });
+
+ it('normalizes away hidden passthrough field-code nodes before layout conversion', () => {
+ const editorState = createMockEditorState([{ id: '1', pos: 10 }]);
+ const converter = createMockConverter([
+ {
+ id: '1',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'run',
+ content: [{ type: 'text', text: 'Section ' }],
+ },
+ {
+ type: 'run',
+ content: [{ type: 'passthroughInline', attrs: { originalName: 'w:fldChar' } }],
+ },
+ {
+ type: 'run',
+ content: [{ type: 'text', text: '1.2(b)' }],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ buildFootnotesInput(editorState, converter, undefined, undefined);
+
+ const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0];
+ expect(docArg?.content?.[0]?.content).toEqual([
+ {
+ type: 'run',
+ content: [{ type: 'text', text: 'Section ' }],
+ },
+ {
+ type: 'run',
+ content: [{ type: 'text', text: '1.2(b)' }],
+ },
+ ]);
});
it('builds the marker as a scaled superscript run instead of a Unicode superscript glyph', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
index 525cb6d327..d500d6c5c0 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
@@ -1,14 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-const { mockInitHeaderFooterRegistry } = vi.hoisted(() => ({
+const { mockInitHeaderFooterRegistry, mockLayoutPerRIdHeaderFooters } = vi.hoisted(() => ({
mockInitHeaderFooterRegistry: vi.fn(),
+ mockLayoutPerRIdHeaderFooters: vi.fn(),
}));
vi.mock('../../header-footer/HeaderFooterRegistryInit.js', () => ({
initHeaderFooterRegistry: mockInitHeaderFooterRegistry,
}));
+vi.mock('../../header-footer/HeaderFooterPerRidLayout.js', () => ({
+ layoutPerRIdHeaderFooters: mockLayoutPerRIdHeaderFooters,
+}));
+
import type { Editor } from '../../Editor.js';
+import type { FlowBlock, HeaderFooterLayout, Layout, Measure, ParaFragment } from '@superdoc/contracts';
+import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge';
import {
HeaderFooterSessionManager,
type SessionManagerDependencies,
@@ -38,11 +45,18 @@ function createMainEditorStub(): Editor {
}
function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor {
+ const textNode = editorDom.ownerDocument.createTextNode('abcdefghij');
+ editorDom.appendChild(textNode);
+
return {
setEditable: vi.fn(),
setOptions: vi.fn(),
commands: {
setTextSelection: vi.fn(),
+ enableTrackChanges: vi.fn(),
+ disableTrackChanges: vi.fn(),
+ enableTrackChangesShowOriginal: vi.fn(),
+ disableTrackChangesShowOriginal: vi.fn(),
},
state: {
doc: {
@@ -54,6 +68,17 @@ function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor {
view: {
dom: editorDom,
focus: vi.fn(),
+ state: {
+ doc: {
+ content: {
+ size: 10,
+ },
+ },
+ },
+ domAtPos: vi.fn((pos: number) => ({
+ node: textNode,
+ offset: Math.max(0, Math.min(textNode.length, pos - 1)),
+ })),
},
on: vi.fn(),
off: vi.fn(),
@@ -68,6 +93,7 @@ describe('HeaderFooterSessionManager', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockLayoutPerRIdHeaderFooters.mockReset();
painterHost = document.createElement('div');
visibleHost = document.createElement('div');
@@ -88,11 +114,14 @@ describe('HeaderFooterSessionManager', () => {
* Sets up a full manager with an active header region and returns the manager
* ready for `computeSelectionRects` assertions.
*
- * The DOM selection mock returns a single rect at (120, 90) with size 200x32,
+ * The DOM range mock returns a single rect at (120, 90) with size 200x32,
* and the editor host is at (100, 50) with size 600x120. The header region is
* at localX=40, localY=30 on page 1 with bodyPageHeight=800.
*/
- async function setupWithZoom(zoom: number | undefined): Promise {
+ async function setupWithZoom(
+ zoom: number | undefined,
+ documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing',
+ ): Promise {
const pageElement = document.createElement('div');
pageElement.dataset.pageIndex = '1';
painterHost.appendChild(pageElement);
@@ -112,22 +141,7 @@ describe('HeaderFooterSessionManager', () => {
destroy: vi.fn(),
};
- const overlayManager = {
- showEditingOverlay: vi.fn(() => ({
- success: true,
- editorHost,
- reason: null,
- })),
- hideEditingOverlay: vi.fn(),
- showSelectionOverlay: vi.fn(),
- hideSelectionOverlay: vi.fn(),
- setOnDimmingClick: vi.fn(),
- getActiveEditorHost: vi.fn(() => editorHost),
- destroy: vi.fn(),
- };
-
mockInitHeaderFooterRegistry.mockReturnValue({
- overlayManager,
headerFooterIdentifier: null,
headerFooterManager,
headerFooterAdapter: null,
@@ -167,10 +181,15 @@ describe('HeaderFooterSessionManager', () => {
scheduleRerender: vi.fn(),
setPendingDocChange: vi.fn(),
getBodyPageCount: vi.fn(() => 2),
+ getStorySessionManager: vi.fn(() => ({
+ activate: vi.fn(() => ({ editor: headerFooterEditor })),
+ exit: vi.fn(),
+ })),
};
manager.setDependencies(deps);
manager.initialize();
+ manager.setDocumentMode(documentMode);
manager.setLayoutResults(
[
{
@@ -203,12 +222,11 @@ describe('HeaderFooterSessionManager', () => {
manager.headerRegions.set(headerRegion.pageIndex, headerRegion);
vi.spyOn(editorDom, 'getBoundingClientRect').mockReturnValue(createRect(100, 50, 600, 120));
- vi.spyOn(document, 'getSelection').mockReturnValue({
- rangeCount: 1,
- getRangeAt: vi.fn(() => ({
- getClientRects: vi.fn(() => [createRect(120, 90, 200, 32)]),
- })),
- } as unknown as Selection);
+ vi.spyOn(document, 'createRange').mockReturnValue({
+ setStart: vi.fn(),
+ setEnd: vi.fn(),
+ getClientRects: vi.fn(() => [createRect(120, 90, 200, 32)]),
+ } as unknown as Range);
manager.activateRegion(headerRegion);
await vi.waitFor(() => expect(manager.activeEditor).toBe(headerFooterEditor));
@@ -250,6 +268,28 @@ describe('HeaderFooterSessionManager', () => {
expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]);
});
+ it('falls back to concrete per-rId layouts when variant layout results are unavailable', async () => {
+ await setupWithZoom(1);
+
+ manager.headerLayoutResults = null;
+ manager.headerLayoutsByRId.set('rId-header-default', {
+ kind: 'header',
+ type: 'default',
+ layout: {
+ height: 47,
+ pages: [{ number: 2, fragments: [] }],
+ },
+ blocks: [{ id: 'blank-header-block' }] as never[],
+ measures: [{ id: 'blank-header-measure' }] as never[],
+ });
+
+ const context = manager.getContext();
+ expect(context).toBeTruthy();
+ expect(context?.layout.pageSize?.h).toBe(47);
+ expect(context?.blocks).toEqual([{ id: 'blank-header-block' }]);
+ expect(context?.measures).toEqual([{ id: 'blank-header-measure' }]);
+ });
+
it('falls back to zoom=1 when zoom is negative', async () => {
await setupWithZoom(-1);
@@ -261,4 +301,466 @@ describe('HeaderFooterSessionManager', () => {
expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]);
});
+
+ it('uses the requested PM range instead of the live DOM selection', async () => {
+ await setupWithZoom(1);
+
+ vi.spyOn(document, 'getSelection').mockReturnValue(null);
+
+ expect(manager.computeSelectionRects(3, 7)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]);
+ });
+
+ it('activates header editing through the story-session manager without creating an overlay host', async () => {
+ const pageElement = document.createElement('div');
+ pageElement.dataset.pageIndex = '0';
+ painterHost.appendChild(pageElement);
+
+ const storyEditor = createHeaderFooterEditorStub(document.createElement('div'));
+ const activate = vi.fn(() => ({ editor: storyEditor }));
+ const exit = vi.fn();
+ const descriptor = { id: 'rId-header-default', variant: 'default' };
+
+ mockInitHeaderFooterRegistry.mockReturnValue({
+ headerFooterIdentifier: null,
+ headerFooterManager: {
+ getDescriptorById: vi.fn(() => descriptor),
+ getDescriptors: vi.fn(() => [descriptor]),
+ ensureEditor: vi.fn(),
+ refresh: vi.fn(),
+ destroy: vi.fn(),
+ },
+ headerFooterAdapter: null,
+ cleanups: [],
+ });
+
+ manager = new HeaderFooterSessionManager({
+ painterHost,
+ visibleHost,
+ selectionOverlay,
+ editor: createMainEditorStub(),
+ defaultPageSize: { w: 612, h: 792 },
+ defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
+ });
+
+ manager.setDependencies({
+ getLayoutOptions: vi.fn(() => ({ zoom: 1 })),
+ getPageElement: vi.fn(() => pageElement),
+ scrollPageIntoView: vi.fn(),
+ waitForPageMount: vi.fn(async () => true),
+ convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
+ isViewLocked: vi.fn(() => false),
+ getBodyPageHeight: vi.fn(() => 800),
+ notifyInputBridgeTargetChanged: vi.fn(),
+ scheduleRerender: vi.fn(),
+ setPendingDocChange: vi.fn(),
+ getBodyPageCount: vi.fn(() => 3),
+ getStorySessionManager: vi.fn(() => ({ activate, exit })),
+ });
+
+ manager.initialize();
+ manager.setDocumentMode('suggesting');
+
+ const region = {
+ kind: 'header' as const,
+ headerFooterRefId: 'rId-header-default',
+ sectionType: 'default',
+ sectionId: 'section-0',
+ sectionIndex: 0,
+ pageIndex: 0,
+ pageNumber: 1,
+ localX: 36,
+ localY: 24,
+ width: 480,
+ height: 72,
+ };
+ manager.headerRegions.set(region.pageIndex, region);
+
+ manager.activateRegion(region);
+ await vi.waitFor(() => expect(manager.activeEditor).toBe(storyEditor));
+
+ expect(storyEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1);
+ expect(storyEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1);
+ expect(storyEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' });
+ expect(activate).toHaveBeenCalledWith(
+ {
+ kind: 'story',
+ storyType: 'headerFooterPart',
+ refId: 'rId-header-default',
+ },
+ expect.objectContaining({
+ commitPolicy: 'onExit',
+ preferHiddenHost: true,
+ hostWidthPx: 480,
+ editorContext: expect.objectContaining({
+ availableWidth: 480,
+ availableHeight: 72,
+ currentPageNumber: 1,
+ totalPageCount: 3,
+ surfaceKind: 'header',
+ }),
+ }),
+ );
+ });
+
+ it('enters header edit mode in suggesting mode and enables tracked changes', async () => {
+ await setupWithZoom(1, 'suggesting');
+
+ const activeEditor = manager.activeEditor as unknown as {
+ commands: {
+ disableTrackChangesShowOriginal: ReturnType;
+ enableTrackChanges: ReturnType;
+ };
+ setOptions: ReturnType;
+ setEditable: ReturnType;
+ view: { dom: HTMLElement };
+ };
+
+ expect(activeEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1);
+ expect(activeEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1);
+ expect(activeEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' });
+ expect(activeEditor.setEditable).toHaveBeenCalledWith(true);
+ expect(activeEditor.view.dom.getAttribute('documentmode')).toBe('suggesting');
+ expect(activeEditor.view.dom.getAttribute('aria-readonly')).toBe('false');
+ });
+
+ it('renders and clears the active header/footer divider while editing', async () => {
+ await setupWithZoom(1, 'suggesting');
+
+ const border = painterHost.querySelector('.superdoc-header-footer-border') as HTMLElement | null;
+ expect(border).toBeTruthy();
+ expect(border?.style.top).toBe('90px');
+
+ manager.exitMode();
+ expect(painterHost.querySelector('.superdoc-header-footer-border')).toBeNull();
+ });
+
+ it('reapplies the initial story selection after focus when entering edit mode', async () => {
+ await setupWithZoom(1, 'suggesting');
+
+ const activeEditor = manager.activeEditor as unknown as {
+ commands: {
+ setTextSelection: ReturnType;
+ };
+ view: {
+ focus: ReturnType;
+ };
+ };
+
+ expect(activeEditor.commands.setTextSelection.mock.calls.length).toBeGreaterThanOrEqual(2);
+ expect(activeEditor.commands.setTextSelection).toHaveBeenNthCalledWith(1, { from: 9, to: 9 });
+ expect(activeEditor.commands.setTextSelection).toHaveBeenNthCalledWith(2, { from: 9, to: 9 });
+ expect(activeEditor.view.focus.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('updates the active header editor when the document mode changes to suggesting', async () => {
+ await setupWithZoom(1);
+
+ const activeEditor = manager.activeEditor as unknown as {
+ commands: {
+ disableTrackChangesShowOriginal: ReturnType;
+ enableTrackChanges: ReturnType;
+ };
+ setOptions: ReturnType;
+ setEditable: ReturnType;
+ view: { dom: HTMLElement };
+ };
+
+ activeEditor.commands.disableTrackChangesShowOriginal.mockClear();
+ activeEditor.commands.enableTrackChanges.mockClear();
+ activeEditor.setOptions.mockClear();
+ activeEditor.setEditable.mockClear();
+
+ manager.setDocumentMode('suggesting');
+
+ expect(activeEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1);
+ expect(activeEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1);
+ expect(activeEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' });
+ expect(activeEditor.setEditable).toHaveBeenCalledWith(true);
+ expect(activeEditor.view.dom.getAttribute('documentmode')).toBe('suggesting');
+ });
+
+ it('exits the active story session when leaving header/footer mode', async () => {
+ const pageElement = document.createElement('div');
+ pageElement.dataset.pageIndex = '0';
+ painterHost.appendChild(pageElement);
+
+ const storyEditor = createHeaderFooterEditorStub(document.createElement('div'));
+ const activate = vi.fn(() => ({ editor: storyEditor }));
+ const exit = vi.fn();
+ const descriptor = { id: 'rId-header-default', variant: 'default' };
+
+ mockInitHeaderFooterRegistry.mockReturnValue({
+ headerFooterIdentifier: null,
+ headerFooterManager: {
+ getDescriptorById: vi.fn(() => descriptor),
+ getDescriptors: vi.fn(() => [descriptor]),
+ ensureEditor: vi.fn(),
+ refresh: vi.fn(),
+ destroy: vi.fn(),
+ },
+ headerFooterAdapter: null,
+ cleanups: [],
+ });
+
+ manager = new HeaderFooterSessionManager({
+ painterHost,
+ visibleHost,
+ selectionOverlay,
+ editor: createMainEditorStub(),
+ defaultPageSize: { w: 612, h: 792 },
+ defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
+ });
+
+ manager.setDependencies({
+ getLayoutOptions: vi.fn(() => ({ zoom: 1 })),
+ getPageElement: vi.fn(() => pageElement),
+ scrollPageIntoView: vi.fn(),
+ waitForPageMount: vi.fn(async () => true),
+ convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
+ isViewLocked: vi.fn(() => false),
+ getBodyPageHeight: vi.fn(() => 800),
+ notifyInputBridgeTargetChanged: vi.fn(),
+ scheduleRerender: vi.fn(),
+ setPendingDocChange: vi.fn(),
+ getBodyPageCount: vi.fn(() => 1),
+ getStorySessionManager: vi.fn(() => ({ activate, exit })),
+ });
+
+ manager.initialize();
+
+ const region = {
+ kind: 'header' as const,
+ headerFooterRefId: 'rId-header-default',
+ sectionType: 'default',
+ sectionId: 'section-0',
+ sectionIndex: 0,
+ pageIndex: 0,
+ pageNumber: 1,
+ localX: 36,
+ localY: 24,
+ width: 480,
+ height: 72,
+ };
+ manager.headerRegions.set(region.pageIndex, region);
+
+ manager.activateRegion(region);
+ await vi.waitFor(() => expect(manager.activeEditor).toBe(storyEditor));
+
+ manager.exitMode();
+ expect(exit).toHaveBeenCalledTimes(1);
+ expect(manager.session.mode).toBe('body');
+ });
+
+ describe('createDecorationProvider โ resolved items', () => {
+ function buildHeaderResult(options?: { y?: number; minY?: number }): HeaderFooterLayoutResult {
+ const y = options?.y ?? 10;
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y,
+ width: 468,
+ };
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ ...(options?.minY != null ? { minY: options.minY } : {}),
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
+ totalHeight: 18,
+ },
+ ];
+ return { kind: 'header', type: 'default', layout, blocks, measures };
+ }
+
+ it('delivers items aligned 1:1 with fragments when variant layout is used', () => {
+ const deps: SessionManagerDependencies = {
+ getLayoutOptions: vi.fn(() => ({})),
+ getPageElement: vi.fn(() => null),
+ scrollPageIntoView: vi.fn(),
+ waitForPageMount: vi.fn(async () => true),
+ convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
+ isViewLocked: vi.fn(() => false),
+ getBodyPageHeight: vi.fn(() => 800),
+ notifyInputBridgeTargetChanged: vi.fn(),
+ scheduleRerender: vi.fn(),
+ setPendingDocChange: vi.fn(),
+ getBodyPageCount: vi.fn(() => 1),
+ };
+
+ manager = new HeaderFooterSessionManager({
+ painterHost,
+ visibleHost,
+ selectionOverlay,
+ editor: createMainEditorStub(),
+ defaultPageSize: { w: 612, h: 792 },
+ defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
+ });
+ manager.setDependencies(deps);
+ manager.headerFooterIdentifier = {
+ headerIds: { default: 'rId-header-default', first: null, even: null, odd: null },
+ footerIds: { default: null, first: null, even: null, odd: null },
+ titlePg: false,
+ alternateHeaders: false,
+ };
+ manager.setLayoutResults([buildHeaderResult()], null);
+
+ const layout: Layout = {
+ version: 1,
+ flowMode: 'paginated',
+ pageGap: 0,
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never],
+ } as unknown as Layout;
+ const provider = manager.createDecorationProvider('header', layout);
+ expect(provider).toBeDefined();
+ const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]);
+ expect(payload).not.toBeNull();
+ expect(payload!.fragments).toHaveLength(1);
+ expect(payload!.items).toBeDefined();
+ expect(payload!.items!.length).toBe(payload!.fragments.length);
+ expect(payload!.items![0]!.blockId).toBe('p1');
+ });
+
+ it('normalizes resolved items when variant layout minY is negative', () => {
+ const deps: SessionManagerDependencies = {
+ getLayoutOptions: vi.fn(() => ({})),
+ getPageElement: vi.fn(() => null),
+ scrollPageIntoView: vi.fn(),
+ waitForPageMount: vi.fn(async () => true),
+ convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
+ isViewLocked: vi.fn(() => false),
+ getBodyPageHeight: vi.fn(() => 800),
+ notifyInputBridgeTargetChanged: vi.fn(),
+ scheduleRerender: vi.fn(),
+ setPendingDocChange: vi.fn(),
+ getBodyPageCount: vi.fn(() => 1),
+ };
+
+ manager = new HeaderFooterSessionManager({
+ painterHost,
+ visibleHost,
+ selectionOverlay,
+ editor: createMainEditorStub(),
+ defaultPageSize: { w: 612, h: 792 },
+ defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
+ });
+ manager.setDependencies(deps);
+ manager.headerFooterIdentifier = {
+ headerIds: { default: 'rId-header-default', first: null, even: null, odd: null },
+ footerIds: { default: null, first: null, even: null, odd: null },
+ titlePg: false,
+ alternateHeaders: false,
+ };
+ manager.setLayoutResults([buildHeaderResult({ y: -12, minY: -12 })], null);
+
+ const layout: Layout = {
+ version: 1,
+ flowMode: 'paginated',
+ pageGap: 0,
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never],
+ } as unknown as Layout;
+ const provider = manager.createDecorationProvider('header', layout);
+ const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]);
+
+ expect(payload).not.toBeNull();
+ expect(payload!.fragments[0]!.y).toBe(0);
+ expect(payload!.items).toBeDefined();
+ expect(payload!.items![0]).toMatchObject({ blockId: 'p1', x: 72, y: 0 });
+ });
+
+ it('normalizes resolved items when per-rId layout minY is negative', async () => {
+ mockLayoutPerRIdHeaderFooters.mockImplementation(
+ async (
+ _input: unknown,
+ _layout: unknown,
+ _sectionMetadata: unknown,
+ deps: { headerLayoutsByRId: Map },
+ ) => {
+ deps.headerLayoutsByRId.set('rId-header-default', buildHeaderResult({ y: -12, minY: -12 }));
+ },
+ );
+
+ const deps: SessionManagerDependencies = {
+ getLayoutOptions: vi.fn(() => ({})),
+ getPageElement: vi.fn(() => null),
+ scrollPageIntoView: vi.fn(),
+ waitForPageMount: vi.fn(async () => true),
+ convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
+ isViewLocked: vi.fn(() => false),
+ getBodyPageHeight: vi.fn(() => 800),
+ notifyInputBridgeTargetChanged: vi.fn(),
+ scheduleRerender: vi.fn(),
+ setPendingDocChange: vi.fn(),
+ getBodyPageCount: vi.fn(() => 1),
+ };
+
+ manager = new HeaderFooterSessionManager({
+ painterHost,
+ visibleHost,
+ selectionOverlay,
+ editor: createMainEditorStub(),
+ defaultPageSize: { w: 612, h: 792 },
+ defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
+ });
+ manager.setDependencies(deps);
+ manager.headerFooterIdentifier = {
+ headerIds: { default: 'rId-header-default', first: null, even: null, odd: null },
+ footerIds: { default: null, first: null, even: null, odd: null },
+ titlePg: false,
+ alternateHeaders: false,
+ };
+
+ const layout: Layout = {
+ version: 1,
+ flowMode: 'paginated',
+ pageGap: 0,
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ sectionIndex: 0,
+ margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
+ sectionRefs: {
+ headerRefs: { default: 'rId-header-default', first: undefined, even: undefined },
+ footerRefs: {},
+ },
+ } as never,
+ ],
+ } as unknown as Layout;
+
+ await manager.layoutPerRId(
+ {
+ headerBlocksByRId: new Map(),
+ footerBlocksByRId: new Map(),
+ constraints: {
+ width: 468,
+ height: 648,
+ pageWidth: 612,
+ pageHeight: 792,
+ margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 },
+ overflowBaseHeight: 36,
+ },
+ },
+ layout,
+ [{ sectionIndex: 0 } as never],
+ );
+
+ const provider = manager.createDecorationProvider('header', layout);
+ const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]);
+
+ expect(mockLayoutPerRIdHeaderFooters).toHaveBeenCalledTimes(1);
+ expect(payload).not.toBeNull();
+ expect(payload!.fragments[0]!.y).toBe(0);
+ expect(payload!.items).toBeDefined();
+ expect(payload!.items![0]).toMatchObject({ blockId: 'p1', x: 72, y: 0 });
+ });
+ });
});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts
index 15fc627a23..c637a76978 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts
@@ -349,6 +349,7 @@ describe('renderCaretOverlay', () => {
expect(caret.style.width).toBe('2px');
expect(caret.style.backgroundColor).toMatch(/#000000|rgb\(0,\s*0,\s*0\)/);
expect(caret.style.borderRadius).toBe('1px');
+ expect(caret.style.boxShadow).toBe('0 0 0 1px rgba(255, 255, 255, 0.92)');
expect(caret.style.pointerEvents).toBe('none');
});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts
index 6556b41d39..2d5fbe5ea1 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts
@@ -152,6 +152,7 @@ vi.mock('../../header-footer/HeaderFooterRegistry', () => ({
clear: vi.fn(),
getBatch: vi.fn(() => []),
getBlocksByRId: vi.fn(() => new Map()),
+ setTrackedChangesRenderConfig: vi.fn(),
})),
}));
@@ -186,6 +187,7 @@ vi.mock('y-prosemirror', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
/**
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts
index da0f0fc702..37b37bfa45 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts
@@ -280,6 +280,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
/**
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts
index 2dcff9abe3..3b70896003 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts
@@ -202,6 +202,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
describe('PresentationEditor - Draggable Annotation Focus Suppression (SD-1179)', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts
index 49e81ea373..edc977cfd5 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts
@@ -217,6 +217,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
describe('PresentationEditor - Focus Wrapping (#wrapHiddenEditorFocus)', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts
index f2fcdad938..eb8b69045e 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts
@@ -64,35 +64,42 @@ vi.mock('@superdoc/pm-adapter', async (importOriginal) => {
};
});
-vi.mock('@superdoc/layout-bridge', () => ({
- incrementalLayout: mockIncrementalLayout,
- normalizeMargin: (value: number | undefined, fallback: number) =>
- Number.isFinite(value) ? (value as number) : fallback,
- selectionToRects: vi.fn(() => []),
- clickToPosition: vi.fn(),
- getFragmentAtPosition: vi.fn(),
- computeLinePmRange: vi.fn(),
- measureCharacterX: vi.fn(),
- extractIdentifierFromConverter: vi.fn(),
- getHeaderFooterType: vi.fn(),
- getBucketForPageNumber: vi.fn(),
- getBucketRepresentative: vi.fn(),
- buildMultiSectionIdentifier: vi.fn(),
- getHeaderFooterTypeForSection: vi.fn(),
- layoutHeaderFooterWithCache: vi.fn(),
- computeDisplayPageNumber: vi.fn(),
- findWordBoundaries: vi.fn(),
- findParagraphBoundaries: vi.fn(),
- createDragHandler: vi.fn(),
- PageGeometryHelper: vi.fn(() => ({
- updateLayout: vi.fn(),
- getPageIndexAtY: vi.fn(() => 0),
- getNearestPageIndex: vi.fn(() => 0),
- getPageTop: vi.fn(() => 0),
- getPageGap: vi.fn(() => 0),
- getLayout: vi.fn(() => ({ pages: [] })),
- })),
-}));
+vi.mock('@superdoc/layout-bridge', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ incrementalLayout: mockIncrementalLayout,
+ normalizeMargin: (value: number | undefined, fallback: number) =>
+ Number.isFinite(value) ? (value as number) : fallback,
+ selectionToRects: vi.fn(() => []),
+ clickToPosition: vi.fn(),
+ getFragmentAtPosition: vi.fn(),
+ computeLinePmRange: vi.fn(),
+ measureCharacterX: vi.fn(),
+ extractIdentifierFromConverter: vi.fn(),
+ getHeaderFooterType: vi.fn(),
+ getBucketForPageNumber: vi.fn(),
+ getBucketRepresentative: vi.fn(),
+ buildMultiSectionIdentifier: vi.fn(),
+ buildEffectiveHeaderFooterRefsBySection: vi.fn(() => new Map()),
+ collectReferencedHeaderFooterRIds: vi.fn(() => new Set()),
+ getHeaderFooterTypeForSection: vi.fn(),
+ layoutHeaderFooterWithCache: vi.fn(),
+ computeDisplayPageNumber: vi.fn(),
+ findWordBoundaries: vi.fn(),
+ findParagraphBoundaries: vi.fn(),
+ createDragHandler: vi.fn(),
+ PageGeometryHelper: vi.fn(() => ({
+ updateLayout: vi.fn(),
+ getPageIndexAtY: vi.fn(() => 0),
+ getNearestPageIndex: vi.fn(() => 0),
+ getPageTop: vi.fn(() => 0),
+ getPageGap: vi.fn(() => 0),
+ getLayout: vi.fn(() => ({ pages: [] })),
+ })),
+ };
+});
vi.mock('@superdoc/painter-dom', () => ({
createDomPainter: vi.fn(() => ({
@@ -113,6 +120,7 @@ vi.mock('@superdoc/measuring-dom', () => ({ measureBlock: vi.fn(() => ({ width:
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: mockResolveLayout,
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
vi.mock('../../header-footer/HeaderFooterRegistry', () => ({
@@ -128,6 +136,7 @@ vi.mock('../../header-footer/HeaderFooterRegistry', () => ({
clear: vi.fn(),
getBatch: vi.fn(() => []),
getBlocksByRId: vi.fn(() => new Map()),
+ setTrackedChangesRenderConfig: vi.fn(),
})),
}));
@@ -174,7 +183,7 @@ describe('PresentationEditor - footnote number marker PM position', () => {
vi.clearAllMocks();
});
- it('adds pmStart/pmEnd to the data-sd-footnote-number marker run', async () => {
+ it('keeps the synthetic footnote number marker out of the editable PM range', async () => {
editor = new PresentationEditor({ element: container });
await new Promise((r) => setTimeout(r, 100));
@@ -185,8 +194,8 @@ describe('PresentationEditor - footnote number marker PM position', () => {
const markerRun = blocks?.[0]?.runs?.[0];
expect(markerRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true');
- expect(markerRun?.pmStart).toBe(5);
- expect(markerRun?.pmEnd).toBe(6);
+ expect(markerRun?.pmStart).toBeUndefined();
+ expect(markerRun?.pmEnd).toBeUndefined();
});
it('appends semantic footnotes as end-of-document blocks in semantic flow mode', async () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts
index da8016ae70..43952f86e3 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts
@@ -270,6 +270,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
/**
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts
index f3753dd292..44b8833b16 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts
@@ -184,6 +184,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
describe('PresentationEditor.getElementAtPos', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts
index cf12ec716b..b75b0eb138 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts
@@ -258,6 +258,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
describe('PresentationEditor - goToAnchor', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts
index f39000cec9..71169dbb0d 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts
@@ -105,6 +105,7 @@ vi.mock('../../header-footer/HeaderFooterRegistry', () => ({
clear: vi.fn(),
getBatch: vi.fn(() => []),
getBlocksByRId: vi.fn(() => new Map()),
+ setTrackedChangesRenderConfig: vi.fn(),
})),
}));
@@ -128,6 +129,7 @@ vi.mock('y-prosemirror', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
describe('SD-1313: toFlowBlocks receives media from storage.image.media', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts
index d10f93e3ec..08aea2d32f 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts
@@ -275,6 +275,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
describe('PresentationEditor - scrollToPosition', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts
index 2066d959f4..28a2da6a98 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts
@@ -270,6 +270,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
/**
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
index 40f9fd8b4e..f603f5b9c0 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
@@ -23,7 +23,9 @@ const {
mockMeasureBlock,
mockEditorConverterStore,
mockCreateHeaderFooterEditor,
+ mockCreateStoryEditor,
createdSectionEditors,
+ createdStoryEditors,
mockOnHeaderFooterDataUpdate,
mockUpdateYdocDocxData,
mockEditorOverlayManager,
@@ -89,18 +91,24 @@ const {
once: emitter.once,
emit: emitter.emit,
destroy: vi.fn(),
+ getJSON: vi.fn(() => ({ type: 'doc', content: [{ type: 'paragraph' }] })),
+ getUpdatedJson: vi.fn(() => ({ type: 'doc', content: [{ type: 'paragraph' }] })),
setEditable: vi.fn(),
+ setDocumentMode: vi.fn(),
setOptions: vi.fn(),
commands: {
setTextSelection: vi.fn(),
+ setCursorById: vi.fn(() => true),
},
state: {
doc: {
content: {
size: 10,
},
+ textBetween: vi.fn(() => 'Lazy note session'),
},
},
+ options: {},
view: {
dom: document.createElement('div'),
focus: vi.fn(),
@@ -111,6 +119,7 @@ const {
};
const editors: Array<{ editor: ReturnType }> = [];
+ const storyEditors: Array<{ editor: ReturnType }> = [];
const mockFlowBlockCacheInstances: Array<{
clear: ReturnType;
setHasExternalChanges: ReturnType;
@@ -150,7 +159,14 @@ const {
editors.push({ editor });
return editor;
}),
+ mockCreateStoryEditor: vi.fn((parentEditor?: EditorInstance) => {
+ const editor = createSectionEditor();
+ editor.options = { ...editor.options, parentEditor };
+ storyEditors.push({ editor });
+ return editor;
+ }),
createdSectionEditors: editors,
+ createdStoryEditors: storyEditors,
mockOnHeaderFooterDataUpdate: vi.fn(),
mockUpdateYdocDocxData: vi.fn(() => Promise.resolve()),
mockEditorOverlayManager: vi.fn().mockImplementation(() => ({
@@ -317,6 +333,7 @@ vi.mock('@superdoc/measuring-dom', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: mockResolveLayout,
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
vi.mock('@extensions/pagination/pagination-helpers.js', () => ({
@@ -324,6 +341,10 @@ vi.mock('@extensions/pagination/pagination-helpers.js', () => ({
onHeaderFooterDataUpdate: mockOnHeaderFooterDataUpdate,
}));
+vi.mock('../../story-editor-factory.js', () => ({
+ createStoryEditor: mockCreateStoryEditor,
+}));
+
vi.mock('../../header-footer/EditorOverlayManager', () => ({
EditorOverlayManager: mockEditorOverlayManager,
}));
@@ -350,6 +371,7 @@ describe('PresentationEditor', () => {
};
mockEditorConverterStore.mediaFiles = {};
createdSectionEditors.length = 0;
+ createdStoryEditors.length = 0;
mockFlowBlockCacheInstances.length = 0;
// Reset static instances
@@ -1025,6 +1047,103 @@ describe('PresentationEditor', () => {
}
},
);
+
+ // SD-2495 anchor-nav fix: when the scrollable ancestor differs from the
+ // visible host (the real-world shape - the host is overflow:visible and
+ // a parent constrains height), scrollTop must land on the ancestor, not
+ // just the host. Happy-dom doesn't propagate inline overflow through
+ // getComputedStyle, so we stub it to mark a wrapper as scrollable.
+ it('writes scrollTop to both the scrollable ancestor and the visibleHost when they differ', async () => {
+ const scrollableWrapper = document.createElement('div');
+ document.body.removeChild(container);
+ scrollableWrapper.appendChild(container);
+ document.body.appendChild(scrollableWrapper);
+
+ const originalGetComputedStyle = window.getComputedStyle.bind(window);
+ const getComputedStyleSpy = vi
+ .spyOn(window, 'getComputedStyle')
+ .mockImplementation((el: Element, pseudo?: string | null) => {
+ if (el === scrollableWrapper) {
+ return { overflowY: 'auto' } as CSSStyleDeclaration;
+ }
+ return originalGetComputedStyle(el, pseudo ?? null);
+ });
+
+ mockIncrementalLayout.mockResolvedValueOnce(buildMixedPageLayout());
+
+ editor = new PresentationEditor({
+ element: container,
+ documentId: 'test-scroll-multi-target',
+ content: { type: 'doc', content: [{ type: 'paragraph' }] },
+ mode: 'docx',
+ layoutEngineOptions: {
+ virtualization: { enabled: true, gap: 10, window: 1, overscan: 0 },
+ },
+ });
+
+ await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
+
+ const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
+ const expectedPageTop = 600 + 10 + 1200 + 10;
+
+ let wrapperScrollTop = 0;
+ let hostScrollTop = 0;
+ let mountedPageEl: HTMLElement | null = null;
+ const mountPageIfScrolled = (value: number) => {
+ if (!mountedPageEl && Math.abs(value - expectedPageTop) < 0.5) {
+ mountedPageEl = document.createElement('div');
+ mountedPageEl.setAttribute('data-page-index', '2');
+ Object.defineProperty(mountedPageEl, 'scrollIntoView', {
+ value: vi.fn(),
+ configurable: true,
+ });
+ pagesHost.appendChild(mountedPageEl);
+ }
+ };
+ Object.defineProperty(scrollableWrapper, 'scrollTop', {
+ get: () => wrapperScrollTop,
+ set: (next) => {
+ wrapperScrollTop = Number(next);
+ mountPageIfScrolled(wrapperScrollTop);
+ },
+ configurable: true,
+ });
+ Object.defineProperty(container, 'scrollTop', {
+ get: () => hostScrollTop,
+ set: (next) => {
+ hostScrollTop = Number(next);
+ mountPageIfScrolled(hostScrollTop);
+ },
+ configurable: true,
+ });
+
+ let now = 0;
+ const performanceNowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now);
+ const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
+ now += 100;
+ cb(now);
+ return 1;
+ });
+
+ try {
+ const didScroll = await editor.scrollToPage(3, 'auto');
+
+ expect(didScroll).toBe(true);
+ // Both writes must land: the ancestor (real scrollable) AND the host
+ // (back-compat for layouts where the host itself is scrollable).
+ expect(wrapperScrollTop).toBe(expectedPageTop);
+ expect(hostScrollTop).toBe(expectedPageTop);
+ } finally {
+ rafSpy.mockRestore();
+ performanceNowSpy.mockRestore();
+ getComputedStyleSpy.mockRestore();
+ // Restore DOM layout so the outer afterEach can clean up normally.
+ if (scrollableWrapper.parentNode) {
+ document.body.removeChild(scrollableWrapper);
+ }
+ document.body.appendChild(container);
+ }
+ });
});
describe('setDocumentMode', () => {
@@ -2319,7 +2438,11 @@ describe('PresentationEditor', () => {
viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 }));
await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
- await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));
+ await vi.waitFor(() =>
+ expect(
+ createdSectionEditors.some(({ editor: sectionEditor }) => sectionEditor === editor.getActiveEditor()),
+ ).toBe(true),
+ );
const sourceEditor = editor.getActiveEditor();
expect(sourceEditor).toBeDefined();
@@ -2387,7 +2510,11 @@ describe('PresentationEditor', () => {
viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 }));
await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
- await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));
+ await vi.waitFor(() =>
+ expect(
+ createdSectionEditors.some(({ editor: sectionEditor }) => sectionEditor === editor.getActiveEditor()),
+ ).toBe(true),
+ );
const sourceEditor = editor.getActiveEditor();
const transaction = { docChanged: true };
@@ -2445,7 +2572,11 @@ describe('PresentationEditor', () => {
viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }));
await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
- await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));
+ await vi.waitFor(() =>
+ expect(
+ createdSectionEditors.some(({ editor: sectionEditor }) => sectionEditor === editor.getActiveEditor()),
+ ).toBe(true),
+ );
const sourceEditor = editor.getActiveEditor();
expect(sourceEditor).toBeDefined();
@@ -2476,64 +2607,6 @@ describe('PresentationEditor', () => {
);
});
- it('clears leftover footer transform when entering footer editing with non-negative minY', async () => {
- mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());
-
- const editorContainer = document.createElement('div');
- editorContainer.className = 'super-editor';
- editorContainer.style.transform = 'translateY(24px)';
- const editorHost = document.createElement('div');
- editorHost.appendChild(editorContainer);
-
- const showEditingOverlay = vi.fn(() => ({
- success: true,
- editorHost,
- reason: null,
- }));
-
- mockEditorOverlayManager.mockImplementationOnce(() => ({
- showEditingOverlay,
- hideEditingOverlay: vi.fn(),
- showSelectionOverlay: vi.fn(),
- hideSelectionOverlay: vi.fn(),
- setOnDimmingClick: vi.fn(),
- getActiveEditorHost: vi.fn(() => editorHost),
- destroy: vi.fn(),
- }));
-
- editor = new PresentationEditor({
- element: container,
- documentId: 'test-doc',
- });
-
- await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
- const mockPage = document.createElement('div');
- mockPage.setAttribute('data-page-index', '0');
- pagesHost.appendChild(mockPage);
-
- const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
- vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
- left: 0,
- top: 0,
- width: 800,
- height: 1000,
- right: 800,
- bottom: 1000,
- x: 0,
- y: 0,
- toJSON: () => ({}),
- } as DOMRect);
-
- // Click inside the footer hitbox (y between footer margin 36 and bottom margin 72)
- viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }));
-
- await vi.waitFor(() => expect(showEditingOverlay).toHaveBeenCalled());
- await vi.waitFor(() => expect(editorContainer.style.transform).toBe(''));
- });
-
it('exits header mode on Escape and announces the transition', async () => {
mockIncrementalLayout.mockResolvedValue(buildLayoutResult());
@@ -2606,6 +2679,272 @@ describe('PresentationEditor', () => {
});
});
+ describe('footnote interactions', () => {
+ const prepareFootnoteEditor = async () => {
+ mockIncrementalLayout.mockResolvedValueOnce({
+ layout: {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ numberText: '1',
+ size: { w: 612, h: 792 },
+ fragments: [],
+ margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 },
+ },
+ ],
+ },
+ measures: [],
+ });
+
+ mockEditorConverterStore.current = {
+ ...mockEditorConverterStore.current,
+ footnotes: [
+ {
+ id: '1',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Lazy note session' }] }],
+ },
+ ],
+ convertedXml: {
+ 'word/footnotes.xml': {
+ elements: [
+ {
+ name: 'w:footnotes',
+ elements: [
+ {
+ name: 'w:footnote',
+ attributes: { 'w:id': '1' },
+ elements: [{ name: 'w:p', elements: [] }],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ editor = new PresentationEditor({
+ element: container,
+ documentId: 'footnote-doc',
+ });
+
+ await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
+ vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 800,
+ height: 1000,
+ right: 800,
+ bottom: 1000,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ } as DOMRect);
+
+ const footnoteFragment = document.createElement('span');
+ footnoteFragment.setAttribute('data-block-id', 'footnote-1-0');
+ viewport.appendChild(footnoteFragment);
+
+ return { viewport, footnoteFragment };
+ };
+
+ const activateFootnoteSession = async () => {
+ const { viewport, footnoteFragment } = await prepareFootnoteEditor();
+
+ expect(editor.getStorySessionManager()).not.toBeNull();
+ expect(editor.getStorySessionManager()?.getActiveSession()).toBeNull();
+
+ footnoteFragment.dispatchEvent(
+ new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }),
+ );
+
+ await vi.waitFor(() => expect(mockCreateStoryEditor.mock.calls.length).toBeGreaterThanOrEqual(1));
+ await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThanOrEqual(1));
+
+ return {
+ viewport,
+ footnoteFragment,
+ sessionEditor: createdStoryEditors.at(-1)?.editor,
+ };
+ };
+
+ const prepareEndnoteEditor = async () => {
+ mockIncrementalLayout.mockResolvedValueOnce({
+ layout: {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ numberText: '1',
+ size: { w: 612, h: 792 },
+ fragments: [],
+ margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 },
+ },
+ ],
+ },
+ measures: [],
+ });
+
+ mockEditorConverterStore.current = {
+ ...mockEditorConverterStore.current,
+ endnotes: [
+ {
+ id: '1',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Lazy endnote session' }] }],
+ },
+ ],
+ convertedXml: {
+ 'word/endnotes.xml': {
+ elements: [
+ {
+ name: 'w:endnotes',
+ elements: [
+ {
+ name: 'w:endnote',
+ attributes: { 'w:id': '1' },
+ elements: [{ name: 'w:p', elements: [] }],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ editor = new PresentationEditor({
+ element: container,
+ documentId: 'endnote-doc',
+ });
+
+ await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
+ vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 800,
+ height: 1000,
+ right: 800,
+ bottom: 1000,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ } as DOMRect);
+
+ const endnoteFragment = document.createElement('span');
+ endnoteFragment.setAttribute('data-block-id', 'endnote-1-0');
+ viewport.appendChild(endnoteFragment);
+
+ return { viewport, endnoteFragment };
+ };
+
+ it('activates a note editing session through the shared story-session manager', async () => {
+ const { sessionEditor } = await activateFootnoteSession();
+
+ expect(editor.getStorySessionManager()).not.toBeNull();
+ expect(editor.getStorySessionManager()?.getActiveSession()?.commitPolicy).toBe('onExit');
+ expect(editor.getActiveEditor()).toBe(sessionEditor);
+ expect(sessionEditor?.setDocumentMode).toHaveBeenCalledWith('editing');
+
+ editor.setDocumentMode('viewing');
+ expect(sessionEditor?.setDocumentMode).toHaveBeenLastCalledWith('viewing');
+ expect(createdSectionEditors.length).toBe(0);
+ });
+
+ it('routes tracked-change navigation to the active note session editor', async () => {
+ const { sessionEditor } = await activateFootnoteSession();
+ const setCursorById = vi.fn(() => true);
+ if (sessionEditor?.commands) {
+ sessionEditor.commands.setCursorById = setCursorById;
+ }
+
+ const didNavigate = await editor.navigateTo({
+ kind: 'entity',
+ entityType: 'trackedChange',
+ entityId: 'tc-note-1',
+ story: { kind: 'story', storyType: 'footnote', noteId: '1' },
+ });
+
+ expect(didNavigate).toBe(true);
+ expect(setCursorById).toHaveBeenCalledWith('tc-note-1', { preferredActiveThreadId: 'tc-note-1' });
+ expect(sessionEditor?.view.focus).toHaveBeenCalled();
+ });
+
+ it('falls back to rendered tracked-change stamps for inactive non-body stories', async () => {
+ const { viewport } = await prepareFootnoteEditor();
+ const page = document.createElement('div');
+ page.className = 'superdoc-page';
+ page.dataset.pageIndex = '0';
+
+ const renderedChange = document.createElement('span');
+ renderedChange.dataset.trackChangeId = 'tc-footnote-2';
+ renderedChange.dataset.storyKey = 'fn:2';
+ renderedChange.scrollIntoView = vi.fn();
+ page.appendChild(renderedChange);
+ viewport.appendChild(page);
+
+ const didNavigate = await editor.navigateTo({
+ kind: 'entity',
+ entityType: 'trackedChange',
+ entityId: 'tc-footnote-2',
+ story: { kind: 'story', storyType: 'footnote', noteId: '2' },
+ });
+
+ expect(didNavigate).toBe(true);
+ expect(renderedChange.scrollIntoView).toHaveBeenCalledWith({
+ behavior: 'auto',
+ block: 'center',
+ inline: 'nearest',
+ });
+ });
+
+ it('activates an inactive endnote story before routing tracked-change navigation', async () => {
+ const { viewport } = await prepareEndnoteEditor();
+ const page = document.createElement('div');
+ page.className = 'superdoc-page';
+ page.dataset.pageIndex = '0';
+
+ const renderedChange = document.createElement('span');
+ renderedChange.dataset.trackChangeId = 'tc-endnote-1';
+ renderedChange.dataset.storyKey = 'en:1';
+ renderedChange.scrollIntoView = vi.fn();
+ vi.spyOn(renderedChange, 'getBoundingClientRect').mockReturnValue({
+ left: 140,
+ top: 720,
+ width: 20,
+ height: 12,
+ right: 160,
+ bottom: 732,
+ x: 140,
+ y: 720,
+ toJSON: () => ({}),
+ } as DOMRect);
+ page.appendChild(renderedChange);
+ viewport.appendChild(page);
+
+ const didNavigate = await editor.navigateTo({
+ kind: 'entity',
+ entityType: 'trackedChange',
+ entityId: 'tc-endnote-1',
+ story: { kind: 'story', storyType: 'endnote', noteId: '1' },
+ });
+
+ expect(didNavigate).toBe(true);
+ await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThanOrEqual(2));
+
+ const sessionEditor = createdStoryEditors.at(-1)?.editor;
+ expect(sessionEditor?.commands.setCursorById).toHaveBeenCalledWith('tc-endnote-1', {
+ preferredActiveThreadId: 'tc-endnote-1',
+ });
+ expect(sessionEditor?.view.focus).toHaveBeenCalled();
+ expect(renderedChange.scrollIntoView).not.toHaveBeenCalled();
+ });
+ });
+
describe('pageStyleUpdate event listener', () => {
const buildLayoutResult = () => ({
layout: {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts
index 221aaf00a0..4020a94542 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts
@@ -289,6 +289,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({
vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
+ resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));
describe('PresentationEditor - Zoom Functionality', () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts
index c056ee07ea..4e9abb5450 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts
@@ -237,4 +237,131 @@ describe('PresentationInputBridge - Context Menu Handling', () => {
expect(dispatchSpy).not.toHaveBeenCalled();
});
});
+
+ describe('stale hidden-editor rerouting', () => {
+ it('does not double-forward layout-surface composing beforeinput when window fallback is enabled', () => {
+ const event = new InputEvent('beforeinput', {
+ data: 'e',
+ inputType: 'insertCompositionText',
+ bubbles: true,
+ cancelable: true,
+ });
+ Object.defineProperty(event, 'isComposing', { value: true, writable: false });
+
+ const forwardedEvents: string[] = [];
+ targetDom.addEventListener('beforeinput', () => {
+ forwardedEvents.push('beforeinput');
+ });
+
+ bridge.destroy();
+ bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, {
+ useWindowFallback: true,
+ });
+ bridge.bind();
+
+ layoutSurface.dispatchEvent(event);
+
+ expect(forwardedEvents).toEqual(['beforeinput']);
+ });
+
+ it('reroutes beforeinput from a stale hidden editor to the active target when window fallback is enabled', () => {
+ const staleBodyEditor = document.createElement('div');
+ staleBodyEditor.className = 'ProseMirror';
+ staleBodyEditor.setAttribute('contenteditable', 'true');
+ document.body.appendChild(staleBodyEditor);
+
+ const staleEvent = new InputEvent('beforeinput', {
+ data: 'a',
+ inputType: 'insertText',
+ bubbles: true,
+ cancelable: true,
+ });
+
+ const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {});
+ const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent');
+
+ bridge.destroy();
+ bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, {
+ useWindowFallback: true,
+ });
+ bridge.bind();
+
+ staleBodyEditor.dispatchEvent(staleEvent);
+
+ expect(targetFocusSpy).toHaveBeenCalled();
+ expect(targetDispatchSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'beforeinput',
+ data: 'a',
+ inputType: 'insertText',
+ }),
+ );
+ expect(staleEvent.defaultPrevented).toBe(true);
+ });
+
+ it('reroutes non-text keyboard commands from a stale hidden editor to the active target', () => {
+ const staleBodyEditor = document.createElement('div');
+ staleBodyEditor.className = 'ProseMirror';
+ staleBodyEditor.setAttribute('contenteditable', 'true');
+ document.body.appendChild(staleBodyEditor);
+
+ const staleEvent = new KeyboardEvent('keydown', {
+ key: 'Backspace',
+ bubbles: true,
+ cancelable: true,
+ });
+
+ const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {});
+ const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent');
+
+ bridge.destroy();
+ bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, {
+ useWindowFallback: true,
+ });
+ bridge.bind();
+
+ staleBodyEditor.dispatchEvent(staleEvent);
+
+ expect(targetFocusSpy).toHaveBeenCalled();
+ expect(targetDispatchSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'keydown',
+ key: 'Backspace',
+ }),
+ );
+ expect(staleEvent.defaultPrevented).toBe(true);
+ });
+
+ it('does not reroute keyboard input from a registered UI surface editor', () => {
+ const commentEditor = document.createElement('div');
+ commentEditor.className = 'ProseMirror';
+ commentEditor.setAttribute('contenteditable', 'true');
+
+ const commentDialog = document.createElement('div');
+ commentDialog.setAttribute('data-editor-ui-surface', '');
+ commentDialog.appendChild(commentEditor);
+ document.body.appendChild(commentDialog);
+
+ const staleEvent = new KeyboardEvent('keydown', {
+ key: 'U',
+ bubbles: true,
+ cancelable: true,
+ });
+
+ const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {});
+ const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent');
+
+ bridge.destroy();
+ bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, {
+ useWindowFallback: true,
+ });
+ bridge.bind();
+
+ commentEditor.dispatchEvent(staleEvent);
+
+ expect(targetFocusSpy).not.toHaveBeenCalled();
+ expect(targetDispatchSpy).not.toHaveBeenCalled();
+ expect(staleEvent.defaultPrevented).toBe(false);
+ });
+ });
});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts
new file mode 100644
index 0000000000..822be59436
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts
@@ -0,0 +1,223 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ computeCaretRectFromVisibleTextOffset,
+ computeSelectionRectsFromVisibleTextOffsets,
+ measureVisibleTextOffset,
+ type VisibleTextOffsetGeometryOptions,
+} from '../selection/VisibleTextOffsetGeometry.js';
+
+function createRect(x: number, y: number, width: number, height: number): DOMRect {
+ return {
+ x,
+ y,
+ width,
+ height,
+ top: y,
+ left: x,
+ right: x + width,
+ bottom: y + height,
+ toJSON: () => ({ x, y, width, height, top: y, left: x, right: x + width, bottom: y + height }),
+ } as DOMRect;
+}
+
+function createGeometryOptions(containers: HTMLElement[]): VisibleTextOffsetGeometryOptions {
+ return {
+ containers,
+ zoom: 1,
+ pageHeight: 792,
+ pageGap: 16,
+ };
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+});
+
+describe('measureVisibleTextOffset', () => {
+ it('measures an element boundary after a tracked-insert wrapper as visible text', () => {
+ const root = document.createElement('div');
+ root.innerHTML =
+ 'refXYZerences
';
+ document.body.appendChild(root);
+
+ const inlineRoot = root.querySelector('[data-run="1"] > span') as HTMLElement;
+ const offset = measureVisibleTextOffset(root, inlineRoot, 2);
+
+ expect(offset).toBe(6);
+ });
+});
+
+describe('computeCaretRectFromVisibleTextOffset', () => {
+ it('skips PM-less marker text and places the caret after inserted visible text', () => {
+ const page = document.createElement('div');
+ page.className = 'superdoc-page';
+ page.dataset.pageIndex = '0';
+ page.innerHTML = `
+
+
+ 1
+ ref
+ XYZ
+ erences
+
+
+ `;
+ document.body.appendChild(page);
+
+ const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement;
+ const line = page.querySelector('.superdoc-line') as HTMLElement;
+ const suffixTextNode = Array.from(page.querySelectorAll('span')).find(
+ (element) => element.textContent === 'erences',
+ )?.firstChild as Text;
+
+ page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792));
+ line.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 16));
+
+ vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(function () {
+ if (this.startContainer === suffixTextNode && this.startOffset === 0) {
+ return createRect(70, 20, 0, 16);
+ }
+ return createRect(0, 0, 0, 0);
+ });
+
+ const rect = computeCaretRectFromVisibleTextOffset(createGeometryOptions([fragment]), 6);
+
+ expect(rect).toMatchObject({
+ pageIndex: 0,
+ x: 70,
+ y: 20,
+ width: 1,
+ height: 16,
+ });
+ });
+});
+
+describe('computeSelectionRectsFromVisibleTextOffsets', () => {
+ it('maps later-word selection offsets after an inserted run to the correct painted range', () => {
+ const page = document.createElement('div');
+ page.className = 'superdoc-page';
+ page.dataset.pageIndex = '0';
+ page.innerHTML = `
+
+
+ 1
+ ref
+ XYZ
+ erences
+ Closing
+
+
+ `;
+ document.body.appendChild(page);
+
+ const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement;
+ const closingTextNode = Array.from(page.querySelectorAll('span')).find(
+ (element) => element.textContent === 'Closing',
+ )?.firstChild as Text;
+
+ page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792));
+
+ vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function () {
+ if (this.startContainer === closingTextNode && this.startOffset === 0 && this.endContainer === closingTextNode) {
+ return [createRect(120, 40, 52, 16)] as unknown as DOMRectList;
+ }
+ return [] as unknown as DOMRectList;
+ });
+
+ const rects = computeSelectionRectsFromVisibleTextOffsets(createGeometryOptions([fragment]), 14, 21);
+
+ expect(rects).toEqual([
+ {
+ pageIndex: 0,
+ x: 120,
+ y: 40,
+ width: 52,
+ height: 16,
+ },
+ ]);
+ });
+
+ it('collapses same-line PM gaps that come from tracked-change wrapper structure', () => {
+ const page = document.createElement('div');
+ page.className = 'superdoc-page';
+ page.dataset.pageIndex = '0';
+ page.innerHTML = `
+
+ `;
+ document.body.appendChild(page);
+
+ const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement;
+ const wordTextNode = Array.from(page.querySelectorAll('span')).find((element) => element.textContent === 'word')
+ ?.firstChild as Text;
+
+ page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792));
+
+ vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function () {
+ if (this.startContainer === wordTextNode && this.startOffset === 0 && this.endContainer === wordTextNode) {
+ return [createRect(140, 48, 36, 16)] as unknown as DOMRectList;
+ }
+ return [] as unknown as DOMRectList;
+ });
+
+ const rects = computeSelectionRectsFromVisibleTextOffsets(createGeometryOptions([fragment]), 3, 7);
+
+ expect(rects).toEqual([
+ {
+ pageIndex: 0,
+ x: 140,
+ y: 48,
+ width: 36,
+ height: 16,
+ },
+ ]);
+ });
+
+ it('preserves logical spaces that are trimmed from painted line text at line breaks', () => {
+ const page = document.createElement('div');
+ page.className = 'superdoc-page';
+ page.dataset.pageIndex = '0';
+ page.innerHTML = `
+
+ `;
+ document.body.appendChild(page);
+
+ const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement;
+ const wordTextNode = Array.from(page.querySelectorAll('span')).find((element) => element.textContent === 'word')
+ ?.firstChild as Text;
+
+ page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792));
+
+ vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function () {
+ if (this.startContainer === wordTextNode && this.startOffset === 0 && this.endContainer === wordTextNode) {
+ return [createRect(180, 60, 40, 16)] as unknown as DOMRectList;
+ }
+ return [] as unknown as DOMRectList;
+ });
+
+ const rects = computeSelectionRectsFromVisibleTextOffsets(createGeometryOptions([fragment]), 4, 8);
+
+ expect(rects).toEqual([
+ {
+ pageIndex: 0,
+ x: 180,
+ y: 60,
+ width: 40,
+ height: 16,
+ },
+ ]);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts
index e442a6f34e..46bfa4fe34 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts
@@ -165,6 +165,15 @@ export type LayoutEngineOptions = {
ruler?: RulerOptions;
/** Proofing / spellcheck configuration. */
proofing?: ProofingConfig;
+ /**
+ * Render visible gray `[` / `]` bracket markers at bookmark start/end
+ * positions โ matching Word's opt-in "Show bookmarks" (File > Options >
+ * Advanced). Off by default because bookmarks are a structural concept,
+ * not a visual one. Auto-generated bookmarks (names starting with `_`,
+ * such as `_Tocโฆ` or `_Refโฆ`) are hidden even when enabled, mirroring
+ * Word's behavior. SD-2454.
+ */
+ showBookmarks?: boolean;
};
export type PresentationEditorOptions = ConstructorParameters[0] & {
@@ -343,6 +352,10 @@ export interface EditorWithConverter extends Editor {
id: string;
content?: unknown[];
}>;
+ endnotes?: Array<{
+ id: string;
+ content?: unknown[];
+ }>;
};
}
@@ -425,7 +438,7 @@ export type PendingMarginClick =
* to prevent unwanted scroll behavior when the hidden editor receives focus.
*
* @remarks
- * This flag is set by {@link PresentationEditor#wrapHiddenEditorFocus} to ensure
+ * This flag is set by {@link PresentationEditor#wrapOffscreenEditorFocus} to ensure
* the wrapping is idempotent (applied only once per view instance).
*/
export interface EditorViewWithScrollFlag {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts
index 7fa546f0db..b8ee13f9c8 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts
@@ -1,48 +1,87 @@
import type { Mark, Node as ProseMirrorNode } from 'prosemirror-model';
+import { BODY_STORY_KEY } from '../../../document-api-adapters/story-runtime/story-key.js';
+import {
+ makeCommentAnchorKey,
+ makeTrackedChangeAnchorKey,
+} from '../../../document-api-adapters/helpers/tracked-change-runtime-ref.js';
-export type CommentPosition = { threadId: string; start: number; end: number };
+export type CommentPosition = {
+ threadId: string;
+ key: string;
+ storyKey: string;
+ kind: 'trackedChange' | 'comment';
+ start: number;
+ end: number;
+};
+
+export interface CollectCommentPositionsOptions {
+ commentMarkName: string;
+ trackChangeMarkNames: string[];
+ storyKey?: string;
+}
export function collectCommentPositions(
doc: ProseMirrorNode | null,
- options: { commentMarkName: string; trackChangeMarkNames: string[] },
+ options: CollectCommentPositionsOptions,
): Record {
if (!doc) {
return {};
}
- const pmPositions: Record = {};
+ const storyKey = options.storyKey ?? BODY_STORY_KEY;
+ const positions: Record = {};
doc.descendants((node, pos) => {
const marks = node.marks || [];
for (const mark of marks) {
- const threadId = getThreadIdFromMark(mark, options);
- if (!threadId) continue;
+ const descriptor = describeThreadMark(mark, options);
+ if (!descriptor) continue;
+ const canonicalKey =
+ descriptor.kind === 'trackedChange'
+ ? makeTrackedChangeAnchorKey({ storyKey, rawId: descriptor.rawId })
+ : makeCommentAnchorKey(descriptor.rawId);
+ const storageKey = descriptor.kind === 'trackedChange' ? canonicalKey : descriptor.rawId;
const nodeEnd = pos + node.nodeSize;
+ const existing = positions[storageKey];
- if (!pmPositions[threadId]) {
- pmPositions[threadId] = { threadId, start: pos, end: nodeEnd };
- } else {
- pmPositions[threadId].start = Math.min(pmPositions[threadId].start, pos);
- pmPositions[threadId].end = Math.max(pmPositions[threadId].end, nodeEnd);
+ if (!existing) {
+ positions[storageKey] = {
+ threadId: descriptor.rawId,
+ key: canonicalKey,
+ storyKey,
+ kind: descriptor.kind,
+ start: pos,
+ end: nodeEnd,
+ };
+ continue;
}
+
+ existing.start = Math.min(existing.start, pos);
+ existing.end = Math.max(existing.end, nodeEnd);
}
});
- return pmPositions;
+ return positions;
+}
+
+interface ThreadMarkDescriptor {
+ rawId: string;
+ kind: 'trackedChange' | 'comment';
}
-function getThreadIdFromMark(
- mark: Mark,
- options: { commentMarkName: string; trackChangeMarkNames: string[] },
-): string | undefined {
+function describeThreadMark(mark: Mark, options: CollectCommentPositionsOptions): ThreadMarkDescriptor | undefined {
if (mark.type.name === options.commentMarkName) {
- return mark.attrs.commentId || mark.attrs.importedId;
+ const commentId = (mark.attrs.commentId as string | undefined) ?? (mark.attrs.importedId as string | undefined);
+ if (!commentId) return undefined;
+ return { rawId: commentId, kind: 'comment' };
}
if (options.trackChangeMarkNames.includes(mark.type.name)) {
- return mark.attrs.id;
+ const rawId = mark.attrs.id as string | undefined;
+ if (!rawId) return undefined;
+ return { rawId, kind: 'trackedChange' };
}
return undefined;
diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts
new file mode 100644
index 0000000000..dade8ad145
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import type { Editor } from './Editor.js';
+import { createStoryEditor } from './story-editor-factory.ts';
+import { initTestEditor } from '../tests/helpers/helpers.js';
+
+const createdEditors: Editor[] = [];
+
+function trackEditor(editor: Editor): Editor {
+ createdEditors.push(editor);
+ return editor;
+}
+
+afterEach(() => {
+ while (createdEditors.length > 0) {
+ const editor = createdEditors.pop();
+ try {
+ editor?.destroy?.();
+ } catch {
+ // best-effort cleanup for test editors
+ }
+ }
+});
+
+describe('createStoryEditor', () => {
+ it('inherits tracked changes configuration from the parent editor', () => {
+ const parent = trackEditor(
+ initTestEditor({
+ mode: 'text',
+ content: 'Hello world
',
+ trackedChanges: {
+ visible: true,
+ mode: 'review',
+ enabled: true,
+ replacements: 'independent',
+ },
+ }).editor as Editor,
+ );
+
+ const child = trackEditor(
+ createStoryEditor(
+ parent,
+ {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header text' }] }],
+ },
+ {
+ documentId: 'hf:part:rId9',
+ isHeaderOrFooter: true,
+ headless: true,
+ },
+ ),
+ );
+
+ expect(child.options.trackedChanges).toEqual({
+ visible: true,
+ mode: 'review',
+ enabled: true,
+ replacements: 'independent',
+ });
+
+ child.options.trackedChanges!.replacements = 'paired';
+ expect(parent.options.trackedChanges?.replacements).toBe('independent');
+ });
+
+ it('inherits presentation editor references from the parent editor', () => {
+ const parent = trackEditor(
+ initTestEditor({
+ mode: 'text',
+ content: 'Hello world
',
+ }).editor as Editor,
+ );
+ const presentationEditor = { element: document.createElement('div') } as unknown as Editor['presentationEditor'];
+ parent.presentationEditor = presentationEditor;
+ (parent as Editor & { _presentationEditor?: typeof presentationEditor })._presentationEditor = presentationEditor;
+
+ const child = trackEditor(
+ createStoryEditor(
+ parent,
+ {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header text' }] }],
+ },
+ {
+ documentId: 'hf:part:rId9',
+ isHeaderOrFooter: true,
+ headless: true,
+ },
+ ),
+ );
+
+ expect(child.presentationEditor).toBe(presentationEditor);
+ expect((child as Editor & { _presentationEditor?: unknown })._presentationEditor).toBe(presentationEditor);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts
index ffc7b8fe08..817668f0e6 100644
--- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts
+++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts
@@ -1,5 +1,6 @@
import type { Editor } from './Editor.js';
import type { EditorOptions } from './types/EditorConfig.js';
+import type { PresentationEditor } from './presentation-editor/index.js';
/**
* Options for creating a story editor (header, footer, footnote, endnote, etc.).
@@ -129,6 +130,9 @@ export function createStoryEditor(
const inheritedExtensions = parentEditor.options.extensions?.length
? [...parentEditor.options.extensions]
: undefined;
+ const inheritedTrackedChanges = parentEditor.options.trackedChanges
+ ? { ...parentEditor.options.trackedChanges }
+ : undefined;
const StoryEditorClass = parentEditor.constructor as new (options: Partial) => Editor;
const storyEditor = new StoryEditorClass({
@@ -144,6 +148,8 @@ export function createStoryEditor(
media,
mediaFiles: media,
fonts: parentEditor.options.fonts,
+ user: parentEditor.options.user,
+ trackedChanges: inheritedTrackedChanges,
isHeaderOrFooter,
isHeadless,
pagination: false,
@@ -156,7 +162,9 @@ export function createStoryEditor(
// Only set element when not headless
...(isHeadless ? {} : { element }),
- // Disable collaboration, comments, and tracked changes for story editors
+ // Disable collaboration and comment threading for story editors.
+ // Tracked-change configuration is inherited from the parent editor so
+ // suggesting-mode story sessions honor the same replacement model.
ydoc: null,
collaborationProvider: null,
isCommentsEnabled: false,
@@ -166,20 +174,34 @@ export function createStoryEditor(
...editorOptions,
} as Partial);
+ const inheritedPresentationEditor =
+ parentEditor.presentationEditor ??
+ (parentEditor as Editor & { _presentationEditor?: PresentationEditor | null })._presentationEditor ??
+ null;
+ if (inheritedPresentationEditor) {
+ storyEditor.presentationEditor = inheritedPresentationEditor;
+ (storyEditor as Editor & { _presentationEditor?: PresentationEditor | null })._presentationEditor =
+ inheritedPresentationEditor;
+ }
+
// Store parent editor reference as a non-enumerable property to avoid
// circular reference issues during serialization while still allowing
// access when needed.
- Object.defineProperty(storyEditor.options, 'parentEditor', {
- enumerable: false,
- configurable: true,
- get() {
- return parentEditor;
- },
- });
+ if (storyEditor.options && typeof storyEditor.options === 'object') {
+ Object.defineProperty(storyEditor.options, 'parentEditor', {
+ enumerable: false,
+ configurable: true,
+ get() {
+ return parentEditor;
+ },
+ });
+ }
// Start non-editable; the caller (e.g. PresentationEditor) will enable
// editing when entering edit mode.
- storyEditor.setEditable(false, false);
+ if (typeof storyEditor.setEditable === 'function') {
+ storyEditor.setEditable(false, false);
+ }
return storyEditor;
}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js
new file mode 100644
index 0000000000..7e7c22a22c
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js
@@ -0,0 +1,34 @@
+// @ts-check
+import { describe, it, expect } from 'vitest';
+import { preProcessNoterefInstruction } from './noteref-preprocessor.js';
+
+describe('preProcessNoterefInstruction', () => {
+ const mockNodesToCombine = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }];
+
+ it('wraps the cached runs in a sd:crossReference node with NOTEREF fieldType', () => {
+ const instruction = 'NOTEREF _Ref9876 \\h';
+ const result = preProcessNoterefInstruction(mockNodesToCombine, instruction);
+ expect(result).toEqual([
+ {
+ name: 'sd:crossReference',
+ type: 'element',
+ attributes: {
+ instruction: 'NOTEREF _Ref9876 \\h',
+ fieldType: 'NOTEREF',
+ },
+ elements: mockNodesToCombine,
+ },
+ ]);
+ });
+
+ it('preserves the instruction text verbatim including the \\f footnote switch', () => {
+ const instruction = 'NOTEREF _Ref9876 \\h \\f';
+ const [node] = preProcessNoterefInstruction(mockNodesToCombine, instruction);
+ expect(node.attributes.instruction).toBe('NOTEREF _Ref9876 \\h \\f');
+ });
+
+ it('handles an empty runs list', () => {
+ const result = preProcessNoterefInstruction([], 'NOTEREF _Ref9876 \\h');
+ expect(result[0].elements).toEqual([]);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js
new file mode 100644
index 0000000000..889fbaed6f
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js
@@ -0,0 +1,36 @@
+// @ts-check
+import { describe, it, expect } from 'vitest';
+import { preProcessRefInstruction } from './ref-preprocessor.js';
+
+describe('preProcessRefInstruction', () => {
+ const mockNodesToCombine = [
+ { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Section 15' }] }] },
+ ];
+
+ it('wraps the cached runs in a sd:crossReference node with REF fieldType', () => {
+ const instruction = 'REF _Ref123456 \\h';
+ const result = preProcessRefInstruction(mockNodesToCombine, instruction);
+ expect(result).toEqual([
+ {
+ name: 'sd:crossReference',
+ type: 'element',
+ attributes: {
+ instruction: 'REF _Ref123456 \\h',
+ fieldType: 'REF',
+ },
+ elements: mockNodesToCombine,
+ },
+ ]);
+ });
+
+ it('preserves the instruction text verbatim including all switches', () => {
+ const instruction = 'REF _Ref123 \\h \\w \\* MERGEFORMAT';
+ const [node] = preProcessRefInstruction(mockNodesToCombine, instruction);
+ expect(node.attributes.instruction).toBe('REF _Ref123 \\h \\w \\* MERGEFORMAT');
+ });
+
+ it('handles an empty runs list', () => {
+ const result = preProcessRefInstruction([], 'REF _Ref123 \\h');
+ expect(result[0].elements).toEqual([]);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js
new file mode 100644
index 0000000000..d386fa0c71
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js
@@ -0,0 +1,36 @@
+// @ts-check
+import { describe, it, expect } from 'vitest';
+import { preProcessStylerefInstruction } from './styleref-preprocessor.js';
+
+describe('preProcessStylerefInstruction', () => {
+ const mockNodesToCombine = [
+ { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Heading 1' }] }] },
+ ];
+
+ it('wraps the cached runs in a sd:crossReference node with STYLEREF fieldType', () => {
+ const instruction = 'STYLEREF "Heading 1" \\l';
+ const result = preProcessStylerefInstruction(mockNodesToCombine, instruction);
+ expect(result).toEqual([
+ {
+ name: 'sd:crossReference',
+ type: 'element',
+ attributes: {
+ instruction: 'STYLEREF "Heading 1" \\l',
+ fieldType: 'STYLEREF',
+ },
+ elements: mockNodesToCombine,
+ },
+ ]);
+ });
+
+ it('preserves quoted style names that contain spaces', () => {
+ const instruction = 'STYLEREF "Last Name" \\l';
+ const [node] = preProcessStylerefInstruction(mockNodesToCombine, instruction);
+ expect(node.attributes.instruction).toBe('STYLEREF "Last Name" \\l');
+ });
+
+ it('handles an empty runs list', () => {
+ const result = preProcessStylerefInstruction([], 'STYLEREF "Heading 1"');
+ expect(result[0].elements).toEqual([]);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js
new file mode 100644
index 0000000000..9e6c070c06
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js
@@ -0,0 +1,191 @@
+/**
+ * Integration tests for SD-2495 / IT-949.
+ *
+ * Why this file exists (root cause recap):
+ * The `sd:crossReference` v3 translator was registered in `registeredHandlers`
+ * but NOT wired into the v2 importer's `defaultNodeListHandler` entity list.
+ * The passthrough fallback refused to wrap it (because it was "registered"),
+ * and no entity claimed it, so every REF field in every imported DOCX was
+ * silently dropped โ erasing "Section 15" and every other cross-reference
+ * from the viewer.
+ *
+ * These tests exercise the full v2 body pipeline: preprocessor โ dispatcher โ
+ * entity handler โ v3 translator โ PM node. If any link in that chain breaks
+ * (most likely: the entity gets removed from the entities list during a
+ * refactor), the `crossReference` PM node disappears and these tests fail.
+ *
+ * The unit tests of the translator alone (`crossReference-translator.test.js`)
+ * don't catch this class of regression because they bypass the dispatcher.
+ */
+import { describe, it, expect } from 'vitest';
+import { defaultNodeListHandler } from './docxImporter.js';
+import { preProcessNodesForFldChar } from '../../field-references/index.js';
+import { crossReferenceEntity } from './crossReferenceImporter.js';
+
+const createEditorStub = () => ({
+ schema: {
+ nodes: {
+ run: { isInline: true, spec: { group: 'inline' } },
+ crossReference: { isInline: true, spec: { group: 'inline', atom: true } },
+ },
+ },
+});
+
+// Produces the exact XML shape Word emits for a REF field with `\h` โ matches
+// the Brillio lease fragment that produces the "Section 15" customer bug.
+const buildRefField = (target, cachedText) => {
+ const run = (inner) => ({
+ name: 'w:r',
+ elements: [{ name: 'w:rPr', elements: [{ name: 'w:i' }] }, ...inner],
+ });
+ return [
+ run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }]),
+ run([
+ {
+ name: 'w:instrText',
+ attributes: { 'xml:space': 'preserve' },
+ elements: [{ type: 'text', text: ` REF ${target} \\w \\h ` }],
+ },
+ ]),
+ run([]),
+ run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }]),
+ run([{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }]),
+ run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }]),
+ ];
+};
+
+describe('SD-2495 v2 importer wiring (IT-949 regression guard)', () => {
+ it('registers crossReferenceEntity in defaultNodeListHandler โ guards the miss that produced IT-949', () => {
+ // This membership assertion is the cheapest possible regression guard
+ // against the exact bug root cause: if a future refactor drops the
+ // entity from the entities list, this fails immediately.
+ expect(defaultNodeListHandler().handlerEntities).toContain(crossReferenceEntity);
+ });
+
+ it('REF field inside a paragraph produces a crossReference PM node with cached text + target', () => {
+ const paragraph = {
+ name: 'w:p',
+ elements: [
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'If terminated under this Section ' }] }],
+ },
+ ...buildRefField('_Ref506192326', '15'),
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:t', elements: [{ type: 'text', text: ', Landlord...' }] }],
+ },
+ ],
+ };
+
+ // Mirror the real body pipeline: preprocess fldChar runs into
+ // sd:crossReference, then dispatch through the v2 entity list.
+ const { processedNodes } = preProcessNodesForFldChar([paragraph], {});
+ const nodeListHandler = defaultNodeListHandler();
+ const pmNodes = nodeListHandler.handler({
+ nodes: processedNodes,
+ docx: {},
+ editor: createEditorStub(),
+ path: [],
+ });
+
+ const para = pmNodes.find((n) => n.type === 'paragraph');
+ expect(para, 'paragraph should be produced').toBeTruthy();
+
+ const crossRefs = collectNodesOfType(para, 'crossReference');
+ expect(crossRefs).toHaveLength(1);
+ expect(crossRefs[0].attrs.target).toBe('_Ref506192326');
+ expect(crossRefs[0].attrs.resolvedText).toBe('15');
+ // Instruction preserves the `\h` switch โ the render layer reads this to
+ // decide whether to attach an internal-link mark (SD-2537 hyperlink vs
+ // plain-text variant).
+ expect(crossRefs[0].attrs.instruction).toMatch(/\\h/);
+ });
+
+ it('REF with \\h switch records the target so the render layer can navigate on click', () => {
+ const paragraph = {
+ name: 'w:p',
+ elements: [...buildRefField('_Ref123', '7')],
+ };
+
+ const { processedNodes } = preProcessNodesForFldChar([paragraph], {});
+ const nodeListHandler = defaultNodeListHandler();
+ const pmNodes = nodeListHandler.handler({
+ nodes: processedNodes,
+ docx: {},
+ editor: createEditorStub(),
+ path: [],
+ });
+
+ const crossRef = collectNodesOfType(pmNodes[0], 'crossReference')[0];
+ expect(crossRef).toBeTruthy();
+ // `display` is derived from switches so PM-adapter knows which variant.
+ // `\w \h` โ numberFullContext. If this regresses, cross-ref visuals change.
+ expect(crossRef.attrs.display).toBe('numberFullContext');
+ });
+
+ it('plain text surrounding a REF field still reaches PM unchanged (guards against REF dispatch consuming sibling runs)', () => {
+ // The `xml:space="preserve"` attribute is what keeps trailing whitespace
+ // around. Without it, OOXML parsers strip leading/trailing whitespace from
+ // w:t elements. The real customer document (Brillio lease) preserves this
+ // attribute on runs adjacent to REF fields so "Section " doesn't collapse
+ // to "Section" before the number. Mirror that here.
+ const textRun = (text) => ({
+ name: 'w:r',
+ elements: [{ name: 'w:t', attributes: { 'xml:space': 'preserve' }, elements: [{ type: 'text', text }] }],
+ });
+ const paragraph = {
+ name: 'w:p',
+ elements: [textRun('Section '), ...buildRefField('_Ref1', '15'), textRun(', Landlord')],
+ };
+
+ const { processedNodes } = preProcessNodesForFldChar([paragraph], {});
+ const nodeListHandler = defaultNodeListHandler();
+ const [pmPara] = nodeListHandler.handler({
+ nodes: processedNodes,
+ docx: {},
+ editor: createEditorStub(),
+ path: [],
+ });
+
+ // Children of the paragraph, in order, excluding the crossReference (it's
+ // an atom โ its cached text contributes to the visual output but lives
+ // inside the xref node, not beside it). We care here about the SURROUNDING
+ // text surviving unchanged.
+ const collectSiblingTextBeforeAndAfterXref = (paraNode) => {
+ const parts = { before: '', after: '' };
+ let sawXref = false;
+ const visitChildren = (nodes) => {
+ for (const child of nodes ?? []) {
+ if (child?.type === 'crossReference') {
+ sawXref = true;
+ continue;
+ }
+ if (Array.isArray(child?.content)) visitChildren(child.content);
+ else if (child?.type === 'text' && typeof child.text === 'string') {
+ if (sawXref) parts.after += child.text;
+ else parts.before += child.text;
+ }
+ }
+ };
+ visitChildren(paraNode.content ?? []);
+ return parts;
+ };
+
+ const { before, after } = collectSiblingTextBeforeAndAfterXref(pmPara);
+ expect(before).toBe('Section ');
+ expect(after).toBe(', Landlord');
+ });
+});
+
+/** Collect all descendants of a given type from a nested PM node tree. */
+function collectNodesOfType(root, type) {
+ const out = [];
+ const visit = (node) => {
+ if (!node) return;
+ if (node.type === type) out.push(node);
+ if (Array.isArray(node.content)) node.content.forEach(visit);
+ };
+ visit(root);
+ return out;
+}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js
new file mode 100644
index 0000000000..6e896a236e
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js
@@ -0,0 +1,7 @@
+import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils';
+import { translator } from '../../v3/handlers/sd/crossReference/crossReference-translator.js';
+
+/**
+ * @type {import("./docxImporter").NodeHandlerEntry}
+ */
+export const crossReferenceEntity = generateV2HandlerEntity('crossReferenceNodeHandler', translator);
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js
index 75b7f2e94f..1d8bcb1913 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js
@@ -2,21 +2,24 @@ import { defaultNodeListHandler } from './docxImporter';
import { carbonCopy } from '../../../utilities/carbonCopy.js';
/**
- * Remove w:footnoteRef placeholders from converted footnote content.
- * In OOXML footnotes, the first run often includes a w:footnoteRef marker which
- * Word uses to render the footnote number. We render numbering ourselves.
+ * Remove w:footnoteRef / w:endnoteRef placeholders from converted note content.
+ * In OOXML notes, the first run often includes a reference marker which Word
+ * uses to render the display number. We render numbering ourselves.
*
* @param {Array} nodes
* @returns {Array}
*/
-const stripFootnoteMarkerNodes = (nodes) => {
+const stripNoteMarkerNodes = (nodes) => {
if (!Array.isArray(nodes) || nodes.length === 0) return nodes;
const walk = (list) => {
if (!Array.isArray(list) || list.length === 0) return;
for (let i = list.length - 1; i >= 0; i--) {
const node = list[i];
if (!node) continue;
- if (node.type === 'passthroughInline' && node.attrs?.originalName === 'w:footnoteRef') {
+ if (
+ node.type === 'passthroughInline' &&
+ (node.attrs?.originalName === 'w:footnoteRef' || node.attrs?.originalName === 'w:endnoteRef')
+ ) {
list.splice(i, 1);
continue;
}
@@ -109,7 +112,7 @@ function importNoteEntries({
path: [el],
});
- const stripped = stripFootnoteMarkerNodes(converted);
+ const stripped = stripNoteMarkerNodes(converted);
results.push({
id,
type,
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
index e1b0b058d4..8de6cd9b36 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
@@ -17,14 +17,16 @@ import { alternateChoiceHandler } from './alternateChoiceImporter.js';
import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumberImporter.js';
import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js';
import { pageReferenceEntity } from './pageReferenceImporter.js';
+import { crossReferenceEntity } from './crossReferenceImporter.js';
import { pictNodeHandlerEntity } from './pictNodeImporter.js';
import { importCommentData } from './documentCommentsImporter.js';
-import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js';
+import { buildTrackedChangeIdMap, buildTrackedChangeIdMapsByPart } from './trackedChangeIdMapper.js';
import { importFootnoteData, importEndnoteData } from './documentFootnotesImporter.js';
import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js';
import { pruneIgnoredNodes } from './ignoredNodes.js';
import { tabNodeEntityHandler } from './tabImporter.js';
import { footnoteReferenceHandlerEntity } from './footnoteReferenceImporter.js';
+import { endnoteReferenceHandlerEntity } from './endnoteReferenceImporter.js';
import { tableNodeHandlerEntity } from './tableImporter.js';
import { tableOfContentsHandlerEntity } from './tableOfContentsImporter.js';
import { indexHandlerEntity, indexEntryHandlerEntity } from './indexImporter.js';
@@ -152,9 +154,11 @@ export const createDocumentJson = (docx, converter, editor) => {
patchNumberingDefinitions(docx);
const numbering = getNumberingDefinitions(docx);
- converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, {
+ const trackedChangeIdMapOptions = {
replacements: converter.trackedChangesOptions?.replacements ?? 'paired',
- });
+ };
+ converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions);
+ converter.trackedChangeIdMapsByPart = buildTrackedChangeIdMapsByPart(docx, trackedChangeIdMapOptions);
const comments = importCommentData({ docx, nodeListHandler, converter, editor });
const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering });
const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering });
@@ -240,6 +244,7 @@ export const defaultNodeListHandler = () => {
trackChangeNodeHandlerEntity,
tableNodeHandlerEntity,
footnoteReferenceHandlerEntity,
+ endnoteReferenceHandlerEntity,
tabNodeEntityHandler,
tableOfContentsHandlerEntity,
indexHandlerEntity,
@@ -249,6 +254,7 @@ export const defaultNodeListHandler = () => {
autoTotalPageCountEntity,
documentStatFieldHandlerEntity,
pageReferenceEntity,
+ crossReferenceEntity,
permStartHandlerEntity,
permEndHandlerEntity,
mathNodeHandlerEntity,
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js
new file mode 100644
index 0000000000..bd254029d4
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js
@@ -0,0 +1,7 @@
+import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils';
+import { translator } from '../../v3/handlers/w/endnoteReference/endnoteReference-translator.js';
+
+/**
+ * @type {import("./docxImporter").NodeHandlerEntry}
+ */
+export const endnoteReferenceHandlerEntity = generateV2HandlerEntity('endnoteReferenceHandler', translator);
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js
index 0a9d6637eb..2710d1b6c0 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js
@@ -134,6 +134,26 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) {
}
}
+/**
+ * Scan a single OOXML part and return a fresh `w:id โ internal UUID` map.
+ *
+ * The scan assumes the top-level element is a document / hdr / ftr / footnotes
+ * / endnotes root. Returns an empty map when the part is absent or malformed.
+ *
+ * @param {object | undefined} part Parsed OOXML part (from SuperConverter).
+ * @param {{ replacements?: TrackChangesReplacements }} [options]
+ * @returns {Map}
+ */
+function buildTrackedChangeIdMapForPart(part, options = {}) {
+ const root = part?.elements?.[0];
+ if (!root?.elements) return new Map();
+
+ const replacements = options.replacements === 'independent' ? 'independent' : 'paired';
+ const idMap = new Map();
+ walkElements(root.elements, idMap, { lastTrackedChange: null, replacements });
+ return idMap;
+}
+
/**
* Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning
* `word/document.xml`.
@@ -153,12 +173,41 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) {
* @returns {Map} Word `w:id` โ internal UUID
*/
export function buildTrackedChangeIdMap(docx, options = {}) {
- const body = docx?.['word/document.xml']?.elements?.[0];
- if (!body?.elements) return new Map();
+ return buildTrackedChangeIdMapForPart(docx?.['word/document.xml'], options);
+}
- const replacements = options.replacements === 'independent' ? 'independent' : 'paired';
- const idMap = new Map();
- walkElements(body.elements, idMap, { lastTrackedChange: null, replacements });
+/**
+ * Builds per-part `w:id โ internal UUID` maps for every revision-capable
+ * content part in the DOCX package.
+ *
+ * Word revision IDs are not globally unique across parts, so each part keeps
+ * its own isolated `w:id` namespace.
+ *
+ * @param {Record | null | undefined} docx
+ * @param {{ replacements?: TrackChangesReplacements }} [options]
+ * @returns {Map>}
+ */
+export function buildTrackedChangeIdMapsByPart(docx, options = {}) {
+ /** @type {Map>} */
+ const mapsByPart = new Map();
+ if (!docx || typeof docx !== 'object') return mapsByPart;
- return idMap;
+ /** @type {Record} */
+ const parts = /** @type {Record} */ (docx);
+
+ mapsByPart.set('word/document.xml', buildTrackedChangeIdMapForPart(parts['word/document.xml'], options));
+
+ for (const partPath of Object.keys(parts)) {
+ if (!/^word\/(?:header|footer)\d+\.xml$/.test(partPath)) continue;
+ mapsByPart.set(partPath, buildTrackedChangeIdMapForPart(parts[partPath], options));
+ }
+
+ if (parts['word/footnotes.xml']) {
+ mapsByPart.set('word/footnotes.xml', buildTrackedChangeIdMapForPart(parts['word/footnotes.xml'], options));
+ }
+ if (parts['word/endnotes.xml']) {
+ mapsByPart.set('word/endnotes.xml', buildTrackedChangeIdMapForPart(parts['word/endnotes.xml'], options));
+ }
+
+ return mapsByPart;
}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js
index 806ee8de63..6842cfcfd8 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js';
+import { buildTrackedChangeIdMap, buildTrackedChangeIdMapsByPart } from './trackedChangeIdMapper.js';
// ---------------------------------------------------------------------------
// Test helpers
@@ -291,3 +291,93 @@ describe('buildTrackedChangeIdMap', () => {
});
});
});
+
+function createDocxWithParts(partMap) {
+ const docx = {};
+ for (const [path, bodyChildren] of Object.entries(partMap)) {
+ const rootName = path.includes('/footnotes.xml')
+ ? 'w:footnotes'
+ : path.includes('/endnotes.xml')
+ ? 'w:endnotes'
+ : path.includes('/header')
+ ? 'w:hdr'
+ : path.includes('/footer')
+ ? 'w:ftr'
+ : 'w:document';
+ docx[path] = {
+ elements: [{ name: rootName, elements: bodyChildren }],
+ };
+ }
+ return docx;
+}
+
+describe('buildTrackedChangeIdMapsByPart', () => {
+ it('returns an empty Map when docx is missing or empty', () => {
+ expect(buildTrackedChangeIdMapsByPart(null).size).toBe(0);
+ expect(buildTrackedChangeIdMapsByPart(undefined).size).toBe(0);
+ });
+
+ it('always includes a body map at `word/document.xml`', () => {
+ const docx = createDocxWithParts({ 'word/document.xml': [paragraph(trackedChange('w:ins', '1'))] });
+ const maps = buildTrackedChangeIdMapsByPart(docx);
+ expect(maps.has('word/document.xml')).toBe(true);
+ expect(maps.get('word/document.xml').get('1')).toBeTruthy();
+ });
+
+ it('scans every header and footer part present in the package', () => {
+ const docx = createDocxWithParts({
+ 'word/document.xml': [],
+ 'word/header1.xml': [paragraph(wordDelete('100', 'gone'), wordInsert('101', 'new'))],
+ 'word/footer2.xml': [paragraph(trackedChange('w:ins', '200'))],
+ });
+ const maps = buildTrackedChangeIdMapsByPart(docx);
+
+ const headerMap = maps.get('word/header1.xml');
+ expect(headerMap).toBeDefined();
+ expect(headerMap.get('100')).toBeTruthy();
+ expect(headerMap.get('100')).toBe(headerMap.get('101'));
+
+ const footerMap = maps.get('word/footer2.xml');
+ expect(footerMap).toBeDefined();
+ expect(footerMap.get('200')).toBeTruthy();
+ });
+
+ it('keeps per-part id spaces isolated when the same w:id appears in multiple parts', () => {
+ const docx = createDocxWithParts({
+ 'word/document.xml': [paragraph(trackedChange('w:ins', 'shared'))],
+ 'word/header1.xml': [paragraph(trackedChange('w:ins', 'shared'))],
+ });
+ const maps = buildTrackedChangeIdMapsByPart(docx);
+ expect(maps.get('word/document.xml').get('shared')).not.toBe(maps.get('word/header1.xml').get('shared'));
+ });
+
+ it('includes footnotes and endnotes parts when present', () => {
+ const docx = createDocxWithParts({
+ 'word/document.xml': [],
+ 'word/footnotes.xml': [paragraph(wordDelete('300', 'x'), wordInsert('301', 'y'))],
+ 'word/endnotes.xml': [paragraph(trackedChange('w:ins', '400'))],
+ });
+ const maps = buildTrackedChangeIdMapsByPart(docx);
+ expect(maps.get('word/footnotes.xml').get('300')).toBe(maps.get('word/footnotes.xml').get('301'));
+ expect(maps.get('word/endnotes.xml').get('400')).toBeTruthy();
+ });
+
+ it('passes replacement mode options through to each part scan', () => {
+ const docx = createDocxWithParts({
+ 'word/document.xml': [],
+ 'word/header1.xml': [paragraph(wordDelete('500', 'gone'), wordInsert('501', 'new'))],
+ });
+ const maps = buildTrackedChangeIdMapsByPart(docx, { replacements: 'independent' });
+
+ expect(maps.get('word/header1.xml').get('500')).not.toBe(maps.get('word/header1.xml').get('501'));
+ });
+
+ it('does not introduce unrelated parts into the map', () => {
+ const docx = createDocxWithParts({
+ 'word/document.xml': [],
+ 'word/styles.xml': [],
+ });
+ const maps = buildTrackedChangeIdMapsByPart(docx);
+ expect(maps.has('word/styles.xml')).toBe(false);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js
index da72ff0a59..9076bb723d 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js
@@ -118,6 +118,7 @@ import { translator as w_personalCompose_translator } from './w/personalCompose/
import { translator as w_personalReply_translator } from './w/personalReply/personalReply-translator.js';
import { translator as w_position_translator } from './w/position/position-translator.js';
import { translator as w_pPr_translator } from './w/pPr/pPr-translator.js';
+import { translator as w_pPrChange_translator } from './w/pPrChange/pPrChange-translator.js';
import { translator as w_pStyle_translator } from './w/pStyle/pStyle-translator.js';
import { translator as w_permEnd_translator } from './w/perm-end/perm-end-translator.js';
import { translator as w_permStart_translator } from './w/perm-start/perm-start-translator.js';
@@ -324,6 +325,7 @@ const translatorList = Array.from(
w_personalReply_translator,
w_position_translator,
w_pPr_translator,
+ w_pPrChange_translator,
w_pStyle_translator,
w_permStart_translator,
w_permEnd_translator,
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js
index 7bb530a8ab..6f255708cd 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js
@@ -57,6 +57,9 @@ function encode(params) {
function decode(params) {
const { node } = params;
const { drawingContent } = node.attrs;
+ if (!hasValidDrawingContent(drawingContent)) {
+ return null;
+ }
// Handle modern DrawingML content (existing logic)
const drawing = {
@@ -70,9 +73,19 @@ function decode(params) {
elements: [drawing],
};
+ const fallback = {
+ name: 'mc:Fallback',
+ elements: [
+ {
+ name: 'w:drawing',
+ elements: carbonCopy(drawing.elements || []),
+ },
+ ],
+ };
+
return {
name: 'mc:AlternateContent',
- elements: [choice],
+ elements: [choice, fallback],
};
}
@@ -103,10 +116,11 @@ export function selectAlternateContentElements(node) {
const requiresAttr = choice?.attributes?.Requires || choice?.attributes?.requires;
if (!requiresAttr) return false;
- return requiresAttr
- .split(/\s+/)
- .filter(Boolean)
- .some((namespace) => SUPPORTED_ALTERNATE_CONTENT_REQUIRES.has(namespace));
+ const requiredNamespaces = requiresAttr.split(/\s+/).filter(Boolean);
+ if (requiredNamespaces.length === 0) return false;
+
+ // ECMA-376 mc:Choice requires ALL listed namespaces to be understood.
+ return requiredNamespaces.every((namespace) => SUPPORTED_ALTERNATE_CONTENT_REQUIRES.has(namespace));
});
const branch = supportedChoice || fallback || choices[0] || null;
@@ -139,3 +153,18 @@ function buildPath(existingPath = [], node, branch) {
if (branch) path.push(branch);
return path;
}
+
+/**
+ * @param {unknown} drawingContent
+ * @returns {boolean}
+ */
+function hasValidDrawingContent(drawingContent) {
+ const drawingChildren = drawingContent?.elements;
+ if (!Array.isArray(drawingChildren) || drawingChildren.length === 0) {
+ return false;
+ }
+
+ return drawingChildren.some(
+ (child) => child && typeof child === 'object' && (child.name === 'wp:inline' || child.name === 'wp:anchor'),
+ );
+}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js
index abbef78c25..f4312d88f5 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js
@@ -125,7 +125,7 @@ describe('mc:AltermateContent translator', () => {
});
describe('decode', () => {
- it('returns mc:AlternateContent structure with w:drawing inside mc:Choice', () => {
+ it('returns mc:AlternateContent with valid choice and fallback drawing branches', () => {
const params = {
node: {
attrs: {
@@ -149,30 +149,25 @@ describe('mc:AltermateContent translator', () => {
},
],
},
- ],
- });
- });
-
- it('handles empty drawingContent gracefully', () => {
- const params = { node: { attrs: {} } };
- const result = translator.decode(params);
-
- expect(result).toEqual({
- name: 'mc:AlternateContent',
- elements: [
{
- name: 'mc:Choice',
- attributes: { Requires: 'wps' },
+ name: 'mc:Fallback',
elements: [
{
name: 'w:drawing',
- elements: [],
+ elements: [{ name: 'wp:inline' }],
},
],
},
],
});
});
+
+ it('returns null when drawingContent is missing/invalid', () => {
+ const params = { node: { attrs: {} } };
+ const result = translator.decode(params);
+
+ expect(result).toBeNull();
+ });
});
});
@@ -183,10 +178,10 @@ describe('selectAlternateContentElements', () => {
expect(SUPPORTED_ALTERNATE_CONTENT_REQUIRES.has('w16sdtfl')).toBe(true);
});
- it('selects supported choice when namespace matches set', () => {
+ it('selects supported choice when all required namespaces are supported', () => {
const choice = {
name: 'mc:Choice',
- attributes: { Requires: 'foo wps bar' },
+ attributes: { Requires: 'wps w14' },
elements: [{ name: 'w:r' }],
};
const node = {
@@ -198,6 +193,22 @@ describe('selectAlternateContentElements', () => {
expect(elements).toEqual(choice.elements);
});
+ it('falls back when mc:Choice requires an unsupported namespace', () => {
+ const fallback = { name: 'mc:Fallback', elements: [{ name: 'w:p' }] };
+ const mixedChoice = {
+ name: 'mc:Choice',
+ attributes: { Requires: 'wps unknownNs' },
+ elements: [{ name: 'w:r' }],
+ };
+ const node = {
+ elements: [mixedChoice, fallback],
+ };
+
+ const { branch, elements } = selectAlternateContentElements(node);
+ expect(branch).toBe(fallback);
+ expect(elements).toEqual(fallback.elements);
+ });
+
it('returns fallback when no choice is supported', () => {
const fallback = { name: 'mc:Fallback', elements: [{ name: 'w:p' }] };
const node = {
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js
index 343333bcc5..3f6419f00b 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js
@@ -108,16 +108,29 @@ function parseDisplay(instruction) {
}
/**
- * Extracts resolved text from processed content.
+ * Extracts resolved text from processed content. Walks recursively because the
+ * cached result between w:fldChar separate/end is typically wrapped in a `run`
+ * node (or deeper: run -> text with marks), so a top-level text-only filter
+ * misses the field's display text.
* @param {Array} content
* @returns {string}
*/
function extractResolvedText(content) {
if (!Array.isArray(content)) return '';
- return content
- .filter((n) => n.type === 'text')
- .map((n) => n.text || '')
- .join('');
+ let out = '';
+ /** @param {Array} nodes */
+ const walk = (nodes) => {
+ for (const node of nodes) {
+ if (!node) continue;
+ if (node.type === 'text') {
+ out += node.text || '';
+ } else if (Array.isArray(node.content)) {
+ walk(node.content);
+ }
+ }
+ };
+ walk(content);
+ return out;
}
/** @type {import('@translator').NodeTranslatorConfig} */
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js
index 080024cc7c..9eb61b2d8a 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { exportSchemaToJson } from '../../../../exporter.js';
import { translator as runTranslator } from '../../w/r/r-translator.js';
+import { translator as crossReferenceTranslator } from './crossReference-translator.js';
const CROSS_REFERENCE_INSTRUCTION = 'REF bm-target \\h';
@@ -61,3 +62,79 @@ describe('crossReference export routing', () => {
expect(exportedRuns.some((node) => hasFieldCharType(node, 'end'))).toBe(true);
});
});
+
+describe('crossReference import resolvedText extraction (SD-2495)', () => {
+ // Mirrors the Brillio-style REF cached payload: cached text lives inside a w:r
+ // wrapper, so a top-level-only `n.type === 'text'` filter returns empty. The
+ // recursive walk must descend through run wrappers to find the display text.
+ const buildRun = (innerElements) => ({
+ type: 'element',
+ name: 'w:r',
+ elements: [{ type: 'element', name: 'w:rPr', elements: [{ type: 'element', name: 'w:i' }] }, ...innerElements],
+ });
+
+ const buildSdCrossReference = (instr, cachedRuns) => ({
+ name: 'sd:crossReference',
+ type: 'element',
+ attributes: { instruction: instr, fieldType: 'REF' },
+ elements: cachedRuns,
+ });
+
+ it('extracts cached text from runs wrapped around a w:t (Brillio shape)', () => {
+ const xmlNode = buildSdCrossReference('REF _Ref506192326 \\w \\h', [
+ buildRun([]), // empty formatting-carrier run
+ buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: '15' }] }]),
+ ]);
+ const encoded = crossReferenceTranslator.encode({
+ nodes: [xmlNode],
+ nodeListHandler: {
+ handler: ({ nodes }) => {
+ // Simulate w:r translator wrapping content in SuperDoc run nodes
+ return nodes
+ .map((run) => {
+ const textEl = run.elements?.find((el) => el?.name === 'w:t');
+ if (!textEl) return null;
+ const text = (textEl.elements || [])
+ .map((child) => (typeof child?.text === 'string' ? child.text : ''))
+ .join('');
+ if (!text) return null;
+ return { type: 'run', attrs: {}, content: [{ type: 'text', text }] };
+ })
+ .filter(Boolean);
+ },
+ },
+ });
+
+ expect(encoded.type).toBe('crossReference');
+ expect(encoded.attrs.target).toBe('_Ref506192326');
+ expect(encoded.attrs.resolvedText).toBe('15');
+ expect(encoded.attrs.display).toBe('numberFullContext');
+ });
+
+ it('concatenates cached text across multiple run wrappers', () => {
+ const xmlNode = buildSdCrossReference('REF _RefABC \\h', [
+ buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: '4(b' }] }]),
+ buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: ')(2)' }] }]),
+ ]);
+ const encoded = crossReferenceTranslator.encode({
+ nodes: [xmlNode],
+ nodeListHandler: {
+ handler: ({ nodes }) =>
+ nodes.map((run) => ({
+ type: 'run',
+ attrs: {},
+ content: [
+ {
+ type: 'text',
+ text: (run.elements?.find((el) => el?.name === 'w:t')?.elements ?? [])
+ .map((c) => c.text || '')
+ .join(''),
+ },
+ ],
+ })),
+ },
+ });
+
+ expect(encoded.attrs.resolvedText).toBe('4(b)(2)');
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js
index e2e8a32421..137d60aad8 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js
@@ -23,14 +23,17 @@ const validXmlAttributes = [
* @returns {import('@translator').SCEncoderResult}
*/
const encode = (params, encodedAttrs = {}) => {
- const { nodeListHandler, extraParams = {}, converter } = params;
+ const { nodeListHandler, extraParams = {}, converter, filename } = params;
const { node } = extraParams;
// Preserve the original OOXML w:id for round-trip export fidelity.
// The internal id is remapped to a shared UUID for replacement pairing.
const originalWordId = encodedAttrs.id;
- if (originalWordId && converter?.trackedChangeIdMap?.has(originalWordId)) {
- encodedAttrs.id = converter.trackedChangeIdMap.get(originalWordId);
+ const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml';
+ const trackedChangeIdMap =
+ converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null;
+ if (originalWordId && trackedChangeIdMap?.has(originalWordId)) {
+ encodedAttrs.id = trackedChangeIdMap.get(originalWordId);
}
encodedAttrs.sourceId = originalWordId || '';
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js
index 12cbcc97df..964d99d75a 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js
@@ -30,7 +30,7 @@ describe('w:del translator', () => {
describe('encode', () => {
const mockNode = { elements: [{ text: 'deleted text' }] };
- function encodeWith({ converter, id = '123' } = {}) {
+ function encodeWith({ converter, id = '123', filename } = {}) {
const mockSubNodes = [{ content: [{ type: 'text', text: 'deleted text' }] }];
const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) };
@@ -46,6 +46,7 @@ describe('w:del translator', () => {
nodeListHandler: mockNodeListHandler,
extraParams: { node: mockNode },
converter,
+ filename,
path: [],
},
{ ...encodedAttrs },
@@ -89,6 +90,19 @@ describe('w:del translator', () => {
expect(attrs.id).toBe('shared-uuid-abc');
expect(attrs.sourceId).toBe('123');
});
+
+ it('prefers the per-part trackedChangeIdMapsByPart entry when filename is provided', () => {
+ const converter = {
+ trackedChangeIdMap: new Map([['123', 'body-uuid']]),
+ trackedChangeIdMapsByPart: new Map([['word/footnotes.xml', new Map([['123', 'footnote-uuid']])]]),
+ };
+
+ const result = encodeWith({ converter, filename: 'footnotes.xml' });
+ const attrs = getMarkAttrs(result);
+
+ expect(attrs.id).toBe('footnote-uuid');
+ expect(attrs.sourceId).toBe('123');
+ });
});
describe('decode', () => {
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js
index 0ed46c4834..9ececf73e7 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js
@@ -23,14 +23,17 @@ const validXmlAttributes = [
* @returns {import('@translator').SCEncoderResult}
*/
const encode = (params, encodedAttrs = {}) => {
- const { nodeListHandler, extraParams = {}, converter } = params;
+ const { nodeListHandler, extraParams = {}, converter, filename } = params;
const { node } = extraParams;
// Preserve the original OOXML w:id for round-trip export fidelity.
// The internal id is remapped to a shared UUID for replacement pairing.
const originalWordId = encodedAttrs.id;
- if (originalWordId && converter?.trackedChangeIdMap?.has(originalWordId)) {
- encodedAttrs.id = converter.trackedChangeIdMap.get(originalWordId);
+ const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml';
+ const trackedChangeIdMap =
+ converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null;
+ if (originalWordId && trackedChangeIdMap?.has(originalWordId)) {
+ encodedAttrs.id = trackedChangeIdMap.get(originalWordId);
}
encodedAttrs.sourceId = originalWordId || '';
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js
index 113d0680b6..be99a7c505 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js
@@ -29,7 +29,7 @@ describe('w:ins translator', () => {
describe('encode', () => {
const mockNode = { elements: [{ text: 'added text' }] };
- function encodeWith({ converter, id = '123' } = {}) {
+ function encodeWith({ converter, id = '123', filename } = {}) {
const mockSubNodes = [{ content: [{ type: 'text', text: 'added text' }] }];
const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) };
@@ -45,6 +45,7 @@ describe('w:ins translator', () => {
nodeListHandler: mockNodeListHandler,
extraParams: { node: mockNode },
converter,
+ filename,
path: [],
},
{ ...encodedAttrs },
@@ -97,6 +98,19 @@ describe('w:ins translator', () => {
expect(attrs.id).toBe('shared-uuid-abc');
expect(attrs.sourceId).toBe('123');
});
+
+ it('prefers the per-part trackedChangeIdMapsByPart entry when filename is provided', () => {
+ const converter = {
+ trackedChangeIdMap: new Map([['123', 'body-uuid']]),
+ trackedChangeIdMapsByPart: new Map([['word/header1.xml', new Map([['123', 'header-uuid']])]]),
+ };
+
+ const { result } = encodeWith({ converter, filename: 'header1.xml' });
+ const attrs = getMarkAttrs(result);
+
+ expect(attrs.id).toBe('header-uuid');
+ expect(attrs.sourceId).toBe('123');
+ });
});
describe('decode', () => {
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js
index c437589c08..886f5f923b 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js
@@ -43,7 +43,13 @@ export function generateParagraphProperties(params) {
elements: [],
};
}
- pPr.elements.push(sectPr);
+ // Per CT_PPr, sectPr must precede pPrChange.
+ const pPrChangeIdx = pPr.elements.findIndex((el) => el.name === 'w:pPrChange');
+ if (pPrChangeIdx === -1) {
+ pPr.elements.push(sectPr);
+ } else {
+ pPr.elements.splice(pPrChangeIdx, 0, sectPr);
+ }
}
return pPr;
}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js
index 57cd80e186..b4d098c543 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js
@@ -68,6 +68,19 @@ describe('generateParagraphProperties', () => {
expect(result.elements[1]).toBe(sectPr);
});
+ it('inserts sectPr before pPrChange to satisfy CT_PPr ordering', () => {
+ const jc = { name: 'w:jc' };
+ const pPrChange = { name: 'w:pPrChange' };
+ const sectPr = { name: 'w:sectPr' };
+ const decoded = { type: 'element', name: 'w:pPr', elements: [jc, pPrChange] };
+ wPPrNodeTranslator.decode.mockReturnValue(decoded);
+ const node = { type: 'paragraph', attrs: { paragraphProperties: { sectPr } } };
+
+ const result = generateParagraphProperties({ node });
+
+ expect(result.elements).toEqual([jc, sectPr, pPrChange]);
+ });
+
it('creates paragraph properties when decoder returns nothing but sectPr exists', () => {
wPPrNodeTranslator.decode.mockReturnValue(undefined);
const sectPr = { name: 'w:sectPr', elements: [] };
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-base-translators.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-base-translators.js
new file mode 100644
index 0000000000..6da4587d16
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-base-translators.js
@@ -0,0 +1,75 @@
+// @ts-check
+import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent';
+import { translator as wAdjustRightIndTranslator } from '../adjustRightInd';
+import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE';
+import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN';
+import { translator as wBidiTranslator } from '../bidi';
+import { translator as wCnfStyleTranslator } from '../cnfStyle';
+import { translator as wContextualSpacingTranslator } from '../contextualSpacing';
+import { translator as wDivIdTranslator } from '../divId';
+import { translator as wFramePrTranslator } from '../framePr';
+import { translator as wIndTranslator } from '../ind';
+import { translator as wJcTranslatorTranslator } from '../jc';
+import { translator as wKeepLinesTranslator } from '../keepLines';
+import { translator as wKeepNextTranslator } from '../keepNext';
+import { translator as wKinsokuTranslator } from '../kinsoku';
+import { translator as wMirrorIndentsTranslator } from '../mirrorIndents';
+import { translator as wNumPrTranslator } from '../numPr';
+import { translator as wOutlineLvlTranslator } from '../outlineLvl';
+import { translator as wOverflowPunctTranslator } from '../overflowPunct';
+import { translator as wPBdrTranslator } from '../pBdr';
+import { translator as wPStyleTranslator } from '../pStyle';
+import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore';
+import { translator as wShdTranslator } from '../shd';
+import { translator as wSnapToGridTranslator } from '../snapToGrid';
+import { translator as wSpacingTranslator } from '../spacing';
+import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens';
+import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers';
+import { translator as wSuppressOverlapTranslator } from '../suppressOverlap';
+import { translator as wTabsTranslator } from '../tabs';
+import { translator as wTextAlignmentTranslator } from '../textAlignment';
+import { translator as wTextDirectionTranslator } from '../textDirection';
+import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap';
+import { translator as wTopLinePunctTranslator } from '../topLinePunct';
+import { translator as wWidowControlTranslator } from '../widowControl';
+import { translator as wWordWrapTranslator } from '../wordWrap';
+import { translator as wRPrTranslator } from '../rpr';
+
+/** @type {import('@translator').NodeTranslator[]} */
+export const basePropertyTranslators = [
+ mcAlternateContentTranslator,
+ wAdjustRightIndTranslator,
+ wAutoSpaceDETranslator,
+ wAutoSpaceDNTranslator,
+ wBidiTranslator,
+ wCnfStyleTranslator,
+ wContextualSpacingTranslator,
+ wDivIdTranslator,
+ wFramePrTranslator,
+ wIndTranslator,
+ wJcTranslatorTranslator,
+ wKeepLinesTranslator,
+ wKeepNextTranslator,
+ wKinsokuTranslator,
+ wMirrorIndentsTranslator,
+ wNumPrTranslator,
+ wOutlineLvlTranslator,
+ wOverflowPunctTranslator,
+ wPBdrTranslator,
+ wPStyleTranslator,
+ wPageBreakBeforeTranslator,
+ wShdTranslator,
+ wSnapToGridTranslator,
+ wSpacingTranslator,
+ wSuppressAutoHyphensTranslator,
+ wSuppressLineNumbersTranslator,
+ wSuppressOverlapTranslator,
+ wTabsTranslator,
+ wTextAlignmentTranslator,
+ wTextDirectionTranslator,
+ wTextboxTightWrapTranslator,
+ wTopLinePunctTranslator,
+ wWidowControlTranslator,
+ wWordWrapTranslator,
+ wRPrTranslator,
+];
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js
index 212fe10601..d1d87a92fa 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js
@@ -1,82 +1,11 @@
// @ts-check
import { NodeTranslator } from '@translator';
import { createNestedPropertiesTranslator } from '@converter/v3/handlers/utils.js';
-import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent';
-import { translator as wAdjustRightIndTranslator } from '../adjustRightInd';
-import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE';
-import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN';
-import { translator as wBidiTranslator } from '../bidi';
-import { translator as wCnfStyleTranslator } from '../cnfStyle';
-import { translator as wContextualSpacingTranslator } from '../contextualSpacing';
-import { translator as wDivIdTranslator } from '../divId';
-import { translator as wFramePrTranslator } from '../framePr';
-import { translator as wIndTranslator } from '../ind';
-import { translator as wJcTranslatorTranslator } from '../jc';
-import { translator as wKeepLinesTranslator } from '../keepLines';
-import { translator as wKeepNextTranslator } from '../keepNext';
-import { translator as wKinsokuTranslator } from '../kinsoku';
-import { translator as wMirrorIndentsTranslator } from '../mirrorIndents';
-import { translator as wNumPrTranslator } from '../numPr';
-import { translator as wOutlineLvlTranslator } from '../outlineLvl';
-import { translator as wOverflowPunctTranslator } from '../overflowPunct';
-import { translator as wPBdrTranslator } from '../pBdr';
-import { translator as wPStyleTranslator } from '../pStyle';
-import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore';
-import { translator as wShdTranslator } from '../shd';
-import { translator as wSnapToGridTranslator } from '../snapToGrid';
-import { translator as wSpacingTranslator } from '../spacing';
-import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens';
-import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers';
-import { translator as wSuppressOverlapTranslator } from '../suppressOverlap';
-import { translator as wTabsTranslator } from '../tabs';
-import { translator as wTextAlignmentTranslator } from '../textAlignment';
-import { translator as wTextDirectionTranslator } from '../textDirection';
-import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap';
-import { translator as wTopLinePunctTranslator } from '../topLinePunct';
-import { translator as wWidowControlTranslator } from '../widowControl';
-import { translator as wWordWrapTranslator } from '../wordWrap';
-import { translator as wRPrTranslator } from '../rpr';
+import { basePropertyTranslators } from './pPr-base-translators.js';
+import { translator as wPPrChangeTranslator } from '../pPrChange';
-// Property translators for w:pPr child elements
-// Each translator handles a specific property of the paragraph properties
/** @type {import('@translator').NodeTranslator[]} */
-const propertyTranslators = [
- mcAlternateContentTranslator,
- wAdjustRightIndTranslator,
- wAutoSpaceDETranslator,
- wAutoSpaceDNTranslator,
- wBidiTranslator,
- wCnfStyleTranslator,
- wContextualSpacingTranslator,
- wDivIdTranslator,
- wFramePrTranslator,
- wIndTranslator,
- wJcTranslatorTranslator,
- wKeepLinesTranslator,
- wKeepNextTranslator,
- wKinsokuTranslator,
- wMirrorIndentsTranslator,
- wNumPrTranslator,
- wOutlineLvlTranslator,
- wOverflowPunctTranslator,
- wPBdrTranslator,
- wPStyleTranslator,
- wPageBreakBeforeTranslator,
- wShdTranslator,
- wSnapToGridTranslator,
- wSpacingTranslator,
- wSuppressAutoHyphensTranslator,
- wSuppressLineNumbersTranslator,
- wSuppressOverlapTranslator,
- wTabsTranslator,
- wTextAlignmentTranslator,
- wTextDirectionTranslator,
- wTextboxTightWrapTranslator,
- wTopLinePunctTranslator,
- wWidowControlTranslator,
- wWordWrapTranslator,
- wRPrTranslator,
-];
+const propertyTranslators = [...basePropertyTranslators, wPPrChangeTranslator];
/**
* The NodeTranslator instance for the w:pPr element.
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js
index 67a2a6a9fb..a4abd11584 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js
@@ -359,4 +359,58 @@ describe('w:pPr translator', () => {
expect(encodedResult).toEqual(initialParagraphProperties);
});
});
+
+ describe('pPrChange integration (SD-2417 regression guard)', () => {
+ it('routes w:pPrChange through the pPr encode pipeline', () => {
+ const xmlNode = {
+ name: 'w:pPr',
+ elements: [
+ { name: 'w:jc', attributes: { 'w:val': 'center' } },
+ {
+ name: 'w:pPrChange',
+ attributes: {
+ 'w:id': '0',
+ 'w:author': 'Regression Guard',
+ 'w:date': '2026-01-01T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:pPr',
+ elements: [{ name: 'w:jc', attributes: { 'w:val': 'left' } }],
+ },
+ ],
+ },
+ ],
+ };
+
+ const encoded = translator.encode({ nodes: [xmlNode] });
+
+ expect(encoded.justification).toBe('center');
+ expect(encoded.change).toEqual({
+ id: '0',
+ author: 'Regression Guard',
+ date: '2026-01-01T00:00:00Z',
+ paragraphProperties: { justification: 'left' },
+ });
+ });
+
+ it('round-trips a paragraph whose pPr carries a pPrChange', () => {
+ const initialParagraphProperties = {
+ justification: 'center',
+ change: {
+ id: '0',
+ author: 'Regression Guard',
+ date: '2026-01-01T00:00:00Z',
+ paragraphProperties: { justification: 'left' },
+ },
+ };
+
+ const decoded = translator.decode({
+ node: { attrs: { paragraphProperties: initialParagraphProperties } },
+ });
+ const encoded = translator.encode({ nodes: [decoded] });
+
+ expect(encoded).toEqual(initialParagraphProperties);
+ });
+ });
});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/index.js
new file mode 100644
index 0000000000..5bfa4e4b24
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/index.js
@@ -0,0 +1 @@
+export * from './pPrChange-translator.js';
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js
new file mode 100644
index 0000000000..dc716b0a10
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js
@@ -0,0 +1,99 @@
+import { NodeTranslator } from '@translator';
+import { carbonCopy } from '@core/utilities/carbonCopy.js';
+import { createNestedPropertiesTranslator, createAttributeHandler } from '@converter/v3/handlers/utils.js';
+import { basePropertyTranslators } from '../pPr/pPr-base-translators.js';
+
+const pPrTranslator = NodeTranslator.from(
+ createNestedPropertiesTranslator('w:pPr', 'paragraphProperties', basePropertyTranslators),
+);
+
+const ATTRIBUTE_HANDLERS = [
+ createAttributeHandler('w:id'),
+ createAttributeHandler('w:author'),
+ createAttributeHandler('w:date'),
+];
+
+function getSectPr(pPrNode) {
+ const sectPr = pPrNode?.elements?.find((el) => el.name === 'w:sectPr');
+ return sectPr ? carbonCopy(sectPr) : undefined;
+}
+
+/**
+ * The NodeTranslator instance for the w:pPrChange element.
+ * @type {import('@translator').NodeTranslator}
+ */
+export const translator = NodeTranslator.from({
+ xmlName: 'w:pPrChange',
+ sdNodeOrKeyName: 'change',
+ type: NodeTranslator.translatorTypes.NODE,
+ attributes: ATTRIBUTE_HANDLERS,
+ encode: (params, encodedAttrs = {}) => {
+ const changeNode = params.nodes[0];
+ const pPrNode = changeNode?.elements?.find((el) => el.name === 'w:pPr');
+
+ let paragraphProperties = pPrNode ? (pPrTranslator.encode({ ...params, nodes: [pPrNode] }) ?? {}) : undefined;
+ const sectPr = getSectPr(pPrNode);
+ if (sectPr) {
+ paragraphProperties = {
+ ...(paragraphProperties || {}),
+ sectPr,
+ };
+ }
+
+ const result = {
+ ...encodedAttrs,
+ ...(paragraphProperties ? { paragraphProperties } : {}),
+ };
+
+ return Object.keys(result).length ? result : undefined;
+ },
+ decode: function (params) {
+ const change = params.node?.attrs?.change;
+ if (!change || typeof change !== 'object') return undefined;
+
+ const decodedAttrs = this.decodeAttributes({
+ node: { ...params.node, attrs: change },
+ });
+ const hasParagraphProperties = Object.prototype.hasOwnProperty.call(change, 'paragraphProperties');
+ const paragraphProperties = hasParagraphProperties ? change.paragraphProperties : undefined;
+
+ let pPrNode =
+ paragraphProperties && typeof paragraphProperties === 'object'
+ ? pPrTranslator.decode({
+ ...params,
+ node: { ...params.node, attrs: { paragraphProperties } },
+ })
+ : undefined;
+
+ const sectPr = paragraphProperties?.sectPr ? carbonCopy(paragraphProperties.sectPr) : undefined;
+ if (sectPr) {
+ if (!pPrNode) {
+ pPrNode = {
+ name: 'w:pPr',
+ type: 'element',
+ attributes: {},
+ elements: [],
+ };
+ }
+ pPrNode.elements = [...(pPrNode.elements || []), sectPr];
+ }
+
+ if (!pPrNode && hasParagraphProperties) {
+ pPrNode = {
+ name: 'w:pPr',
+ type: 'element',
+ attributes: {},
+ elements: [],
+ };
+ }
+
+ if (!pPrNode && !Object.keys(decodedAttrs).length) return undefined;
+
+ return {
+ name: 'w:pPrChange',
+ type: 'element',
+ attributes: decodedAttrs,
+ elements: pPrNode ? [pPrNode] : [],
+ };
+ },
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js
new file mode 100644
index 0000000000..94c042b3c0
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js
@@ -0,0 +1,415 @@
+vi.mock('../../../../exporter.js', () => {
+ const processOutputMarks = vi.fn((marks) => marks || []);
+ const generateRunProps = vi.fn((processedMarks) => ({
+ name: 'w:rPr',
+ elements: [],
+ }));
+ return { processOutputMarks, generateRunProps };
+});
+
+import { describe, it, expect } from 'vitest';
+import { translator } from './pPrChange-translator.js';
+import { NodeTranslator } from '@translator';
+
+describe('w:pPrChange translator', () => {
+ describe('config', () => {
+ it('should have correct properties', () => {
+ expect(translator.xmlName).toBe('w:pPrChange');
+ expect(translator.sdNodeOrKeyName).toBe('change');
+ expect(translator).toBeInstanceOf(NodeTranslator);
+ });
+ });
+
+ describe('encode', () => {
+ it('should encode a w:pPrChange element with attributes and nested w:pPr', () => {
+ const xmlNode = {
+ name: 'w:pPrChange',
+ attributes: {
+ 'w:id': '0',
+ 'w:author': 'Luccas Correa',
+ 'w:date': '2026-04-02T11:25:00Z',
+ },
+ elements: [
+ {
+ name: 'w:pPr',
+ elements: [
+ { name: 'w:pStyle', attributes: { 'w:val': 'ListParagraph' } },
+ {
+ name: 'w:numPr',
+ elements: [{ name: 'w:numId', attributes: { 'w:val': '1' } }],
+ },
+ { name: 'w:ind', attributes: { 'w:hanging': '360' } },
+ ],
+ },
+ ],
+ };
+
+ const result = translator.encode({ nodes: [xmlNode] });
+
+ expect(result).toEqual({
+ id: '0',
+ author: 'Luccas Correa',
+ date: '2026-04-02T11:25:00Z',
+ paragraphProperties: {
+ styleId: 'ListParagraph',
+ numberingProperties: { numId: 1 },
+ indent: { hanging: 360 },
+ },
+ });
+ });
+
+ it('should encode a w:pPrChange with an empty w:pPr as an empty paragraphProperties object', () => {
+ const xmlNode = {
+ name: 'w:pPrChange',
+ attributes: {
+ 'w:id': '5',
+ 'w:author': 'Test Author',
+ 'w:date': '2026-01-01T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:pPr',
+ elements: [],
+ },
+ ],
+ };
+
+ const result = translator.encode({ nodes: [xmlNode] });
+
+ expect(result).toEqual({
+ id: '5',
+ author: 'Test Author',
+ date: '2026-01-01T00:00:00Z',
+ paragraphProperties: {},
+ });
+ });
+
+ it('should encode nested sectPr from the changed paragraph properties', () => {
+ const sectPr = {
+ name: 'w:sectPr',
+ elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }],
+ };
+ const xmlNode = {
+ name: 'w:pPrChange',
+ attributes: {
+ 'w:id': '6',
+ 'w:author': 'Section Author',
+ 'w:date': '2026-01-02T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:pPr',
+ elements: [sectPr],
+ },
+ ],
+ };
+
+ const result = translator.encode({ nodes: [xmlNode] });
+
+ expect(result).toEqual({
+ id: '6',
+ author: 'Section Author',
+ date: '2026-01-02T00:00:00Z',
+ paragraphProperties: {
+ sectPr,
+ },
+ });
+ });
+
+ it('should encode a w:pPrChange with only attributes and no children', () => {
+ const xmlNode = {
+ name: 'w:pPrChange',
+ attributes: {
+ 'w:id': '3',
+ 'w:author': 'Author',
+ 'w:date': '2026-01-01T00:00:00Z',
+ },
+ elements: [],
+ };
+
+ const result = translator.encode({ nodes: [xmlNode] });
+
+ expect(result).toEqual({
+ id: '3',
+ author: 'Author',
+ date: '2026-01-01T00:00:00Z',
+ });
+ });
+
+ it('should return undefined if no attributes or children are present', () => {
+ const xmlNode = {
+ name: 'w:pPrChange',
+ attributes: {},
+ elements: [],
+ };
+
+ const result = translator.encode({ nodes: [xmlNode] });
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('decode', () => {
+ it('should decode a change object with attributes and nested paragraphProperties', () => {
+ const superDocNode = {
+ attrs: {
+ change: {
+ id: '0',
+ author: 'Luccas Correa',
+ date: '2026-04-02T11:25:00Z',
+ paragraphProperties: {
+ styleId: 'ListParagraph',
+ numberingProperties: { numId: 1 },
+ indent: { hanging: 360 },
+ },
+ },
+ },
+ };
+
+ const result = translator.decode({ node: superDocNode });
+
+ expect(result.name).toBe('w:pPrChange');
+ expect(result.attributes).toEqual({
+ 'w:id': '0',
+ 'w:author': 'Luccas Correa',
+ 'w:date': '2026-04-02T11:25:00Z',
+ });
+ expect(result.elements).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'w:pPr',
+ elements: expect.arrayContaining([
+ { name: 'w:pStyle', attributes: { 'w:val': 'ListParagraph' } },
+ expect.objectContaining({
+ name: 'w:numPr',
+ elements: [{ name: 'w:numId', attributes: { 'w:val': '1' } }],
+ }),
+ { name: 'w:ind', attributes: { 'w:hanging': '360' } },
+ ]),
+ }),
+ ]),
+ );
+ });
+
+ it('should decode a change object with only attributes', () => {
+ const superDocNode = {
+ attrs: {
+ change: {
+ id: '5',
+ author: 'Test Author',
+ date: '2026-01-01T00:00:00Z',
+ },
+ },
+ };
+
+ const result = translator.decode({ node: superDocNode });
+
+ expect(result).toEqual({
+ name: 'w:pPrChange',
+ type: 'element',
+ attributes: {
+ 'w:id': '5',
+ 'w:author': 'Test Author',
+ 'w:date': '2026-01-01T00:00:00Z',
+ },
+ elements: [],
+ });
+ });
+
+ it('should return undefined if change is empty', () => {
+ const superDocNode = {
+ attrs: {
+ change: {},
+ },
+ };
+
+ const result = translator.decode({ node: superDocNode });
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should decode a change with an explicit empty paragraphProperties object', () => {
+ const superDocNode = {
+ attrs: {
+ change: {
+ id: '8',
+ author: 'Empty Paragraph Props',
+ date: '2026-01-03T00:00:00Z',
+ paragraphProperties: {},
+ },
+ },
+ };
+
+ const result = translator.decode({ node: superDocNode });
+
+ expect(result).toEqual({
+ name: 'w:pPrChange',
+ type: 'element',
+ attributes: {
+ 'w:id': '8',
+ 'w:author': 'Empty Paragraph Props',
+ 'w:date': '2026-01-03T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:pPr',
+ type: 'element',
+ attributes: {},
+ elements: [],
+ },
+ ],
+ });
+ });
+
+ it('should decode a change with sectPr-only paragraph properties', () => {
+ const sectPr = {
+ name: 'w:sectPr',
+ elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }],
+ };
+ const superDocNode = {
+ attrs: {
+ change: {
+ id: '7',
+ author: 'Section Author',
+ date: '2026-01-02T00:00:00Z',
+ paragraphProperties: {
+ sectPr,
+ },
+ },
+ },
+ };
+
+ const result = translator.decode({ node: superDocNode });
+
+ expect(result).toEqual({
+ name: 'w:pPrChange',
+ type: 'element',
+ attributes: {
+ 'w:id': '7',
+ 'w:author': 'Section Author',
+ 'w:date': '2026-01-02T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:pPr',
+ type: 'element',
+ attributes: {},
+ elements: [sectPr],
+ },
+ ],
+ });
+ });
+
+ it('should return undefined if change is missing', () => {
+ const superDocNode = {
+ attrs: {},
+ };
+
+ const result = translator.decode({ node: superDocNode });
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('round-trip', () => {
+ it('maintains consistency for a pPrChange with nested properties', () => {
+ const initialChange = {
+ id: '0',
+ author: 'Luccas Correa',
+ date: '2026-04-02T11:25:00Z',
+ paragraphProperties: {
+ styleId: 'ListParagraph',
+ numberingProperties: { numId: 1 },
+ indent: { hanging: 360 },
+ },
+ };
+
+ const decoded = translator.decode({ node: { attrs: { change: initialChange } } });
+ const encoded = translator.encode({ nodes: [decoded] });
+
+ expect(encoded).toEqual(initialChange);
+ });
+
+ it('maintains consistency for a pPrChange with justification', () => {
+ const initialChange = {
+ id: '2',
+ author: 'Another Author',
+ date: '2026-03-15T10:00:00Z',
+ paragraphProperties: {
+ justification: 'center',
+ spacing: { before: 200, after: 100 },
+ },
+ };
+
+ const decoded = translator.decode({ node: { attrs: { change: initialChange } } });
+ const encoded = translator.encode({ nodes: [decoded] });
+
+ expect(encoded).toEqual(initialChange);
+ });
+
+ it('preserves an empty w:pPr when starting from XML', () => {
+ const initialXml = {
+ name: 'w:pPrChange',
+ type: 'element',
+ attributes: {
+ 'w:id': '10',
+ 'w:author': 'Empty pPr Round Trip',
+ 'w:date': '2026-01-05T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:pPr',
+ type: 'element',
+ attributes: {},
+ elements: [],
+ },
+ ],
+ };
+
+ const encoded = translator.encode({ nodes: [initialXml] });
+ const decoded = translator.decode({ node: { attrs: { change: encoded } } });
+
+ expect(decoded).toEqual(initialXml);
+ });
+
+ it('maintains consistency for a pPrChange with sectPr-only paragraph properties', () => {
+ const initialChange = {
+ id: '9',
+ author: 'Section Round Trip',
+ date: '2026-01-04T00:00:00Z',
+ paragraphProperties: {
+ sectPr: {
+ name: 'w:sectPr',
+ elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }],
+ },
+ },
+ };
+
+ const decoded = translator.decode({ node: { attrs: { change: initialChange } } });
+ const encoded = translator.encode({ nodes: [decoded] });
+
+ expect(encoded).toEqual(initialChange);
+ });
+
+ it('maintains consistency for a pPrChange with sectPr alongside other properties', () => {
+ const initialChange = {
+ id: '12',
+ author: 'Mixed Round Trip',
+ date: '2026-01-07T00:00:00Z',
+ paragraphProperties: {
+ justification: 'center',
+ indent: { hanging: 360 },
+ sectPr: {
+ name: 'w:sectPr',
+ elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }],
+ },
+ },
+ };
+
+ const decoded = translator.decode({ node: { attrs: { change: initialChange } } });
+ const encoded = translator.encode({ nodes: [decoded] });
+
+ expect(encoded).toEqual(initialChange);
+ });
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js
index 9d0e1896d5..c37d2c8eb4 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js
@@ -8,17 +8,43 @@ import { wrapTextInRun } from '@converter/exporter';
*/
export function translateContentBlock(params) {
const { node } = params;
- const { vmlAttributes, horizontalRule } = node.attrs;
+ const { vmlAttributes, horizontalRule, attributes, style } = node.attrs;
+ const hasLegacyVmlMarkers = detectLegacyVmlRectMarkers(attributes, style);
// Handle VML v:rect elements (like horizontal rules)
- if (vmlAttributes || horizontalRule) {
+ if (vmlAttributes || horizontalRule || hasLegacyVmlMarkers) {
return translateVRectContentBlock(params);
}
const alternateContent = alternateChoiceTranslator.decode(params);
+ if (!alternateContent) {
+ return null;
+ }
return wrapTextInRun(alternateContent);
}
+/**
+ * Detects legacy VML rect signatures preserved in contentBlock attrs.
+ * This prevents generic legacy w:pict content from being routed into
+ * DrawingML alternate-content export paths.
+ *
+ * @param {Record|null|undefined} rawAttributes
+ * @param {unknown} style
+ * @returns {boolean}
+ */
+function detectLegacyVmlRectMarkers(rawAttributes, style) {
+ if (style) return true;
+ if (!rawAttributes || typeof rawAttributes !== 'object') return false;
+
+ const attrs = rawAttributes;
+
+ if (typeof attrs.style === 'string' && attrs.style.trim().length > 0) return true;
+ if (attrs.fillcolor != null) return true;
+ if (attrs.stroked != null) return true;
+
+ return Object.keys(attrs).some((key) => key.startsWith('o:'));
+}
+
// Nominal full-width value for VML style. Word ignores this when o:hr="t"
// is present and renders the rect at full page width instead.
const FULL_WIDTH_PT = '468pt';
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js
index 1af103dbd6..093e4b0b78 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js
@@ -63,6 +63,42 @@ describe('translateContentBlock', () => {
expect(alternateChoiceTranslator.decode).not.toHaveBeenCalled();
expect(result.elements[0].name).toBe('w:pict');
});
+
+ it('should use translateVRectContentBlock when legacy VML markers exist in attributes', () => {
+ const params = {
+ node: {
+ attrs: {
+ attributes: {
+ style: 'position:absolute;width:100pt;height:20pt',
+ fillcolor: 'yellow',
+ stroked: 'f',
+ },
+ },
+ },
+ };
+
+ generateRandomSigned32BitIntStrId.mockReturnValue('12345678');
+
+ const result = translateContentBlock(params);
+
+ expect(alternateChoiceTranslator.decode).not.toHaveBeenCalled();
+ expect(result.elements[0].name).toBe('w:pict');
+ });
+
+ it('should return null when alternateChoiceTranslator returns null', () => {
+ alternateChoiceTranslator.decode.mockReturnValue(null);
+
+ const params = {
+ node: {
+ attrs: {},
+ },
+ };
+
+ const result = translateContentBlock(params);
+
+ expect(result).toBeNull();
+ expect(wrapTextInRun).not.toHaveBeenCalled();
+ });
});
describe('translateVRectContentBlock', () => {
diff --git a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts
index b07139b2c9..16062847f6 100644
--- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts
+++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts
@@ -2,7 +2,7 @@ import type { Transaction } from 'prosemirror-state';
import type { Editor } from '../Editor.js';
import type { DefaultEventMap } from '../EventEmitter.js';
import type { PartChangedEvent } from '../parts/types.js';
-import type { DocumentProtectionState } from '@superdoc/document-api';
+import type { DocumentProtectionState, StoryLocator } from '@superdoc/document-api';
/** Source of a protection state change. */
export type ProtectionChangeSource = 'init' | 'local-mutation' | 'remote-part-sync';
@@ -121,6 +121,15 @@ export interface ListDefinitionsPayload {
editor?: unknown;
}
+/** Payload emitted with the `tracked-changes-changed` event. */
+export interface TrackedChangesChangedPayload {
+ editor: Editor;
+ /** Stories whose tracked-change snapshot has changed. `undefined` means full rebuild. */
+ stories?: StoryLocator[];
+ /** Optional origin hint. */
+ source?: string;
+}
+
/**
* Event map for the Editor class
*/
@@ -204,4 +213,12 @@ export interface EditorEventMap extends DefaultEventMap {
/** Called when document protection state changes (init, local mutation, or remote sync). */
protectionChanged: [{ editor: Editor; state: DocumentProtectionState; source: ProtectionChangeSource }];
+
+ /**
+ * Story-aware tracked-change invalidation signal.
+ *
+ * Emitted by the host-level `TrackedChangeIndex` service whenever one or
+ * more story caches are invalidated.
+ */
+ 'tracked-changes-changed': [TrackedChangesChangedPayload];
}
diff --git a/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue b/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue
index e61acfc20b..5834e158c3 100644
--- a/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue
+++ b/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue
@@ -1,5 +1,7 @@