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: JoaaoVerona michaelreavant ArturQuirino +kiluazen +kendaller 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:

-

Sample image

+

Sample image

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 = ` +
+
+ abc + word +
+
+ `; + 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 = ` +
+
+ abc +
+
+ word +
+
+ `; + 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 @@ diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index b2f77519e9..6639e26425 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -5,16 +5,43 @@ type SuperDocConfig = ConstructorParameters[0]; type SuperDocInstance = InstanceType; type SuperDocReadyPayload = Parameters>[0]; type OverrideType = 'markdown' | 'html' | 'text'; +type StoryLocator = + | { kind: 'story'; storyType: 'body' } + | { kind: 'story'; storyType: 'headerFooterPart'; refId: string } + | { kind: 'story'; storyType: 'footnote' | 'endnote'; noteId: string }; type ContentOverrideInput = { contentOverride?: string; overrideType?: OverrideType; }; +type BehaviorHarnessCommentSnapshot = { + commentId?: string; + importedId?: string; + trackedChange?: boolean; + trackedChangeText?: string | null; + trackedChangeType?: string | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string; + trackedChangeAnchorKey?: string | null; + deletedText?: string | null; + resolvedTime?: number | null; +}; +type BehaviorHarnessApi = { + getActiveStorySession: () => StoryLocator | null; + getActiveStoryText: () => string | null; + getBodyStoryText: () => string | null; + getCommentsSnapshot: () => BehaviorHarnessCommentSnapshot[]; + getEditorCommentPositions: () => Record; + getActiveCommentId: () => string | null; +}; type HarnessWindow = Window & typeof globalThis & { superdocReady?: boolean; superdoc?: SuperDocInstance; editor?: unknown; + behaviorHarness?: BehaviorHarnessApi; behaviorHarnessInit?: (input?: ContentOverrideInput) => void; }; @@ -25,6 +52,7 @@ const layout = params.get('layout') !== '0'; const showCaret = params.get('showCaret') === '1'; const showSelection = params.get('showSelection') === '1'; const toolbar = params.get('toolbar'); +const responsiveToContainer = params.get('responsiveToContainer') === '1'; const comments = params.get('comments'); const trackChanges = params.get('trackChanges') === '1'; const replacementsParam = params.get('replacements'); @@ -39,6 +67,63 @@ if (!showCaret) { } let instance: SuperDocInstance | null = null; +const commentsPanel = document.querySelector('#comments-panel'); + +function getEditorText(editor: any): string | null { + const state = editor?.state; + const doc = state?.doc; + if (!doc || typeof doc.textBetween !== 'function' || typeof doc.content?.size !== 'number') return null; + return doc.textBetween(0, doc.content.size, '\n', '\n'); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function buildBehaviorHarnessApi(): BehaviorHarnessApi { + return { + getActiveStorySession: () => { + const session = (harnessWindow.editor as any)?.presentationEditor + ?.getStorySessionManager?.() + ?.getActiveSession?.(); + return session?.locator ?? null; + }, + getActiveStoryText: () => { + const activeEditor = (harnessWindow.editor as any)?.presentationEditor?.getActiveEditor?.(); + if (!activeEditor || activeEditor === harnessWindow.editor) return null; + return getEditorText(activeEditor); + }, + getBodyStoryText: () => getEditorText(harnessWindow.editor), + getCommentsSnapshot: () => { + const comments = (harnessWindow.superdoc as any)?.commentsStore?.commentsList ?? []; + return comments.map((comment: any) => { + const raw = typeof comment?.getValues === 'function' ? comment.getValues() : comment; + return cloneJson({ + commentId: raw?.commentId, + importedId: raw?.importedId, + trackedChange: raw?.trackedChange === true, + trackedChangeText: raw?.trackedChangeText ?? null, + trackedChangeType: raw?.trackedChangeType ?? null, + trackedChangeDisplayType: raw?.trackedChangeDisplayType ?? null, + trackedChangeStory: raw?.trackedChangeStory ?? null, + trackedChangeStoryKind: raw?.trackedChangeStoryKind ?? null, + trackedChangeStoryLabel: raw?.trackedChangeStoryLabel ?? '', + trackedChangeAnchorKey: raw?.trackedChangeAnchorKey ?? null, + deletedText: raw?.deletedText ?? null, + resolvedTime: raw?.resolvedTime ?? null, + }); + }); + }, + getEditorCommentPositions: () => { + const positions = (harnessWindow.superdoc as any)?.commentsStore?.editorCommentPositions ?? {}; + return cloneJson(positions); + }, + getActiveCommentId: () => { + const activeComment = (harnessWindow.superdoc as any)?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }, + }; +} function applyContentOverride(config: SuperDocConfig, input?: ContentOverrideInput) { if (!input?.contentOverride || !input?.overrideType) return; @@ -74,10 +159,15 @@ function init(file?: File, content?: ContentOverrideInput) { telemetry: { enabled: false }, onReady: ({ superdoc }: SuperDocReadyPayload) => { harnessWindow.superdoc = superdoc; + if (comments === 'panel' && commentsPanel) { + commentsPanel.replaceChildren(); + superdoc.addCommentsList(commentsPanel); + } superdoc.activeEditor.on('create', (payload: unknown) => { if (!payload || typeof payload !== 'object' || !('editor' in payload)) return; harnessWindow.editor = (payload as { editor: unknown }).editor; }); + harnessWindow.behaviorHarness = buildBehaviorHarnessApi(); harnessWindow.superdocReady = true; }, }; @@ -94,9 +184,24 @@ function init(file?: File, content?: ContentOverrideInput) { config.toolbar = '#toolbar'; } + if (responsiveToContainer) { + config.modules = { + ...(config.modules ?? {}), + toolbar: { responsiveToContainer: true }, + }; + } + // Comments if (comments === 'on' || comments === 'panel') { config.comments = { visible: true }; + if (comments === 'panel') { + config.modules = { + ...(config.modules ?? {}), + comments: { + ...((config.modules as Record | undefined)?.comments as Record | undefined), + }, + }; + } } else if (comments === 'readonly') { config.comments = { visible: true, readOnly: true }; } else if (comments === 'disabled') { @@ -128,6 +233,10 @@ function init(file?: File, content?: ContentOverrideInput) { } instance = new SuperDoc(config); + if (commentsPanel) { + commentsPanel.classList.toggle('is-visible', comments === 'panel'); + if (comments !== 'panel') commentsPanel.replaceChildren(); + } if (!showSelection) { const style = document.createElement('style'); diff --git a/tests/behavior/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts index b6e79f1746..0c6f60ac42 100644 --- a/tests/behavior/harness/vite.config.ts +++ b/tests/behavior/harness/vite.config.ts @@ -1,6 +1,20 @@ +import { createRequire } from 'node:module'; import { defineConfig } from 'vite'; +import { getAliases } from '../../../packages/superdoc/vite.config.js'; + +const superdocRequire = createRequire(new URL('../../../packages/superdoc/package.json', import.meta.url)); +const vue = superdocRequire('@vitejs/plugin-vue').default; export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify('behavior-harness'), + __IS_DEBUG__: true, + }, + plugins: [vue()], + resolve: { + alias: getAliases(true), + conditions: ['source'], + }, server: { port: 9990, strictPort: true, diff --git a/tests/behavior/helpers/comments.ts b/tests/behavior/helpers/comments.ts index 0c8a9d7765..435c953bdc 100644 --- a/tests/behavior/helpers/comments.ts +++ b/tests/behavior/helpers/comments.ts @@ -8,7 +8,17 @@ import { listComments } from './document-api.js'; /** Locator for the active (clicked/focused) floating comment dialog. */ export const activeCommentDialog = (page: Page): Locator => - page.locator('.comment-placeholder .comments-dialog.is-active, .comment-placeholder .comments-dialog').last(); + page + .locator( + '.comment-placeholder .comments-dialog.is-active, #comments-panel .comments-dialog.is-active, .comment-placeholder .comments-dialog, #comments-panel .comments-dialog', + ) + .last(); + +const commentDialogLocator = (page: Page): Locator => + page.locator('.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'); + +const activeCommentDialogLocator = (page: Page): Locator => + page.locator('.comment-placeholder .comments-dialog.is-active, #comments-panel .comments-dialog.is-active'); const locatorTop = async (locator: Locator): Promise => { const target = locator.first(); @@ -83,17 +93,17 @@ export async function activateCommentDialog( await superdoc.waitForStable(); } - const activeDialog = superdoc.page.locator('.comment-placeholder .comments-dialog.is-active').last(); + const activeDialog = activeCommentDialogLocator(superdoc.page).last(); const dialog = activeCommentDialog(superdoc.page); const hasActiveDialog = (await activeDialog.count()) > 0; if (!hasActiveDialog) { // Fallback: click the floating dialog directly to trigger setFocus โ†’ is-active - const floatingDialog = superdoc.page.locator('.comment-placeholder .comments-dialog').last(); - await expect(floatingDialog).toBeVisible({ timeout: timeoutMs }); + const visibleDialog = commentDialogLocator(superdoc.page).last(); + await expect(visibleDialog).toBeVisible({ timeout: timeoutMs }); // Click near the top-left to avoid accidentally hitting interactive controls // such as the "N more replies" collapse/expand pill in the middle of the card. - await floatingDialog.click({ position: { x: 12, y: 12 } }); + await visibleDialog.click({ position: { x: 12, y: 12 }, force: true }); await superdoc.waitForStable(); const hasActiveDialogNow = (await activeDialog.count()) > 0; diff --git a/tests/behavior/helpers/document-api.ts b/tests/behavior/helpers/document-api.ts index 278f972128..6b24facabb 100644 --- a/tests/behavior/helpers/document-api.ts +++ b/tests/behavior/helpers/document-api.ts @@ -3,7 +3,11 @@ import type { TextAddress, SelectionTarget, MatchContext, + StoryLocator, TrackChangeType, + TrackChangesAcceptInput, + TrackChangesListInput, + TrackChangesRejectInput, CommentsListResult, TrackChangesListResult, TextMutationReceipt, @@ -320,10 +324,7 @@ export async function deleteText( }); } -export async function listTrackChanges( - page: Page, - query: { limit?: number; offset?: number; type?: TrackChangeType } = {}, -): Promise { +export async function listTrackChanges(page: Page, query: TrackChangesListInput = {}): Promise { return page.evaluate((input) => { const result = (window as any).editor.doc.trackChanges.list(input); if (Array.isArray(result?.changes)) { @@ -376,16 +377,24 @@ export async function listSeparate( return invokeListMutation(page, 'separate', input, options) as Promise; } -export async function acceptTrackChange(page: Page, input: { id: string }): Promise { +export async function acceptTrackChange(page: Page, input: TrackChangesAcceptInput): Promise { await page.evaluate( - (payload) => (window as any).editor.doc.trackChanges.decide({ decision: 'accept', target: { id: payload.id } }), + (payload) => + (window as any).editor.doc.trackChanges.decide({ + decision: 'accept', + target: payload.story ? { id: payload.id, story: payload.story } : { id: payload.id }, + }), input, ); } -export async function rejectTrackChange(page: Page, input: { id: string }): Promise { +export async function rejectTrackChange(page: Page, input: TrackChangesRejectInput): Promise { await page.evaluate( - (payload) => (window as any).editor.doc.trackChanges.decide({ decision: 'reject', target: { id: payload.id } }), + (payload) => + (window as any).editor.doc.trackChanges.decide({ + decision: 'reject', + target: payload.story ? { id: payload.id, story: payload.story } : { id: payload.id }, + }), input, ); } diff --git a/tests/behavior/helpers/story-fixtures.ts b/tests/behavior/helpers/story-fixtures.ts new file mode 100644 index 0000000000..411bae01b7 --- /dev/null +++ b/tests/behavior/helpers/story-fixtures.ts @@ -0,0 +1,503 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import type { StoryLocator } from '@superdoc/document-api'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../../..'); +const editorFixtureRoot = path.resolve(repoRoot, 'packages/super-editor/src/editors/v1/tests/data'); +const generatedFixtureRoot = path.resolve(os.tmpdir(), `superdoc-behavior-story-fixtures-${process.pid}`); + +const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + +function ensureDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(targetPath: string, contents: string): void { + ensureDir(path.dirname(targetPath)); + fs.writeFileSync(targetPath, contents); +} + +function run(command: string, args: string[], cwd?: string): void { + execFileSync(command, args, { + cwd, + stdio: 'ignore', + }); +} + +function rebuildDocx(sourceName: string, targetPath: string, replacements: Record): void { + const sourcePath = path.resolve(editorFixtureRoot, sourceName); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'superdoc-behavior-story-fixture-build-')); + try { + run('unzip', ['-qq', sourcePath, '-d', tempRoot]); + for (const [relativePath, contents] of Object.entries(replacements)) { + writeFile(path.resolve(tempRoot, relativePath), contents); + } + + ensureDir(path.dirname(targetPath)); + fs.rmSync(targetPath, { force: true }); + run('zip', ['-q', '-X', '-r', targetPath, '.'], tempRoot); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function ensureGeneratedFixture(filename: string, sourceName: string, replacements: Record): string { + const targetPath = path.resolve(generatedFixtureRoot, filename); + if (!fs.existsSync(targetPath)) { + rebuildDocx(sourceName, targetPath, replacements); + } + return targetPath; +} + +function documentXmlWithEndnotes(): string { + return ` + + + + Simple endnote text + + with longer content + + + + + + + + + + + + +`; +} + +function complexFootnoteMappingDocumentXml(): string { + return ` + + + + Complex mapped note + + and field-coded note + + . + + + + + + + + + + + +`; +} + +function multiPageHeaderFooterDocumentXml(): string { + const paragraphs = Array.from({ length: 48 }, (_, index) => { + const number = index + 1; + return ` + + Multipage footer coverage paragraph ${number}. This filler text keeps the same default header and footer story flowing onto later pages. + `; + }).join(''); + + return ` + + + ${paragraphs} + + + + + + + + + + +`; +} + +function twoSectionFooterDocumentXml(): string { + return ` + + + + + Section 1 + + + First section body content. + + + + + + + + + + + + + + + + Section 2 + + + Second section content lives on the next page. + + + + + + + + + + +`; +} + +function footerFootnoteTransitionDocumentXml(): string { + return ` + + + + Footer transition anchor + + . + + + + + + + + + + +`; +} + +function simpleFootnotesXml(): string { + return ` + + + + + + + + + + + + This is a simple footnote + + + +`; +} + +function simpleFooterXml(text: string): string { + return ` + + + + + + + ${text} + + +`; +} + +function complexFootnotesXml(): string { + return ` + + + + + + + + + + + + + If only one closing is contemplated, references to โ€œInitial Closingโ€ should be modified. + + + + + + + + STYLEREF 1 \\s + + 1.2(b) + + The Company may have tax reporting and/or withholding obligations in connection with the conversion of Convertible Securities into Company stock. + + + +`; +} + +function endnotesXml(): string { + return ` + + + + + + + + + + + + This is a simple endnote + + + + + + + A longer endnote + + + + And more endnote content + + + +`; +} + +function storyOnlyTrackedChangeDocumentXml(): string { + return ` + + + + Body review anchor + with footnote + + and endnote + + . + + + + + + + + + + + +`; +} + +function trackedHeaderXml(): string { + return ` + + + + Header base + + HDR_TC_ALPHA + + + +`; +} + +function trackedFooterXml(): string { + return ` + + + + Footer base + + FTR_TC_BRAVO + + + +`; +} + +function inlinePageFieldFooterXml(): string { + return ` + + + + + + + Finance QA + + PAGE + + + +`; +} + +function trackedFootnotesXml(): string { + return ` + + + + + + + + + + + + Footnote base + + FN_TC_CHARLIE + + + + +`; +} + +function trackedEndnotesXml(): string { + return ` + + + + + + + + + + + + Endnote base + + EN_TC_DELTA + + + + +`; +} + +export const H_F_NORMAL_DOC_PATH = path.resolve(editorFixtureRoot, 'h_f-normal.docx'); +export const H_F_NORMAL_ODD_EVEN_FIRSTPG_DOC_PATH = path.resolve(editorFixtureRoot, 'h_f-normal-odd-even-firstpg.docx'); +export const LONGER_HEADER_SIGN_AREA_DOC_PATH = path.resolve(editorFixtureRoot, 'longer-header-sign-area.docx'); +export const BASIC_FOOTNOTES_DOC_PATH = path.resolve(editorFixtureRoot, 'basic-footnotes.docx'); +export const COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH = ensureGeneratedFixture( + 'complex-imported-footnotes.docx', + 'h_f-normal.docx', + { + 'word/document.xml': complexFootnoteMappingDocumentXml(), + 'word/footnotes.xml': complexFootnotesXml(), + }, +); +export const BASIC_ENDNOTES_DOC_PATH = ensureGeneratedFixture('basic-endnotes.docx', 'h_f-normal.docx', { + 'word/document.xml': documentXmlWithEndnotes(), + 'word/endnotes.xml': endnotesXml(), +}); +export const MULTI_PAGE_HEADER_FOOTER_DOC_PATH = ensureGeneratedFixture( + 'multi-page-header-footer.docx', + 'h_f-normal.docx', + { + 'word/document.xml': multiPageHeaderFooterDocumentXml(), + }, +); +export const TWO_SECTION_FOOTER_DOC_PATH = ensureGeneratedFixture('two-section-footer.docx', 'h_f-normal.docx', { + 'word/document.xml': twoSectionFooterDocumentXml(), + 'word/footer1.xml': simpleFooterXml('Appendix footer'), + 'word/footer2.xml': simpleFooterXml('Main footer'), +}); +export const FOOTER_FOOTNOTE_TRANSITION_DOC_PATH = ensureGeneratedFixture( + 'footer-footnote-transition.docx', + 'h_f-normal.docx', + { + 'word/document.xml': footerFootnoteTransitionDocumentXml(), + 'word/footnotes.xml': simpleFootnotesXml(), + 'word/footer2.xml': simpleFooterXml('Transition footer'), + }, +); +export const FOOTER_INLINE_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( + 'footer-inline-page-field.docx', + 'h_f-normal.docx', + { + 'word/footer2.xml': inlinePageFieldFooterXml(), + }, +); +export const STORY_ONLY_TRACKED_CHANGES_DOC_PATH = ensureGeneratedFixture( + 'story-only-tracked-changes.docx', + 'h_f-normal.docx', + { + 'word/document.xml': storyOnlyTrackedChangeDocumentXml(), + 'word/header2.xml': trackedHeaderXml(), + 'word/footer2.xml': trackedFooterXml(), + 'word/footnotes.xml': trackedFootnotesXml(), + 'word/endnotes.xml': trackedEndnotesXml(), + }, +); + +export type StoryTrackedChangeFixtureEntry = { + surface: 'header' | 'footer' | 'footnote' | 'endnote'; + story: StoryLocator; + storyKind: 'headerFooter' | 'footnote' | 'endnote'; + storyLabel?: string; + storyLabelPrefix?: string; + excerpt: string; +}; + +export function readStoryOnlyTrackedChangesManifest(): StoryTrackedChangeFixtureEntry[] { + return [ + { + surface: 'header', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId8' }, + storyKind: 'headerFooter', + storyLabelPrefix: 'Header/Footer', + excerpt: 'HDR_TC_ALPHA', + }, + { + surface: 'footer', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId10' }, + storyKind: 'headerFooter', + storyLabelPrefix: 'Header/Footer', + excerpt: 'FTR_TC_BRAVO', + }, + { + surface: 'footnote', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + storyKind: 'footnote', + storyLabel: 'Footnote 1', + excerpt: 'FN_TC_CHARLIE', + }, + { + surface: 'endnote', + story: { kind: 'story', storyType: 'endnote', noteId: '1' }, + storyKind: 'endnote', + storyLabel: 'Endnote 1', + excerpt: 'EN_TC_DELTA', + }, + ]; +} diff --git a/tests/behavior/helpers/story-replacements.ts b/tests/behavior/helpers/story-replacements.ts new file mode 100644 index 0000000000..3092c62563 --- /dev/null +++ b/tests/behavior/helpers/story-replacements.ts @@ -0,0 +1,55 @@ +import type { Page } from '@playwright/test'; + +export type StoryReplacementResult = { + success: boolean; + activeDocumentId: string | null; + deletedText: string; + insertedText: string; +}; + +export async function replaceFirstLettersInActiveStory( + page: Page, + insertedText: string, + letterCount = 2, +): Promise { + return page.evaluate( + ({ nextText, count }) => { + const presentationEditor = (window as any).editor?.presentationEditor; + const bodyEditor = (window as any).editor; + const activeEditor = presentationEditor?.getActiveEditor?.(); + + if (!activeEditor || activeEditor === bodyEditor) { + throw new Error('Expected an active story editor.'); + } + + const storyText = activeEditor.state.doc.textBetween(0, activeEditor.state.doc.content.size, '\n', '\n') ?? ''; + const firstWordMatch = storyText.match(/[A-Za-z]{2,}/); + if (!firstWordMatch || firstWordMatch.index == null) { + throw new Error(`No replaceable word found in active story text: "${storyText}"`); + } + + const replaceCount = Math.max(1, Math.min(count, firstWordMatch[0].length)); + const deletedText = storyText.slice(firstWordMatch.index, firstWordMatch.index + replaceCount); + const characterPositions: number[] = []; + + activeEditor.state.doc.descendants((node: any, pos: number) => { + if (!node?.isText || !node.text) return; + for (let offset = 0; offset < node.text.length; offset += 1) { + characterPositions.push(pos + offset); + } + }); + + const from = characterPositions[firstWordMatch.index]; + const to = characterPositions[firstWordMatch.index + replaceCount - 1] + 1; + const success = activeEditor.commands.insertTrackedChange({ from, to, text: nextText }); + + return { + success, + activeDocumentId: activeEditor.options.documentId ?? null, + deletedText, + insertedText: nextText, + }; + }, + { nextText: insertedText, count: letterCount }, + ); +} diff --git a/tests/behavior/helpers/story-surfaces.ts b/tests/behavior/helpers/story-surfaces.ts new file mode 100644 index 0000000000..e471f070c9 --- /dev/null +++ b/tests/behavior/helpers/story-surfaces.ts @@ -0,0 +1,288 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import type { StoryLocator } from '@superdoc/document-api'; +import type { SuperDocFixture } from '../fixtures/superdoc.js'; + +type NoteStoryType = 'footnote' | 'endnote'; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getTextPointInternal( + locator: Locator, + { + searchText, + offsetWithinMatch = 0, + align = 'center', + }: { + searchText: string; + offsetWithinMatch?: number; + align?: 'center' | 'boundary'; + }, +) { + const point = await locator.evaluate( + ( + element, + params: { + searchText: string; + offsetWithinMatch: number; + align: 'center' | 'boundary'; + }, + ) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) return null; + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) return null; + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd( + currentNode, + params.align === 'center' ? Math.min(textLength, clampedOffset + params.searchText.length) : clampedOffset, + ); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallbackRect = currentNode.parentElement?.getBoundingClientRect(); + if (!fallbackRect) return null; + return { + x: + params.align === 'center' + ? fallbackRect.left + fallbackRect.width / 2 + : fallbackRect.left + Math.min(2, fallbackRect.width / 2), + y: fallbackRect.top + fallbackRect.height / 2, + }; + } + + return { + x: params.align === 'center' ? rect.left + rect.width / 2 : rect.left + 0.5, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch, align }, + ); + + expect(point).toBeTruthy(); + return point!; +} + +export async function getRenderedTextPoint( + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + return getTextPointInternal(locator, { searchText, offsetWithinMatch, align: 'center' }); +} + +export async function getTextBoundaryPoint( + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + return getTextPointInternal(locator, { searchText, offsetWithinMatch, align: 'boundary' }); +} + +export async function clickTextBoundary( + page: Page, + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + const point = await getTextBoundaryPoint(locator, searchText, offsetWithinMatch); + await page.mouse.click(point.x, point.y); + return point; +} + +export async function doubleClickWord(page: Page, locator: Locator, word: string): Promise { + const point = await getRenderedTextPoint(locator, word); + await page.mouse.dblclick(point.x, point.y); +} + +export async function tripleClickWord(page: Page, locator: Locator, word: string): Promise { + const point = await getRenderedTextPoint(locator, word); + await page.mouse.click(point.x, point.y, { clickCount: 3 }); +} + +export function getHeaderSurfaceLocator(page: Page, pageIndex = 0): Locator { + return page.locator('.superdoc-page-header').nth(pageIndex); +} + +export function getFooterSurfaceLocator(page: Page, pageIndex = 0): Locator { + return page.locator('.superdoc-page-footer').nth(pageIndex); +} + +export function getHeaderEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror').first(); +} + +export function getFooterEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror').first(); +} + +export function getNoteSurfaceLocator(page: Page, input: { storyType: NoteStoryType; noteId: string }): Locator { + const prefix = input.storyType === 'endnote' ? 'endnote' : 'footnote'; + return page + .locator( + `[data-block-id^="${prefix}-${input.noteId}-"], [data-block-id^="__sd_semantic_${prefix}-${input.noteId}-"]`, + ) + .first(); +} + +export function getActiveNoteEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="note"] .ProseMirror').first(); +} + +export async function getActiveStorySession(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveStorySession === 'function') { + return harness.getActiveStorySession(); + } + + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator ?? null; + }); +} + +export async function waitForActiveStory( + page: Page, + expected: + | null + | Partial + | { + match: (story: StoryLocator | null) => boolean; + description: string; + }, +): Promise { + if (expected === null) { + await expect.poll(() => getActiveStorySession(page)).toBeNull(); + return; + } + + if ('match' in expected) { + await expect + .poll(async () => expected.match(await getActiveStorySession(page)), { message: expected.description }) + .toBe(true); + return; + } + + await expect.poll(() => getActiveStorySession(page)).toEqual(expect.objectContaining(expected)); +} + +export async function exitActiveStory(page: Page): Promise { + await page.evaluate(() => { + (window as any).editor?.presentationEditor?.exitActiveStorySurface?.(); + }); + await expect.poll(() => getActiveStorySession(page)).toBeNull(); +} + +export async function getActiveStoryText(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveStoryText === 'function') { + return harness.getActiveStoryText(); + } + + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + if (!activeEditor) return null; + return activeEditor.state?.doc?.textBetween?.(0, activeEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +export async function moveActiveStoryCursorToEnd(page: Page): Promise { + await page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const bodyEditor = (window as any).editor; + if (!activeEditor || activeEditor === bodyEditor) return; + + const doc = activeEditor.state?.doc; + if (!doc) return; + + const pos = Math.max(1, doc.content.size - 1); + activeEditor.commands?.setTextSelection?.({ from: pos, to: pos }); + activeEditor.view?.focus?.(); + }); +} + +export async function getBodyStoryText(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getBodyStoryText === 'function') { + return harness.getBodyStoryText(); + } + + const bodyEditor = (window as any).editor; + return bodyEditor?.state?.doc?.textBetween?.(0, bodyEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +export async function activateHeader(superdoc: SuperDocFixture, pageIndex = 0): Promise { + const header = getHeaderSurfaceLocator(superdoc.page, pageIndex); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await header.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); + return header; +} + +export async function activateFooter(superdoc: SuperDocFixture, pageIndex = 0): Promise { + const footer = getFooterSurfaceLocator(superdoc.page, pageIndex); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await footer.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); + return footer; +} + +export async function activateNote( + superdoc: SuperDocFixture, + input: { storyType: NoteStoryType; noteId: string; expectedText?: string }, +): Promise { + const note = getNoteSurfaceLocator(superdoc.page, input); + await note.scrollIntoViewIfNeeded(); + await note.waitFor({ state: 'visible', timeout: 15_000 }); + if (input.expectedText) { + await expect(note).toContainText(input.expectedText); + } + + const box = await note.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: input.storyType, + noteId: input.noteId, + }); + return note; +} + +export async function expectActiveStoryText(page: Page, expectedText: string): Promise { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toBe(normalizeText(expectedText)); +} + +export async function expectActiveStoryTextToContain(page: Page, expectedText: string): Promise { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toContain(normalizeText(expectedText)); +} diff --git a/tests/behavior/helpers/story-tracked-changes.ts b/tests/behavior/helpers/story-tracked-changes.ts new file mode 100644 index 0000000000..2513b16035 --- /dev/null +++ b/tests/behavior/helpers/story-tracked-changes.ts @@ -0,0 +1,244 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import type { StoryLocator, TrackChangeInfo, TrackChangeType } from '@superdoc/document-api'; +import { storyLocatorToKey } from '@superdoc/document-api'; +import type { SuperDocFixture } from '../fixtures/superdoc.js'; +import { listTrackChanges } from './document-api.js'; + +type TrackedChangeCommentSnapshot = { + commentId?: string; + importedId?: string; + trackedChange?: boolean; + trackedChangeText?: string | null; + trackedChangeType?: string | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string; + trackedChangeAnchorKey?: string | null; + deletedText?: string | null; + resolvedTime?: number | null; +}; + +function normalizeTrackedChangeExcerpt(change: TrackChangeInfo): string { + return String(change.excerpt ?? '').trim(); +} + +function mapTrackChangeTypeToCommentType(type: TrackChangeType | undefined): string | null { + if (!type) return null; + if (type === 'insert') return 'trackInsert'; + if (type === 'delete') return 'trackDelete'; + return 'trackFormat'; +} + +function sameStory(left: StoryLocator | null | undefined, right: StoryLocator | null | undefined): boolean { + if (!left || !right) return false; + return storyLocatorToKey(left) === storyLocatorToKey(right); +} + +function trackedChangeIdMatches(comment: TrackedChangeCommentSnapshot, id: string): boolean { + const canonicalId = String(id); + if (comment.commentId != null && String(comment.commentId) === canonicalId) return true; + if (comment.importedId != null && String(comment.importedId) === canonicalId) return true; + return comment.trackedChangeAnchorKey?.endsWith(`::${canonicalId}`) === true; +} + +export async function getCommentsSnapshot(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getCommentsSnapshot !== 'function') { + throw new Error('behaviorHarness.getCommentsSnapshot is unavailable.'); + } + + return harness.getCommentsSnapshot(); + }); +} + +export async function getEditorCommentPositions(page: Page): Promise> { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getEditorCommentPositions !== 'function') { + throw new Error('behaviorHarness.getEditorCommentPositions is unavailable.'); + } + + return harness.getEditorCommentPositions(); + }); +} + +export async function getActiveCommentId(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveCommentId === 'function') { + return harness.getActiveCommentId(); + } + + const activeComment = (window as any).superdoc?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }); +} + +export async function findTrackedChange( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const result = await listTrackChanges(page, { in: input.story, ...(input.type ? { type: input.type } : {}) }); + const matched = result.changes.find((change) => { + if (input.id && change.id !== input.id) return false; + if (input.excerpt && !normalizeTrackedChangeExcerpt(change).includes(input.excerpt)) return false; + return true; + }); + + if (!matched) { + throw new Error( + `No tracked change found for ${storyLocatorToKey(input.story)} (${input.id ?? input.excerpt ?? input.type ?? 'any'}).`, + ); + } + + return matched; +} + +export async function findTrackedChangeComment( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const commentType = mapTrackChangeTypeToCommentType(input.type); + const comments = await getCommentsSnapshot(page); + const matched = comments.find((comment) => { + if (comment.trackedChange !== true) return false; + if (!sameStory(comment.trackedChangeStory ?? null, input.story)) return false; + if (input.id && !trackedChangeIdMatches(comment, input.id)) return false; + if (commentType && comment.trackedChangeType !== commentType) return false; + if (input.excerpt) { + const haystack = [comment.trackedChangeText, comment.deletedText].filter(Boolean).join(' '); + if (!haystack.includes(input.excerpt)) return false; + } + return true; + }); + + if (!matched) { + throw new Error( + `No tracked-change comment found for ${storyLocatorToKey(input.story)} (${input.id ?? input.excerpt ?? input.type ?? 'any'}).`, + ); + } + + return matched; +} + +export function getTrackedChangeDialogLocator( + page: Page, + input: { excerpt?: string | null; activeOnly?: boolean }, +): Locator { + const selector = input.activeOnly ? '.comments-dialog.is-active' : '.comments-dialog'; + if (input.excerpt) { + return page.locator(selector, { hasText: input.excerpt }).first(); + } + + return page.locator(selector).first(); +} + +async function setActiveTrackedChangeComment(page: Page, comment: TrackedChangeCommentSnapshot): Promise { + const preferredId = comment.commentId ?? comment.importedId; + if (preferredId == null) { + throw new Error('Tracked-change comment is missing commentId/importedId.'); + } + + const activeId = String(preferredId); + await page.evaluate((commentId) => { + const store = (window as any).superdoc?.commentsStore; + store?.$patch?.({ activeComment: commentId }); + }, activeId); + + await expect.poll(() => getActiveCommentId(page)).toBe(activeId); + return activeId; +} + +export async function activateTrackedChangeDialog( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise<{ change: TrackChangeInfo; comment: TrackedChangeCommentSnapshot; dialog: Locator }> { + const change = await findTrackedChange(superdoc.page, input); + const comment = await findTrackedChangeComment(superdoc.page, { + story: input.story, + ...(input.id ? { id: change.id } : {}), + ...(input.excerpt ? { excerpt: input.excerpt } : {}), + ...(input.type ? { type: input.type } : {}), + }); + await setActiveTrackedChangeComment(superdoc.page, comment); + const dialog = getTrackedChangeDialogLocator(superdoc.page, { + excerpt: input.excerpt ?? change.excerpt ?? comment.trackedChangeText ?? comment.deletedText ?? null, + activeOnly: true, + }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.click({ position: { x: 12, y: 12 } }); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(String(comment.commentId ?? comment.importedId)); + return { change, comment, dialog }; +} + +export async function acceptTrackedChangeFromSidebar( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const { change, dialog } = await activateTrackedChangeDialog(superdoc, input); + await dialog.locator('.comment-header .overflow-menu__icon').first().click({ force: true }); + await superdoc.waitForStable(); + return change; +} + +export async function rejectTrackedChangeFromSidebar( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const { change, dialog } = await activateTrackedChangeDialog(superdoc, input); + await dialog.locator('.comment-header .overflow-menu__icon').nth(1).click({ force: true }); + await superdoc.waitForStable(); + return change; +} + +export async function getTrackedChangeAnchorPosition( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise<{ key: string; bounds: Record; pageIndex: number | null } | null> { + const comment = await findTrackedChangeComment(page, input); + const key = comment.trackedChangeAnchorKey ?? comment.commentId ?? comment.importedId; + if (!key) return null; + + const positions = await getEditorCommentPositions(page); + const entry = positions[key]; + if (!entry?.bounds) return null; + + return { + key: String(key), + bounds: entry.bounds, + pageIndex: Number.isFinite(entry.pageIndex) ? Number(entry.pageIndex) : null, + }; +} diff --git a/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts new file mode 100644 index 0000000000..9656d1321a --- /dev/null +++ b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts @@ -0,0 +1,137 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_FOOTNOTES_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; + +test.use({ + config: { + toolbar: 'full', + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +type TrackedChangePosition = { + key: string; + top: number; + left: number; + pageIndex: number | null; +}; + +function getFootnoteLocator(page: Page, noteId: string): Locator { + return page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); +} + +async function getBodyTrackedChangePosition(page: Page): Promise { + return page.evaluate(() => { + const positions = (window as any).superdoc?.commentsStore?.editorCommentPositions ?? {}; + for (const [key, entry] of Object.entries(positions)) { + if (!key.startsWith('tc::body::')) { + continue; + } + + const bounds = (entry as { bounds?: { top?: unknown; left?: unknown } }).bounds; + if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) { + continue; + } + + const pageIndex = (entry as { pageIndex?: unknown }).pageIndex; + return { + key, + top: Number(bounds.top), + left: Number(bounds.left), + pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null, + }; + } + + return null; + }); +} + +async function getTrackedChangePositionByKey(page: Page, key: string): Promise { + return page.evaluate((targetKey: string) => { + const entry = (window as any).superdoc?.commentsStore?.editorCommentPositions?.[targetKey]; + const bounds = entry?.bounds; + if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) { + return null; + } + + const pageIndex = entry?.pageIndex; + return { + key: targetKey, + top: Number(bounds.top), + left: Number(bounds.left), + pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null, + }; + }, key); +} + +async function activateFootnote( + superdoc: { page: Page; waitForStable: (ms?: number) => Promise }, + noteId: string, +) { + const footnote = getFootnoteLocator(superdoc.page, noteId); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator?.storyType ?? null; + }), + ) + .toBe('footnote'); + + return footnote; +} + +test('body tracked-change anchors stay in body space while editing a footnote in suggesting mode', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const bodyLine = superdoc.page.locator('.superdoc-line', { hasText: 'Simple text1 with footnotes' }).first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + + const lineBox = await bodyLine.boundingBox(); + expect(lineBox).toBeTruthy(); + + await superdoc.page.mouse.click(lineBox!.x + 12, lineBox!.y + lineBox!.height / 2); + await superdoc.page.keyboard.insertText('BODYFIX '); + await superdoc.waitForStable(); + + await expect.poll(() => getBodyTrackedChangePosition(superdoc.page)).not.toBeNull(); + const before = await getBodyTrackedChangePosition(superdoc.page); + expect(before).toBeTruthy(); + + const footnote = await activateFootnote(superdoc, '1'); + await expect(footnote).toContainText('This is a simple footnote'); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText('NOTEFIX'); + await superdoc.waitForStable(); + + await expect.poll(() => getTrackedChangePositionByKey(superdoc.page, before!.key)).not.toBeNull(); + const after = await getTrackedChangePositionByKey(superdoc.page, before!.key); + expect(after).toBeTruthy(); + + expect(after!.pageIndex).toBe(before!.pageIndex); + expect(Math.abs(after!.top - before!.top)).toBeLessThanOrEqual(40); + expect(Math.abs(after!.left - before!.left)).toBeLessThanOrEqual(40); +}); diff --git a/tests/behavior/tests/comments/footer-inline-page-field-replacement-grouping.spec.ts b/tests/behavior/tests/comments/footer-inline-page-field-replacement-grouping.spec.ts new file mode 100644 index 0000000000..f7f2ce696c --- /dev/null +++ b/tests/behavior/tests/comments/footer-inline-page-field-replacement-grouping.spec.ts @@ -0,0 +1,67 @@ +import { expect, test, type Locator, type Page } from '../../fixtures/superdoc.js'; +import { FOOTER_INLINE_PAGE_FIELD_DOC_PATH } from '../../helpers/story-fixtures.js'; +import { activateFooter, getTextBoundaryPoint } from '../../helpers/story-surfaces.js'; +import { getCommentsSnapshot } from '../../helpers/story-tracked-changes.js'; + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +async function dragSelectRenderedText(page: Page, locator: Locator, text: string): Promise { + const start = await getTextBoundaryPoint(locator, text, 0); + const end = await getTextBoundaryPoint(locator, text, text.length); + + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(end.x, end.y, { steps: 8 }); + await page.mouse.up(); +} + +test('footer replacement stays grouped when visible text is followed by inline page field markers', async ({ + superdoc, +}) => { + await superdoc.loadDocument(FOOTER_INLINE_PAGE_FIELD_DOC_PATH); + await superdoc.waitForStable(); + + const footer = await activateFooter(superdoc); + await dragSelectRenderedText(superdoc.page, footer, 'Finance QA'); + await superdoc.waitForStable(); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const selection = activeEditor?.state?.selection; + return selection ? { from: selection.from, to: selection.to } : null; + }), + ) + .toEqual({ from: 2, to: 12 }); + + await superdoc.page.keyboard.type('QA'); + await superdoc.waitForStable(); + + await expect + .poll(async () => { + const comments = await getCommentsSnapshot(superdoc.page); + return comments + .filter((comment) => comment.trackedChange === true) + .map((comment) => ({ + insertedText: comment.trackedChangeText ?? null, + deletedText: comment.deletedText ?? null, + type: comment.trackedChangeType ?? null, + })); + }) + .toEqual([ + { + insertedText: 'QA', + deletedText: 'Finance QA', + type: 'both', + }, + ]); +}); diff --git a/tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts b/tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts new file mode 100644 index 0000000000..5df9304dd4 --- /dev/null +++ b/tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts @@ -0,0 +1,426 @@ +import { expect, test, type Page } from '../../fixtures/superdoc.js'; +import { + H_F_NORMAL_ODD_EVEN_FIRSTPG_DOC_PATH as FIRST_PAGE_HEADER_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, + MULTI_PAGE_HEADER_FOOTER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { + activateFooter, + activateHeader, + exitActiveStory, + getFooterEditorLocator, + getFooterSurfaceLocator, + getHeaderEditorLocator, + getHeaderSurfaceLocator, + moveActiveStoryCursorToEnd, +} from '../../helpers/story-surfaces.js'; + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +async function insertTrackedTextInActiveStory(page: Page, insertedText: string): Promise { + await page.keyboard.press('End'); + await page.keyboard.insertText(insertedText); +} + +async function readTrackedChangeState(page: Page, insertedText: string) { + return page.evaluate((text) => { + const harness = (window as any).behaviorHarness; + const comments = harness?.getCommentsSnapshot?.() ?? []; + const positions = harness?.getEditorCommentPositions?.() ?? {}; + const floating = (window as any).superdoc?.commentsStore?.getFloatingComments ?? []; + + const match = comments.find( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeText === text && + comment?.trackedChangeStory?.storyType === 'headerFooterPart', + ); + + const anchorKey = match?.trackedChangeAnchorKey ?? null; + const position = anchorKey ? (positions[anchorKey] ?? null) : null; + + return { + anchorKey, + hasComment: Boolean(match), + hasBounds: Boolean(position?.bounds), + floatingMatchCount: floating.filter( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeText === text && + comment?.trackedChangeStory?.storyType === 'headerFooterPart', + ).length, + storyRefId: match?.trackedChangeStory?.refId ?? null, + }; + }, insertedText); +} + +async function readTrackedChangeAnchorGeometry(page: Page, insertedText: string) { + return page.evaluate((text) => { + const harness = (window as any).behaviorHarness; + const comments = harness?.getCommentsSnapshot?.() ?? []; + const positions = harness?.getEditorCommentPositions?.() ?? {}; + + const match = comments.find( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeText === text && + comment?.trackedChangeStory?.storyType === 'headerFooterPart', + ); + + const anchorKey = match?.trackedChangeAnchorKey ?? null; + const position = anchorKey ? (positions[anchorKey] ?? null) : null; + const rects = Array.isArray(position?.rects) ? position.rects : []; + + return { + pageIndex: position?.pageIndex ?? null, + boundsHeight: position?.bounds?.height ?? null, + rectPageIndexes: rects.map((rect: any) => rect?.pageIndex).filter((value: any) => Number.isFinite(value)), + }; + }, insertedText); +} + +async function readFloatingBubbleInstances(page: Page, insertedText: string) { + return page.evaluate((text) => { + const harness = (window as any).behaviorHarness; + const comments = harness?.getCommentsSnapshot?.() ?? []; + + const match = comments.find( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeText === text && + comment?.trackedChangeStory?.storyType === 'headerFooterPart', + ); + + const anchorKey = match?.trackedChangeAnchorKey != null ? String(match.trackedChangeAnchorKey) : null; + const threadId = match?.commentId != null ? String(match.commentId) : null; + if (!anchorKey || !threadId) { + return { + anchorKey: null, + threadId: null, + count: 0, + pageIndexes: [], + activePageIndexes: [], + }; + } + + const renderedInstanceElements = [ + ...Array.from( + document.querySelectorAll(`.comment-placeholder[data-comment-position-key="${anchorKey}"]`), + ), + ...Array.from( + document.querySelectorAll( + `#comments-panel .comments-dialog[data-comment-position-key="${anchorKey}"]`, + ), + ), + ].filter((element) => element.dataset.commentThreadId === threadId); + + const parsePageIndex = (value: string | undefined) => { + const pageIndex = Number(value ?? 'NaN'); + return Number.isFinite(pageIndex) ? pageIndex : null; + }; + + const instanceElementsById = new Map(); + renderedInstanceElements.forEach((element) => { + const instanceId = + element.dataset.commentInstanceId ?? + `${threadId}::page:${parsePageIndex(element.dataset.commentPageIndex) ?? 'unknown'}`; + const existingElement = instanceElementsById.get(instanceId); + const shouldPreferCurrentElement = + existingElement == null || + (element.classList.contains('comments-dialog') && !existingElement.classList.contains('comments-dialog')); + + if (shouldPreferCurrentElement) { + instanceElementsById.set(instanceId, element); + } + }); + + const renderedInstances = [...instanceElementsById.values()]; + + return { + anchorKey, + threadId, + count: renderedInstances.length, + pageIndexes: renderedInstances + .map((element) => parsePageIndex(element.dataset.commentPageIndex)) + .filter((value): value is number => value != null) + .sort((a, b) => a - b), + activePageIndexes: renderedInstances + .filter( + (element) => + element.matches('.comments-dialog.is-active') || + Boolean(element.querySelector('.comments-dialog.is-active')), + ) + .map((element) => parsePageIndex(element.dataset.commentPageIndex)) + .filter((value): value is number => value != null) + .sort((a, b) => a - b), + }; + }, insertedText); +} + +async function clickFloatingBubbleInstance(page: Page, anchorKey: string, pageIndex: number): Promise { + const bubble = page.locator( + [ + `.comment-placeholder[data-comment-position-key="${anchorKey}"][data-comment-page-index="${pageIndex}"] .comments-dialog`, + `#comments-panel .comments-dialog[data-comment-position-key="${anchorKey}"][data-comment-page-index="${pageIndex}"]`, + ].join(', '), + ); + await expect(bubble).toBeVisible(); + await bubble.click(); +} + +async function readFirstPageHeaderIdentity(page: Page) { + return page.evaluate(() => { + const presentationEditor = (window as any).editor?.presentationEditor; + const layoutSnapshot = presentationEditor?.getLayoutSnapshot?.(); + const page0 = layoutSnapshot?.layout?.pages?.[0] ?? null; + const expectedRefId = page0?.sectionRefs?.headerRefs?.first ?? null; + const fragment = document.querySelector('.superdoc-page-header [data-block-id]'); + const blockId = fragment?.getAttribute('data-block-id') ?? null; + const renderedRefId = typeof blockId === 'string' ? (blockId.match(/^hf-header-([^:-]+)-/)?.[1] ?? null) : null; + return { expectedRefId, renderedRefId }; + }); +} + +async function expectRenderedHeaderTrackChange( + page: Page, + insertedText: string, + storyRefId?: string | null, +): Promise { + const selector = storyRefId + ? `[data-story-key="hf:part:${storyRefId}"][data-track-change-id]` + : '[data-track-change-id]'; + + await expect( + getHeaderSurfaceLocator(page) + .locator(selector, { + hasText: insertedText, + }) + .first(), + ).toBeVisible(); +} + +async function expectRenderedFooterTrackChange(page: Page, insertedText: string, pageIndex = 0): Promise { + await expect( + getFooterSurfaceLocator(page, pageIndex) + .locator('[data-track-change-id]', { + hasText: insertedText, + }) + .first(), + ).toBeVisible(); +} + +test('header tracked changes get immediate bounds while editing and stay rendered after exit', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const insertedText = 'HDRLIVE'; + await activateHeader(superdoc); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + }), + ); + + await expect(getHeaderEditorLocator(superdoc.page)).toContainText(insertedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expectRenderedHeaderTrackChange(superdoc.page, insertedText); +}); + +test('footer tracked changes get immediate bounds while editing and stay rendered after exit', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const insertedText = 'FTRLIVE'; + await activateFooter(superdoc); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + }), + ); + + await expect(getFooterEditorLocator(superdoc.page)).toContainText(insertedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expectRenderedFooterTrackChange(superdoc.page, insertedText); +}); + +test('repeated footer tracked changes render on later pages without activating that footer', async ({ superdoc }) => { + await superdoc.loadDocument(MULTI_PAGE_HEADER_FOOTER_DOC_PATH); + await superdoc.waitForStable(); + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(2); + + const insertedText = 'FTRMULTIPAGE'; + await activateFooter(superdoc, 0); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + storyRefId: expect.any(String), + }), + ); + + await expect(getFooterEditorLocator(superdoc.page)).toContainText(insertedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expectRenderedFooterTrackChange(superdoc.page, insertedText, 0); + + const secondPageFooter = getFooterSurfaceLocator(superdoc.page, 1); + await secondPageFooter.scrollIntoViewIfNeeded(); + await secondPageFooter.waitFor({ state: 'visible', timeout: 15_000 }); + await expectRenderedFooterTrackChange(superdoc.page, insertedText, 1); +}); + +test('repeated footer tracked-change anchors stay on the page that was edited', async ({ superdoc }) => { + await superdoc.loadDocument(MULTI_PAGE_HEADER_FOOTER_DOC_PATH); + await superdoc.waitForStable(); + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(3); + + const insertedText = 'FTRANCHORP3'; + await activateFooter(superdoc, 2); + await moveActiveStoryCursorToEnd(superdoc.page); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + storyRefId: expect.any(String), + }), + ); + + await expect(getFooterEditorLocator(superdoc.page)).toContainText(insertedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeAnchorGeometry(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual({ + pageIndex: 2, + boundsHeight: expect.any(Number), + rectPageIndexes: [0, 1, 2], + }); + + const anchorGeometry = await readTrackedChangeAnchorGeometry(superdoc.page, insertedText); + expect(anchorGeometry.boundsHeight).toBeLessThan(120); +}); + +test('repeated footer tracked changes render one floating bubble per page instance and activate the clicked page', async ({ + superdoc, +}) => { + await superdoc.loadDocument(MULTI_PAGE_HEADER_FOOTER_DOC_PATH); + await superdoc.waitForStable(); + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(3); + + const insertedText = 'FTRBUBBLEP3'; + await activateFooter(superdoc, 2); + await moveActiveStoryCursorToEnd(superdoc.page); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expect + .poll(() => readFloatingBubbleInstances(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual({ + anchorKey: expect.any(String), + threadId: expect.any(String), + count: 3, + pageIndexes: [0, 1, 2], + activePageIndexes: [], + }); + + const floatingBubbleState = await readFloatingBubbleInstances(superdoc.page, insertedText); + await clickFloatingBubbleInstance(superdoc.page, floatingBubbleState.anchorKey!, 2); + await superdoc.waitForStable(); + + await expect(getFooterEditorLocator(superdoc.page, 2)).toBeVisible(); + await expect(getFooterEditorLocator(superdoc.page, 2)).toContainText(insertedText); + await expect + .poll(() => readFloatingBubbleInstances(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual({ + anchorKey: floatingBubbleState.anchorKey, + threadId: floatingBubbleState.threadId, + count: 3, + pageIndexes: [0, 1, 2], + activePageIndexes: [2], + }); +}); + +test('first-page header tracked changes stay bound to the first-page story', async ({ superdoc }) => { + await superdoc.loadDocument(FIRST_PAGE_HEADER_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(() => readFirstPageHeaderIdentity(superdoc.page), { timeout: 10_000 }) + .toEqual({ + expectedRefId: expect.any(String), + renderedRefId: expect.any(String), + }); + + const initialIdentity = await readFirstPageHeaderIdentity(superdoc.page); + expect(initialIdentity.renderedRefId).toBe(initialIdentity.expectedRefId); + + const insertedText = 'FIRSTPGTC'; + await activateHeader(superdoc); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + storyRefId: initialIdentity.expectedRefId, + }), + ); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => window.scrollTo(0, 0)); + await expect(getHeaderSurfaceLocator(superdoc.page, 0)).toBeVisible(); + + await expectRenderedHeaderTrackChange(superdoc.page, insertedText, initialIdentity.expectedRefId); +}); diff --git a/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts b/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts new file mode 100644 index 0000000000..77413d5def --- /dev/null +++ b/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts @@ -0,0 +1,70 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { getActiveCommentId, findTrackedChangeComment } from '../../helpers/story-tracked-changes.js'; +import { activateFooter, activateHeader } from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest().filter( + (entry) => entry.surface === 'header' || entry.surface === 'footer', +); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + showCaret: true, + showSelection: true, + }, +}); + +async function clearActiveComment(page: Page) { + await page.evaluate(() => { + (window as any).superdoc?.commentsStore?.$patch?.({ activeComment: null }); + }); +} + +async function clickRenderedTrackedChange(page: Page, locator: import('@playwright/test').Locator): Promise { + await locator.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await locator.boundingBox(); + if (!box) { + throw new Error('Tracked-change marker is not clickable: no bounding box available.'); + } + + await page.mouse.click( + box.x + Math.min(8, Math.max(box.width / 2, 1)), + box.y + Math.min(8, Math.max(box.height / 2, 1)), + ); +} + +for (const entry of STORY_CASES) { + test(`${entry.surface} tracked-change text activates its bubble and a body click clears it`, async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + const surface = entry.surface === 'header' ? await activateHeader(superdoc) : await activateFooter(superdoc); + + const comment = await findTrackedChangeComment(superdoc.page, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await clearActiveComment(superdoc.page); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + + await clickRenderedTrackedChange( + superdoc.page, + surface.locator('[data-track-change-id]', { hasText: entry.excerpt }).first(), + ); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(String(comment.commentId ?? comment.importedId)); + + const bodyLine = superdoc.page.locator('.superdoc-line').first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + await bodyLine.click(); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + }); +} diff --git a/tests/behavior/tests/comments/header-footer-undo-cross-container.spec.ts b/tests/behavior/tests/comments/header-footer-undo-cross-container.spec.ts new file mode 100644 index 0000000000..efecd886ff --- /dev/null +++ b/tests/behavior/tests/comments/header-footer-undo-cross-container.spec.ts @@ -0,0 +1,149 @@ +import type { Page } from '@playwright/test'; +import { expect, test, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_FOOTER_DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateFooter, + activateHeader, + getFooterSurfaceLocator, + getHeaderSurfaceLocator, + moveActiveStoryCursorToEnd, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; +import { assertDocumentApiReady } from '../../helpers/document-api.js'; + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +type SurfaceKind = 'header' | 'footer'; + +async function getHeaderFooterTrackedChangeCount(page: Page, text: string) { + return page.evaluate((insertedText) => { + const comments = (window as any).behaviorHarness?.getCommentsSnapshot?.() ?? []; + return comments.filter( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeText === insertedText && + comment?.trackedChangeStory?.storyType === 'headerFooterPart', + ).length; + }, text); +} + +async function getHeaderFooterSidebarCount(page: Page, text: string) { + return page.evaluate((insertedText) => { + const items = Array.from(document.querySelectorAll('#comments-panel .tracked-change-text')); + return items.filter((item) => (item.textContent ?? '').includes(insertedText)).length; + }, text); +} + +async function activateSurface(superdoc: SuperDocFixture, surface: SurfaceKind) { + if (surface === 'header') { + return activateHeader(superdoc); + } + return activateFooter(superdoc); +} + +function getSurfaceLocator(page: Page, surface: SurfaceKind) { + return surface === 'header' ? getHeaderSurfaceLocator(page) : getFooterSurfaceLocator(page); +} + +async function clickBodySurface(page: Page) { + const bodyLine = page.locator('.superdoc-line').first(); + await bodyLine.scrollIntoViewIfNeeded(); + await bodyLine.click(); +} + +async function activateBlankDocumentHeader(superdoc: SuperDocFixture) { + const pageSurface = superdoc.page.locator('.superdoc-page').first(); + await pageSurface.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await pageSurface.boundingBox(); + expect(box).toBeTruthy(); + + await superdoc.page.mouse.dblclick(box!.x + 120, box!.y + 60); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); + + return getHeaderSurfaceLocator(superdoc.page); +} + +async function clickBlankDocumentBody(page: Page) { + const pageSurface = page.locator('.superdoc-page').first(); + const box = await pageSurface.boundingBox(); + expect(box).toBeTruthy(); + await page.mouse.click(box!.x + 140, box!.y + 180); +} + +for (const surface of ['header', 'footer'] as const) { + test(`undo/redo from the body restores tracked ${surface} edits after leaving the active story`, async ({ + superdoc, + }) => { + const insertedText = surface === 'header' ? 'HDRUNDO' : 'FTRUNDO'; + + await assertDocumentApiReady(superdoc.page); + await superdoc.loadDocument(HEADER_FOOTER_DOC_PATH); + await superdoc.waitForStable(); + + const surfaceLocator = getSurfaceLocator(superdoc.page, surface); + await activateSurface(superdoc, surface); + await moveActiveStoryCursorToEnd(superdoc.page); + await superdoc.page.keyboard.insertText(insertedText); + await superdoc.waitForStable(); + + await expect(surfaceLocator).toContainText(insertedText); + await expect.poll(() => getHeaderFooterTrackedChangeCount(superdoc.page, insertedText)).toBe(1); + await expect.poll(() => getHeaderFooterSidebarCount(superdoc.page, insertedText)).toBe(1); + + await clickBodySurface(superdoc.page); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, null); + + await superdoc.undo(); + await superdoc.waitForStable(); + + await expect(surfaceLocator).not.toContainText(insertedText); + await expect.poll(() => getHeaderFooterTrackedChangeCount(superdoc.page, insertedText)).toBe(0); + await expect.poll(() => getHeaderFooterSidebarCount(superdoc.page, insertedText)).toBe(0); + + await superdoc.redo(); + await superdoc.waitForStable(); + + await expect(surfaceLocator).toContainText(insertedText); + await expect.poll(() => getHeaderFooterTrackedChangeCount(superdoc.page, insertedText)).toBe(1); + await expect.poll(() => getHeaderFooterSidebarCount(superdoc.page, insertedText)).toBe(1); + }); +} + +test('undo from the body removes blank-document tracked header edits after leaving the active story', async ({ + superdoc, +}) => { + const insertedText = 'BLANKHDRUNDO'; + + await assertDocumentApiReady(superdoc.page); + await superdoc.waitForStable(); + + const headerSurface = await activateBlankDocumentHeader(superdoc); + await moveActiveStoryCursorToEnd(superdoc.page); + await superdoc.page.keyboard.insertText(insertedText); + await superdoc.waitForStable(); + + await expect(headerSurface).toContainText(insertedText); + await expect.poll(() => getHeaderFooterTrackedChangeCount(superdoc.page, insertedText)).toBe(1); + await expect.poll(() => getHeaderFooterSidebarCount(superdoc.page, insertedText)).toBe(1); + + await clickBlankDocumentBody(superdoc.page); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, null); + + await superdoc.undo(); + await superdoc.waitForStable(); + + await expect(headerSurface).not.toContainText(insertedText); + await expect.poll(() => getHeaderFooterTrackedChangeCount(superdoc.page, insertedText)).toBe(0); + await expect.poll(() => getHeaderFooterSidebarCount(superdoc.page, insertedText)).toBe(0); +}); diff --git a/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts new file mode 100644 index 0000000000..17cf0692e4 --- /dev/null +++ b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts @@ -0,0 +1,161 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_FOOTNOTES_DOC_PATH as FOOTNOTE_DOC_PATH } from '../../helpers/story-fixtures.js'; + +test.use({ + config: { + toolbar: 'full', + comments: 'on', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +function getInsertedTrackChangeLocator(container: Locator, insertedText: string): Locator { + return container + .locator('[data-track-change-id], .track-insert[data-id], .track-delete[data-id], .track-format[data-id]') + .filter({ hasText: insertedText }) + .first(); +} + +async function getTextClickPoint(locator: Locator, searchText: string) { + const point = await locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode = walker.nextNode() as Text | null; + + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining < textLength) { + const range = doc.createRange(); + const startOffset = Math.max(0, remaining); + const endOffset = Math.min(textLength, remaining + targetText.length); + range.setStart(currentNode, startOffset); + range.setEnd(currentNode, endOffset); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + return null; + } + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(point).toBeTruthy(); + return point!; +} + +async function getTrackChangeThreadIdAtPoint(page: Page, x: number, y: number): Promise { + return page.evaluate( + ({ x: clientX, y: clientY }) => { + const target = document.elementFromPoint(clientX, clientY); + const trackedChangeElement = target?.closest?.( + '[data-track-change-id], .track-insert[data-id], .track-delete[data-id], .track-format[data-id]', + ); + + return ( + trackedChangeElement?.getAttribute('data-track-change-id')?.trim() ?? + trackedChangeElement?.getAttribute('data-id')?.trim() ?? + null + ); + }, + { x, y }, + ); +} + +async function clearActiveComment(page: Page): Promise { + await page.evaluate(() => { + const store = (window as any).superdoc?.commentsStore; + store?.$patch?.({ activeComment: null }); + }); +} + +async function getActiveCommentId(page: Page): Promise { + return page.evaluate(() => { + const activeComment = (window as any).superdoc?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }); +} + +async function activateFootnote( + superdoc: { page: Page; waitForStable: (ms?: number) => Promise }, + noteId: string, +) { + const footnote = superdoc.page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator?.storyType ?? null; + }), + ) + .toBe('footnote'); + + return footnote; +} + +test('clicking tracked-change text inside an active footnote activates its floating bubble', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const insertedText = 'NOTEFIX'; + const footnote = await activateFootnote(superdoc, '1'); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(insertedText); + await superdoc.waitForStable(); + + const insertedChange = getInsertedTrackChangeLocator(footnote, insertedText); + await expect(insertedChange).toBeVisible(); + + const clickPoint = await getTextClickPoint(footnote, insertedText); + const threadId = await getTrackChangeThreadIdAtPoint(superdoc.page, clickPoint.x, clickPoint.y); + expect(threadId).toBeTruthy(); + await clearActiveComment(superdoc.page); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + + await superdoc.page.mouse.click(clickPoint.x, clickPoint.y); + await superdoc.waitForStable(); + + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(threadId); +}); diff --git a/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts b/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts index 3c77ba17b1..5262e8dd2e 100644 --- a/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts +++ b/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts @@ -4,6 +4,7 @@ import { test, expect } from '../../fixtures/superdoc.js'; test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); const TEXT = 'Agreement signed by both parties'; +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; type EditorCommand = [name: string, ...args: unknown[]]; @@ -45,7 +46,7 @@ test('reject tracked mixed marks + textStyle on selection restores original form await superdoc.waitForStable(); await superdoc.assertTrackedChangeExists('format'); - const trackedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const trackedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text'), }); await expect(trackedDialog).toHaveCount(1); diff --git a/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts b/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts index 3347cee1c3..c75f881262 100644 --- a/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts +++ b/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts @@ -6,6 +6,7 @@ import { assertDocumentApiReady, getDocumentText, listTrackChanges } from '../.. const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOC_PATH = path.resolve(__dirname, 'fixtures/sd-1960-word-replacement-no-comments.docx'); +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); @@ -101,7 +102,7 @@ async function loadImportedReplacement(page: Page, loadDocument: (filePath: stri const deleteSegment = deleteSegments.length > 0 ? combineSegments(deleteSegments) : null; const insertSegment = insertSegments.length > 0 ? combineSegments(insertSegments) : null; - const replacementDialog = page.locator('.comment-placeholder .comments-dialog', { + const replacementDialog = page.locator(TRACKED_CHANGE_DIALOGS, { has: page.locator('.change-type', { hasText: 'Replaced' }), }); diff --git a/tests/behavior/tests/comments/story-surface-context-menu.spec.ts b/tests/behavior/tests/comments/story-surface-context-menu.spec.ts new file mode 100644 index 0000000000..a4af30b576 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-context-menu.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, + readStoryOnlyTrackedChangesManifest, +} from '../../helpers/story-fixtures.js'; +import { + activateFooter, + activateHeader, + activateNote, + getFooterSurfaceLocator, + getHeaderSurfaceLocator, + getNoteSurfaceLocator, +} from '../../helpers/story-surfaces.js'; + +const CONTEXT_MENU_CASES = readStoryOnlyTrackedChangesManifest().filter( + (entry) => entry.surface === 'header' || entry.surface === 'footer' || entry.surface === 'footnote', +); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + showSelection: true, + }, +}); + +for (const entry of CONTEXT_MENU_CASES) { + test(`story-surface context menu exposes tracked-change actions for ${entry.surface}`, async ({ + superdoc, + browserName, + }) => { + test.skip(browserName === 'firefox', 'Firefox collapses selection on right-click natively'); + + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + let surface; + if (entry.surface === 'header') { + surface = await activateHeader(superdoc); + } else if (entry.surface === 'footer') { + surface = await activateFooter(superdoc); + } else { + surface = await activateNote(superdoc, { + storyType: 'footnote', + noteId: '1', + expectedText: 'FN_TC_CHARLIE', + }); + } + + const renderedSurface = + entry.surface === 'header' + ? getHeaderSurfaceLocator(superdoc.page) + : entry.surface === 'footer' + ? getFooterSurfaceLocator(superdoc.page) + : getNoteSurfaceLocator(superdoc.page, { storyType: 'footnote', noteId: '1' }); + + const trackedChange = renderedSurface + .locator('[data-track-change-id], .track-insert[data-id], .track-delete[data-id], .track-format[data-id]', { + hasText: entry.excerpt, + }) + .first(); + await expect(trackedChange).toBeVisible(); + + const box = await trackedChange.boundingBox(); + expect(box).toBeTruthy(); + const clickX = box!.x + Math.max(4, box!.width / 2); + const clickY = box!.y + Math.max(4, box!.height / 2); + + await superdoc.page.mouse.click(clickX, clickY, { + button: 'right', + }); + + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + await expect(menu.locator('.context-menu-item').filter({ hasText: 'Accept change' })).toBeVisible(); + await expect(menu.locator('.context-menu-item').filter({ hasText: 'Reject change' })).toBeVisible(); + const menuBox = await menu.boundingBox(); + expect(menuBox).toBeTruthy(); + expect(Math.abs(menuBox!.x - (clickX + 10))).toBeLessThan(36); + expect(Math.abs(menuBox!.y - (clickY + 10))).toBeLessThan(36); + + await surface.scrollIntoViewIfNeeded(); + }); +} diff --git a/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts b/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts new file mode 100644 index 0000000000..db8c0710f8 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { findTrackedChangeComment, getCommentsSnapshot } from '../../helpers/story-tracked-changes.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + }, +}); + +test('imported story-only tracked changes bootstrap sidebar threads for every non-body story', async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await assertDocumentApiReady(superdoc.page); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total).toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(0); + + const comments = await getCommentsSnapshot(superdoc.page); + expect(comments.filter((comment) => comment.trackedChange)).toHaveLength(STORY_CASES.length); + + for (const entry of STORY_CASES) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + + const comment = await findTrackedChangeComment(superdoc.page, { + story: entry.story, + excerpt: entry.excerpt, + }); + + expect(comment.trackedChangeStoryKind).toBe(entry.storyKind); + if (entry.storyLabel) { + expect(comment.trackedChangeStoryLabel).toBe(entry.storyLabel); + } else if (entry.storyLabelPrefix) { + expect(comment.trackedChangeStoryLabel ?? '').toContain(entry.storyLabelPrefix); + } + expect(comment.trackedChangeAnchorKey).toMatch(/^tc::/); + expect(comment.resolvedTime ?? null).toBeNull(); + } +}); diff --git a/tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts b/tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts new file mode 100644 index 0000000000..41cfb10f14 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts @@ -0,0 +1,173 @@ +import type { Locator, Page } from '@playwright/test'; +import type { StoryLocator } from '@superdoc/document-api'; +import { expect, test } from '../../fixtures/superdoc.js'; +import { + BASIC_FOOTNOTES_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { replaceFirstLettersInActiveStory } from '../../helpers/story-replacements.js'; +import { + activateFooter, + activateHeader, + activateNote, + exitActiveStory, + getActiveNoteEditorLocator, + getActiveStorySession, + getFooterEditorLocator, + getFooterSurfaceLocator, + getHeaderEditorLocator, + getHeaderSurfaceLocator, +} from '../../helpers/story-surfaces.js'; +import { findTrackedChangeComment } from '../../helpers/story-tracked-changes.js'; + +test.use({ + config: { + toolbar: 'full', + comments: 'on', + trackChanges: true, + documentMode: 'suggesting', + replacements: 'independent', + showCaret: true, + showSelection: true, + }, +}); + +type HeaderFooterStory = Extract; + +async function getActiveHeaderFooterStory(page: Page): Promise { + const story = await getActiveStorySession(page); + if (!story || story.kind !== 'story' || story.storyType !== 'headerFooterPart' || typeof story.refId !== 'string') { + throw new Error(`Expected an active header/footer story, received: ${JSON.stringify(story)}`); + } + return story; +} + +async function expectIndependentReplacementBubbles( + page: Page, + insertedText: string, + deletedText: string, +): Promise { + await expect( + page + .locator('.comment-placeholder .comments-dialog', { + hasText: `Deleted "${deletedText}"`, + }) + .first(), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page + .locator('.comment-placeholder .comments-dialog', { + hasText: `Added "${insertedText}"`, + }) + .first(), + ).toBeVisible({ timeout: 10_000 }); +} + +async function expectReplacementTrackedChangeComments( + page: Page, + story: StoryLocator, + insertedText: string, + deletedText: string, +): Promise { + await findTrackedChangeComment(page, { + story, + excerpt: insertedText, + type: 'insert', + }); + await findTrackedChangeComment(page, { + story, + excerpt: deletedText, + type: 'delete', + }); +} + +async function expectActiveStoryEditorText(editor: Locator, insertedText: string): Promise { + await expect(editor).toContainText(insertedText); +} + +test('header replacement shows a visible tracked-change bubble and stays rendered after exiting the header', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + const story = await getActiveHeaderFooterStory(superdoc.page); + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'HDRREP'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectReplacementTrackedChangeComments(superdoc.page, story, result.insertedText, result.deletedText); + await expectActiveStoryEditorText(getHeaderEditorLocator(superdoc.page), result.insertedText); + await expectIndependentReplacementBubbles(superdoc.page, result.insertedText, result.deletedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expect( + getHeaderSurfaceLocator(superdoc.page) + .locator(`[data-story-key="hf:part:${story.refId}"][data-track-change-id]`, { + hasText: result.insertedText, + }) + .first(), + ).toBeVisible(); +}); + +test('footer replacement shows a visible tracked-change bubble and stays rendered after exiting the footer', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + await activateFooter(superdoc); + const story = await getActiveHeaderFooterStory(superdoc.page); + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'FTRREP'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectReplacementTrackedChangeComments(superdoc.page, story, result.insertedText, result.deletedText); + await expectActiveStoryEditorText(getFooterEditorLocator(superdoc.page), result.insertedText); + await expectIndependentReplacementBubbles(superdoc.page, result.insertedText, result.deletedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expect( + getFooterSurfaceLocator(superdoc.page) + .locator(`[data-story-key="hf:part:${story.refId}"][data-track-change-id]`, { + hasText: result.insertedText, + }) + .first(), + ).toBeVisible(); +}); + +test('footnote replacement shows a visible tracked-change bubble inside the active note', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + await activateNote(superdoc, { + storyType: 'footnote', + noteId: '1', + expectedText: 'This is a simple footnote', + }); + const story: StoryLocator = { + kind: 'story', + storyType: 'footnote', + noteId: '1', + }; + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'FNREP'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectReplacementTrackedChangeComments(superdoc.page, story, result.insertedText, result.deletedText); + await expectActiveStoryEditorText(getActiveNoteEditorLocator(superdoc.page), result.insertedText); + await expectIndependentReplacementBubbles(superdoc.page, result.insertedText, result.deletedText); +}); diff --git a/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts b/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts new file mode 100644 index 0000000000..ee87ad538a --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts @@ -0,0 +1,150 @@ +import { test, expect, type Locator, type Page, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { listTrackChanges } from '../../helpers/document-api.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { acceptTrackedChangeFromSidebar, rejectTrackedChangeFromSidebar } from '../../helpers/story-tracked-changes.js'; +import { + activateFooter, + activateHeader, + expectActiveStoryTextToContain, + getFooterSurfaceLocator, + getHeaderSurfaceLocator, + getNoteSurfaceLocator, +} from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + }, +}); + +function getSurfaceLocator(page: Page, surface: (typeof STORY_CASES)[number]['surface']): Locator { + if (surface === 'header') return getHeaderSurfaceLocator(page); + if (surface === 'footer') return getFooterSurfaceLocator(page); + return getNoteSurfaceLocator(page, { + storyType: surface, + noteId: '1', + }); +} + +async function expectSurfaceExcerpt( + superdoc: SuperDocFixture, + entry: (typeof STORY_CASES)[number], + visible: boolean, +): Promise { + const surface = getSurfaceLocator(superdoc.page, entry.surface); + await surface.scrollIntoViewIfNeeded(); + if (visible) { + if (entry.surface === 'header') { + await activateHeader(superdoc); + await expectActiveStoryTextToContain(superdoc.page, entry.excerpt); + return; + } + + if (entry.surface === 'footer') { + await activateFooter(superdoc); + await expectActiveStoryTextToContain(superdoc.page, entry.excerpt); + return; + } + + await expect(surface).toContainText(entry.excerpt); + return; + } + + await expect(surface).not.toContainText(entry.excerpt); +} + +for (const entry of STORY_CASES) { + test(`accept from sidebar resolves only the ${entry.surface} tracked change and supports undo/redo`, async ({ + superdoc, + }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expectSurfaceExcerpt(superdoc, entry, true); + + await acceptTrackedChangeFromSidebar(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, true); + + for (const otherEntry of STORY_CASES.filter((candidate) => candidate.surface !== entry.surface)) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: otherEntry.story })).total).toBe(1); + } + + await superdoc.undo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expectSurfaceExcerpt(superdoc, entry, true); + + await superdoc.redo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, true); + }); + + test(`reject from sidebar resolves only the ${entry.surface} tracked change and supports undo/redo`, async ({ + superdoc, + }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expectSurfaceExcerpt(superdoc, entry, true); + + await rejectTrackedChangeFromSidebar(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, false); + + for (const otherEntry of STORY_CASES.filter((candidate) => candidate.surface !== entry.surface)) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: otherEntry.story })).total).toBe(1); + } + + await superdoc.undo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expectSurfaceExcerpt(superdoc, entry, true); + + await superdoc.redo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, false); + }); +} diff --git a/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts b/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts new file mode 100644 index 0000000000..b7dddef566 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { activateTrackedChangeDialog } from '../../helpers/story-tracked-changes.js'; +import { getActiveStoryText, getBodyStoryText, waitForActiveStory } from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + }, +}); + +for (const entry of STORY_CASES) { + test(`sidebar tracked-change dialog navigates into the ${entry.surface} story`, async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + const bodyBefore = await getBodyStoryText(superdoc.page); + const { dialog } = await activateTrackedChangeDialog(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await waitForActiveStory(superdoc.page, entry.story); + await expect(dialog).toContainText(entry.excerpt); + await expect.poll(() => getActiveStoryText(superdoc.page)).toContain(entry.excerpt); + expect(await getBodyStoryText(superdoc.page)).toBe(bodyBefore); + }); +} diff --git a/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts new file mode 100644 index 0000000000..bb7b83a1b2 --- /dev/null +++ b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type Page } from '../../fixtures/superdoc.js'; +import { + BASIC_FOOTNOTES_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { replaceFirstLettersInActiveStory } from '../../helpers/story-replacements.js'; +import { activateFooter, activateHeader, activateNote } from '../../helpers/story-surfaces.js'; + +const FOOTNOTE_DOC_PATH = BASIC_FOOTNOTES_DOC_PATH; + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + replacements: 'independent', + }, +}); + +async function expectIndependentStoryThreads(page: Page, deletedText: string, insertedText: string) { + await expect + .poll( + () => + page.evaluate( + ({ deleted, inserted }) => { + const comments = (window as any).superdoc?.commentsStore?.commentsList ?? []; + const trackedChangeComments = comments.filter((comment: any) => comment?.trackedChange); + const matchingComments = trackedChangeComments.filter( + (comment: any) => comment?.deletedText === deleted || comment?.trackedChangeText === inserted, + ); + const floatingComments = (window as any).superdoc?.commentsStore?.getFloatingComments ?? []; + const hasFloatingMatch = floatingComments.some( + (comment: any) => comment?.deletedText === deleted || comment?.trackedChangeText === inserted, + ); + const panelText = Array.from(document.querySelectorAll('#comments-panel .comments-dialog')) + .map((node) => node.textContent ?? '') + .filter(Boolean); + + return { + hasFloatingMatch, + matchingTypes: matchingComments.map((comment: any) => comment?.trackedChangeType).sort(), + matchingDeletedTexts: matchingComments.map((comment: any) => comment?.deletedText).filter(Boolean), + matchingInsertedTexts: matchingComments.map((comment: any) => comment?.trackedChangeText).filter(Boolean), + panelHasDeletedText: panelText.some((text) => text.includes(deleted)), + panelHasInsertedText: panelText.some((text) => text.includes(inserted)), + }; + }, + { deleted: deletedText, inserted: insertedText }, + ), + { timeout: 10_000 }, + ) + .toEqual( + expect.objectContaining({ + hasFloatingMatch: true, + matchingTypes: ['trackDelete', 'trackInsert'], + matchingDeletedTexts: [deletedText], + matchingInsertedTexts: [insertedText], + panelHasDeletedText: true, + panelHasInsertedText: true, + }), + ); +} + +async function expectActiveStoryReplacementMode(page: Page) { + await expect + .poll(() => + page.evaluate(() => (window as any).editor?.presentationEditor?.getActiveEditor?.()?.options?.trackedChanges), + ) + .toEqual( + expect.objectContaining({ + replacements: 'independent', + }), + ); +} + +test('header replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + expect(result.activeDocumentId).not.toBe( + (await superdoc.page.evaluate(() => (window as any).editor?.options?.documentId)) ?? null, + ); + + await superdoc.waitForStable(); + await expectIndependentStoryThreads(superdoc.page, result.deletedText, result.insertedText); +}); + +test('footer replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateFooter(superdoc); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectIndependentStoryThreads(superdoc.page, result.deletedText, result.insertedText); +}); + +test('footnote replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateNote(superdoc, { storyType: 'footnote', noteId: '1' }); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectIndependentStoryThreads(superdoc.page, result.deletedText, result.insertedText); +}); diff --git a/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts b/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts index 729eb48154..c94b7f9779 100644 --- a/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts @@ -28,6 +28,8 @@ type TrackedSegment = { type: TrackChangeType; }; +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; + async function listTrackedSegments(page: Page): Promise { return page.evaluate(() => { const segments: Array<{ from: number; id: string; text: string; to: number; type: TrackChangeType }> = []; @@ -95,18 +97,18 @@ test.describe("trackedChanges.replacements='independent'", () => { await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(2); - const dialogs = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const dialogs = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text'), }); await expect(dialogs).toHaveCount(2); - await expect( - superdoc.page.locator('.comment-placeholder .comments-dialog .change-type', { hasText: 'Replaced' }), - ).toHaveCount(0); + await expect(superdoc.page.locator(`${TRACKED_CHANGE_DIALOGS} .change-type`, { hasText: 'Replaced' })).toHaveCount( + 0, + ); - const deletedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const deletedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-deleted', { hasText: 'ME' }), }); - const insertedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const insertedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-inserted', { hasText: 'it' }), }); diff --git a/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts b/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts index fc7c64d928..e1d8bd138b 100644 --- a/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts @@ -8,6 +8,8 @@ test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true, sho const TRACK_TEXT = 'ABCDE'; const PARTIAL_TEXT = 'BC'; const ACCEPT_TRACKED_CHANGES_BUTTON = 'Accept tracked changes'; +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; +const TRACKED_CHANGE_TEXT = `${TRACKED_CHANGE_DIALOGS} .tracked-change-text`; test('toolbar accept partially resolves a tracked insertion and updates the bubble text', async ({ superdoc }) => { await insertTrackedChange(superdoc.page, { from: 1, to: 1, text: TRACK_TEXT }); @@ -17,7 +19,7 @@ test('toolbar accept partially resolves a tracked insertion and updates the bubb await superdoc.setTextSelection(selectionStart, selectionStart + PARTIAL_TEXT.length); await superdoc.waitForStable(); - const trackedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const trackedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-inserted', { hasText: TRACK_TEXT }), }); await expect(trackedDialog).toBeVisible(); @@ -29,7 +31,7 @@ test('toolbar accept partially resolves a tracked insertion and updates the bubb await expect.poll(() => getDocumentText(superdoc.page)).toBe(TRACK_TEXT); await expect.poll(() => getMarkedText(superdoc.page, 'trackInsert')).toBe('ADE'); - await expect(superdoc.page.locator('.comment-placeholder .comments-dialog .tracked-change-text')).toBeVisible(); + await expect(superdoc.page.locator(TRACKED_CHANGE_TEXT)).toBeVisible(); await superdoc.snapshot('tracked-change-partial-insert-after-accept'); }); @@ -49,7 +51,7 @@ test('context menu reject partially resolves a tracked insertion and updates the await expect.poll(() => getSelectedText(superdoc.page)).toBe(PARTIAL_TEXT); - const trackedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const trackedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-inserted', { hasText: TRACK_TEXT }), }); await expect(trackedDialog).toBeVisible(); @@ -66,7 +68,7 @@ test('context menu reject partially resolves a tracked insertion and updates the await expect.poll(() => getDocumentText(superdoc.page)).toBe('ADE'); await expect.poll(() => getMarkedText(superdoc.page, 'trackInsert')).toBe('ADE'); - await expect(superdoc.page.locator('.comment-placeholder .comments-dialog .tracked-change-text')).toBeVisible(); + await expect(superdoc.page.locator(TRACKED_CHANGE_TEXT)).toBeVisible(); await superdoc.snapshot('tracked-change-partial-insert-after-context-reject'); }); diff --git a/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts b/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts index 65f0612be2..04642dffb9 100644 --- a/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts +++ b/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts @@ -13,10 +13,11 @@ type ChangeType = 'addition' | 'deletion' | 'replacement'; type Decision = 'accept' | 'reject'; const CHANGE_TYPES: ChangeType[] = ['addition', 'deletion', 'replacement']; +const trackedChangePanelSelector = '#comments-panel .comment-item .comments-dialog:not(.is-resolved)'; const getUnresolvedTrackedBubbleCount = async (page: Page): Promise => page - .locator('.superdoc__right-sidebar .comment-placeholder .comments-dialog:not(.is-resolved)', { + .locator(trackedChangePanelSelector, { has: page.locator('.tracked-change-text'), }) .count(); @@ -134,8 +135,8 @@ for (const changeType of CHANGE_TYPES) { } test('partial undo updates tracked-change bubble text to match the document (SD-2277)', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const bubbleText = sidebar.locator('.tracked-change-text.is-inserted'); + const commentsPanel = superdoc.page.locator('#comments-panel'); + const bubbleText = commentsPanel.locator('.tracked-change-text.is-inserted'); await assertDocumentApiReady(superdoc.page); await superdoc.setDocumentMode('suggesting'); @@ -148,6 +149,7 @@ test('partial undo updates tracked-change bubble text to match the document (SD- await superdoc.waitForStable(); await expectTrackedState(superdoc.page, { changes: 1, bubbles: 1 }); + await expect(commentsPanel).toContainText('hello world'); await expect(bubbleText).toContainText('hello world'); await superdoc.undo(); diff --git a/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts b/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts index e58a39eb5d..091f6449f6 100644 --- a/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts +++ b/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts @@ -12,9 +12,14 @@ async function historyRedo(superdoc: Pick) { return superdoc.page.evaluate(() => (window as any).editor.doc.history.redo()); } +const trackedChangePanel = (superdoc: SuperDocFixture) => superdoc.page.locator('#comments-panel'); + +const trackedChangePanelEntries = (superdoc: SuperDocFixture) => + trackedChangePanel(superdoc).locator('.tracked-change-text'); + test('undo tracked insertion removes suggestion bubble and sidebar entry', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const sidebarTrackedChange = sidebar.locator('.tracked-change-text'); + const commentsPanel = trackedChangePanel(superdoc); + const panelTrackedChange = trackedChangePanelEntries(superdoc); await superdoc.setDocumentMode('suggesting'); await superdoc.waitForStable(); @@ -23,15 +28,15 @@ test('undo tracked insertion removes suggestion bubble and sidebar entry', async await superdoc.waitForStable(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect(sidebar).toBeVisible(); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect(commentsPanel).toBeVisible(); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); const result = await historyUndo(superdoc); await superdoc.waitForStable(); expect(result.noop).toBe(false); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(0); - await expect(sidebarTrackedChange).toHaveCount(0); + await expect(panelTrackedChange).toHaveCount(0); await expect( superdoc.page.locator('.floating-comment > .comments-dialog', { has: superdoc.page.locator('.tracked-change-text'), @@ -40,8 +45,7 @@ test('undo tracked insertion removes suggestion bubble and sidebar entry', async }); test('redo restores tracked insertion bubble and sidebar entry after undo', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const sidebarTrackedChange = sidebar.locator('.tracked-change-text'); + const panelTrackedChange = trackedChangePanelEntries(superdoc); await superdoc.setDocumentMode('suggesting'); await superdoc.waitForStable(); @@ -50,7 +54,7 @@ test('redo restores tracked insertion bubble and sidebar entry after undo', asyn await superdoc.waitForStable(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); const undoResult = await historyUndo(superdoc); @@ -58,7 +62,7 @@ test('redo restores tracked insertion bubble and sidebar entry after undo', asyn expect(undoResult.noop).toBe(false); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(0); - await expect(sidebarTrackedChange).toHaveCount(0); + await expect(panelTrackedChange).toHaveCount(0); await expect( superdoc.page.locator('.floating-comment > .comments-dialog', { has: superdoc.page.locator('.tracked-change-text'), @@ -70,13 +74,12 @@ test('redo restores tracked insertion bubble and sidebar entry after undo', asyn expect(redoResult.noop).toBe(false); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); }); test('redo is a no-op when the document did not change', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const sidebarTrackedChange = sidebar.locator('.tracked-change-text'); + const panelTrackedChange = trackedChangePanelEntries(superdoc); await superdoc.setDocumentMode('suggesting'); await superdoc.waitForStable(); @@ -85,7 +88,7 @@ test('redo is a no-op when the document did not change', async ({ superdoc }) => await superdoc.waitForStable(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); const textBeforeRedo = await getDocumentText(superdoc.page); @@ -95,6 +98,6 @@ test('redo is a no-op when the document did not change', async ({ superdoc }) => expect(result.noop).toBe(true); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); expect(await getDocumentText(superdoc.page)).toBe(textBeforeRedo); }); diff --git a/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts new file mode 100644 index 0000000000..3f6bd073fa --- /dev/null +++ b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_ENDNOTES_DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateNote, + expectActiveStoryTextToContain, + getBodyStoryText, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; + +test.use({ + config: { + showCaret: true, + showSelection: true, + }, +}); + +test('double-click rendered endnote to edit it through the presentation surface', async ({ superdoc, browserName }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host endnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(BASIC_ENDNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const bodyBefore = await getBodyStoryText(superdoc.page); + const endnote = await activateNote(superdoc, { + storyType: 'endnote', + noteId: '1', + expectedText: 'This is a simple endnote', + }); + + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: 'endnote', + noteId: '1', + }); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' edited'); + await superdoc.waitForStable(); + await expect(endnote).toContainText('This is a simple endnote edited'); + + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'simple endnote edite'); + await expect(endnote).toContainText('This is a simple endnote edite'); + + expect(await getBodyStoryText(superdoc.page)).toBe(bodyBefore); +}); diff --git a/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts new file mode 100644 index 0000000000..60b16978be --- /dev/null +++ b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts @@ -0,0 +1,868 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + BASIC_FOOTNOTES_DOC_PATH as DOC_PATH, + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; + +test.use({ config: { showCaret: true, showSelection: true } }); + +type FootnoteBehaviorHarness = { + page: Page; + loadDocument: (docPath: string) => Promise; + waitForStable: (ms?: number) => Promise; +}; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getTextClickPoint(locator: Locator, searchText: string, offsetWithinMatch = 0) { + return locator.evaluate( + (element, params: { searchText: string; offsetWithinMatch: number }) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) { + return null; + } + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd(currentNode, clampedOffset); + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallbackRect = currentNode.parentElement?.getBoundingClientRect(); + if (!fallbackRect) { + return null; + } + return { + x: fallbackRect.left + 2, + y: fallbackRect.top + fallbackRect.height / 2, + }; + } + + return { + x: rect.left + 1, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch }, + ); +} + +async function getBoundaryClickPoint(locator: Locator, searchText: string, offsetWithinMatch = 0) { + return locator.evaluate( + (element, params: { searchText: string; offsetWithinMatch: number }) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) { + return null; + } + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd(currentNode, clampedOffset); + const rect = range.getBoundingClientRect(); + if (!rect) { + return null; + } + + return { + x: rect.left + 0.5, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch }, + ); +} + +async function getWordRect(locator: Locator, searchText: string) { + return locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining < textLength) { + const range = doc.createRange(); + const endOffset = Math.min(textLength, remaining + targetText.length); + range.setStart(currentNode, remaining); + range.setEnd(currentNode, endOffset); + const rect = range.getBoundingClientRect(); + if (!rect) { + return null; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); +} + +async function getSelectionOverlayRect(page: Page) { + const selectionRect = page.locator('.presentation-editor__selection-rect').first(); + await expect(selectionRect).toBeVisible(); + const box = await selectionRect.boundingBox(); + expect(box).toBeTruthy(); + return box!; +} + +async function expectVisibleCaret(page: Page) { + const caret = page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); + const box = await caret.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.y).toBeGreaterThanOrEqual(0); + return box!; +} + +async function expectCaretAlignedToVisibleBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, + tolerancePx = 3, +) { + const boundaryPoint = await getBoundaryClickPoint(footnote, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + const caretBox = await expectVisibleCaret(page); + expect(Math.abs(caretBox.x - boundaryPoint!.x)).toBeLessThanOrEqual(tolerancePx); +} + +async function getActiveSelectionPosition(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + return activeEditor?.state?.selection?.from ?? null; + }); +} + +async function getHitTestPosition(page: Page, x: number, y: number) { + return page.evaluate( + ({ x: clientX, y: clientY }) => { + const hit = (window as any).editor?.presentationEditor?.hitTest?.(clientX, clientY); + return hit?.pos ?? null; + }, + { x, y }, + ); +} + +async function getActiveStorySession(page: Page) { + return page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator ?? null; + }); +} + +async function expectInsertedMarkerBeforeEdited(footnote: Locator) { + const text = await footnote.textContent(); + expect(text).toBeTruthy(); + + const insertedIndex = text!.indexOf('X'); + const editedIndex = text!.indexOf('edited'); + + expect(insertedIndex).toBeGreaterThanOrEqual(0); + expect(editedIndex).toBeGreaterThan(insertedIndex); +} + +async function getActiveStoryText(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + return activeEditor?.state?.doc?.textBetween?.(0, activeEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +async function getBodyStoryText(page: Page) { + return page.evaluate(() => { + const bodyEditor = (window as any).editor; + return bodyEditor?.state?.doc?.textBetween?.(0, bodyEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +function getFootnoteLocator(page: Page, noteId: string): Locator { + return page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); +} + +function getBodyFragmentLocator(page: Page, text: string): Locator { + return page + .locator('[data-block-id]:not([data-block-id^="footnote-"]):not([data-block-id^="__sd_semantic_footnote-"])') + .filter({ hasText: text }) + .first(); +} + +async function insertTextIntoBodyAtVisibleBoundary( + page: Page, + bodySurface: Locator, + searchText: string, + offsetWithinMatch: number, + insertedText: string, +): Promise { + const boundaryPoint = await getBoundaryClickPoint(bodySurface, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + const hitPosition = await getHitTestPosition(page, boundaryPoint!.x, boundaryPoint!.y); + expect(hitPosition).not.toBeNull(); + + await page.evaluate( + ({ position, text }) => { + const editor = (window as any).editor; + if (!editor?.view) { + throw new Error('Body editor view is unavailable.'); + } + + editor.view.dispatch(editor.state.tr.insertText(text, position, position)); + }, + { position: hitPosition, text: insertedText }, + ); + + return hitPosition!; +} + +async function loadAndActivateFootnote( + superdoc: FootnoteBehaviorHarness, + noteId: string, + expectedText: string, + docPath = DOC_PATH, +): Promise { + await superdoc.loadDocument(docPath); + await superdoc.waitForStable(); + + const footnote = getFootnoteLocator(superdoc.page, noteId); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(footnote).toContainText(expectedText); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await expectVisibleCaret(superdoc.page); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ + kind: 'story', + storyType: 'footnote', + noteId, + }); + + return footnote; +} + +async function clickFootnoteBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, +): Promise<{ x: number; y: number }> { + const boundaryPoint = await getBoundaryClickPoint(footnote, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + await page.mouse.click(boundaryPoint!.x, boundaryPoint!.y); + return boundaryPoint!; +} + +async function expectCaretAtClickBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, +): Promise { + const boundaryPoint = await clickFootnoteBoundary(page, footnote, searchText, offsetWithinMatch); + await expect(page.locator('.presentation-editor__selection-caret').first()).toBeVisible(); + await expect.poll(() => getActiveSelectionPosition(page)).not.toBeNull(); + + const selectionAfterClick = await getActiveSelectionPosition(page); + const hitAfterClick = await getHitTestPosition(page, boundaryPoint.x, boundaryPoint.y); + const domSelectionAfterClick = await getActiveDomSelection(page); + + expect(selectionAfterClick).not.toBeNull(); + expect(selectionAfterClick).toBe(hitAfterClick); + expect(domSelectionAfterClick?.anchorPos).toBe(selectionAfterClick); + + return selectionAfterClick!; +} + +async function expectStoryText(page: Page, expectedText: string) { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toBe(normalizeText(expectedText)); +} + +async function expectStoryTextToContain(page: Page, expectedText: string) { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toContain(normalizeText(expectedText)); +} + +async function getActiveDomSelection(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const view = activeEditor?.view; + const selection = view?.dom?.ownerDocument?.getSelection?.(); + if (!view || !selection || !selection.anchorNode) { + return null; + } + + const anchorInside = view.dom.contains(selection.anchorNode); + const focusInside = selection.focusNode ? view.dom.contains(selection.focusNode) : false; + + let anchorPos = null; + let focusPos = null; + try { + if (anchorInside) { + anchorPos = view.posAtDOM(selection.anchorNode, selection.anchorOffset, -1); + } + if (focusInside && selection.focusNode) { + focusPos = view.posAtDOM(selection.focusNode, selection.focusOffset, -1); + } + } catch {} + + return { + anchorInside, + focusInside, + anchorOffset: selection.anchorOffset, + focusOffset: selection.focusOffset, + anchorPos, + focusPos, + text: selection.toString(), + }; + }); +} + +test('double-click rendered footnote to edit it through the presentation surface', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + const storyHost = superdoc.page.locator('.presentation-editor__story-hidden-host[data-story-kind="note"]').first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + if (browserName === 'firefox') { + await superdoc.page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + activeEditor?.commands?.insertContent?.(' edited'); + }); + } else { + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' edited'); + } + await superdoc.waitForStable(); + if (browserName !== 'firefox') { + await expect(footnote).toContainText('This is a simple footnote edited', { timeout: 10_000 }); + const selectionAtEnd = await getActiveSelectionPosition(superdoc.page); + expect(selectionAtEnd).not.toBeNull(); + + const startPoint = await getTextClickPoint(footnote, 'This', 0); + expect(startPoint).toBeTruthy(); + await superdoc.page.mouse.click(startPoint!.x, startPoint!.y); + await superdoc.waitForStable(); + await expectVisibleCaret(superdoc.page); + const selectionAfterClick = await getActiveSelectionPosition(superdoc.page); + expect(selectionAfterClick).not.toBeNull(); + expect(selectionAfterClick!).toBeLessThan(selectionAtEnd!); + + await superdoc.page.keyboard.insertText('X'); + await superdoc.waitForStable(); + await expectInsertedMarkerBeforeEdited(footnote); + } + + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + await expectInsertedMarkerBeforeEdited(footnote); +}); + +test('clicking inside footnote text inserts at the exact requested character boundary', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 4); + + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + + await expectStoryTextToContain(superdoc.page, 'footanote'); + await expect(footnote).toContainText('footanote'); +}); + +test('footnote caret placement supports inserts at the note start, inside a word, and at the note end', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'This', 0); + await superdoc.page.keyboard.insertText('X'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footnote'); + await expect(footnote).toContainText('XThis is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 4); + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footanote'); + await expect(footnote).toContainText('XThis is a simple footanote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footanote', 'footanote'.length); + await superdoc.page.keyboard.insertText('!'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footanote!'); + await expect(footnote).toContainText('XThis is a simple footanote!'); +}); + +test('footnote caret placement stays correct on later note lines above table content', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '2', 'A longer one with a table'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'with', 1); + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + + await expectStoryTextToContain(superdoc.page, 'A longer one waith a table'); + await expectStoryTextToContain(superdoc.page, 'And multi-paragraph content'); + await expect(footnote).toContainText('A longer one waith a table'); +}); + +test('footnote backspace deletes the character immediately before the visible caret', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'simple', 3); + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + + await expectStoryText(superdoc.page, 'This is a siple footnote'); + await expect(footnote).toContainText('This is a siple footnote'); +}); + +test('double-click word selection stays horizontally aligned with rendered footnote text', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + const simplePoint = await getTextClickPoint(footnote, 'simple', 2); + const simpleRect = await getWordRect(footnote, 'simple'); + expect(simplePoint).toBeTruthy(); + expect(simpleRect).toBeTruthy(); + + await superdoc.page.mouse.dblclick(simplePoint!.x, simplePoint!.y); + await superdoc.waitForStable(); + + const domSelectionAfterClick = await getActiveDomSelection(superdoc.page); + expect(domSelectionAfterClick?.text).toBe('simple'); + + const overlayRect = await getSelectionOverlayRect(superdoc.page); + expect(Math.abs(overlayRect.x - simpleRect!.left)).toBeLessThanOrEqual(2.5); + expect(Math.abs(overlayRect.width - simpleRect!.width)).toBeLessThanOrEqual(3); +}); + +test.describe('suggesting mode routing', () => { + test.use({ + config: { + toolbar: 'full', + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, + }); + + test('typing stays in the active footnote even if body focus is restored underneath the session', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'simple', 3); + + const originalBodyText = await getBodyStoryText(superdoc.page); + expect(originalBodyText).toContain('Simple text'); + + await superdoc.page.evaluate(() => { + (window as any).editor?.view?.focus?.(); + }); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const bodyEditor = (window as any).editor; + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + + return { + bodyHasFocus: bodyEditor?.view?.hasFocus?.() ?? false, + activeIsBody: activeEditor === bodyEditor, + sessionLocator: session?.locator ?? null, + }; + }), + ) + .toEqual({ + bodyHasFocus: true, + activeIsBody: false, + sessionLocator: { + kind: 'story', + storyType: 'footnote', + noteId: '1', + }, + }); + + await superdoc.page.keyboard.type('Z'); + await superdoc.waitForStable(); + + await expectStoryText(superdoc.page, 'This is a simZple footnote'); + await expect(footnote).toContainText('This is a simZple footnote'); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(originalBodyText); + }); + + test('tracked inserts keep the active footnote caret aligned with the rendered insertion point', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote( + superdoc, + '1', + 'If only one closing is contemplated', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'references', 3); + + let insertedText = ''; + for (const nextChar of ['X', 'Y', 'Z']) { + insertedText += nextChar; + await superdoc.page.keyboard.insertText(nextChar); + await superdoc.waitForStable(300); + + await expectStoryTextToContain(superdoc.page, `ref${insertedText}erences`); + await expect(footnote).toContainText(`ref${insertedText}erences`); + await expectCaretAlignedToVisibleBoundary( + superdoc.page, + footnote, + `ref${insertedText}erences`, + 3 + insertedText.length, + ); + } + }); + + test('word selection overlay stays aligned after a tracked insert splits the note text', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote( + superdoc, + '1', + 'If only one closing is contemplated', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'references', 3); + await superdoc.page.keyboard.insertText('XYZ'); + await superdoc.waitForStable(300); + await expect(footnote).toContainText('refXYZerences'); + + const selectedWord = 'Closing'; + const selectedWordPoint = await getTextClickPoint(footnote, selectedWord, 2); + const selectedWordRect = await getWordRect(footnote, selectedWord); + expect(selectedWordPoint).toBeTruthy(); + expect(selectedWordRect).toBeTruthy(); + + await superdoc.page.mouse.dblclick(selectedWordPoint!.x, selectedWordPoint!.y); + await superdoc.waitForStable(); + + const domSelectionAfterClick = await getActiveDomSelection(superdoc.page); + expect(domSelectionAfterClick?.text).toBe(selectedWord); + + const overlayRect = await getSelectionOverlayRect(superdoc.page); + expect(Math.abs(overlayRect.x - selectedWordRect!.left)).toBeLessThanOrEqual(2.5); + expect(Math.abs(overlayRect.width - selectedWordRect!.width)).toBeLessThanOrEqual(3); + }); + + test('footnote clicks stay accurately mapped after returning to the body in suggesting mode', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(1000); + + const footnote = getFootnoteLocator(superdoc.page, '1'); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(footnote).toContainText('This is a simple footnote'); + + const noteBox = await footnote.boundingBox(); + expect(noteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(noteBox!.x + noteBox!.width / 2, noteBox!.y + noteBox!.height / 2); + await superdoc.waitForStable(300); + + const initialBoundary = await getBoundaryClickPoint(footnote, 'simple', 3); + expect(initialBoundary).toBeTruthy(); + await superdoc.page.mouse.click(initialBoundary!.x, initialBoundary!.y); + await superdoc.waitForStable(200); + await superdoc.page.keyboard.type('Z'); + await superdoc.waitForStable(300); + + await expectStoryText(superdoc.page, 'This is a simZple footnote'); + await expect(footnote).toContainText('This is a simZple footnote'); + + const bodySurface = getBodyFragmentLocator(superdoc.page, 'Simple text'); + const bodyBox = await bodySurface.boundingBox(); + expect(bodyBox).toBeTruthy(); + await superdoc.page.mouse.click(bodyBox!.x + bodyBox!.width / 2, bodyBox!.y + bodyBox!.height / 2); + await superdoc.waitForStable(300); + + const bodyTextAfterReturn = await getBodyStoryText(superdoc.page); + expect(bodyTextAfterReturn).toContain('Simple text'); + + // First click re-enters the note. + const reentryActivationBoundary = await getBoundaryClickPoint(footnote, 'footnote', 2); + expect(reentryActivationBoundary).toBeTruthy(); + await superdoc.page.mouse.click(reentryActivationBoundary!.x, reentryActivationBoundary!.y); + await superdoc.waitForStable(300); + + // Second click inside the now-active note must still map to the exact + // requested boundary after the tracked insert. + const reentryBoundary = await getBoundaryClickPoint(footnote, 'simZple', 4); + expect(reentryBoundary).toBeTruthy(); + await superdoc.page.mouse.click(reentryBoundary!.x, reentryBoundary!.y); + await superdoc.waitForStable(300); + + const reentryState = await superdoc.page.evaluate(({ x, y }) => { + const editor = (window as any).editor; + const presentation = editor?.presentationEditor; + const activeEditor = presentation?.getActiveEditor?.(); + const session = presentation?.getStorySessionManager?.()?.getActiveSession?.(); + const view = activeEditor?.view; + const selection = activeEditor?.state?.selection?.from ?? null; + const hit = presentation?.hitTest?.(x, y)?.pos ?? null; + const domSelection = view?.dom?.ownerDocument?.getSelection?.(); + + let anchorPos = null; + try { + if (view && domSelection?.anchorNode && view.dom.contains(domSelection.anchorNode)) { + anchorPos = view.posAtDOM(domSelection.anchorNode, domSelection.anchorOffset, -1); + } + } catch {} + + return { + session: session?.locator ?? null, + selection, + hit, + anchorPos, + }; + }, reentryBoundary!); + + expect(reentryState.session).toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + expect(reentryState.selection).toBe(reentryState.hit); + expect(reentryState.anchorPos).toBe(reentryState.selection); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(bodyTextAfterReturn); + }); + + test('body edits do not corrupt footnote click mapping after a footnote edit', async ({ superdoc, browserName }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 1); + await superdoc.page.keyboard.insertText('X0'); + await superdoc.waitForStable(300); + await expect(footnote).toContainText('fX0ootnote'); + + await superdoc.page.evaluate(() => { + (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.exit?.(); + }); + await superdoc.waitForStable(300); + await expect.poll(() => getActiveStorySession(superdoc.page)).toBeNull(); + + const bodySurface = getBodyFragmentLocator(superdoc.page, 'Simple text'); + await insertTextIntoBodyAtVisibleBoundary(superdoc.page, bodySurface, 'footnotes', 1, 'X0'); + await superdoc.waitForStable(300); + + const bodyTextAfterBodyEdit = await getBodyStoryText(superdoc.page); + expect(bodyTextAfterBodyEdit).toContain('fX0ootnotes'); + await expect(footnote).toContainText('fX0ootnote'); + + await clickFootnoteBoundary(superdoc.page, footnote, 'fX0ootnote', 4); + await superdoc.waitForStable(300); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'fX0ootnote', 6); + + await superdoc.page.keyboard.insertText('Z'); + await superdoc.waitForStable(300); + + await expect(footnote).toContainText('fX0ootZnote'); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(bodyTextAfterBodyEdit); + }); + + test('complex imported footnotes stay aligned when the note starts with hidden separator content', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + let footnote = await loadAndActivateFootnote( + superdoc, + '1', + 'If only one closing is contemplated', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'contemplated', 1); + await superdoc.page.keyboard.insertText('x'); + await superdoc.waitForStable(300); + await expectStoryTextToContain(superdoc.page, 'cxontemplated'); + await expect(footnote).toContainText('cxontemplated'); + }); + + test('complex imported footnotes stay aligned when the note contains hidden field-code passthrough nodes', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + let footnote = await loadAndActivateFootnote( + superdoc, + '2', + 'The Company may have tax reporting', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'reporting', 1); + await superdoc.page.keyboard.insertText('x'); + await superdoc.waitForStable(300); + await expectStoryTextToContain(superdoc.page, 'rxeporting'); + await expect(footnote).toContainText('rxeporting'); + }); +}); diff --git a/tests/behavior/tests/headers/double-click-edit-header.spec.ts b/tests/behavior/tests/headers/double-click-edit-header.spec.ts index 6a2808dd6a..4f11f54da4 100644 --- a/tests/behavior/tests/headers/double-click-edit-header.spec.ts +++ b/tests/behavior/tests/headers/double-click-edit-header.spec.ts @@ -1,89 +1,366 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test, expect } from '../../fixtures/superdoc.js'; +import { test, expect, type Locator, type Page, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { LONGER_HEADER_SIGN_AREA_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateFooter, + activateHeader, + getRenderedTextPoint, + clickTextBoundary, + expectActiveStoryTextToContain, + getActiveStorySession, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); +test.use({ config: { showCaret: true, showSelection: true } }); -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available โ€” run pnpm corpus:pull'); +async function measureRenderedWordRect(locator: Locator, searchText: string) { + const rect = await locator.evaluate((element, expectedText) => { + const doc = element.ownerDocument; + if (!doc) { + return null; + } -test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { - await superdoc.loadDocument(DOC_PATH); + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const text = node.textContent ?? ''; + const matchIndex = text.indexOf(expectedText); + if (matchIndex >= 0) { + const range = doc.createRange(); + range.setStart(node, matchIndex); + range.setEnd(node, matchIndex + expectedText.length); + const bounds = range.getBoundingClientRect(); + const containerBounds = element.getBoundingClientRect(); + return { + left: bounds.left - containerBounds.left, + top: bounds.top - containerBounds.top, + width: bounds.width, + height: bounds.height, + }; + } + node = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(rect).toBeTruthy(); + return rect!; +} + +async function expectRenderedSurfaceStable( + page: Page, + surface: Locator, + word: string, + activate: () => Promise, +): Promise { + const before = await measureRenderedWordRect(surface, word); + await activate(); + + await expect(page.locator('.superdoc-header-editor-host, .superdoc-footer-editor-host')).toHaveCount(0); + + const after = await measureRenderedWordRect(surface, word); + expect(Math.abs(after.left - before.left)).toBeLessThan(1); + expect(Math.abs(after.top - before.top)).toBeLessThan(1); + expect(Math.abs(after.width - before.width)).toBeLessThan(1); + expect(Math.abs(after.height - before.height)).toBeLessThan(1); +} + +async function expectVisibleCaretNearClickedBoundary( + page: Page, + surface: Locator, + word: string, + offsetWithinWord = 0, +): Promise { + const point = await clickTextBoundary(page, surface, word, offsetWithinWord); + await page.waitForTimeout(100); + + const caret = page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); + await page.waitForTimeout(950); + const opacity = await caret.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity || '0')); + expect(opacity).toBeGreaterThan(0.2); + + const caretMetrics = await caret.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }); + + expect(Math.abs(caretMetrics.left - point.x)).toBeLessThanOrEqual(8); + expect(Math.abs(caretMetrics.top + caretMetrics.height / 2 - point.y)).toBeLessThanOrEqual(3); + expect(caretMetrics.height).toBeGreaterThan(8); +} + +async function expectVisibleCaretAfterActivationDoubleClick(page: Page, surface: Locator, word: string): Promise { + const point = await getRenderedTextPoint(surface, word); + await page.mouse.dblclick(point.x, point.y); + await waitForActiveStory(page, { storyType: 'headerFooterPart' }); + await page.waitForTimeout(150); + + const caret = page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); + + const caretMetrics = await caret.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }); + + expect(Math.abs(caretMetrics.left - point.x)).toBeLessThanOrEqual(8); + expect(Math.abs(caretMetrics.top + caretMetrics.height / 2 - point.y)).toBeLessThanOrEqual(3); + expect(caretMetrics.height).toBeGreaterThan(8); +} + +async function expectBlankDocumentHeaderCaretAfterActivation(superdoc: SuperDocFixture): Promise { + const pageSurface = superdoc.page.locator('.superdoc-page').first(); + await pageSurface.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await pageSurface.boundingBox(); + expect(box).toBeTruthy(); + + const activationPoint = { + x: box!.x + 120, + y: box!.y + 60, + }; + + await superdoc.page.mouse.dblclick(activationPoint.x, activationPoint.y); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); await superdoc.waitForStable(); - // Header should be visible - const header = superdoc.page.locator('.superdoc-page-header').first(); - await header.waitFor({ state: 'visible', timeout: 15_000 }); + const caret = superdoc.page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); - // Double-click at the header's coordinates (header has pointer-events:none, - // so we must use raw mouse to reach the viewport host's dblclick handler) - const box = await header.boundingBox(); + const caretMetrics = await caret.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + height: rect.height, + }; + }); + + expect(Math.abs(caretMetrics.left - activationPoint.x)).toBeLessThanOrEqual(80); + expect(Math.abs(caretMetrics.top + caretMetrics.height / 2 - activationPoint.y)).toBeLessThanOrEqual(40); + expect(caretMetrics.height).toBeGreaterThan(8); +} + +async function expectHoverAffordanceForSurface( + superdoc: SuperDocFixture, + surface: Locator, + tooltipText: string, +): Promise { + await surface.scrollIntoViewIfNeeded(); + await superdoc.waitForStable(); + + const box = await surface.boundingBox(); expect(box).toBeTruthy(); - await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + + await superdoc.page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + + const overlay = superdoc.page.locator('.presentation-editor__hover-overlay'); + const tooltip = superdoc.page.locator('.presentation-editor__hover-tooltip'); + await expect(overlay).toBeVisible(); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText(tooltipText); + + const overlayBox = await overlay.boundingBox(); + expect(overlayBox).toBeTruthy(); + expect(Math.abs(overlayBox!.x - box!.x)).toBeLessThan(4); + expect(Math.abs(overlayBox!.y - box!.y)).toBeLessThan(4); + expect(Math.abs(overlayBox!.width - box!.width)).toBeLessThan(4); + expect(Math.abs(overlayBox!.height - box!.height)).toBeLessThan(4); +} + +async function exitToBody(superdoc: SuperDocFixture) { + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + + if (await getActiveStorySession(superdoc.page)) { + const bodyLine = superdoc.page.locator('.superdoc-line').first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + await bodyLine.click(); + await superdoc.waitForStable(); + } + + await waitForActiveStory(superdoc.page, null); +} + +test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // After dblclick, SuperDoc creates a separate editor host for the header - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + await activateHeader(superdoc); - // Focus the PM editor inside the host, select all, move to end, then insert text - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + // Editing runs through the hidden-host PM while the visible header remains painted. await superdoc.page.keyboard.press('End'); - // Use insertText instead of type() to avoid character-by-character key events - // which may trigger PM shortcuts await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); - // Editor host should contain the typed text - await expect(editorHost).toContainText('Edited'); + await exitToBody(superdoc); - // Press Escape to exit header edit mode - await superdoc.page.keyboard.press('Escape'); + await activateHeader(superdoc); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); + + await superdoc.snapshot('header-edited'); +}); + +test('activating a header keeps the painted header stable and does not show a visible editor host', async ({ + superdoc, +}) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // After exiting, the static header is re-rendered with the edited content - await expect(header).toContainText('Edited'); + const headerSurface = superdoc.page.locator('.superdoc-page-header').first(); + await expectRenderedSurfaceStable(superdoc.page, headerSurface, 'Generic', async () => { + await activateHeader(superdoc); + }); +}); - await superdoc.snapshot('header-edited'); +test('header editing shows a visible caret at the clicked boundary', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const headerSurface = await activateHeader(superdoc); + await expectVisibleCaretNearClickedBoundary(superdoc.page, headerSurface, 'Generic', 3); }); -test('double-click footer to enter edit mode, type, and exit', async ({ superdoc }) => { +test('double-clicking into an inactive header places the initial caret at the clicked word', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // Footer should be visible โ€” scroll into view first since it's at page bottom - const footer = superdoc.page.locator('.superdoc-page-footer').first(); - await footer.scrollIntoViewIfNeeded(); - await footer.waitFor({ state: 'visible', timeout: 15_000 }); + const headerSurface = superdoc.page.locator('.superdoc-page-header').first(); + await expectVisibleCaretAfterActivationDoubleClick(superdoc.page, headerSurface, 'Generic'); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); +}); - // Double-click at the footer's coordinates - const box = await footer.boundingBox(); - expect(box).toBeTruthy(); - await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); +test('double-click footer to enter edit mode, type, and exit', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // After dblclick, SuperDoc creates a separate editor host for the footer - const editorHost = superdoc.page.locator('.superdoc-footer-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + await activateFooter(superdoc); + + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); - // Focus the PM editor inside the host, select all, move to end, then insert text - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); await superdoc.page.keyboard.press('End'); await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); - // Editor host should contain the typed text - await expect(editorHost).toContainText('Edited'); + await exitToBody(superdoc); - // Press Escape to exit footer edit mode - await superdoc.page.keyboard.press('Escape'); + await activateFooter(superdoc); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); + + await superdoc.snapshot('footer-edited'); +}); + +test('activating a footer keeps the painted footer stable and does not show a visible editor host', async ({ + superdoc, +}) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // After exiting, the static footer is re-rendered with the edited content - await expect(footer).toContainText('Edited'); + const footerSurface = superdoc.page.locator('.superdoc-page-footer').first(); + await expectRenderedSurfaceStable(superdoc.page, footerSurface, 'Footer', async () => { + await activateFooter(superdoc); + }); +}); - await superdoc.snapshot('footer-edited'); +test('footer editing shows a visible caret at the clicked boundary', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const footerSurface = await activateFooter(superdoc); + await expectVisibleCaretNearClickedBoundary(superdoc.page, footerSurface, 'Footer', 2); +}); + +test('double-clicking into an inactive footer places the initial caret at the clicked word', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const footerSurface = superdoc.page.locator('.superdoc-page-footer').first(); + await footerSurface.scrollIntoViewIfNeeded(); + await expectVisibleCaretAfterActivationDoubleClick(superdoc.page, footerSurface, 'Footer'); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); +}); + +test('double-clicking a footer while a header is active switches directly to the footer session', async ({ + superdoc, +}) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + const headerStory = await getActiveStorySession(superdoc.page); + expect(headerStory).toEqual(expect.objectContaining({ storyType: 'headerFooterPart' })); + const headerRefId = headerStory && 'refId' in headerStory ? headerStory.refId : null; + + const footerSurface = superdoc.page.locator('.superdoc-page-footer').first(); + await footerSurface.scrollIntoViewIfNeeded(); + const footerBox = await footerSurface.boundingBox(); + expect(footerBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(footerBox!.x + footerBox!.width / 2, footerBox!.y + footerBox!.height / 2); + await superdoc.waitForStable(); + + await expectActiveStoryTextToContain(superdoc.page, 'Footer'); + const footerStory = await getActiveStorySession(superdoc.page); + expect(footerStory).toEqual(expect.objectContaining({ storyType: 'headerFooterPart' })); + const footerRefId = footerStory && 'refId' in footerStory ? footerStory.refId : null; + expect(footerRefId).not.toBe(headerRefId); +}); + +test('editing a header shows the active header/footer divider', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + + const divider = superdoc.page.locator('.superdoc-header-footer-border'); + await expect(divider).toHaveCount(1); + await expect(divider.first()).toBeVisible(); +}); + +test('editing a header still shows the footer hover affordance', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + await expect(superdoc.page.locator('.superdoc-header-footer-border')).toHaveCount(1); + + const footerSurface = superdoc.page.locator('.superdoc-page-footer').first(); + await expectHoverAffordanceForSurface(superdoc, footerSurface, 'Double-click to edit footer'); + await expect(superdoc.page.locator('.superdoc-header-footer-border')).toHaveCount(1); +}); + +test('editing a footer still shows the header hover affordance', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + await activateFooter(superdoc); + await expect(superdoc.page.locator('.superdoc-header-footer-border')).toHaveCount(1); + + const headerSurface = superdoc.page.locator('.superdoc-page-header').first(); + await expectHoverAffordanceForSurface(superdoc, headerSurface, 'Double-click to edit header'); + await expect(superdoc.page.locator('.superdoc-header-footer-border')).toHaveCount(1); +}); + +test('blank document header activation shows a visible caret', async ({ superdoc }) => { + await superdoc.waitForStable(); + await expectBlankDocumentHeaderCaretAfterActivation(superdoc); }); diff --git a/tests/behavior/tests/headers/footer-page-number-activation.spec.ts b/tests/behavior/tests/headers/footer-page-number-activation.spec.ts new file mode 100644 index 0000000000..78f83a78f6 --- /dev/null +++ b/tests/behavior/tests/headers/footer-page-number-activation.spec.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from '../../fixtures/superdoc.js'; +import { listTrackChanges } from '../../helpers/document-api.js'; +import { activateFooter } from '../../helpers/story-surfaces.js'; + +const FOOTER_PAGE_NUMBER_DOC_PATH = fileURLToPath( + new URL('../../test-data/footer-page-number-test.docx', import.meta.url), +); + +test.skip(!fs.existsSync(FOOTER_PAGE_NUMBER_DOC_PATH), 'Test document not available โ€” run pnpm corpus:pull'); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +test('activating a footer with page-number content does not create a tracked change', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTER_PAGE_NUMBER_DOC_PATH); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total).toBe(0); + + await activateFooter(superdoc); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total).toBe(0); +}); diff --git a/tests/behavior/tests/headers/header-footer-line-height.spec.ts b/tests/behavior/tests/headers/header-footer-line-height.spec.ts index 89bbb5a138..f6061a8387 100644 --- a/tests/behavior/tests/headers/header-footer-line-height.spec.ts +++ b/tests/behavior/tests/headers/header-footer-line-height.spec.ts @@ -1,12 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test, expect } from '../../fixtures/superdoc.js'; +import { H_F_NORMAL_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); - -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available โ€” run pnpm corpus:pull'); +test.use({ config: { showCaret: true, showSelection: true } }); test('header editor uses line-height 1, not the default 1.2', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); @@ -21,15 +16,10 @@ test('header editor uses line-height 1, not the default 1.2', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - // The ProseMirror element inside the header editor should have lineHeight: 1 - // (matching OOXML Header style w:line="240" w:lineRule="auto" = 240/240 = 1.0) - const pm = editorHost.locator('.ProseMirror'); - await expect(pm).toHaveCSS('line-height', /^\d+(\.\d+)?px$/); - - const lineHeight = await pm.evaluate((el) => el.style.lineHeight); + const pm = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror') + .first(); + const lineHeight = await pm.evaluate((el) => (el as HTMLElement).style.lineHeight); expect(lineHeight).toBe('1'); }); @@ -47,11 +37,10 @@ test('footer editor uses line-height 1, not the default 1.2', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-footer-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - const pm = editorHost.locator('.ProseMirror'); - const lineHeight = await pm.evaluate((el) => el.style.lineHeight); + const pm = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror') + .first(); + const lineHeight = await pm.evaluate((el) => (el as HTMLElement).style.lineHeight); expect(lineHeight).toBe('1'); }); @@ -68,12 +57,14 @@ test('body editor still uses default line-height 1.2', async ({ superdoc }) => { expect(lineHeight).toBe('1.2'); }); -test('header content is not clipped when entering edit mode', async ({ superdoc }) => { +test('header content remains visible while hidden-host editing is active', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); const header = superdoc.page.locator('.superdoc-page-header').first(); await header.waitFor({ state: 'visible', timeout: 15_000 }); + const beforeBox = await header.boundingBox(); + expect(beforeBox).toBeTruthy(); // Double-click to enter header edit mode const box = await header.boundingBox(); @@ -81,20 +72,13 @@ test('header content is not clipped when entering edit mode', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - // The ProseMirror content should not overflow the editor host container - const overflow = await editorHost.evaluate((host) => { - const pm = host.querySelector('.ProseMirror') as HTMLElement; - if (!pm) return { error: 'no PM' }; - return { - pmScrollHeight: pm.scrollHeight, - pmOffsetHeight: pm.offsetHeight, - hostHeight: host.offsetHeight, - isOverflowing: pm.scrollHeight > host.offsetHeight, - }; - }); - expect(overflow).not.toHaveProperty('error'); - expect(overflow.isOverflowing).toBe(false); + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + const afterBox = await header.boundingBox(); + expect(afterBox).toBeTruthy(); + expect(afterBox!.height).toBeGreaterThan(0); + expect(Math.abs((afterBox?.height ?? 0) - (beforeBox?.height ?? 0))).toBeLessThan(1); }); diff --git a/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts b/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts index 49ea916249..d46590be68 100644 --- a/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts +++ b/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts @@ -1,49 +1,49 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Locator, Page } from '@playwright/test'; import { expect, test } from '../../fixtures/superdoc.js'; +import { LONGER_HEADER_SIGN_AREA_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + getFooterEditorLocator, + getFooterSurfaceLocator, + getHeaderEditorLocator, + getHeaderSurfaceLocator, +} from '../../helpers/story-surfaces.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; test.use({ config: { showSelection: true } }); -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available โ€” run pnpm corpus:pull'); - -async function enterHeaderFooterEditMode( - page: Page, - surfaceSelector: string, - editorHostSelector: string, -): Promise { - const surface = page.locator(surfaceSelector).first(); +async function enterHeaderFooterEditMode(surface: Locator, editor: Locator): Promise { await surface.scrollIntoViewIfNeeded(); await surface.waitFor({ state: 'visible', timeout: 15_000 }); const box = await surface.boundingBox(); expect(box).toBeTruthy(); - await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); - - const editorHost = page.locator(editorHostSelector).first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + await surface.page().mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); - - return pm; + await editor.waitFor({ state: 'visible', timeout: 10_000 }); + return editor; } async function assertSelectionOverlayRenders( page: Page, - editor: Locator, + _editor: Locator, expectedSelectionText: string, ): Promise { - await editor.click(); await page.keyboard.press(`${MOD_KEY}+A`); await expect - .poll(async () => page.evaluate(() => document.getSelection()?.toString().trim() ?? '')) + .poll(async () => + page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const selection = activeEditor?.state?.selection; + const doc = activeEditor?.state?.doc; + if (!selection || !doc) { + return ''; + } + return doc.textBetween(selection.from, selection.to, '\n', '\n').trim(); + }), + ) .toBe(expectedSelectionText); await expect.poll(async () => page.locator('.presentation-editor__selection-rect').count()).toBeGreaterThan(0); @@ -52,14 +52,73 @@ async function assertSelectionOverlayRenders( await expect(selectionRect.first()).toBeVisible(); } +async function getRenderedWordRect(surface: Locator, word: string) { + const rect = await surface.evaluate((element, targetWord) => { + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const text = node.textContent ?? ''; + const matchIndex = text.indexOf(targetWord); + if (matchIndex >= 0) { + const range = doc.createRange(); + range.setStart(node, matchIndex); + range.setEnd(node, matchIndex + targetWord.length); + const bounds = range.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + }; + } + node = walker.nextNode() as Text | null; + } + + return null; + }, word); + + expect(rect).toBeTruthy(); + return rect!; +} + +async function assertWordSelectionOverlayAlignment(page: Page, surface: Locator, word: string): Promise { + const wordRect = await getRenderedWordRect(surface, word); + expect(wordRect).toBeTruthy(); + + await page.mouse.dblclick(wordRect.left + wordRect.width / 2, wordRect.top + wordRect.height / 2); + await page.waitForTimeout(100); + + const selectionRect = page.locator('.presentation-editor__selection-rect').first(); + await expect(selectionRect).toBeVisible(); + + const overlayRect = await selectionRect.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + }; + }); + + expect(Math.abs(overlayRect.left - wordRect.left)).toBeLessThan(2); + expect(Math.abs(overlayRect.top - wordRect.top)).toBeLessThan(2); + expect(Math.abs(overlayRect.width - wordRect.width)).toBeLessThan(2); + expect(Math.abs(overlayRect.height - wordRect.height)).toBeLessThan(2); +} + test('layout engine renders selection rectangles while editing a header', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); const editor = await enterHeaderFooterEditMode( - superdoc.page, - '.superdoc-page-header', - '.superdoc-header-editor-host', + getHeaderSurfaceLocator(superdoc.page), + getHeaderEditorLocator(superdoc.page), ); await assertSelectionOverlayRenders(superdoc.page, editor, 'Generic content header'); @@ -70,10 +129,27 @@ test('layout engine renders selection rectangles while editing a footer', async await superdoc.waitForStable(); const editor = await enterHeaderFooterEditMode( - superdoc.page, - '.superdoc-page-footer', - '.superdoc-footer-editor-host', + getFooterSurfaceLocator(superdoc.page), + getFooterEditorLocator(superdoc.page), ); await assertSelectionOverlayRenders(superdoc.page, editor, 'Footer'); }); + +test('header word selection overlay aligns with the rendered word bounds', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const surface = getHeaderSurfaceLocator(superdoc.page); + await enterHeaderFooterEditMode(surface, getHeaderEditorLocator(superdoc.page)); + await assertWordSelectionOverlayAlignment(superdoc.page, surface, 'Generic'); +}); + +test('footer word selection overlay aligns with the rendered word bounds', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const surface = getFooterSurfaceLocator(superdoc.page); + await enterHeaderFooterEditMode(surface, getFooterEditorLocator(superdoc.page)); + await assertWordSelectionOverlayAlignment(superdoc.page, surface, 'Footer'); +}); diff --git a/tests/behavior/tests/headers/sd-2732-footer-surface-regressions.spec.ts b/tests/behavior/tests/headers/sd-2732-footer-surface-regressions.spec.ts new file mode 100644 index 0000000000..864e7652c2 --- /dev/null +++ b/tests/behavior/tests/headers/sd-2732-footer-surface-regressions.spec.ts @@ -0,0 +1,163 @@ +import { expect, test, type Page } from '../../fixtures/superdoc.js'; +import { FOOTER_FOOTNOTE_TRANSITION_DOC_PATH, TWO_SECTION_FOOTER_DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateFooter, + clickTextBoundary, + expectActiveStoryText, + expectActiveStoryTextToContain, + getActiveStorySession, + getNoteSurfaceLocator, + getTextBoundaryPoint, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; + +test.use({ + config: { + documentMode: 'editing', + showCaret: true, + showSelection: true, + }, +}); + +function trackPageErrors(page: Page) { + const errors: string[] = []; + + const handlePageError = (error: Error) => { + errors.push(`pageerror:${error.message}`); + }; + + const handleConsole = (message: { type(): string; text(): string }) => { + if (message.type() === 'error') { + errors.push(`console:${message.text()}`); + } + }; + + page.on('pageerror', handlePageError); + page.on('console', handleConsole); + + return { + errors, + stop() { + page.off('pageerror', handlePageError); + page.off('console', handleConsole); + }, + }; +} + +test('page-2 footer clicks keep mapping to the second-section footer text', async ({ superdoc }) => { + await superdoc.loadDocument(TWO_SECTION_FOOTER_DOC_PATH); + await superdoc.waitForStable(); + + await superdoc.assertPageCount(2); + + const footer = await activateFooter(superdoc, 1); + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: 'headerFooterPart', + refId: 'rId9', + }); + await expectActiveStoryText(superdoc.page, 'Appendix footer'); + + let footerText = 'Appendix footer'; + + await clickTextBoundary(superdoc.page, footer, footerText, 0); + await superdoc.page.keyboard.type('S'); + await superdoc.waitForStable(); + footerText = 'SAppendix footer'; + await expectActiveStoryText(superdoc.page, footerText); + + await clickTextBoundary(superdoc.page, footer, footerText, 'SAppendix fo'.length); + await superdoc.page.keyboard.type('M'); + await superdoc.waitForStable(); + footerText = 'SAppendix foMoter'; + await expectActiveStoryText(superdoc.page, footerText); + + await clickTextBoundary(superdoc.page, footer, footerText, footerText.length); + await superdoc.page.keyboard.type('E'); + await superdoc.waitForStable(); + footerText = 'SAppendix foMoterE'; + await expectActiveStoryText(superdoc.page, footerText); +}); + +test('clicking from a footer into a footnote exits footer mode cleanly and keeps note clicks mapped', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(FOOTER_FOOTNOTE_TRANSITION_DOC_PATH); + await superdoc.waitForStable(); + + await activateFooter(superdoc); + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: 'headerFooterPart', + refId: 'rId10', + }); + await expectActiveStoryText(superdoc.page, 'Transition footer'); + + const tracker = trackPageErrors(superdoc.page); + try { + const footnote = getNoteSurfaceLocator(superdoc.page, { + storyType: 'footnote', + noteId: '1', + }); + await footnote.scrollIntoViewIfNeeded(); + await expect(footnote).toContainText('This is a simple footnote'); + + await clickTextBoundary(superdoc.page, footnote, 'footnote', 1); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + await expectActiveStoryTextToContain(superdoc.page, 'This is a simple footnote'); + + const inNotePoint = await getTextBoundaryPoint(footnote, 'simple', 3); + await superdoc.page.mouse.click(inNotePoint.x, inNotePoint.y); + await superdoc.waitForStable(); + + const clickState = await superdoc.page.evaluate(({ x, y }) => { + const presentation = (window as any).editor?.presentationEditor; + const activeEditor = presentation?.getActiveEditor?.(); + const session = presentation?.getStorySessionManager?.()?.getActiveSession?.(); + const selection = activeEditor?.state?.selection; + + return { + session: session?.locator ?? null, + selection: selection ? { from: selection.from, to: selection.to } : null, + hit: presentation?.hitTest?.(x, y)?.pos ?? null, + }; + }, inNotePoint); + + expect(clickState.session).toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + expect(clickState.selection).toEqual({ + from: clickState.hit, + to: clickState.hit, + }); + + await superdoc.page.keyboard.type('Z'); + await superdoc.waitForStable(); + + await expectActiveStoryTextToContain(superdoc.page, 'simZple'); + await expect(footnote).toContainText('simZple footnote'); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + expect(tracker.errors).toEqual([]); + } finally { + tracker.stop(); + } +}); diff --git a/tests/behavior/tests/navigation/extract-docx.spec.ts b/tests/behavior/tests/navigation/extract-docx.spec.ts new file mode 100644 index 0000000000..96db6b2233 --- /dev/null +++ b/tests/behavior/tests/navigation/extract-docx.spec.ts @@ -0,0 +1,259 @@ +/** + * Import-driven extract tests for SD-2672. + * + * Each test loads a real .docx fixture via `superdoc.loadDocument()`, which + * exercises the full DOCX import path (super-converter, normalization, + * paraId synthesis, placeholder cell injection, vMerge folding) before + * calling `doc.extract()`. This is the layer our schema-driven adapter unit + * tests do not cover and where real OOXML weirdness manifests. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = path.resolve(__dirname, 'fixtures'); + +const fixture = (name: string) => path.join(FIXTURES_DIR, name); + +function requireFixture(name: string): string { + const p = fixture(name); + if (!fs.existsSync(p)) { + throw new Error(`Missing SD-2672 fixture "${name}" in ${FIXTURES_DIR}.`); + } + return p; +} + +async function loadAndExtract( + superdoc: { loadDocument: (p: string) => Promise; page: { evaluate: (fn: () => T) => Promise } }, + fixtureName: string, +): Promise<{ + blocks: Array>; + comments: unknown[]; + trackedChanges: unknown[]; + revision: string; +}> { + await superdoc.loadDocument(requireFixture(fixtureName)); + return superdoc.page.evaluate(() => (window as any).editor.doc.extract({}) as any); +} + +// --------------------------------------------------------------------------- +// Baseline: plain 3x3 table authored by Word COM +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: plain 3x3 table emits one block per cell paragraph', async ({ superdoc }) => { + // NOTE: Word's COM API inserts a leading empty paragraph into every cell. + // That's real document state, so extraction correctly surfaces it as a + // separate block. The test verifies the 9 authored cells are reachable + // at their correct grid coordinates, not the exact block count. + const result = await loadAndExtract(superdoc, 'sd-2672-plain-3x3.docx'); + + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `r${r}c${c}`); + expect(block, `r${r}c${c}`).toBeDefined(); + const tc = (block as any).tableContext; + expect(tc.rowIndex).toBe(r); + expect(tc.columnIndex).toBe(c); + expect(tc.rowspan).toBe(1); + expect(tc.colspan).toBe(1); + expect(tc.tableOrdinal).toBe(0); + } + } + + // No flattened 'type: table' block. + expect(result.blocks.find((b: any) => b.type === 'table')).toBeUndefined(); +}); + +// --------------------------------------------------------------------------- +// Merged cells: colspan + rowspan authored by Word +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: merged cells report rowspan/colspan on anchors only', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-merged-table.docx'); + + const top = result.blocks.find((b: any) => b.text === 'top-span'); + const left = result.blocks.find((b: any) => b.text === 'left-span'); + expect(top, 'top-span anchor').toBeDefined(); + expect(left, 'left-span anchor').toBeDefined(); + + const topCtx = (top as any).tableContext; + expect(topCtx.rowIndex).toBe(0); + expect(topCtx.columnIndex).toBe(0); + expect(topCtx.colspan).toBe(2); + expect(topCtx.rowspan).toBe(1); + + const leftCtx = (left as any).tableContext; + expect(leftCtx.rowIndex).toBe(1); + expect(leftCtx.columnIndex).toBe(0); + expect(leftCtx.rowspan).toBe(2); + expect(leftCtx.colspan).toBe(1); + + // No continuation cell at (0,1), (2,0), etc. + const blocksAt = (r: number, c: number) => + result.blocks.filter((b: any) => b.tableContext?.rowIndex === r && b.tableContext?.columnIndex === c); + expect(blocksAt(0, 1)).toHaveLength(0); + expect(blocksAt(2, 0)).toHaveLength(0); +}); + +// --------------------------------------------------------------------------- +// RTL table: bidiVisual should not affect logical grid coords +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: RTL table reports logical grid columns', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-rtl-table.docx'); + + // Every cell we wrote lands somewhere with a tableContext. + for (let r = 0; r < 2; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `rtl-r${r}c${c}`); + expect(block, `rtl-r${r}c${c}`).toBeDefined(); + expect((block as any).tableContext).toBeDefined(); + } + } + + // Each row's non-empty cells cover grid columns {0, 1, 2}. Empty blocks + // from Word's leading-paragraph padding are ignored; what we assert is that + // each authored cell lands at a distinct logical column. + for (let r = 0; r < 2; r++) { + const rowCols = new Set( + result.blocks + .filter((b: any) => b.tableContext?.rowIndex === r && b.text.length > 0) + .map((b: any) => b.tableContext.columnIndex), + ); + expect(rowCols).toEqual(new Set([0, 1, 2])); + } +}); + +// --------------------------------------------------------------------------- +// gridBefore + vMerge: no phantom blocks, correct grid coords on the anchor +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: gridBefore + vMerge does not emit phantom cells', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-gridbefore-vmerge.docx'); + + // The fixture injects `` on row 0 (shifting its cells + // one column right) and `` on row 1's first cell (so it's a + // continuation of row 0's vertically-merged anchor, which the importer + // folds into row 0 as rowspan>=2). + const tableBlocks = result.blocks.filter((b: any) => b.tableContext); + expect(tableBlocks.length).toBeGreaterThan(0); + + // No phantom empty-text blocks from the placeholder column. + const phantoms = tableBlocks.filter( + (b: any) => b.text === '' && b.tableContext.rowIndex === 0 && b.tableContext.columnIndex === 0, + ); + expect(phantoms).toHaveLength(0); + + // Every emitted block's authored text comes from a real cell in the + // base 3x3 fixture ("rNcN"). A continuation cell (vMerge="continue") + // must not surface as its own block with authored text. + const realCells = tableBlocks.filter((b: any) => /^r\dc\d$/.test(b.text)); + expect(realCells.length).toBeGreaterThan(0); +}); + +// --------------------------------------------------------------------------- +// SDT-wrapped table: transparency +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: SDT-wrapped table does not emit a wrapper block', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-sdt-table.docx'); + + // No wrapper 'sdt' block emitted. + expect(result.blocks.some((b: any) => b.type === 'sdt')).toBe(false); + + // The inner table's cells still come through with tableContext intact. + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `r${r}c${c}`); + expect(block, `sdt-wrapped r${r}c${c}`).toBeDefined(); + expect((block as any).tableContext?.rowIndex).toBe(r); + expect((block as any).tableContext?.columnIndex).toBe(c); + } + } +}); + +// --------------------------------------------------------------------------- +// Nested table: fresh ordinal + parent context +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: nested table gets its own ordinal and parent coords', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-nested-table.docx'); + + // Inner table cells carry text "nested-a" through "nested-d". + const inner = result.blocks.filter((b: any) => /^nested-[a-d]$/.test(b.text)); + expect(inner.length).toBe(4); + + const outerOrdinals = new Set( + result.blocks + .filter((b: any) => /^r\dc\d$/.test(b.text)) + .map((b: any) => b.tableContext?.tableOrdinal) + .filter((v: unknown) => typeof v === 'number'), + ); + const innerOrdinals = new Set(inner.map((b: any) => b.tableContext?.tableOrdinal)); + + expect(outerOrdinals.size).toBe(1); + expect(innerOrdinals.size).toBe(1); + // Inner table MUST have a different ordinal from the outer. + const [outer] = outerOrdinals; + const [innerO] = innerOrdinals; + expect(innerO).not.toBe(outer); + + // Every inner cell has parent context pointing at the outer cell (1,1). + for (const block of inner) { + const tc = (block as any).tableContext; + expect(tc.parentTableOrdinal).toBe(outer); + expect(tc.parentRowIndex).toBe(1); + expect(tc.parentColumnIndex).toBe(1); + } + + // The outer cell's "before-nested" and "after-nested" paragraphs should + // emit alongside the nested table, all with the outer cell's tableContext. + const before = result.blocks.find((b: any) => b.text === 'before-nested'); + const after = result.blocks.find((b: any) => b.text === 'after-nested'); + expect(before, 'before-nested').toBeDefined(); + expect(after, 'after-nested').toBeDefined(); + expect((before as any).tableContext.rowIndex).toBe(1); + expect((before as any).tableContext.columnIndex).toBe(1); + expect((before as any).tableContext.tableOrdinal).toBe(outer); +}); + +// --------------------------------------------------------------------------- +// Multi-paragraph cell: one block per paragraph, shared tableContext +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: multi-paragraph cells emit one block per paragraph', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-multipara-cell.docx'); + + const p1 = result.blocks.find((b: any) => b.text === 'cell-00-line-1'); + const p2 = result.blocks.find((b: any) => b.text === 'cell-00-line-2'); + expect(p1, 'line 1').toBeDefined(); + expect(p2, 'line 2').toBeDefined(); + + // Distinct nodeIds, but they share the same tableContext (both in (0,0)). + expect((p1 as any).nodeId).not.toBe((p2 as any).nodeId); + expect((p1 as any).tableContext.rowIndex).toBe(0); + expect((p1 as any).tableContext.columnIndex).toBe(0); + expect((p2 as any).tableContext.rowIndex).toBe(0); + expect((p2 as any).tableContext.columnIndex).toBe(0); +}); + +// --------------------------------------------------------------------------- +// scrollToElement round-trip: extract's nodeId resolves in the browser +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: merged-cell paragraph nodeId works with scrollToElement', async ({ superdoc }) => { + await superdoc.loadDocument(requireFixture('sd-2672-merged-table.docx')); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const anchor = result.blocks.find((b: any) => b.text === 'left-span'); + expect(anchor).toBeDefined(); + + const navResult = await superdoc.page.evaluate( + (id) => (window as any).superdoc.scrollToElement(id), + (anchor as any).nodeId, + ); + expect(navResult).toBe(true); +}); diff --git a/tests/behavior/tests/navigation/extract.spec.ts b/tests/behavior/tests/navigation/extract.spec.ts index 6a66600758..83e58eb6b9 100644 --- a/tests/behavior/tests/navigation/extract.spec.ts +++ b/tests/behavior/tests/navigation/extract.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/superdoc.js'; +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; import { addCommentByText, replaceText, findFirstSelectionTarget } from '../../helpers/document-api.js'; test('@behavior SD-2525: doc.extract returns blocks with nodeIds and full text', async ({ superdoc }) => { @@ -122,3 +122,169 @@ test('@behavior SD-2525: extract nodeIds work with scrollToElement', async ({ su ); expect(navResult).toBe(true); }); + +// --------------------------------------------------------------------------- +// SD-2672: Table-aware extraction +// --------------------------------------------------------------------------- + +/** + * Inserts a table at the current selection, then types a unique label + * `r{row}c{col}` into each cell so we can assert which block came from + * which (row, column). + */ +async function insertLabeledTable(superdoc: SuperDocFixture, rows: number, cols: number): Promise { + await superdoc.executeCommand('insertTable', { rows, cols }); + await superdoc.waitForStable(); + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + await superdoc.type(`r${r}c${c}`); + const isLastCell = r === rows - 1 && c === cols - 1; + if (!isLastCell) await superdoc.press('Tab'); + } + } + await superdoc.waitForStable(); +} + +test('@behavior SD-2672: extract emits a block per cell paragraph with tableContext', async ({ superdoc }) => { + await insertLabeledTable(superdoc, 2, 3); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + + // No flattened "type: table" block should be returned. + expect(result.blocks.find((b: any) => b.type === 'table')).toBeUndefined(); + + // Every cell's paragraph should appear as its own block with tableContext. + for (let r = 0; r < 2; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `r${r}c${c}`); + expect(block, `block for r${r}c${c}`).toBeDefined(); + expect(block.tableContext).toBeDefined(); + expect(block.tableContext.tableOrdinal).toBe(0); + expect(block.tableContext.rowIndex).toBe(r); + expect(block.tableContext.columnIndex).toBe(c); + expect(block.tableContext.rowspan).toBe(1); + expect(block.tableContext.colspan).toBe(1); + expect(block.nodeId).toBeTruthy(); + expect(block.type).toBe('paragraph'); + } + } +}); + +test('@behavior SD-2672: nodeIds inside a table cell work with scrollToElement', async ({ superdoc }) => { + await insertLabeledTable(superdoc, 2, 2); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const cellBlock = result.blocks.find((b: any) => b.text === 'r1c1'); + expect(cellBlock).toBeDefined(); + + const navResult = await superdoc.page.evaluate( + (id) => (window as any).superdoc.scrollToElement(id), + cellBlock.nodeId, + ); + expect(navResult).toBe(true); +}); + +test('@behavior SD-2672: empty cells emit a block with empty text', async ({ superdoc }) => { + // Insert a table without filling any cells; every cell holds one empty paragraph. + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2 }); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const tableBlocks = result.blocks.filter((b: any) => b.tableContext); + + expect(tableBlocks).toHaveLength(4); + for (const block of tableBlocks) { + expect(block.text).toBe(''); + expect(block.nodeId).toBeTruthy(); + expect(block.type).toBe('paragraph'); + } + + const coords = tableBlocks.map((b: any) => `${b.tableContext.rowIndex},${b.tableContext.columnIndex}`).sort(); + expect(coords).toEqual(['0,0', '0,1', '1,0', '1,1']); +}); + +test('@behavior SD-2672: blocks outside tables have no tableContext', async ({ superdoc }) => { + await superdoc.type('Before the table'); + await superdoc.press('Enter'); + await insertLabeledTable(superdoc, 1, 2); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + + const before = result.blocks.find((b: any) => b.text === 'Before the table'); + expect(before).toBeDefined(); + expect(before.tableContext).toBeUndefined(); + + const insideCell = result.blocks.find((b: any) => b.text === 'r0c1'); + expect(insideCell).toBeDefined(); + expect(insideCell.tableContext).toBeDefined(); +}); + +test('@behavior SD-2672: nested tables get a fresh ordinal and parent context', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 1, cols: 1 }); + await superdoc.waitForStable(); + // Cursor lands inside the only cell. Insert a nested 1x2 table here. + await superdoc.executeCommand('insertTable', { rows: 1, cols: 2 }); + await superdoc.waitForStable(); + // Type into the inner cells. + await superdoc.type('inner-a'); + await superdoc.press('Tab'); + await superdoc.type('inner-b'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + + const innerA = result.blocks.find((b: any) => b.text === 'inner-a'); + const innerB = result.blocks.find((b: any) => b.text === 'inner-b'); + expect(innerA).toBeDefined(); + expect(innerB).toBeDefined(); + + // Both inner cells share the inner table's ordinal and reference the outer + // table as parent. + expect(innerA.tableContext.tableOrdinal).toBe(innerB.tableContext.tableOrdinal); + expect(innerA.tableContext.parentTableOrdinal).toBeDefined(); + expect(innerA.tableContext.parentTableOrdinal).not.toBe(innerA.tableContext.tableOrdinal); + expect(innerA.tableContext.parentRowIndex).toBe(0); + expect(innerA.tableContext.parentColumnIndex).toBe(0); + expect(innerA.tableContext.rowIndex).toBe(0); + expect(innerA.tableContext.columnIndex).toBe(0); + expect(innerB.tableContext.columnIndex).toBe(1); +}); + +test('@behavior SD-2672: merged cells carry rowspan/colspan on the anchor', async ({ superdoc }) => { + await insertLabeledTable(superdoc, 2, 3); + + // Merge cells (0,0) through (1,1): a 2-row x 2-column block in the top-left. + await superdoc.page.evaluate(() => { + const docApi = (window as any).editor.doc; + const tableResult = docApi.find({ select: { type: 'node', nodeType: 'table' }, limit: 1 }); + const tableAddress = tableResult.items[0].address; + docApi.tables.mergeCells({ + target: tableAddress, + start: { rowIndex: 0, columnIndex: 0 }, + end: { rowIndex: 1, columnIndex: 1 }, + }); + }); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const tableBlocks = result.blocks.filter((b: any) => b.tableContext); + + // Anchor cell (0,0) carries the merged content and the spans. + const anchorBlocks = tableBlocks.filter( + (b: any) => b.tableContext.rowIndex === 0 && b.tableContext.columnIndex === 0, + ); + expect(anchorBlocks.length).toBeGreaterThan(0); + for (const block of anchorBlocks) { + expect(block.tableContext.rowspan).toBe(2); + expect(block.tableContext.colspan).toBe(2); + } + + // Continuation cells (0,1), (1,0), (1,1) emit nothing; the anchor absorbed them. + const continuationCoords = ['0,1', '1,0', '1,1']; + for (const coord of continuationCoords) { + const [r, c] = coord.split(',').map(Number); + const found = tableBlocks.find((b: any) => b.tableContext.rowIndex === r && b.tableContext.columnIndex === c); + expect(found, `no anchor expected at (${r},${c}); should be folded into (0,0)`).toBeUndefined(); + } +}); diff --git a/tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx b/tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx new file mode 100644 index 0000000000..406033ddbc Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx b/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx new file mode 100644 index 0000000000..06829ef754 Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-gridbefore-vmerge.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-gridbefore-vmerge.docx new file mode 100644 index 0000000000..bb62cb0255 Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/sd-2672-gridbefore-vmerge.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-merged-table.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-merged-table.docx new file mode 100644 index 0000000000..926c9a6853 Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/sd-2672-merged-table.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-multipara-cell.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-multipara-cell.docx new file mode 100644 index 0000000000..f5821b2ecd Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/sd-2672-multipara-cell.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-nested-table.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-nested-table.docx new file mode 100644 index 0000000000..9277be2103 Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/sd-2672-nested-table.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-plain-3x3.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-plain-3x3.docx new file mode 100644 index 0000000000..b3a2ea76d9 Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/sd-2672-plain-3x3.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-rtl-table.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-rtl-table.docx new file mode 100644 index 0000000000..2c70655f5f Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/sd-2672-rtl-table.docx differ diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-sdt-table.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-sdt-table.docx new file mode 100644 index 0000000000..200d175a64 Binary files /dev/null and b/tests/behavior/tests/navigation/fixtures/sd-2672-sdt-table.docx differ diff --git a/tests/behavior/tests/navigation/pageref-standalone-click.spec.ts b/tests/behavior/tests/navigation/pageref-standalone-click.spec.ts new file mode 100644 index 0000000000..c021a232b4 --- /dev/null +++ b/tests/behavior/tests/navigation/pageref-standalone-click.spec.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LOWERCASE_DOC = path.resolve(__dirname, 'fixtures/pageref-standalone-h.docx'); +const UPPERCASE_DOC = path.resolve(__dirname, 'fixtures/pageref-standalone-uppercase-h.docx'); + +// SD-2537: PAGEREF fields with the \h switch render as internal hyperlinks +// whose clicks navigate to the referenced bookmark. This test covers the +// standalone case โ€” a PAGEREF \h NOT wrapped in a element. +// The wrapped case (Word TOCs) is covered by toc-anchor-scroll.spec.ts. +// +// Both fixtures are a 7-entry TOC where the first entry's outer +// has been removed, leaving only the inner PAGEREF \h field for that row. +// The other entries retain their wrappers and serve as a control. +// +// The "Introduction" entry uses bookmark id _Toc227765979. Its page number +// anchor only exists if the PAGEREF \h switch produces a link on its own. + +test.skip(!fs.existsSync(LOWERCASE_DOC), 'Standalone PAGEREF fixture missing'); +test.skip(!fs.existsSync(UPPERCASE_DOC), 'Uppercase PAGEREF fixture missing'); + +test('@behavior SD-2537: standalone PAGEREF with \\h renders a clickable anchor', async ({ superdoc }) => { + await superdoc.loadDocument(LOWERCASE_DOC); + await superdoc.waitForStable(2000); + + // The first TOC entry has its outer stripped. The page + // number should still be an anchor because the PAGEREF \h synthesizes one. + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]'); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); +}); + +test('@behavior SD-2537: clicking standalone PAGEREF navigates to the bookmark', async ({ superdoc }) => { + await superdoc.loadDocument(LOWERCASE_DOC); + await superdoc.waitForStable(2000); + + const selBefore = await superdoc.getSelection(); + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]').first(); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); + await pageNumberLink.click(); + await superdoc.waitForStable(2000); + + // goToAnchor moves the caret to the bookmark target. + const selAfter = await superdoc.getSelection(); + expect(selAfter.from).not.toBe(selBefore.from); +}); + +test('@behavior SD-2537: standalone PAGEREF with uppercase \\H also renders a clickable anchor', async ({ + superdoc, +}) => { + // ECMA-376 ยง17.16.1 says field switches are case-insensitive. \H should + // behave identically to \h. + await superdoc.loadDocument(UPPERCASE_DOC); + await superdoc.waitForStable(2000); + + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]'); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); +}); diff --git a/tests/behavior/tests/search/search-and-navigate.spec.ts b/tests/behavior/tests/search/search-and-navigate.spec.ts index f890e746e9..84e6d3aecd 100644 --- a/tests/behavior/tests/search/search-and-navigate.spec.ts +++ b/tests/behavior/tests/search/search-and-navigate.spec.ts @@ -1,19 +1,12 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test, expect } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); - -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available โ€” run pnpm corpus:pull'); +import { H_F_NORMAL_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; test('search and navigate to results in document', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); // Search for text that spans across content - const query = 'works of the Licensed Material'; + const query = 'NetHack'; const matches = await superdoc.page.evaluate((q: string) => { return (window as any).editor?.commands?.search?.(q) ?? []; }, query); diff --git a/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts new file mode 100644 index 0000000000..cb9a59d83d --- /dev/null +++ b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts @@ -0,0 +1,330 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + BASIC_ENDNOTES_DOC_PATH as ENDNOTE_DOC_PATH, + BASIC_FOOTNOTES_DOC_PATH as FOOTNOTE_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; + +test.use({ + config: { + showCaret: true, + showSelection: true, + }, +}); + +const MULTI_CLICK_RESET_MS = 450; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getWordClickPoint(locator: Locator, searchText: string) { + const point = await locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode = walker.nextNode() as Text | null; + + while (currentNode) { + const length = currentNode.textContent?.length ?? 0; + if (remaining < length) { + const range = doc.createRange(); + const startOffset = Math.max(0, remaining); + const endOffset = Math.min(length, remaining + targetText.length); + range.setStart(currentNode, startOffset); + range.setEnd(currentNode, endOffset); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallback = currentNode.parentElement?.getBoundingClientRect(); + if (!fallback) { + return null; + } + return { + x: fallback.left + Math.min(8, fallback.width / 2), + y: fallback.top + fallback.height / 2, + }; + } + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + remaining -= length; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(point).toBeTruthy(); + return point!; +} + +async function getFirstWord(locator: Locator) { + const word = await locator.evaluate((element) => { + const text = element.textContent ?? ''; + const match = text.match(/\p{L}[\p{L}\p{N}]*/u); + return match?.[0] ?? null; + }); + + expect(word).toBeTruthy(); + return word!; +} + +async function getSelectionOverlayRects(page: Page) { + return page.evaluate(() => + Array.from(document.querySelectorAll('.presentation-editor__selection-rect')) + .map((element) => { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return null; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }) + .filter(Boolean), + ); +} + +async function getActiveSelection(page: Page) { + return page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const state = activeEditor?.state; + const selection = state?.selection; + if (!state?.doc || !selection) { + return null; + } + + return { + from: selection.from, + to: selection.to, + empty: selection.empty, + text: state.doc.textBetween(selection.from, selection.to, '\n', '\n'), + }; + }); +} + +async function getActiveEditorText(page: Page) { + const text = await page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const state = activeEditor?.state; + if (!state?.doc) { + return null; + } + + return state.doc.textBetween(0, state.doc.content.size, '\n', '\n'); + }); + + return normalizeText(text); +} + +async function expectWordSelection(page: Page, expectedWord: string) { + const selection = await getActiveSelection(page); + expect(selection).toBeTruthy(); + expect(normalizeText(selection?.text)).toBe(expectedWord); +} + +async function expectParagraphSelection(page: Page, expectedText: string, minWordLength: number) { + const selection = await getActiveSelection(page); + expect(selection).toBeTruthy(); + expect(selection?.empty).toBe(false); + expect(normalizeText(selection?.text)).toBe(expectedText); + expect(normalizeText(selection?.text).length).toBeGreaterThanOrEqual(minWordLength); +} + +test('body surface supports double-click word selection and triple-click paragraph selection', async ({ superdoc }) => { + await superdoc.type('alpha beta gamma'); + await superdoc.waitForStable(); + + const line = superdoc.page.locator('.superdoc-line').first(); + const point = await getWordClickPoint(line, 'beta'); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'beta'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, 'alpha beta gamma', 'beta'.length); +}); + +test('body surface selection does not leak into visible footnotes', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const bodyLine = superdoc.page.locator('.superdoc-line', { hasText: 'Simple text1 with footnotes' }).first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + + const point = await getWordClickPoint(bodyLine, 'Simple'); + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + + await expectWordSelection(superdoc.page, 'Simple'); + + const selectionRects = await getSelectionOverlayRects(superdoc.page); + expect(selectionRects).toHaveLength(1); +}); + +test('active header supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const header = superdoc.page.locator('.superdoc-page-header').first(); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + + const headerBox = await header.boundingBox(); + expect(headerBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(headerBox!.x + headerBox!.width / 2, headerBox!.y + headerBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText.length).toBeGreaterThan(0); + + const word = await getFirstWord(header); + const point = await getWordClickPoint(header, word); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, word); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, word.length); +}); + +test('active footer supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const footer = superdoc.page.locator('.superdoc-page-footer').first(); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + + const footerBox = await footer.boundingBox(); + expect(footerBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(footerBox!.x + footerBox!.width / 2, footerBox!.y + footerBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText.length).toBeGreaterThan(0); + + const word = await getFirstWord(footer); + const point = await getWordClickPoint(footer, word); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, word); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, word.length); +}); + +test('active footnote supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const footnote = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const footnoteBox = await footnote.boundingBox(); + expect(footnoteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(footnoteBox!.x + footnoteBox!.width / 2, footnoteBox!.y + footnoteBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText).toBe('This is a simple footnote'); + + const point = await getWordClickPoint(footnote, 'footnote'); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'footnote'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, 'footnote'.length); +}); + +test('active endnote supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host endnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(ENDNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const endnote = superdoc.page.locator('[data-block-id^="endnote-1-"]').first(); + await endnote.scrollIntoViewIfNeeded(); + await endnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const endnoteBox = await endnote.boundingBox(); + expect(endnoteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(endnoteBox!.x + endnoteBox!.width / 2, endnoteBox!.y + endnoteBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText).toBe('This is a simple endnote'); + + const point = await getWordClickPoint(endnote, 'endnote'); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'endnote'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, 'endnote'.length); +}); diff --git a/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts b/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts index 4a608f7515..3245704973 100644 --- a/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts +++ b/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts @@ -23,12 +23,25 @@ test('font family applies and label updates when selected from overflow menu', a // Open overflow menu await overflowBtn.click(); - await superdoc.page.locator('.overflow-menu_items').waitFor({ state: 'visible', timeout: 5000 }); + // await superdoc.page.locator('.overflow-menu_items').waitFor({ state: 'visible', timeout: 5000 }); + const overflowMenu = superdoc.page.locator('.overflow-menu_items'); + await overflowMenu.waitFor({ state: 'visible', timeout: 5000 }); await superdoc.waitForStable(); // Select Georgia from font family dropdown - await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + // await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + const overflowFontFamilyBtn = overflowMenu.locator('[data-item="btn-fontFamily"]'); + if (!(await overflowFontFamilyBtn.isVisible())) { + test.skip(); + } + await overflowFontFamilyBtn.click(); + await superdoc.waitForStable(); + // Wait for the dropdown options to appear + await superdoc.page + .locator('[data-item="btn-fontFamily-option"]') + .first() + .waitFor({ state: 'visible', timeout: 5000 }); await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); await superdoc.waitForStable(); diff --git a/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts b/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts new file mode 100644 index 0000000000..eb3bdc9a38 --- /dev/null +++ b/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +/** + * SD-2328 regression. + * + * When `modules.toolbar.responsiveToContainer` is on, overflow and compaction + * must track the toolbar container's width, not the viewport. The bug this + * guards: side panels shrink the toolbar container while the window stays + * wide, and toolbar buttons spill past the container's right edge. + * + * The test keeps the viewport wide (1600px) on purpose so a viewport-driven + * width read would leave the toolbar at full size. A container-driven read + * must trigger compaction and overflow. + */ +test.use({ config: { toolbar: 'full', responsiveToContainer: true } }); + +test('toolbar buttons stay inside the container when it narrows (SD-2328)', async ({ superdoc }) => { + const { page } = superdoc; + + await page.setViewportSize({ width: 1600, height: 900 }); + await superdoc.waitForStable(); + + // Shrink only the container (mirrors the side-panel / drawer scenario). + const containerWidth = 1100; + await page.evaluate((w) => { + const el = document.getElementById('toolbar'); + if (!el) throw new Error('#toolbar not found in harness'); + el.style.width = `${w}px`; + el.style.maxWidth = `${w}px`; + }, containerWidth); + + // Let the ResizeObserver fire through the 300ms throttle. + await page.waitForTimeout(500); + await superdoc.waitForStable(); + + const result = await page.evaluate(() => { + const container = document.getElementById('toolbar'); + if (!container) return null; + const containerRect = container.getBoundingClientRect(); + const items = Array.from(container.querySelectorAll('.button-group > .toolbar-item-ctn')); + const overflowing = items + .map((el) => { + const rect = (el as HTMLElement).getBoundingClientRect(); + return { + id: (el as HTMLElement).getAttribute('data-item-id') ?? '', + right: rect.right, + width: rect.width, + }; + }) + // Skip zero-width items (collapsed / hidden by the overflow pipeline). + .filter((entry) => entry.width > 0 && entry.right > containerRect.right + 1); + // The side-position class is applied to the ButtonGroup root (which is + // also the `.button-group` element), so the two classes land on the same + // node - use a compound selector, not a descendant one. + const sideGroups = Array.from(container.querySelectorAll('.button-group.superdoc-toolbar-group-side')); + const sideGroupMinWidths = sideGroups.map((el) => getComputedStyle(el as Element).minWidth); + return { + containerRight: containerRect.right, + containerWidth: containerRect.width, + overflowing, + sideGroupMinWidths, + }; + }); + + expect(result, 'harness toolbar container must exist').not.toBeNull(); + expect(result!.containerWidth).toBe(containerWidth); + expect( + result!.overflowing, + `buttons must not extend past the toolbar container's right edge (container right = ${result!.containerRight}px)`, + ).toEqual([]); + // At 1100px (โ‰ค lg = 1280) every side group must drop its 120px min-width so + // the center group has room for the overflow menu. Assert both sides: the + // `compactSideGroups` prop is threaded through left, center, and right group + // instances, so one-sided coverage would miss a per-position regression. + expect(result!.sideGroupMinWidths.length, 'expected left and right side groups').toBeGreaterThanOrEqual(2); + for (const minWidth of result!.sideGroupMinWidths) { + expect(minWidth, 'side groups should compact at โ‰ค lg breakpoint').not.toBe('120px'); + } +});