diff --git a/.github/scripts/agent-docs-l1.mjs b/.github/scripts/agent-docs-l1.mjs index 96d634f64c..d852c6cded 100755 --- a/.github/scripts/agent-docs-l1.mjs +++ b/.github/scripts/agent-docs-l1.mjs @@ -24,7 +24,6 @@ export const CONFIG = { '.git', '.tmp', 'dist', - 'devtools/visual-testing/node_modules', 'tests/consumer-typecheck/node_modules', ], intentionalDifferentPairs: ['packages/superdoc/AGENTS.md:packages/superdoc/CLAUDE.md'], @@ -32,13 +31,10 @@ export const CONFIG = { knownCommands: [ 'pnpm test', 'pnpm test:behavior', - 'pnpm test:layout', - 'pnpm test:visual', 'pnpm dev', 'pnpm build', - 'pnpm corpus:upload', - 'pnpm corpus:pull', - 'pnpm layout:compare', + 'pnpm docs:download', + 'pnpm docs:upload', ], docBasenames: new Set(['AGENTS.md', 'CLAUDE.md', 'CLAUDE.local.md']), rulesDir: '.claude/rules', diff --git a/.github/workflows/baseline-superdoc.yml b/.github/workflows/baseline-superdoc.yml deleted file mode 100644 index adc97eea03..0000000000 --- a/.github/workflows/baseline-superdoc.yml +++ /dev/null @@ -1,234 +0,0 @@ -# Generates visual baselines for released SuperDoc versions -# Triggered manually via workflow_dispatch -name: πŸ“Έ Baseline superdoc - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to baseline (e.g., 1.5.0-next.1). Leave empty to auto-detect from latest tag.' - required: false - type: string - -permissions: - contents: read - actions: write - -concurrency: - group: baseline-superdoc-${{ github.run_id }} - cancel-in-progress: false - -jobs: - generate-baselines: - name: Generate Baselines - runs-on: ubuntu-24.04 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ github.sha }} - - - name: Resolve release version - id: version - run: | - # If manual dispatch with version provided, use it - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "βœ… Using manually specified version: $VERSION" - exit 0 - fi - - # Find tags pointing at the release commit - HEAD_SHA="${{ github.sha }}" - echo "Looking for tags at commit: $HEAD_SHA" - - # Get all tags pointing at this commit - TAGS=$(git tag --points-at "$HEAD_SHA" 2>/dev/null || true) - echo "Tags found: $TAGS" - - if [ -z "$TAGS" ]; then - echo "⚠️ No tags found at commit $HEAD_SHA" - echo "skip=true" >> $GITHUB_OUTPUT - exit 0 - fi - - # Find a version tag (v* pattern, prefer -next. for prerelease) - VERSION_TAG="" - for tag in $TAGS; do - if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - VERSION_TAG="$tag" - # Prefer -next. tags if multiple exist - if [[ "$tag" == *"-next."* ]]; then - break - fi - fi - done - - if [ -z "$VERSION_TAG" ]; then - echo "⚠️ No version tag (v*) found at commit" - echo "skip=true" >> $GITHUB_OUTPUT - exit 0 - fi - - # Strip leading 'v' to get npm version - VERSION="${VERSION_TAG#v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "βœ… Resolved version: $VERSION (from tag $VERSION_TAG)" - - - name: Skip if no release - if: steps.version.outputs.skip == 'true' - run: | - echo "No release tag found - semantic-release may have been a no-op." - echo "Exiting successfully." - - - name: Setup pnpm - if: steps.version.outputs.skip != 'true' - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - if: steps.version.outputs.skip != 'true' - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - cache: pnpm - cache-dependency-path: devtools/visual-testing/pnpm-lock.yaml - - - name: Cache apt packages - if: steps.version.outputs.skip != 'true' - uses: actions/cache@v5 - with: - path: ~/apt-cache - key: apt-canvas-${{ runner.os }}-v1 - - - name: Install canvas system dependencies - if: steps.version.outputs.skip != 'true' - 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 - if: steps.version.outputs.skip != 'true' - working-directory: devtools/visual-testing - run: pnpm install - - - name: Get Playwright version - if: steps.version.outputs.skip != 'true' - id: playwright-version - working-directory: devtools/visual-testing - run: | - echo "version=$(node -p "require('./package.json').devDependencies['@playwright/test'].replace('^', '')")" >> $GITHUB_OUTPUT - - - name: Cache Playwright browsers - if: steps.version.outputs.skip != 'true' - uses: actions/cache@v5 - id: playwright-cache - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - - - name: Install Playwright browsers - if: steps.version.outputs.skip != 'true' && steps.playwright-cache.outputs.cache-hit != 'true' - working-directory: devtools/visual-testing - run: pnpm exec playwright install --with-deps chromium firefox webkit - - - name: Install Playwright system deps (cache hit) - if: steps.version.outputs.skip != 'true' && steps.playwright-cache.outputs.cache-hit == 'true' - working-directory: devtools/visual-testing - run: pnpm exec playwright install-deps chromium firefox webkit - - - name: Wait for npm propagation - if: steps.version.outputs.skip != 'true' - run: | - VERSION="${{ steps.version.outputs.version }}" - echo "Waiting for superdoc@$VERSION to be available on npm..." - - for i in {1..30}; do - if npm view "superdoc@$VERSION" version 2>/dev/null; then - echo "βœ… Version $VERSION is available on npm" - exit 0 - fi - echo "Attempt $i/30: Version not yet available, waiting 10s..." - sleep 10 - done - - echo "❌ Timed out waiting for npm propagation" - exit 1 - - - name: Switch to released version - if: steps.version.outputs.skip != 'true' - working-directory: devtools/visual-testing - env: - PNPM_FROZEN_LOCKFILE: 'false' - NPM_CONFIG_FROZEN_LOCKFILE: 'false' - run: | - VERSION="${{ steps.version.outputs.version }}" - echo "Installing superdoc@$VERSION..." - pnpm superdoc "$VERSION" - - # Verify installation - INSTALLED=$(node -p "require('./packages/harness/node_modules/superdoc/package.json').version") - echo "Installed version: $INSTALLED" - - if [ "$INSTALLED" != "$VERSION" ]; then - echo "❌ Version mismatch! Expected $VERSION, got $INSTALLED" - exit 1 - fi - - - name: Generate baselines - if: steps.version.outputs.skip != 'true' - working-directory: devtools/visual-testing - env: - SD_TESTING_R2_ACCOUNT_ID: ${{ secrets.SD_TESTING_R2_ACCOUNT_ID }} - SD_TESTING_R2_BUCKET_NAME: ${{ secrets.SD_TESTING_R2_BUCKET_NAME }} - SD_TESTING_R2_BASELINES_BUCKET_NAME: ${{ secrets.SD_TESTING_R2_BASELINES_BUCKET_NAME }} - SD_TESTING_R2_ACCESS_KEY_ID: ${{ secrets.SD_TESTING_R2_ACCESS_KEY_ID }} - SD_TESTING_R2_SECRET_ACCESS_KEY: ${{ secrets.SD_TESTING_R2_SECRET_ACCESS_KEY }} - run: | - VERSION="${{ steps.version.outputs.version }}" - pnpm baseline "$VERSION" --ci - - - name: Summary - if: steps.version.outputs.skip != 'true' - run: | - VERSION="${{ steps.version.outputs.version }}" - cat >> $GITHUB_STEP_SUMMARY << EOF - ## πŸ“Έ Visual Baselines Generated - - | Property | Value | - |----------|-------| - | **Version** | \`$VERSION\` | - | **Browsers** | Chromium, Firefox, WebKit | - | **Trigger** | Manual | - - ### R2 Paths - - Visual: \`baselines/v.$VERSION//\` - EOF - - - name: Summary (skipped) - if: steps.version.outputs.skip == 'true' - run: | - cat >> $GITHUB_STEP_SUMMARY << EOF - ## ⏭️ Baseline Generation Skipped - - No release tag was found at the triggering commit. This typically means: - - semantic-release determined no release was needed - - The commit didn't include releasable changes - - This is expected behavior and not an error. - EOF - - - name: Cleanup corpus cache - if: always() - run: | - rm -rf ~/.cache/superdoc-corpus || true diff --git a/.github/workflows/ci-demos.yml b/.github/workflows/ci-demos.yml index 22db83c3d8..9910960ee7 100644 --- a/.github/workflows/ci-demos.yml +++ b/.github/workflows/ci-demos.yml @@ -3,13 +3,17 @@ name: CI Demos on: pull_request: paths: + - '.github/workflows/ci-demos.yml' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.nvmrc' - 'demos/**' - 'packages/superdoc/**' - 'packages/react/**' - 'packages/super-editor/**' - 'packages/layout-engine/**' - 'shared/**' - - 'package.json' workflow_dispatch: jobs: @@ -19,12 +23,15 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '20' cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -37,17 +44,15 @@ jobs: # Other demos don't need this; cheap enough to run unconditionally. run: pnpm --filter @superdoc-dev/react run build - - name: Install Playwright - working-directory: demos/__tests__ - run: npx playwright install chromium + - name: Pack build artifact + run: tar -czf demos-build-dist.tgz packages/superdoc/dist packages/react/dist - - name: Cache workspace - uses: actions/cache/save@v4 + - name: Upload build artifact + uses: actions/upload-artifact@v4 with: - path: | - . - ~/.cache/ms-playwright - key: demos-workspace-${{ github.sha }} + name: demos-build-dist-${{ github.run_id }} + path: demos-build-dist.tgz + if-no-files-found: error smoke-test: needs: build @@ -65,15 +70,52 @@ jobs: - linked-sections - nextjs-ssr steps: - - name: Restore workspace - uses: actions/cache/restore@v4 - with: - path: | - . - ~/.cache/ms-playwright - key: demos-workspace-${{ github.sha }} + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: demos-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf demos-build-dist.tgz + + - name: Resolve Playwright version + id: playwright-version + working-directory: demos/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: demos/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: demos/__tests__ + run: pnpm exec playwright install-deps chromium - name: Run smoke test working-directory: demos/__tests__ diff --git a/.github/workflows/ci-examples.yml b/.github/workflows/ci-examples.yml index ba18ee4508..247c62dd81 100644 --- a/.github/workflows/ci-examples.yml +++ b/.github/workflows/ci-examples.yml @@ -3,6 +3,11 @@ name: CI Examples on: pull_request: paths: + - '.github/workflows/ci-examples.yml' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.nvmrc' - 'examples/**' - 'packages/superdoc/**' - 'packages/react/**' @@ -20,12 +25,15 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '20' cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -39,17 +47,15 @@ jobs: - name: Build collaboration package run: pnpm --filter @superdoc-dev/superdoc-yjs-collaboration build - - name: Install Playwright - working-directory: examples/__tests__ - run: npx playwright install chromium + - name: Pack build artifact + run: tar -czf examples-build-dist.tgz packages/superdoc/dist packages/react/dist packages/collaboration-yjs/dist - - name: Cache workspace - uses: actions/cache/save@v4 + - name: Upload build artifact + uses: actions/upload-artifact@v4 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + name: examples-build-dist-${{ github.run_id }} + path: examples-build-dist.tgz + if-no-files-found: error getting-started: needs: build @@ -59,13 +65,30 @@ jobs: matrix: example: [react, vue, vanilla, cdn, angular, nuxt, laravel, solid] steps: - - name: Restore workspace - uses: actions/cache/restore@v4 + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz - name: Setup PHP if: matrix.example == 'laravel' @@ -83,6 +106,28 @@ jobs: working-directory: examples/getting-started/laravel run: cp .env.example .env && php artisan key:generate + - name: Resolve Playwright version + id: playwright-version + working-directory: examples/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install-deps chromium + - name: Run smoke test working-directory: examples/__tests__ run: EXAMPLE=${{ matrix.example }} npx playwright test @@ -95,13 +140,30 @@ jobs: matrix: example: [superdoc-yjs, hocuspocus, liveblocks] steps: - - name: Restore workspace - uses: actions/cache/restore@v4 + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz - name: Create .env for cloud providers working-directory: examples/editor/collaboration/providers/${{ matrix.example }} @@ -110,6 +172,28 @@ jobs: echo "VITE_LIVEBLOCKS_PUBLIC_KEY=${{ secrets.VITE_LIVEBLOCKS_PUBLIC_KEY }}" > .env fi + - name: Resolve Playwright version + id: playwright-version + working-directory: examples/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install-deps chromium + - name: Run smoke test working-directory: examples/__tests__ run: EXAMPLE=editor/collaboration/providers/${{ matrix.example }} npx playwright test @@ -122,13 +206,52 @@ jobs: matrix: example: [track-changes, comments, toolbar] steps: - - name: Restore workspace - uses: actions/cache/restore@v4 + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz + + - name: Resolve Playwright version + id: playwright-version + working-directory: examples/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install-deps chromium - name: Run smoke test working-directory: examples/__tests__ @@ -142,21 +265,52 @@ jobs: matrix: example: [selection-capture, configurable-toolbar] steps: - - name: Restore workspace - uses: actions/cache/restore@v4 - with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '20' cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz + + - name: Resolve Playwright version + id: playwright-version + working-directory: examples/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install-deps chromium - name: Run smoke test working-directory: examples/__tests__ @@ -170,13 +324,52 @@ jobs: matrix: example: [redlining] steps: - - name: Restore workspace - uses: actions/cache/restore@v4 + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz + + - name: Resolve Playwright version + id: playwright-version + working-directory: examples/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install-deps chromium - name: Run smoke test working-directory: examples/__tests__ @@ -190,13 +383,52 @@ jobs: matrix: example: [react-shadcn, react-mui, vue-vuetify, svelte-shadcn, vanilla] steps: - - name: Restore workspace - uses: actions/cache/restore@v4 + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz + + - name: Resolve Playwright version + id: playwright-version + working-directory: examples/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install-deps chromium - name: Run smoke test working-directory: examples/__tests__ @@ -210,13 +442,52 @@ jobs: matrix: example: [custom-mark, custom-node] steps: - - name: Restore workspace - uses: actions/cache/restore@v4 + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz + + - name: Resolve Playwright version + id: playwright-version + working-directory: examples/__tests__ + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright Chromium cache + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: examples/__tests__ + run: pnpm exec playwright install-deps chromium - name: Run smoke test working-directory: examples/__tests__ @@ -226,13 +497,30 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - name: Restore workspace - uses: actions/cache/restore@v4 + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 with: - path: | - . - ~/.cache/ms-playwright - key: examples-workspace-${{ github.sha }} + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: examples-build-dist-${{ github.run_id }} + path: . + + - name: Restore build artifact + run: tar -xzf examples-build-dist.tgz - name: Run AI redlining (server-side) tests working-directory: examples/document-engine/ai-redlining diff --git a/.github/workflows/visual-baseline.yml b/.github/workflows/visual-baseline.yml index 0e6f50b632..3536600253 100644 --- a/.github/workflows/visual-baseline.yml +++ b/.github/workflows/visual-baseline.yml @@ -23,6 +23,10 @@ jobs: SD_VISUAL_TESTING_R2_ACCESS_KEY_ID: ${{ secrets.SD_VISUAL_TESTING_R2_ACCESS_KEY_ID }} SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY: ${{ secrets.SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY }} SD_VISUAL_TESTING_R2_BUCKET: ${{ secrets.SD_VISUAL_TESTING_R2_BUCKET }} + SUPERDOC_CORPUS_R2_ACCOUNT_ID: ${{ secrets.SUPERDOC_CORPUS_R2_ACCOUNT_ID }} + SUPERDOC_CORPUS_R2_ACCESS_KEY_ID: ${{ secrets.SUPERDOC_CORPUS_R2_ACCESS_KEY_ID }} + SUPERDOC_CORPUS_R2_SECRET_ACCESS_KEY: ${{ secrets.SUPERDOC_CORPUS_R2_SECRET_ACCESS_KEY }} + SUPERDOC_CORPUS_R2_BUCKET: ${{ secrets.SUPERDOC_CORPUS_R2_BUCKET }} steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/visual-test.yml b/.github/workflows/visual-test.yml index 4ed8a5ebe9..50ece330c0 100644 --- a/.github/workflows/visual-test.yml +++ b/.github/workflows/visual-test.yml @@ -30,6 +30,10 @@ jobs: SD_VISUAL_TESTING_R2_ACCESS_KEY_ID: ${{ secrets.SD_VISUAL_TESTING_R2_ACCESS_KEY_ID }} SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY: ${{ secrets.SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY }} SD_VISUAL_TESTING_R2_BUCKET: ${{ secrets.SD_VISUAL_TESTING_R2_BUCKET }} + SUPERDOC_CORPUS_R2_ACCOUNT_ID: ${{ secrets.SUPERDOC_CORPUS_R2_ACCOUNT_ID }} + SUPERDOC_CORPUS_R2_ACCESS_KEY_ID: ${{ secrets.SUPERDOC_CORPUS_R2_ACCESS_KEY_ID }} + SUPERDOC_CORPUS_R2_SECRET_ACCESS_KEY: ${{ secrets.SUPERDOC_CORPUS_R2_SECRET_ACCESS_KEY }} + SUPERDOC_CORPUS_R2_BUCKET: ${{ secrets.SUPERDOC_CORPUS_R2_BUCKET }} steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index 6fcff232ae..8ef30e2c6d 100644 --- a/.gitignore +++ b/.gitignore @@ -71,14 +71,8 @@ perf-baseline-results.json .claude plans/ -tests/layout-snapshots -tests/layout/candidate/ -tests/layout/reference/ -tests/layout/reports/ -tmp/layout-compare/ test-corpus/ .pnpm-store -devtools/visual-testing/pnpm-lock.yaml .bun-cache/ diff --git a/.prettierignore b/.prettierignore index a7001fd92c..1e929184c9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,5 @@ examples demos -devtools/visual-testing/results public dist node_modules diff --git a/AGENTS.md b/AGENTS.md index 3a29083be5..2cdad71b5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,9 +78,10 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm test` - unit tests - `pnpm dev` - dev server from `examples/` - `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`. -- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + ts-jsdoc hygiene + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. -- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps twelve stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. +- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + ts-jsdoc hygiene + public-method fixture coverage + bundled font license gate + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure + docs snippet typecheck) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. +- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps fourteen stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`, `public-method-coverage`, `font-license-gate`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`, `docs-snippet-typecheck`. Legacy alias: `pnpm run check:public-contract`. - `pnpm check:public:docapi` - Document API public surface only. Wraps four stages: `contract-parity`, `contract-outputs`, `examples`, `overview-alignment`. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`. +- `pnpm check:font-licenses` - validate bundled font legal metadata: every `shared/font-system/assets/*.woff2` has a manifest row, stable hash, matching runtime bundled-manifest entry, and required notices. Also runs inside `check:public:superdoc`. - `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it. - `pnpm generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs. - `pnpm report:public:superdoc` - print public-contract tier metadata (supported / legacy / legacy-raw / asset / deprecated). Read-only, not a gate. Use `check:public:superdoc` (or its `contract-tiers` stage) to enforce. Source of truth: `packages/superdoc/scripts/type-surface.config.cjs`. @@ -95,7 +96,6 @@ Naming convention: `check:*` = non-mutating, safe in CI. `generate:*` = mutates |---|---|---| | Logic works? | `pnpm test` | seconds | | Editing works? | `pnpm test:behavior` | minutes | -| Layout regressed? | `pnpm test:layout` | ~10 min | -| Pixel diff? | `pnpm test:visual` | ~5 min | +| Pixel diff? | `pnpm --dir tests/visual test` | ~5 min | Per-package detail: `tests/behavior/AGENTS.md`, `tests/visual/AGENTS.md`. Eval suite: `evals/AGENTS.md`. diff --git a/README.md b/README.md index 168d1fdd44..1247e22d5a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Special thanks to these community members who have contributed code to SuperDoc: sergiogomes wookieb xy200303 +garhm Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started. diff --git a/TESTING.md b/TESTING.md index 64d7d7afac..463cd484e2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -9,8 +9,7 @@ How to verify your changes before pushing. | Logic works? | `pnpm test` | ~30s | Hard | | Document API smoke? | `pnpm test:document-api-smoke` | ~1 min | Hard | | Editing works? | `pnpm test:behavior` | ~3 min | Hard | -| Layout regressed? | `pnpm test:layout` | ~10 min | Manual | -| Visual pixel diff? | `pnpm test:visual` | ~5 min | Manual | +| Visual pixel diff? | `pnpm --dir tests/visual test` | ~5 min | Soft | ## Unit Tests @@ -65,80 +64,32 @@ These assert on **document state**, not pixels. Located in `tests/behavior/`. Se pnpm --filter @superdoc-testing/behavior setup # install browser binaries ``` -## Layout Comparison - -Compare layout engine output (JSON structure) across ~382 real-world documents against a published npm version. This is the primary tool for catching rendering regressions. - -```bash -pnpm test:layout # interactive -pnpm test:layout -- --reference 1.16.0 # specific version -pnpm test:layout -- --match tables --limit 5 # filtered, faster -``` - -The command handles everything: corpus download, build, snapshot generation, comparison. - -**First-time setup:** - -```bash -npx wrangler login # Cloudflare auth for downloading test documents -pnpm test:layout # downloads corpus automatically on first run -``` - -After the first run, the corpus is cached locally β€” no auth needed for subsequent runs. - -**Reports** are written to `tests/layout/reports/`. Each report includes: - -- `summary.md` β€” overview with widespread changes and per-doc details -- `summary.json` β€” machine-readable version of the summary -- `docs/` β€” per-document `.diff.json` files with detailed diffs - -### Reading the Report - -The summary separates **unique changes** (diffs specific to a few docs) from **widespread-only** docs (every diff in the doc is a pattern that appears in 50%+ of all changed docs): - -``` -- Changed docs: 382 - - Unique changes: 2 - - Widespread-only: 380 -``` - -**Widespread changes** are diff patterns appearing in 50%+ of changed docs. These typically represent schema evolution (e.g., a new `margins` field), not regressions. They're listed separately so you can focus on what matters. - -### Triage workflow - -1. Open `summary.md` β€” check the changed docs count -2. Skip **Widespread-Only Docs** β€” these are schema evolution -3. Focus on **Docs With Unique Changes** β€” open their `.diff.json` files -4. Each diff has `path` (JSONPath), `kind`, `reference`/`candidate` values, and a `widespread` flag -5. Decide if the change is intentional (your PR) or a regression - -**Advanced:** For lower-level access, use `pnpm layout:compare` directly. See `tests/layout/README.md`. - ## Visual Comparison (Pixel Diff) -After `pnpm test:layout` finds changes, it prints a hint to run pixel comparison. This generates an HTML before/after report showing exactly what changed visually. +Playwright visual regression tests that screenshot rendered documents and compare them pixel-by-pixel against R2-stored baselines. Located in `tests/visual/`. ```bash -pnpm test:visual # reads latest layout report, compares changed docs +cd tests/visual +pnpm docs:download # sync the shared test corpus from R2 (first time / new docs) +pnpm test # run the visual suite +pnpm report # view the HTML report ``` -The command automatically: -- Reads the latest layout comparison report -- Extracts documents with unique changes -- Runs pixel-level comparison against the same reference version -- Generates an interactive HTML report in `devtools/visual-testing/results/` +Baselines are generated in CI from the `stable` branch β€” never locally (macOS font rendering differs from Linux). See `tests/visual/README.md` for setup (R2 env vars, wrangler auth) and `tests/visual/AGENTS.md` for fixture details. + +Bulk layout regression comparison across the full corpus is maintainer-internal tooling and no longer lives in this repo. ## Uploading Test Documents -Upload a `.docx` file to the shared test corpus (used by layout, visual, and behavior tests): +Upload a `.docx` file to the shared test corpus (used by visual and behavior tests): ```bash -pnpm corpus:upload ./path/to/my-file.docx +pnpm --dir tests/visual docs:upload ./path/to/my-file.docx # Prompts for: issue ID or short description # -> uploads as rendering/paragraph-between-borders.docx ``` -After uploading, pull it locally with `pnpm corpus:pull` so it's available for all test suites. +After uploading, pull it locally with `pnpm --dir tests/visual docs:download` so it's available for all test suites. ## When to Run What @@ -146,11 +97,11 @@ After uploading, pull it locally with `pnpm corpus:pull` so it's available for a |---|---| | A utility function or algorithm | `pnpm test` | | An editing command or extension | `pnpm test` + `pnpm test:behavior` | -| Layout engine or style resolution | `pnpm test` + `pnpm test:layout` | -| DomPainter rendering | `pnpm test` + `pnpm test:layout` | -| PM adapter (data conversion) | `pnpm test` + `pnpm test:layout` | +| Layout engine or style resolution | `pnpm test` + `pnpm --dir tests/visual test` | +| DomPainter rendering | `pnpm test` + `pnpm --dir tests/visual test` | +| PM adapter (data conversion) | `pnpm test` + `pnpm --dir tests/visual test` | | Table rendering or spacing | All three | -| Super-converter (import/export) | `pnpm test` + `pnpm test:layout` | +| Super-converter (import/export) | `pnpm test` + `pnpm --dir tests/visual test` | ## CI Behavior @@ -158,16 +109,18 @@ After uploading, pull it locally with `pnpm corpus:pull` so it's available for a |---|---|---| | Unit tests | Yes | Yes | | Behavior tests | Yes (sharded across 3 runners) | Yes | -| Layout comparison | No (run manually) | No | +| Visual tests | Yes (on rendering-related paths) | No (soft gate β€” diffs post a PR comment) | ## Troubleshooting -**`pnpm test:layout` says auth expired:** +**Corpus download (`pnpm docs:download` in `tests/visual`) says auth expired or missing:** ```bash npx wrangler login ``` +R2 account ids and bucket names must be set via env vars (see `tests/visual/.env.example`). + **Behavior tests fail with port conflict:** ```bash @@ -182,7 +135,3 @@ pnpm test:behavior:headed # see the browser pnpm test:behavior:ui # Playwright inspector pnpm test:behavior:trace # record traces ``` - -**Layout comparison shows many diffs but none are from your PR:** - -You're probably comparing against an old npm version. The diffs include all changes on `main` since that release. Use `npm@next` (the default) for the closest baseline to current `main`. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index ea41944e0b..93389c5d5e 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -1,6 +1,118 @@ -# Third Party Licenses +# Third-Party Licenses -This project includes code from third-party libraries. Their licenses are listed below. +This file lists third-party components redistributed in SuperDoc and the license +terms that govern them. + +--- + +## Bundled fonts + +SuperDoc bundles open substitute and fallback fonts so that documents +referencing a non-embedded Microsoft core font can render with a reviewed +physical font. + +### Scope - applies to all delivery models + +These font notices apply wherever SuperDoc distributes or serves the fonts: + +- embedded or bundled within SuperDoc and its published packages; +- served to browsers as web fonts from a SuperDoc- or customer-operated host; and +- redistributed by a customer that embeds SuperDoc. + +Web-font delivery is distribution under the SIL Open Font License, so these terms +are written to the broadest redistribution case and cover lighter delivery +models too. The authoritative per-family record and the full license texts ship +alongside the fonts at `shared/font-system/assets/` (`LICENSES.md`, `OFL.txt`, +`Apache-2.0.txt`, `AGPL-3.0.txt`, `GPL-2.0.txt`, +`GUST-Font-License-1.0.txt`). Distribute that notice set together with the font +files. + +SPDX license expression for this bundled font set: `OFL-1.1 AND Apache-2.0 AND (AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817) AND (GPL-2.0-only WITH Font-exception-2.0) AND LicenseRef-GUST-Font-License-1.0`. + +C059, URW Gothic, and Liberation Sans Narrow are copyleft fonts with +document-embedding exceptions. The exceptions mean embedding the fonts into a +rendered document does not impose AGPL/GPL terms on that document. Distributing +the font binaries themselves carries the corresponding-source obligation, +satisfied by the unmodified upstream release pointers recorded in +`shared/font-system/assets/LICENSES.md`. + +### Components + +| Family | Replaces | License | Reserved Font Name | Version | +| --- | --- | --- | --- | --- | +| Carlito | Calibri | OFL-1.1 | "Carlito" | 1.103 | +| Caladea | Cambria | Apache-2.0 | none | 1.002 | +| Liberation Sans | Arial | OFL-1.1 | none declared | 2.1.5 | +| Liberation Sans Narrow | Arial Narrow | GPL-2.0-only WITH Font-exception-2.0 | none | 1.07.4 | +| Liberation Serif | Times New Roman | OFL-1.1 | none declared | 2.1.5 | +| Liberation Mono | Courier New | OFL-1.1 | none declared | 2.1.5 | +| Caprasimo | Cooper Black | OFL-1.1 | none | 1.001 | +| Archivo Black | Arial Black | OFL-1.1 | none | 1.006 | +| C059 | Century, Century Schoolbook | AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817 | none | 1.00 | +| URW Gothic | Century Gothic | AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817 | none | 1.00 | +| Bacasime Antique | Baskerville Old Face | OFL-1.1 | "Playfair" | 2.000 | +| Oregano Italic | Brush Script MT | OFL-1.1 | "Oregano Italic" | 1.000 | +| Gelasio | Georgia | OFL-1.1 | none | 1.008 | +| Cardo | Garamond | OFL-1.1 | none | Regular 1.0451; Bold 1.0011; Italic 0.991 | +| Comic Relief | Comic Sans MS | OFL-1.1 | none | 1.200 | +| Noto Sans | Tahoma | OFL-1.1 | none | 2.015 | +| Selawik | Segoe UI | OFL-1.1 | "Selawik" | 1.01 | +| Noto Sans Mono | Lucida Console | OFL-1.1 | none | 2.014 | +| Inconsolata SemiExpanded | Consolas | OFL-1.1 | none | 3.001 | +| PT Sans | Trebuchet MS | OFL-1.1 | "PT Sans", "PT Serif", "ParaType" | 2.003W OFL | +| PT Sans Narrow | Gill Sans MT Condensed | OFL-1.1 | "PT Sans", "PT Serif", "ParaType" | 2.003W OFL | +| TeX Gyre Bonum | Bookman Old Style | LicenseRef-GUST-Font-License-1.0 | none | 2.004 | + +### Copyright & trademark notices from the font `name` tables + +- **Carlito** (OFL-1.1): `Copyright (c) 2010-2013 by tyPoland Lukasz Dziedzic with Reserved Font Name "Carlito". Licensed under the SIL Open Font License, Version 1.1.` Carlito is a trademark of tyPoland Lukasz Dziedzic. +- **Caladea** (Apache-2.0): `Copyright (c) 2012 Huerta Tipografia`. Caladea is a trademark of Huerta Tipografia. No Reserved Font Name. No upstream `NOTICE` file. +- **Liberation Sans / Serif / Mono** (OFL-1.1): `Digitized data copyright (c) 2010 Google Corporation.` / `Copyright (c) 2012 Red Hat, Inc.` Liberation is a trademark of Red Hat, Inc. registered in U.S. Patent and Trademark Office and certain other jurisdictions. The v2.1.5 files declare no OFL Reserved Font Name. SuperDoc names the unmodified fonts. +- **Liberation Sans Narrow** (GPL-2.0-only WITH Font-exception-2.0): `Copyright 2010 Oracle and/or its affiliates`. Liberation is a trademark of Red Hat, Inc. registered in the U.S. and other countries. +- **Caprasimo** (OFL-1.1): `Copyright 2023 The Caprasimo Project Authors (https://github.com/docrepair-fonts/caprasimo-fonts).` The v1.001 file declares no OFL Reserved Font Name. +- **Archivo Black** (OFL-1.1): `Copyright 2017 The Archivo Black Project Authors (https://github.com/Omnibus-Type/ArchivoBlack).` The v1.006 file declares no OFL Reserved Font Name. +- **C059** (AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817): `(URW)++,Copyright 2014 by (URW)++ Design & Development`. These files declare no trademark string and no Reserved Font Name. +- **URW Gothic** (AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817): `(URW)++,Copyright 2014 by (URW)++ Design & Development`. These files declare no trademark string and no Reserved Font Name. +- **Bacasime Antique** (OFL-1.1): `Copyright 2023 The Bacasime Antique Project Authors (https://github.com/docrepair-fonts/bacasime-antique-fonts), with Reserved Font Name "Playfair".` Playfair is a trademark of Claus Eggers SΓΈrensen. +- **Oregano Italic** (OFL-1.1): `Copyright (c) 2012 by Brian J. Bonislawsky DBA Astigmatic (AOETI) (astigma@astigmatic.com), with Reserved Font Name "Oregano Italic".` Oregano Italic is a trademark of Astigmatic. +- **Gelasio** (OFL-1.1): `Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio).` The v1.008 files declare no OFL Reserved Font Name. +- **Cardo** (OFL-1.1): `Copyright (c) 2002-2011, David J. Perry (hospes02@scholarsfonts.net).` These files declare no OFL Reserved Font Name. +- **Comic Relief** (OFL-1.1): `Copyright 2013 The Comic Relief Project Authors (https://github.com/loudifier/Comic-Relief).` The v1.200 files declare no OFL Reserved Font Name. +- **Noto Sans** (OFL-1.1): `Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic).` Noto is a trademark of Google LLC. The v2.015 files declare no OFL Reserved Font Name. +- **Selawik** (OFL-1.1): `Β© 2015 Microsoft Corporation (www.microsoft.com), with Reserved Font Name "Selawik". Selawik is a trademark of Microsoft Corporation in the United States and/or other countries.` Selawik is a trademark of the Microsoft group of companies. +- **Noto Sans Mono** (OFL-1.1): `Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic).` Noto is a trademark of Google LLC. The v2.014 files declare no OFL Reserved Font Name. +- **Inconsolata SemiExpanded** (OFL-1.1): `Copyright 2006 The Inconsolata Project Authors (https://github.com/cyrealtype/Inconsolata).` These v3.001 files declare no OFL Reserved Font Name. +- **PT Sans** (OFL-1.1): `Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public), with Reserved Font Names "PT Sans", "PT Serif" and "ParaType".` +- **PT Sans Narrow** (OFL-1.1): `Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public), with Reserved Font Names "PT Sans", "PT Serif" and "ParaType".` PT Sans is a trademark of the ParaType Ltd. +- **TeX Gyre Bonum** (LicenseRef-GUST-Font-License-1.0): `Copyright 2006, 2009 for TeX Gyre extensions by B. Jackowski and J.M. Nowacki (on behalf of TeX users groups).` + +### Format conversion + +The static-source bundled faces are format-only source-font-to-WOFF2 conversions +(`fontTools`, `flavor="woff2"`, Brotli; no subsetting; WOFF2 metadata omitted). +No design, metric, glyph, `cmap`, or `name`-table change. Gelasio, Noto Sans, +and Noto Sans Mono ship as static WOFF2 instances generated from upstream +variable TrueType sources at the regular and bold weights; Noto Sans and Noto +Sans Mono are also instanced at normal width. + +Verified for this ship set: 55 / 65 faces are static-source conversions with a +WOFF2 `name` table byte-identical to their source font, and 10 / 65 faces are +static variable-font instances with no subsetting. All metrics are preserved. +Under OFL FAQ 2.2.1 the static-source conversions are not Modified Versions and +retain the original font names. For Caladea, this also serves as the Apache-2.0 +section 4(b) notice. + +### License texts + +- OFL-1.1: `shared/font-system/assets/OFL.txt`, with per-font copyright notices stacked at top. +- Apache-2.0: `shared/font-system/assets/Apache-2.0.txt`. +- AGPL-3.0-only WITH PS-or-PDF-font-exception-20170817: `shared/font-system/assets/AGPL-3.0.txt`. +- GPL-2.0-only WITH Font-exception-2.0: `shared/font-system/assets/GPL-2.0.txt`. +- LicenseRef-GUST-Font-License-1.0: `shared/font-system/assets/GUST-Font-License-1.0.txt`. + +The fonts remain under their own OFL-1.1 / Apache-2.0 / AGPL-3.0 / GPL-2.0 / +GUST terms and are not relicensed under SuperDoc's terms (AGPLv3 community build +or commercial). --- @@ -12,7 +124,7 @@ This project includes code from third-party libraries. Their licenses are listed **License:** MIT -``` +```text The MIT License (MIT) Copyright (c) 2015 Thomas Bluemel diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs index 3bd9569786..067e8e7596 100644 --- a/apps/cli/.releaserc.cjs +++ b/apps/cli/.releaserc.cjs @@ -36,6 +36,10 @@ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === br // stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. // Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. const shouldCommentOnRelease = !isPrerelease; +// Linear release comments are the shipped-version breadcrumb inside Linear +// itself, so keep them on for prereleases even while GitHub PR comments stay +// gated separately. +const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; @@ -76,10 +80,10 @@ if (!isPrerelease) { // Linear integration - labels issues with version on release config.plugins.push([ - 'semantic-release-linear-app', + '../../scripts/semantic-release/linear-commit-sync.cjs', { teamKeys: ['SD'], - addComment: shouldCommentOnRelease, + addComment: shouldCommentOnLinearRelease, packageName: 'superdoc-cli', commentTemplate: 'shipped in {package} {releaseLink} {channel}', }, diff --git a/apps/create/.releaserc.cjs b/apps/create/.releaserc.cjs index 2723c05fe5..d9078f8021 100644 --- a/apps/create/.releaserc.cjs +++ b/apps/create/.releaserc.cjs @@ -16,6 +16,10 @@ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === br // stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. // Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. const shouldCommentOnRelease = !isPrerelease; +// Linear release comments are the shipped-version breadcrumb inside Linear +// itself, so keep them on for prereleases even while GitHub PR comments stay +// gated separately. +const shouldCommentOnLinearRelease = true; const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; @@ -36,10 +40,10 @@ if (!isPrerelease) { } config.plugins.push([ - 'semantic-release-linear-app', + '../../scripts/semantic-release/linear-commit-sync.cjs', { teamKeys: ['SD'], - addComment: shouldCommentOnRelease, + addComment: shouldCommentOnLinearRelease, packageName: 'create', commentTemplate: 'shipped in {package} {releaseLink} {channel}', }, diff --git a/apps/create/package.json b/apps/create/package.json index 29a8375b04..08692f17fd 100644 --- a/apps/create/package.json +++ b/apps/create/package.json @@ -20,8 +20,7 @@ "@semantic-release/github": "^11.0.0", "semantic-release": "catalog:", "semantic-release-ai-notes": "catalog:", - "semantic-release-commit-filter": "catalog:", - "semantic-release-linear-app": "catalog:" + "semantic-release-commit-filter": "catalog:" }, "scripts": { "build": "bun build src/index.ts --outdir dist --target node", diff --git a/apps/docs/AGENTS.md b/apps/docs/AGENTS.md index a3795acfe7..b972853d9f 100644 --- a/apps/docs/AGENTS.md +++ b/apps/docs/AGENTS.md @@ -105,7 +105,7 @@ When documenting a customer-facing operation, name the right layer: - **Modules** (`modules/*`) β€” built-in SuperDoc-rendered UI. Use this when the customer is happy with the built-in look and just wants config. - **Core** (`core/*`) β€” low-level instance/reference surface. Lifecycle, refs, host events. Reach for it when the higher layers don't cover the case (rare). -Recommend the highest layer that solves the problem. Mutate documents through the Document API. Build custom React UI through `superdoc/ui/react`. Reach `superdoc.activeEditor.*` only when documenting legacy compat or a known escape hatch (and call it out as such). +Recommend the highest layer that solves the problem. Mutate documents through the Document API. Build custom React UI through `superdoc/ui/react`. For custom font controls, document `useSuperDocFontOptions()` / `ui.fonts` for family options, `useSuperDocFontSizeOptions()` / `ui.fonts.getSizeOptions()` for size options, and `ui.toolbar.execute(...)` for applying the choice. Do not expose internal fallback or support-status wording to end users. Reach `superdoc.activeEditor.*` only when documenting legacy compat or a known escape hatch (and call it out as such). ## API naming diff --git a/apps/docs/advanced/headless-toolbar-examples.mdx b/apps/docs/advanced/headless-toolbar-examples.mdx index aef19113c1..9601e84a02 100644 --- a/apps/docs/advanced/headless-toolbar-examples.mdx +++ b/apps/docs/advanced/headless-toolbar-examples.mdx @@ -90,7 +90,7 @@ Fixed bottom bar. Compact, mobile-inspired layout with Svelte 5 runes for reacti ## Vanilla JS -Zero-framework proof. Plain buttons, native selects, DOM manipulation. No build step required. +Zero-framework proof. Plain buttons, focus-preserving menus, DOM manipulation. No build step required. **Stack:** No framework, plain HTML/CSS/JS, Lucide @@ -120,6 +120,8 @@ document.querySelector('#toolbar').addEventListener('click', (e) => { ``` +For font family, font size, and other selection commands, use a button menu that prevents `mousedown` focus changes before executing the command. Native selects can move browser focus and visually clear the editor selection while their menu is open. + -Don't pass `toolbar` to the SuperDoc constructor. The headless toolbar replaces the built-in UI entirely β€” no flags needed. +Don't pass `toolbar` to the SuperDoc constructor. The headless toolbar replaces the built-in UI entirely - no flags needed. ## Core concepts @@ -48,7 +48,7 @@ Every time the editor state changes, the toolbar produces a `ToolbarSnapshot`. R ```ts snapshot.commands['bold']?.active // true if bold is on snapshot.commands['bold']?.disabled // true if the command can't run -snapshot.commands['font-size']?.value // '12pt' β€” the current value +snapshot.commands['font-size']?.value // '12pt' - the current value ``` ### Execute @@ -246,19 +246,19 @@ toolbar.destroy(); ## Command reference -Snapshot values match the format you pass to `execute()`. What you read is what you write β€” no conversion needed. +Snapshot values match the format you pass to `execute()`. What you read is what you write - no conversion needed. ### Text formatting | Command | Payload | `value` in snapshot | |---------|---------|---------------------| -| `bold` | β€” | β€” | -| `italic` | β€” | β€” | -| `underline` | β€” | β€” | -| `strikethrough` | β€” | β€” | -| `clear-formatting` | β€” | β€” | -| `copy-format` | β€” | β€” | +| `bold` | - | - | +| `italic` | - | - | +| `underline` | - | - | +| `strikethrough` | - | - | +| `clear-formatting` | - | - | +| `copy-format` | - | - | ### Font controls @@ -276,55 +276,62 @@ Snapshot values match the format you pass to `execute()`. What you read is what | `text-align` | `'left'` \| `'center'` \| `'right'` \| `'justify'` | alignment string | | `line-height` | number (e.g. `1.5`) | number | | `linked-style` | style object from helpers | style ID string | -| `bullet-list` | β€” | β€” | -| `numbered-list` | β€” | β€” | -| `indent-increase` | β€” | β€” | -| `indent-decrease` | β€” | β€” | +| `bullet-list` | - | - | +| `numbered-list` | - | - | +| `indent-increase` | - | - | +| `indent-decrease` | - | - | ### Insert | Command | Payload | `value` in snapshot | |---------|---------|---------------------| | `link` | `{ href: string \| null }` | href string or `null` | -| `image` | β€” (opens file picker) | β€” | -| `table-insert` | `{ rows: number, cols: number }` | β€” | +| `image` | - (opens file picker) | - | +| `table-insert` | `{ rows: number, cols: number }` | - | ### Table actions | Command | Payload | `value` in snapshot | |---------|---------|---------------------| -| `table-add-row-before` | β€” | β€” | -| `table-add-row-after` | β€” | β€” | -| `table-delete-row` | β€” | β€” | -| `table-add-column-before` | β€” | β€” | -| `table-add-column-after` | β€” | β€” | -| `table-delete-column` | β€” | β€” | -| `table-delete` | β€” | β€” | -| `table-merge-cells` | β€” | β€” | -| `table-split-cell` | β€” | β€” | -| `table-remove-borders` | β€” | β€” | -| `table-fix` | β€” | β€” | +| `table-add-row-before` | - | - | +| `table-add-row-after` | - | - | +| `table-delete-row` | - | - | +| `table-add-column-before` | - | - | +| `table-add-column-after` | - | - | +| `table-delete-column` | - | - | +| `table-delete` | - | - | +| `table-merge-cells` | - | - | +| `table-split-cell` | - | - | +| `table-remove-borders` | - | - | +| `table-fix` | - | - | ### Document | Command | Payload | `value` in snapshot | |---------|---------|---------------------| -| `undo` | β€” | β€” | -| `redo` | β€” | β€” | -| `ruler` | β€” | β€” | +| `undo` | - | - | +| `redo` | - | - | +| `ruler` | - | - | | `zoom` | number (e.g. `125`) | number | +| `zoom-fit-width` | none | none | | `document-mode` | `'editing'` \| `'suggesting'` \| `'viewing'` | mode string | ### Track changes | Command | Payload | `value` in snapshot | |---------|---------|---------------------| -| `track-changes-accept-selection` | β€” | β€” | -| `track-changes-reject-selection` | β€” | β€” | +| `track-changes-accept-selection` | - | - | +| `track-changes-reject-selection` | - | - | ## Constants -`headlessToolbarConstants` provides preset option arrays for dropdown-style controls. Each option has `{ label, value }` β€” use `value` when calling `execute()`. +`headlessToolbarConstants` provides preset option arrays for dropdown-style controls. Each option has `{ label, value }`. Use `value` when calling `execute()`. + +For a font dropdown that also includes fonts used by the active document, use `useSuperDocFontOptions()` from `superdoc/ui/react` or `ui.fonts` from `superdoc/ui`. + +`DEFAULT_FONT_FAMILY_OPTIONS` mirrors the built-in picker list. It can include bundled font choices beyond the core defaults; use the font report when you need per-font rendering details. + +For font family, font size, and other commands that apply to selected text, prefer a button menu or popover that prevents `mousedown` from moving focus out of the editor. Native selects can visually clear the editor selection while their menu is open. @@ -354,7 +361,7 @@ const { | Constant | Contents | |----------|----------| -| `DEFAULT_FONT_FAMILY_OPTIONS` | Aptos, Georgia, Arial, Courier New, Times New Roman | +| `DEFAULT_FONT_FAMILY_OPTIONS` | Built-in picker list. Includes strict defaults plus explicitly advertised bundled fallback choices. | | `DEFAULT_FONT_SIZE_OPTIONS` | 8pt through 96pt (14 sizes) | | `DEFAULT_TEXT_ALIGN_OPTIONS` | left, center, right, justify | | `DEFAULT_LINE_HEIGHT_OPTIONS` | 1.00, 1.15, 1.50, 2.00, 2.50, 3.00 | diff --git a/apps/docs/editor/built-in-ui/toolbar.mdx b/apps/docs/editor/built-in-ui/toolbar.mdx index 7ea46deab0..28554c3dc9 100644 --- a/apps/docs/editor/built-in-ui/toolbar.mdx +++ b/apps/docs/editor/built-in-ui/toolbar.mdx @@ -108,8 +108,8 @@ Use button names with `excludeItems`, `groups`, and `icons` configuration. ### Font controls - - Font family selector + + Editable font family selector @@ -124,6 +124,12 @@ Use button names with `excludeItems`, `groups`, and `icons` configuration. Background highlight color picker +The font family control is editable. Type to autocomplete, press `Enter` to apply, `Tab` to move to font size, or `Esc` to revert. The caret opens the full list. + +If a typed name does not match an option, SuperDoc preserves that custom font name in the document. Load the font on the host page if you want it to render with its real glyphs. + +The font size control follows the same flow: click the value to type a size, use the caret to open the list, and press `Tab` to return to the editor. + ### Paragraph @@ -331,15 +337,19 @@ Icons should be 24x24 viewBox SVGs with `fill="currentColor"` for proper theming ## Font configuration -The toolbar dropdown lists only what you register in `fonts`. Imported documents don't auto-populate the list: if a user opens a `.docx` that uses Cambria and Cambria isn't registered, the current-selection indicator shows "Cambria" but the user can't pick it from the dropdown. +By default, the font family combobox lists SuperDoc's bundled defaults plus fonts used by the active document. The list is sorted alphabetically. + +Choosing a fallback-backed font keeps the Word-facing name in the document. For example, picking Calibri stores and exports Calibri, while the row preview can render with the bundled fallback that actually paints it. + +Use `fonts` only when you want to replace the default and document-derived list with your own static options. - Display name shown in the dropdown, and the value applied to the selected text. Must match the first family name in `key`: otherwise the dropdown won't light up the current font when the cursor is inside a run that uses it. + Display name shown in the dropdown. - Stable identity for the option. Also used as the row's preview `font-family` so each row renders in its own typeface. Typically a full CSS stack (e.g. `'Cambria, serif'`). + Value applied to the selected text. Usually the logical font name or a CSS font stack. Font weight @@ -359,7 +369,7 @@ fonts: [ ``` - Registering a font in `fonts` makes it **selectable**. It doesn't load any font files. To make the font actually render with its real glyphs, load it on your host page via `@font-face` or a `` to a font service. Fonts the browser can't find fall back to the CSS generic. + A custom `fonts` list makes fonts selectable. It does not load font files. To make a custom font render with its real glyphs, load it on your host page via `@font-face` or a `` to a font service. ### Loading web fonts @@ -378,35 +388,6 @@ modules: { } ``` -### Substituting Microsoft Office fonts - -Most `.docx` files use Cambria, Calibri, or Aptos: fonts bundled with Word on desktop but not present in browsers. Google hosts free metric-compatible replacements: - -| Office font | Free substitute | Notes | -|---|---|---| -| Calibri | [Carlito](https://fonts.google.com/specimen/Carlito) | Sans-serif, metric-identical: designed as a direct drop-in | -| Cambria | [Tinos](https://fonts.google.com/specimen/Tinos) or [Gelasio](https://fonts.google.com/specimen/Gelasio) | Serif | -| Aptos | [Carlito](https://fonts.google.com/specimen/Carlito) or [Inter](https://fonts.google.com/specimen/Inter) | Sans-serif: no free metric-identical match exists yet | - -Download the `.woff2` files from Google Fonts, host them with your app, and alias each substitute to the original Word name via `@font-face`. The browser will then render real Carlito/Tinos glyphs whenever the document asks for Calibri/Cambria: no document or SuperDoc changes needed. - -```html - -``` - -Self-hosting the `.woff2` files is more reliable than linking to Google Fonts CDN URLs directly: those URLs change when Google updates a font version. - ## Responsive behavior The toolbar adapts at these widths: diff --git a/apps/docs/editor/custom-ui/api-reference.mdx b/apps/docs/editor/custom-ui/api-reference.mdx index b77ad697c4..2033c484f7 100644 --- a/apps/docs/editor/custom-ui/api-reference.mdx +++ b/apps/docs/editor/custom-ui/api-reference.mdx @@ -23,7 +23,7 @@ Returns the setter the editor mount component calls in `onReady`. Most component ```tsx const setSuperDoc = useSetSuperDoc(); - setSuperDoc(superdoc)} /> + setSuperDoc(superdoc)} />; ``` ### `useSuperDocUI()` @@ -86,15 +86,34 @@ const toolbar = useSuperDocToolbar(); // { context, commands: Record } ``` +### `useSuperDocFontOptions()` + +Returns font-family picker options for custom toolbar UIs. The list includes the built-in defaults plus fonts used by the active document, sorted alphabetically. + +```ts +const options = useSuperDocFontOptions(); +// [{ label: 'Calibri', value: 'Calibri', previewFamily: 'Carlito' }, ...] +``` + +Use `option.value` with `ui.toolbar.execute('font-family', option.value)`. Use `option.previewFamily` only to render the row preview. + +### `useSuperDocFontSizeOptions()` + +Returns the default font-size picker options for custom toolbar UIs. + +```ts +const sizes = useSuperDocFontSizeOptions(); +// [{ label: '8', value: '8pt' }, { label: '9', value: '9pt' }, ...] +``` + +Use `option.value` with `ui.toolbar.execute('font-size', option.value)`. + ### `useSuperDocSlice(picker, initial)` Subscribe to any slice from `ui.select(...)`. Use when the typed hooks above don't cover what you need. ```ts -const documentMode = useSuperDocSlice( - (ui) => ui.select((state) => state.documentMode, Object.is), - null, -); +const documentMode = useSuperDocSlice((ui) => ui.select((state) => state.documentMode, Object.is), null); ``` ### `useSuperDocHost()` @@ -148,7 +167,7 @@ items[0]?.invoke?.(); Live snapshot, mutations, navigation. ```ts -ui.comments.getSnapshot(); // sync read +ui.comments.getSnapshot(); // sync read ui.comments.subscribe(({ snapshot }) => {}); ui.comments.createFromSelection({ text }); ui.comments.createFromCapture(capture, { text }); @@ -156,6 +175,8 @@ ui.comments.reply(parentCommentId, { text }); ui.comments.resolve(commentId); ui.comments.reopen(commentId); ui.comments.delete(commentId); +ui.comments.setActive(commentId); // highlight without scrolling or moving selection +ui.comments.setActive(null); // clear active highlight ui.comments.scrollTo(commentId); ``` @@ -180,13 +201,26 @@ ui.trackChanges.scrollTo(changeId); Mode, export, replace. ```ts -ui.document.getSnapshot(); // { ready, mode, dirty } +ui.document.getSnapshot(); // { ready, mode, dirty } ui.document.subscribe(({ snapshot }) => {}); ui.document.setMode('suggesting'); await ui.document.export({ exportType: ['docx'], commentsType: 'external', triggerDownload: true }); await ui.document.replaceFile(file); ``` +### `ui.zoom` + +Zoom state, viewport metrics, and the two mutations. The snapshot updates on value changes, mode-only transitions, and viewport metric updates. + +```ts +ui.zoom.getSnapshot(); // { mode, value, fitZoom, min, max, metrics } +ui.zoom.observe((snapshot) => {}); +ui.zoom.set(125); // numeric zoom; switches the host to manual mode +ui.zoom.setMode('fit-width'); // continuous fit to the available width +``` + +In React, `useSuperDocZoom()` returns the same snapshot plus bound `set` / `setMode` actions. The toolbar registry also exposes a `zoom-fit-width` toggle command for custom toolbars. + ### `ui.selection` Live slice, capture, restore, painted geometry. @@ -194,12 +228,12 @@ Live slice, capture, restore, painted geometry. ```ts ui.selection.getSnapshot(); ui.selection.subscribe(({ snapshot }) => {}); -const captured = ui.selection.capture(); // frozen, holds across focus changes -const restore = ui.selection.restore(captured); // { success, reason? } +const captured = ui.selection.capture(); // frozen, holds across focus changes +const restore = ui.selection.restore(captured); // { success, reason? } -ui.selection.getRects(); // ViewportRect[] -ui.selection.getRects(captured); // rects of a captured selection -ui.selection.getAnchorRect({ placement: 'start' }); // single rect for popovers +ui.selection.getRects(); // ViewportRect[] +ui.selection.getRects(captured); // rects of a captured selection +ui.selection.getAnchorRect({ placement: 'start' }); // single rect for popovers ui.selection.getAnchorRect({ placement: 'union' }, captured); ``` @@ -210,11 +244,13 @@ Geometry. Browser-only. ```ts ui.viewport.getRect({ target: { kind: 'entity', entityType: 'comment', entityId } }); await ui.viewport.scrollIntoView({ target, block: 'center', behavior: 'smooth' }); +ui.viewport.observe(({ reason }) => {}); -ui.viewport.getHost(); // painted host element | null -ui.viewport.entityAt({ x, y }); // ViewportEntityHit[] -ui.viewport.positionAt({ x, y }); // ViewportPositionHit | null -ui.viewport.contextAt({ x, y }); // ViewportContext (always returns) +ui.viewport.getHost(); // painted host element | null +ui.viewport.getScrollContainer(); // scroll element | null +ui.viewport.entityAt({ x, y }); // ViewportEntityHit[] +ui.viewport.positionAt({ x, y }); // ViewportPositionHit | null +ui.viewport.contextAt({ x, y }); // ViewportContext (always returns) ``` ### `ui.toolbar` @@ -224,12 +260,24 @@ Aggregate toolbar surface. Mirrors the headless-toolbar shape. ```ts ui.toolbar.getSnapshot(); ui.toolbar.subscribe(({ snapshot }) => {}); -ui.toolbar.execute(id); // built-ins that take no payload -ui.toolbar.execute(id, payload); // built-ins that take a payload +ui.toolbar.execute(id); // built-ins that take no payload +ui.toolbar.execute(id, payload); // built-ins that take a payload ``` `payload` is optional. Pass it for built-ins like `font-size`, `font-family`, and `link` that accept a value; omit it for togglable built-ins like `bold` or `italic`. +### `ui.fonts` + +Read-only font picker options for custom toolbar UIs. + +```ts +ui.fonts.getOptions(); +ui.fonts.getSizeOptions(); +ui.fonts.observe((snapshot) => {}); +``` + +Family options have `{ label, value, previewFamily }`. Apply `value` through the `font-family` command. Size options have `{ label, value }`. Apply `value` through the `font-size` command. + ### `ui.select(selector, equality?)` Raw selector substrate underlying every domain handle. @@ -247,29 +295,33 @@ Tears down every subscription. The provider calls this on unmount. Imported from `superdoc/ui`. -| Type | Shape | -|---|---| -| `SuperDocUI` | The controller. | -| `SuperDocUIState` | The unified state model. | -| `SelectionSlice` | `useSuperDocSelection()` return shape. | -| `SelectionCapture` | Output of `ui.selection.capture()`. | -| `CommentsSlice` | `useSuperDocComments()` return shape. | -| `TrackChangesSlice` | `useSuperDocTrackChanges()` return shape. | -| `TrackChangesItem` | `{ id, change }`. | -| `DocumentSlice` | `useSuperDocDocument()` return shape. | -| `ToolbarSnapshotSlice` | `useSuperDocToolbar()` return shape. | -| `UIToolbarCommandState` | Per-command state shape. | -| `CustomCommandRegistration` | Input to `ui.commands.register`. | -| `CustomCommandRegistrationResult` | Return from `ui.commands.register`. | -| `ContextMenuContribution` | The `contextMenu` field on a registration. | -| `ContextMenuWhenInput` | Argument to `contextMenu.when({ entities, selection, point?, position?, insideSelection? })`. | -| `ContextMenuItem` | Item returned from `ui.commands.getContextMenuItems(input)`. Carries `invoke()` when produced from a `ViewportContext` bundle. | -| `SelectionAnchorRectOptions` | `{ placement: 'start' \| 'end' \| 'union' }`. | -| `SelectionRestoreResult` | `{ success: true } \| { success: false, reason }`. | -| `ScrollIntoViewInput` | Input to `ui.viewport.scrollIntoView`. | -| `ViewportRect` | Plain value rectangle. | -| `ViewportEntityHit` | `{ type: 'comment' \| 'trackedChange', id }`. | -| `ViewportPositionHit` | `{ point: SelectionPoint, target: SelectionTarget }`. | -| `ViewportContext` | Bundle returned from `ui.viewport.contextAt({ x, y })`. | +| Type | Shape | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `SuperDocUI` | The controller. | +| `SuperDocUIState` | The unified state model. | +| `SelectionSlice` | `useSuperDocSelection()` return shape. | +| `SelectionCapture` | Output of `ui.selection.capture()`. | +| `CommentsSlice` | `useSuperDocComments()` return shape. | +| `TrackChangesSlice` | `useSuperDocTrackChanges()` return shape. | +| `TrackChangesItem` | `{ id, change }`. | +| `DocumentSlice` | `useSuperDocDocument()` return shape. | +| `ToolbarSnapshotSlice` | `useSuperDocToolbar()` return shape. | +| `FontsHandle` | `ui.fonts` handle shape. | +| `FontsSlice` | `ui.fonts` snapshot shape. | +| `FontFamilyOption` | `{ label, value, previewFamily }`. | +| `FontSizeOption` | `{ label, value }`. | +| `UIToolbarCommandState` | Per-command state shape. | +| `CustomCommandRegistration` | Input to `ui.commands.register`. | +| `CustomCommandRegistrationResult` | Return from `ui.commands.register`. | +| `ContextMenuContribution` | The `contextMenu` field on a registration. | +| `ContextMenuWhenInput` | Argument to `contextMenu.when({ entities, selection, point?, position?, insideSelection? })`. | +| `ContextMenuItem` | Item returned from `ui.commands.getContextMenuItems(input)`. Carries `invoke()` when produced from a `ViewportContext` bundle. | +| `SelectionAnchorRectOptions` | `{ placement: 'start' \| 'end' \| 'union' }`. | +| `SelectionRestoreResult` | `{ success: true } \| { success: false, reason }`. | +| `ScrollIntoViewInput` | Input to `ui.viewport.scrollIntoView`. | +| `ViewportRect` | Plain value rectangle. | +| `ViewportEntityHit` | `{ type: 'comment' \| 'trackedChange', id }`. | +| `ViewportPositionHit` | `{ point: SelectionPoint, target: SelectionTarget }`. | +| `ViewportContext` | Bundle returned from `ui.viewport.contextAt({ x, y })`. | For the source of truth, the types ship with the `superdoc` package and are exported alongside the runtime values. diff --git a/apps/docs/editor/custom-ui/comments.mdx b/apps/docs/editor/custom-ui/comments.mdx index 54ad352cc1..211e8dd68c 100644 --- a/apps/docs/editor/custom-ui/comments.mdx +++ b/apps/docs/editor/custom-ui/comments.mdx @@ -126,6 +126,36 @@ export function CommentComposer({ onPosted }: { onPosted(): void }) { See the [selection capture example](https://github.com/superdoc-dev/superdoc/tree/main/examples/editor/custom-ui/selection-capture) for a runnable vanilla version: open a composer, move focus to a textarea, and post against the original selection. +### When the built-in bubble opens your composer + +If you keep the built-in floating comment bubble but render your own composer, listen for the pending comment event. The event carries `pendingSelection`, captured before SuperDoc inserts the pending mark, so you do not need to continuously cache the last non-empty selection. + +```tsx +import { useEffect, useState } from 'react'; +import type { SuperDocCommentsUpdatePayload } from 'superdoc'; +import type { SelectionInfo } from 'superdoc/ui'; + +const [pendingSelection, setPendingSelection] = useState(null); + +useEffect(() => { + if (!superdoc) return; + + const handleCommentsUpdate = ({ type, pendingSelection }: SuperDocCommentsUpdatePayload) => { + if (type === 'pending') setPendingSelection(pendingSelection ?? null); + }; + + superdoc.on('comments-update', handleCommentsUpdate); + return () => superdoc.off('comments-update', handleCommentsUpdate); +}, [superdoc]); + +function postComment(text: string) { + if (!ui || !pendingSelection) return; + return ui.comments.createFromCapture(pendingSelection, { text: text.trim() }); +} +``` + +`pendingSelection` is a Document API selection snapshot. It has the `target` that `createFromCapture` needs, but it can be `null` for PDF selections, mismatched documents, or selections with no text anchor. + ## Resolve and reopen ```tsx @@ -136,6 +166,17 @@ ui.comments.delete(commentId); All three return a Document API receipt. The next snapshot from `useSuperDocComments()` reflects the change. +## Activate a comment highlight without scrolling + +Use `setActive` when your UI handles scrolling and only needs SuperDoc to highlight the comment. + +```tsx +scrollYourSidebarOrEditor(commentId); +const activated = ui.comments.setActive(commentId); +``` + +Pass `null` to clear the highlight. Reply ids activate their anchored thread root, unknown ids return `false`, and a later editor selection change can clear the highlight because `setActive` does not move the caret. + ## Scroll to a comment ```tsx diff --git a/apps/docs/editor/custom-ui/selection-and-viewport.mdx b/apps/docs/editor/custom-ui/selection-and-viewport.mdx index df4260521c..b0db6bfd5e 100644 --- a/apps/docs/editor/custom-ui/selection-and-viewport.mdx +++ b/apps/docs/editor/custom-ui/selection-and-viewport.mdx @@ -116,12 +116,7 @@ export function SelectionPopover() { } const update = () => setRect(ui.selection.getAnchorRect({ placement: 'start' })); update(); - window.addEventListener('scroll', update, true); - window.addEventListener('resize', update); - return () => { - window.removeEventListener('scroll', update, true); - window.removeEventListener('resize', update); - }; + return ui.viewport.observe(update); }, [ui, selection.empty, selection.target, selection.quotedText]); if (!rect) return null; @@ -182,6 +177,17 @@ document.addEventListener('contextmenu', (event) => { The painted host is separate from the offscreen ProseMirror DOM. Reach for `getHost()` when you need the element a `MouseEvent.target` would resolve to inside the editor. +## Get the scroll container + +Use `ui.viewport.getScrollContainer()` when your UI needs to scroll the editor or attach scroll listeners. It returns `null` when the page window is the scroller. + +```ts +const scroller = ui.viewport.getScrollContainer(); +(scroller ?? window).scrollTo({ top: 0, behavior: 'smooth' }); +``` + +If you cache rects from `getRect`, subscribe to `ui.viewport.observe(() => remeasure())` and re-query them when geometry changes. + ## The right-click bundle `ui.viewport.contextAt({ x, y })` composes the four primitives above into one bundle. Use it for custom right-click menus where the same shape filters items and runs handlers. See [Custom right-click menu](/editor/custom-ui/context-menu) for the full pattern. @@ -231,6 +237,14 @@ if (result.success) { } ``` +For a body text range, pass the same `TextAddress` or `TextTarget` shape you get from selection and Document API calls: + +```ts +const result = ui.viewport.getRect({ + target: { kind: 'text', blockId: 'p-123', range: { start: 8, end: 21 } }, +}); +``` + Rects are plain values in viewport coordinates. Multi-page or multi-line targets return `rects` carrying every painted occurrence in document order. `success: false` reasons: @@ -245,6 +259,6 @@ Rects are plain values in viewport coordinates. Multi-page or multi-line targets ## Trade-offs - The selection slice updates on every editor change. Subscribe via the typed hook, not via raw `ui.select(...)`, so the controller can dedupe by shallow equality. -- `getRect`, `getRects`, `getAnchorRect`, `entityAt`, and `positionAt` are synchronous and DOM-driven. Run them inside a `useLayoutEffect` if you need them to settle before paint. Text-anchored `getRect` targets are deferred until story-aware text resolution lands. +- `getRect`, `getRects`, `getAnchorRect`, `entityAt`, and `positionAt` are synchronous and DOM-driven. Run them inside a `useLayoutEffect` if you need them to settle before paint. Body text-range `getRect` targets are supported; header, footer, footnote, and endnote text targets are deferred until story-aware text resolution lands. - `scrollIntoView` honors `behavior: 'smooth'` for body content. Non-body entities (header / footer / footnote / endnote) snap to view because story activation has to mount the surface synchronously before alignment. If smooth animation across non-body entities matters, scroll the editor surface yourself first with `behavior: 'smooth'`, then call `scrollIntoView` to land the entity. - `contextAt({ x, y })` always returns a bundle. Non-numeric coords coerce to `(0, 0)`, which is a real point and may sit inside the painted host. Pass real coordinates if you want the result to reflect a specific click. diff --git a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx index 4c307f97dc..0fe7bf225e 100644 --- a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx +++ b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx @@ -42,7 +42,7 @@ const TEXT_BUTTONS = [ export function Toolbar() { return ( -
+
{TEXT_BUTTONS.map((b) => ( ))} @@ -74,9 +74,12 @@ See the [configurable toolbar example](https://github.com/superdoc-dev/superdoc/ Some commands take a value. Pass it to `execute()`. ```tsx +import { useSuperDocCommand, useSuperDocFontSizeOptions, useSuperDocUI } from 'superdoc/ui/react'; + function FontSizePicker() { const ui = useSuperDocUI(); const size = useSuperDocCommand('font-size'); + const options = useSuperDocFontSizeOptions(); return ( ); } ``` +`useSuperDocCommand('font-size').value` is the current value for the active selection. The same pattern works for `font-family`, `text-color`, and other value commands. + +## Font family picker + +Use `useSuperDocFontOptions()` for a custom font dropdown. It returns the built-in defaults plus fonts used by the active document, sorted alphabetically. + +`label` is what you show. `value` is what you pass to the `font-family` command. `previewFamily` is only for rendering the option row. + +```tsx +import { useEffect, useRef, useState } from 'react'; +import type { SelectionCapture } from 'superdoc/ui'; +import { useSuperDocCommand, useSuperDocFontOptions, useSuperDocUI } from 'superdoc/ui/react'; + +function firstFontName(value: unknown) { + return typeof value === 'string' + ? value + .split(',')[0] + ?.trim() + .replace(/^["']|["']$/g, '') || '' + : ''; +} + +function FontFamilyPicker() { + const ui = useSuperDocUI(); + const font = useSuperDocCommand('font-family'); + const options = useSuperDocFontOptions(); + const capturedSelection = useRef(null); + const menuRef = useRef(null); + const [open, setOpen] = useState(false); + const current = firstFontName(font.value).toLowerCase(); + const selectedOption = + options.find((option) => { + return ( + firstFontName(option.value).toLowerCase() === current || + firstFontName(option.previewFamily).toLowerCase() === current + ); + }) ?? null; + + useEffect(() => { + if (!open) return; + const close = (event: MouseEvent) => { + if (!menuRef.current?.contains(event.target as Node | null)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + const rememberSelection = () => { + const capture = ui?.selection.capture(); + if (capture) capturedSelection.current = capture; + }; + + const applyFont = (value: string) => { + if (!ui) return; + if (capturedSelection.current) { + ui.selection.restore(capturedSelection.current); + capturedSelection.current = null; + } + setOpen(false); + ui.toolbar.execute('font-family', value); + }; + + return ( +
+ + {open && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} +``` + +When a user picks Calibri, SuperDoc stores and exports Calibri. The preview can render with the bundled fallback that actually paints it. + +Avoid native selects for this control. They take browser focus and visually clear the editor selection while the menu is open. + +## Interactive UI vs explicit document edits + +Use command execution for a toolbar. It preserves the active editor selection and matches the built-in toolbar behavior. + +```tsx +ui?.toolbar.execute('font-family', option.value); +ui?.toolbar.execute('font-size', option.value); +``` + +Use the Document API when your code already has a target and wants one explicit document mutation. Batch related formatting in one `format.apply` call. + +```ts +editor.doc.format.apply({ + target, + inline: { + fontFamily: 'Inter', + fontSize: 15, + }, +}); +``` + ## What the hook returns -| Field | Type | Meaning | -|---|---|---| -| `active` | `boolean` | The command is "on" for the current selection (cursor is inside bold text). | -| `disabled` | `boolean` | The command can't run right now (no selection, wrong document mode). | -| `value` | `unknown` | The command's current value when applicable (font name, size, color). | -| `source` | `'built-in' \| 'custom'` | Where the command came from. | +| Field | Type | Meaning | +| ---------- | ------------------------ | --------------------------------------------------------------------------- | +| `active` | `boolean` | The command is "on" for the current selection (cursor is inside bold text). | +| `disabled` | `boolean` | The command can't run right now (no selection, wrong document mode). | +| `value` | `unknown` | The command's current value when applicable (font name, size, color). | +| `source` | `'built-in' \| 'custom'` | Where the command came from. | `useSuperDocCommand` returns a fallback `{ active: false, disabled: true, source: 'built-in' }` while the editor is initializing, so your buttons render disabled with no flicker. @@ -108,18 +237,18 @@ function FontSizePicker() { Common ids you'll wire to buttons: -| Group | Ids | -|---|---| -| Text | `bold`, `italic`, `underline`, `strikethrough` | -| Inline | `link`, `font-family`, `font-size`, `text-color`, `highlight-color` | -| Layout | `text-align`, `line-height`, `indent-increase`, `indent-decrease` | -| Lists | `bullet-list`, `numbered-list` | -| Style | `linked-style`, `clear-formatting`, `copy-format` | -| History | `undo`, `redo` | -| Tracked changes | `track-changes-accept-selection`, `track-changes-reject-selection` | -| View | `ruler`, `zoom`, `document-mode` | -| Tables | `table-insert`, `table-add-row-before`, `table-add-row-after`, `table-delete-row`, `table-add-column-before`, `table-add-column-after`, `table-delete-column`, `table-merge-cells`, `table-split-cell`, `table-delete` | -| Insert | `image` | +| Group | Ids | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Text | `bold`, `italic`, `underline`, `strikethrough` | +| Inline | `link`, `font-family`, `font-size`, `text-color`, `highlight-color` | +| Layout | `text-align`, `line-height`, `indent-increase`, `indent-decrease` | +| Lists | `bullet-list`, `numbered-list` | +| Style | `linked-style`, `clear-formatting`, `copy-format` | +| History | `undo`, `redo` | +| Tracked changes | `track-changes-accept-selection`, `track-changes-reject-selection` | +| View | `ruler`, `zoom`, `zoom-fit-width`, `document-mode` | +| Tables | `table-insert`, `table-add-row-before`, `table-add-row-after`, `table-delete-row`, `table-add-column-before`, `table-add-column-after`, `table-delete-column`, `table-merge-cells`, `table-split-cell`, `table-delete` | +| Insert | `image` | `PublicToolbarItemId` in `superdoc/ui` is the source of truth. Anything you can pass to `createHeadlessToolbar({ commands })` works as a `useSuperDocCommand` id. diff --git a/apps/docs/editor/superdoc/configuration.mdx b/apps/docs/editor/superdoc/configuration.mdx index 51848528aa..23c2143fc3 100644 --- a/apps/docs/editor/superdoc/configuration.mdx +++ b/apps/docs/editor/superdoc/configuration.mdx @@ -561,6 +561,76 @@ new SuperDoc({ + + Zoom behavior for the document. Use `mode: 'fit-width'` to keep DOCX and PDF documents fitted to the available container width. Calling `setZoom()` switches back to manual zoom. + + + + Initial zoom level as a percentage. In `fit-width` mode, this is the paint zoom until the first fit computes. + + + Starting zoom mode. `'manual'` holds the current value. `'fit-width'` keeps the document fitted to the container. + + + Bounds and padding for the applied fit-width zoom. + + + Lower bound for the applied zoom percentage. + + + Upper bound for the applied zoom percentage. The default never enlarges the document past its natural size; raise it to let wide containers scale the page up. + + + Horizontal padding in pixels reserved inside the available width before computing the fit. + + + + + + For custom behavior, listen to [`viewport-change`](/editor/superdoc/events#viewport-change) and call `setZoom()` yourself. + + + + ```javascript Usage + const superdoc = new SuperDoc({ + selector: '#editor', + document: file, + zoom: { + mode: 'fit-width', + fitWidth: { min: 35, max: 100, padding: 24 }, + }, + }); + ``` + + ```javascript Full Example + import { SuperDoc } from 'superdoc'; + import 'superdoc/style.css'; + + const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + zoom: { + initial: 100, + mode: 'fit-width', + fitWidth: { min: 35, max: 100, padding: 24 }, + }, + onZoomChange: ({ zoom, mode }) => { + console.log(`Zoom is now ${zoom}% (${mode})`); + }, + }); + ``` + + + + + Minimal React example for fit-width zoom with current zoom and viewport metrics. + + + **Removed in v1.0**: Use `viewOptions.layout` instead. `'paginated'` β†’ `'print'`, `'responsive'` β†’ `'web'`. @@ -684,6 +754,26 @@ All handlers are optional functions in the configuration: ``` + + Called when zoom changes from `setZoom()`, the toolbar zoom control, or `fit-width` mode. + + ```javascript + onZoomChange: ({ zoom, mode }) => { + setZoomIndicator(zoom, mode); + } + ``` + + + + Called when the fit-width calculation changes. Pixel-level width jitter is deduped. `getViewportMetrics()` always reads the latest measurements. + + ```javascript + onViewportChange: ({ availableWidth, documentWidth, fitZoom }) => { + updateFitIndicator({ availableWidth, documentWidth, fitZoom }); + } + ``` + + Custom handler for accepting tracked changes from comment bubbles. Replaces default accept behavior when provided. diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index 3cffc4de66..4e94074349 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -300,10 +300,11 @@ When comments are modified. ```javascript Usage -superdoc.on('comments-update', ({ type, comment, changes }) => { - // type: 'add', 'update', 'deleted', 'resolved' +superdoc.on('comments-update', ({ type, comment, changes, pendingSelection }) => { + // type: 'pending', 'add', 'update', 'deleted', 'resolved' // comment: the comment object (when applicable) // changes: per-field change set (when the update is a mutation) + // pendingSelection: SelectionInfo | null on 'pending' }); ``` @@ -316,10 +317,11 @@ const superdoc = new SuperDoc({ document: yourFile, }); -superdoc.on('comments-update', ({ type, comment, changes }) => { - // type: 'add', 'update', 'deleted', 'resolved' +superdoc.on('comments-update', ({ type, comment, changes, pendingSelection }) => { + // type: 'pending', 'add', 'update', 'deleted', 'resolved' // comment: the comment object (when applicable) // changes: per-field change set (when the update is a mutation) + // pendingSelection: SelectionInfo | null on 'pending' }); ``` @@ -440,12 +442,12 @@ superdoc.on('pagination-update', ({ totalPages, superdoc }) => { ### `zoomChange` -When the zoom level changes via `setZoom()`. +When the zoom level changes, from any source: `setZoom()`, the toolbar zoom control, or [`fit-width` mode](/editor/superdoc/configuration#param-zoom). The payload carries the value and the mode that produced it. Also available as the `onZoomChange` config callback. ```javascript Usage -superdoc.on('zoomChange', ({ zoom }) => { - console.log(`Zoom: ${zoom}%`); +superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom: ${zoom}% (${mode})`); }); ``` @@ -458,8 +460,41 @@ const superdoc = new SuperDoc({ document: yourFile, }); -superdoc.on('zoomChange', ({ zoom }) => { - console.log(`Zoom: ${zoom}%`); +superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom: ${zoom}% (${mode})`); +}); +``` + + +### `viewport-change` + +When the fit-width calculation changes. Pixel-level width changes that do not affect the rounded fit are deduped. `getViewportMetrics()` always reads the latest measurements. + +- `availableWidth` - container width in pixels, minus the comments sidebar when visible +- `documentWidth` - the widest document page width in pixels at 100% zoom (zoom-independent; DOCX from laid-out pages with page-styles fallback, PDF from rendered pages) +- `fitZoom` - the unclamped zoom percentage that fits the page into the available width + +HTML documents reflow to the container, so an HTML-only instance reports no metrics. + +For most use cases, prefer [`zoom.mode: 'fit-width'`](/editor/superdoc/configuration#param-zoom). Subscribe to this event only when you want to apply custom zoom behavior. + + +```javascript Usage +superdoc.on('viewport-change', ({ fitZoom }) => { + superdoc.setZoom(Math.min(100, Math.max(35, fitZoom))); +}); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onViewportChange: ({ availableWidth, documentWidth, fitZoom }) => { + console.log(`Need ${fitZoom}% to fit ${documentWidth}px into ${availableWidth}px`); + }, }); ``` diff --git a/apps/docs/editor/superdoc/methods.mdx b/apps/docs/editor/superdoc/methods.mdx index 3e807a5000..202f3ce838 100644 --- a/apps/docs/editor/superdoc/methods.mdx +++ b/apps/docs/editor/superdoc/methods.mdx @@ -538,7 +538,7 @@ const superdoc = new SuperDoc({ ### `setZoom` -Set the zoom level for all documents. Propagates to all presentation editors, PDF viewers, and whiteboard layers. +Set an explicit zoom level and switch zoom mode to `manual`. Use `setZoomMode('fit-width')` to turn automatic fitting back on. Zoom level as a percentage (e.g., `100`, `150`, `200`). Must be a positive finite number. @@ -560,8 +560,8 @@ const superdoc = new SuperDoc({ onReady: ({ superdoc }) => { superdoc.setZoom(150); - superdoc.on('zoomChange', ({ zoom }) => { - console.log(`Zoom changed to ${zoom}%`); + superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom changed to ${zoom}% (${mode})`); }); }, }); @@ -569,6 +569,97 @@ const superdoc = new SuperDoc({ +### `setZoomMode` + +Switch between manual zoom and automatic fit-width zoom. `'fit-width'` keeps DOCX and PDF documents fitted to the available container width, using the bounds from [`zoom.fitWidth`](/editor/superdoc/configuration#param-zoom). Calling `setZoom()` switches back to `'manual'`. + + + The zoom mode to switch to. + + + + +```javascript Usage +superdoc.setZoomMode('fit-width'); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: ({ superdoc }) => { + superdoc.setZoomMode('fit-width'); + + superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom: ${zoom}% (${mode})`); + }); + }, +}); +``` + + + +### `getZoomState` + +Get the current zoom mode, value, latest fit calculation, and effective fit bounds. + +**Returns:** `SuperDocZoomState` - `{ mode, value, fitZoom, min, max }`. `fitZoom` is `null` before the first viewport measurement. + + + +```javascript Usage +const { mode, value, fitZoom } = superdoc.getZoomState(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: ({ superdoc }) => { + const state = superdoc.getZoomState(); + console.log(`Mode: ${state.mode}, value: ${state.value}%`); + }, +}); +``` + + + +### `getViewportMetrics` + +Get the latest fit-width measurements. Use this when you need custom zoom behavior instead of `zoom.mode: 'fit-width'`. + +**Returns:** `SuperDocViewportMetrics | null` - `{ availableWidth, documentWidth, fitZoom }`, or `null` until the first measurement (editors still mounting). + + + +```javascript Usage +const metrics = superdoc.getViewportMetrics(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: ({ superdoc }) => { + const metrics = superdoc.getViewportMetrics(); + if (metrics) { + superdoc.setZoom(Math.min(100, metrics.fitZoom)); + } + }, +}); +``` + + + ### `focus` Focus the active editor or first available. diff --git a/apps/docs/extensions/comments.mdx b/apps/docs/extensions/comments.mdx index a4248078c1..f3c179028e 100644 --- a/apps/docs/extensions/comments.mdx +++ b/apps/docs/extensions/comments.mdx @@ -246,8 +246,9 @@ const superdoc = new SuperDoc({ ## Events ```javascript -superdoc.on('comments-update', ({ type, comment }) => { +superdoc.on('comments-update', ({ type, comment, pendingSelection }) => { // type: 'pending' | 'add' | 'update' | 'deleted' | 'new' | 'resolved' | 'selected' | ... + // pendingSelection: SelectionInfo | null on 'pending' }); ``` diff --git a/apps/docs/getting-started/fonts.mdx b/apps/docs/getting-started/fonts.mdx index d83f2c625c..c0008cad91 100644 --- a/apps/docs/getting-started/fonts.mdx +++ b/apps/docs/getting-started/fonts.mdx @@ -1,10 +1,10 @@ --- title: Font support sidebarTitle: Font Support -keywords: "fonts, font loading, calibri, cambria, aptos, font fallback, custom fonts, font dropdown, office fonts" +keywords: 'fonts, font loading, calibri, cambria, aptos, font fallback, custom fonts, font dropdown, office fonts' --- -SuperDoc keeps the font name from the Word document. Rendering that font is the browser's job. If the browser can't find Calibri, Cambria, Aptos, or your brand font, it falls back to another font and the layout shifts. +SuperDoc keeps the font name from the Word document. When SuperDoc ships an approved fallback for that font, it renders with the fallback but keeps the original name for export. When no fallback is available, load the real font in your app. ## Load fonts in your app @@ -18,11 +18,13 @@ Fonts are your host page's responsibility. `@font-face`, a hosted stylesheet, or } ``` -For Office fonts that aren't free (Calibri, Cambria, Aptos), either license them or alias compatible substitutes: [Carlito](https://fonts.google.com/specimen/Carlito) for Calibri, [Caladea](https://fonts.google.com/specimen/Caladea) for Cambria. +For custom or licensed fonts, load the real file yourself. SuperDoc's built-in fallbacks cover only the fonts it ships and verifies. -## Register fonts in the toolbar +## Toolbar font list -The toolbar's font dropdown shows whatever you register in `modules.toolbar.fonts`. Each entry is a `{ label, key }` pair where `key` is the CSS `font-family` value: +The built-in toolbar lists SuperDoc's defaults plus fonts used by the active document, sorted alphabetically. If you pass `modules.toolbar.fonts`, that custom list replaces the default list. + +Each custom entry is a `{ label, key }` pair where `key` is the CSS `font-family` value: ```javascript new SuperDoc({ @@ -40,24 +42,30 @@ new SuperDoc({ }); ``` -Registering a font makes it **selectable** in the dropdown. It does **not** load the font file. You still need the `@font-face` (or equivalent) on your host page. +Registering a custom toolbar font makes it selectable. It does not load the font file. You still need the `@font-face` (or equivalent) on your host page. + +For custom UI, use `useSuperDocFontOptions()` or `ui.fonts` to get the same default-plus-document font list. Use `useSuperDocFontSizeOptions()` or `ui.fonts.getSizeOptions()` for size pickers. ## Programmatic font changes For AI agents or scripts setting a brand font: ```javascript -editor.doc.format.fontFamily({ target, value: 'Inter' }); +editor.doc.format.apply({ + target, + inline: { fontFamily: 'Inter', fontSize: 15 }, +}); ``` ## Common pitfalls -- **Font name preserved, browser falls back.** The font name from the DOCX is intact, but the actual file isn't loaded. Add `@font-face`. -- **Dropdown doesn't show a font.** Imported documents don't auto-populate the toolbar list. If a `.docx` uses Cambria but Cambria isn't in `fonts`, the user can't pick it from the dropdown: even though the current selection indicator may show "Cambria" if that's what the run uses. -- **Office font licensing.** Calibri, Cambria, and Aptos are licensed Microsoft fonts. Self-hosting requires a license. Free substitutes are common in non-pixel-perfect contexts. +- **Font name preserved, browser falls back.** SuperDoc keeps the DOCX font name. If no bundled fallback or loaded real font exists, the browser chooses its own fallback. +- **Custom toolbar list hides document fonts.** Passing `modules.toolbar.fonts` replaces the built-in list. Include every option you want users to pick. +- **Office font licensing.** Calibri, Cambria, and Aptos are licensed Microsoft fonts. Self-hosting the real files requires a license. ## Where to next - [Built-in UI > Toolbar](/editor/built-in-ui/toolbar#font-configuration): full toolbar font configuration options +- [Custom UI > Toolbar and commands](/editor/custom-ui/toolbar-and-commands#font-family-picker): custom font picker example - [Document API > Available operations](/document-api/available-operations): programmatic font formatting - [Editor > Theming](/editor/theming/overview): set the default font for SuperDoc's UI chrome diff --git a/apps/docs/getting-started/frameworks/react.mdx b/apps/docs/getting-started/frameworks/react.mdx index 594e4805e5..4bb434a797 100644 --- a/apps/docs/getting-started/frameworks/react.mdx +++ b/apps/docs/getting-started/frameworks/react.mdx @@ -59,6 +59,23 @@ function App() { ``` +### Responsive zoom + +Pass `zoom` with `mode: 'fit-width'` to keep the document fitted to its container as it resizes. SuperDoc observes the container for you; no resize listeners needed. Calling `setZoom()` (or the user picking a percentage in the toolbar) switches back to manual mode. + +```jsx + console.log(`Zoom: ${zoom}% (${mode})`)} +/> +``` + +For custom behavior, listen to `onViewportChange` instead and apply your own zoom with `getInstance().setZoom()`. See [zoom configuration](/editor/superdoc/configuration#param-zoom). + ## Handle file uploads ```jsx diff --git a/apps/docs/llms-full.txt b/apps/docs/llms-full.txt index 96cbc42796..551feef4a4 100644 --- a/apps/docs/llms-full.txt +++ b/apps/docs/llms-full.txt @@ -119,8 +119,8 @@ API docs: https://docs.superdoc.dev/api-reference/introduction When a customer wants to do something through SuperDoc, recommend the highest layer that solves it: - **Document API** (`editor.doc.*`): request/response document operations: comments, tracked changes, formatting, insert/replace/delete. Same shape on server, client, and AI agents. Use this for any document mutation. -- **`superdoc/ui` and `superdoc/ui/react`**: typed browser controller for custom toolbar, comments sidebar, tracked-change review panel, custom commands, selection capture, viewport, document controls (export / mode toggle / dirty indicator). **Use this for new custom React UI.** Imports: `createSuperDocUI` from `superdoc/ui`; `SuperDocUIProvider`, `useSuperDocCommand`, `useSuperDocComments`, `useSuperDocTrackChanges`, `useSuperDocSelection`, `useSuperDocDocument` from `superdoc/ui/react`. -- **Built-in modules** (`modules/*`): SuperDoc-rendered UI for toolbar, comments, tracked-change review. Use when the built-in look is fine. +- **`superdoc/ui` and `superdoc/ui/react`**: typed browser controller for custom toolbar, comments sidebar, tracked-change review panel, custom commands, selection capture, viewport, document controls (export / mode toggle / dirty indicator). **Use this for new custom React UI.** Imports: `createSuperDocUI` from `superdoc/ui`; `SuperDocUIProvider`, `useSuperDocCommand`, `useSuperDocFontOptions`, `useSuperDocFontSizeOptions`, `useSuperDocComments`, `useSuperDocTrackChanges`, `useSuperDocSelection`, `useSuperDocDocument` from `superdoc/ui/react`. For font controls, read option lists from `useSuperDocFontOptions()` / `useSuperDocFontSizeOptions()` and apply values with `ui.toolbar.execute(...)`. +- **Built-in modules** (`modules/*`): SuperDoc-rendered UI for toolbar, comments, tracked-change review. Use when the built-in look is fine. The built-in font family control is editable and preserves custom typed font names. - **Core** (`core/*`): low-level lifecycle, refs, host events. Reach for it when the higher layers don't cover the case. **Anti-patterns to avoid in customer code:** @@ -129,6 +129,7 @@ When a customer wants to do something through SuperDoc, recommend the highest la - `superdoc.on('editor-update', ...)` to drive React state: use `useSuperDoc*` hooks instead. They re-render one slice per change instead of every event. - `superdoc.addCommentsList()` / `removeCommentsList()` for custom comments: those are built-in-UI helpers. Custom React comments use `useSuperDocComments` + `ui.comments.*`. - `createHeadlessToolbar()` for new React work: still supported, but the typed React surface (`useSuperDocCommand`) is the recommended path. Headless stays for non-React stacks and existing integrations. +- Native ` -
-
- - - - - - - -
- - -
-
-
- -
- - - - - -`; - - const outputPath = path.join(outputFolder, reportFileName); - fs.writeFileSync(outputPath, html, 'utf8'); - return outputPath; -} diff --git a/devtools/visual-testing/scripts/set-superdoc-version.test.ts b/devtools/visual-testing/scripts/set-superdoc-version.test.ts deleted file mode 100644 index 0bb1a4c9d6..0000000000 --- a/devtools/visual-testing/scripts/set-superdoc-version.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import path from 'path'; -import { toPosixPath, isSuperdocPackage, resolveSpecifier, type FsLike } from './set-superdoc-version.js'; - -describe('toPosixPath', () => { - it('should convert path separators to forward slashes', () => { - // On Unix, path.sep is '/', on Windows it's '\' - // This test uses the actual path.sep to build a test case - const input = ['foo', 'bar', 'baz'].join(path.sep); - expect(toPosixPath(input)).toBe('foo/bar/baz'); - }); - - it('should leave forward slashes unchanged', () => { - expect(toPosixPath('foo/bar/baz')).toBe('foo/bar/baz'); - }); - - it('should handle empty string', () => { - expect(toPosixPath('')).toBe(''); - }); - - it('should handle single segment', () => { - expect(toPosixPath('foo')).toBe('foo'); - }); -}); - -describe('isSuperdocPackage', () => { - it('should return true for directory with superdoc package.json', () => { - const mockFs: FsLike = { - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue(JSON.stringify({ name: 'superdoc' })), - }; - - expect(isSuperdocPackage('/some/path', { fs: mockFs })).toBe(true); - expect(mockFs.existsSync).toHaveBeenCalledWith(path.join('/some/path', 'package.json')); - }); - - it('should return false for directory without package.json', () => { - const mockFs: FsLike = { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - }; - - expect(isSuperdocPackage('/some/path', { fs: mockFs })).toBe(false); - }); - - it('should return false for directory with different package name', () => { - const mockFs: FsLike = { - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue(JSON.stringify({ name: 'other-package' })), - }; - - expect(isSuperdocPackage('/some/path', { fs: mockFs })).toBe(false); - }); - - it('should return false for invalid JSON', () => { - const mockFs: FsLike = { - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('not valid json'), - }; - - expect(isSuperdocPackage('/some/path', { fs: mockFs })).toBe(false); - }); -}); - -describe('resolveSpecifier', () => { - const rootDir = '/test/root'; - const harnessDir = '/test/root/packages/harness'; - - describe('npm version strings', () => { - it('should treat non-existent paths as npm versions', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - statSync: vi.fn(), - }; - - const result = resolveSpecifier('1.4.0', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result).toEqual({ - specifier: '1.4.0', - isFile: false, - installPath: null, - }); - }); - - it('should handle prerelease versions', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - statSync: vi.fn(), - }; - - const result = resolveSpecifier('1.4.0-next.3', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result).toEqual({ - specifier: '1.4.0-next.3', - isFile: false, - installPath: null, - }); - }); - - it('should handle caret ranges', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - statSync: vi.fn(), - }; - - const result = resolveSpecifier('^1.4.0', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result).toEqual({ - specifier: '^1.4.0', - isFile: false, - installPath: null, - }); - }); - }); - - describe('local specifiers', () => { - it('should treat "local" like a version string (handled at CLI level)', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - statSync: vi.fn(), - }; - - const result = resolveSpecifier('local', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result).toEqual({ - specifier: 'local', - isFile: false, - installPath: null, - }); - expect(mockFs.existsSync).toHaveBeenCalled(); - }); - }); - - describe('file: prefix', () => { - it('should handle explicit file: prefix with existing path', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn(), - statSync: vi.fn(), - }; - - const result = resolveSpecifier('file:../superdoc', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result.specifier).toBe('file:../superdoc'); - expect(result.isFile).toBe(true); - expect(result.installPath).toBeTruthy(); - }); - - it('should handle explicit file: prefix with non-existent path', () => { - const mockFs = { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - statSync: vi.fn(), - }; - - const result = resolveSpecifier('file:../nonexistent', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result.specifier).toBe('file:../nonexistent'); - expect(result.isFile).toBe(true); - expect(result.installPath).toBe(null); - }); - }); - - describe('local paths', () => { - it('should detect superdoc package directory', () => { - const superdocDir = path.join(rootDir, 'superdoc'); - - const mockFs = { - existsSync: vi.fn((p: string) => { - if (p === superdocDir) return true; - if (p === path.join(superdocDir, 'package.json')) return true; - return false; - }), - statSync: vi.fn().mockReturnValue({ isDirectory: () => true }), - readFileSync: vi.fn().mockReturnValue(JSON.stringify({ name: 'superdoc' })), - }; - - const result = resolveSpecifier('superdoc', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result.isFile).toBe(true); - expect(result.installPath).toBe(superdocDir); - expect(result.specifier).toMatch(/^file:/); - }); - - it('should detect monorepo structure (packages/superdoc)', () => { - const monorepoDir = path.join(rootDir, 'superdoc-monorepo'); - const packagesSuperdocDir = path.join(monorepoDir, 'packages', 'superdoc'); - - const mockFs = { - existsSync: vi.fn((p: string) => { - if (p === monorepoDir) return true; - if (p === path.join(monorepoDir, 'package.json')) return true; - if (p === packagesSuperdocDir) return true; - if (p === path.join(packagesSuperdocDir, 'package.json')) return true; - return false; - }), - statSync: vi.fn().mockReturnValue({ isDirectory: () => true }), - readFileSync: vi.fn((p: string) => { - if (p === path.join(monorepoDir, 'package.json')) { - return JSON.stringify({ name: 'superdoc-monorepo' }); - } - if (p === path.join(packagesSuperdocDir, 'package.json')) { - return JSON.stringify({ name: 'superdoc' }); - } - return '{}'; - }), - }; - - const result = resolveSpecifier('superdoc-monorepo', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result.isFile).toBe(true); - expect(result.installPath).toBe(packagesSuperdocDir); - expect(result.specifier).toMatch(/^file:/); - }); - - it('should return error for directory without superdoc', () => { - const someDir = path.join(rootDir, 'some-dir'); - - const mockFs = { - existsSync: vi.fn((p: string) => { - if (p === someDir) return true; - return false; - }), - statSync: vi.fn().mockReturnValue({ isDirectory: () => true }), - readFileSync: vi.fn(), - }; - - const result = resolveSpecifier('some-dir', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result.error).toBeTruthy(); - expect(result.error).toContain('No superdoc package found'); - }); - - it('should handle tarball files', () => { - const tarballPath = path.join(rootDir, 'superdoc-1.4.0.tgz'); - - const mockFs = { - existsSync: vi.fn().mockReturnValue(true), - statSync: vi.fn().mockReturnValue({ isDirectory: () => false }), - readFileSync: vi.fn(), - }; - - const result = resolveSpecifier('superdoc-1.4.0.tgz', { - rootDir, - harnessDir, - deps: { fs: mockFs }, - }); - - expect(result.isFile).toBe(true); - expect(result.installPath).toBe(tarballPath); - expect(result.specifier).toMatch(/^file:/); - }); - }); -}); diff --git a/devtools/visual-testing/scripts/set-superdoc-version.ts b/devtools/visual-testing/scripts/set-superdoc-version.ts deleted file mode 100644 index 9f2bd4edeb..0000000000 --- a/devtools/visual-testing/scripts/set-superdoc-version.ts +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env tsx - -/** - * Set the superdoc version in the harness package. - * - * Usage: - * pnpm version # npm version (e.g., 1.4.0, 1.4.0-next.3) - * pnpm version # Local path (directory or tarball, already built/packed) - * pnpm version local # Build + pack local repo superdoc, install tarball - * pnpm version ~/dev/superdoc # Monorepo root (auto-finds packages/superdoc) - * - * Examples: - * pnpm version 1.4.0 - * pnpm version 1.4.0-next.3 - * pnpm version local - * pnpm version ../superdoc - * pnpm version ~/dev/superdoc - * pnpm version ./superdoc-1.4.0.tgz - */ - -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { spawnSync } from 'child_process'; -import { colors } from './terminal.js'; -import { findWorkspaceRoot } from './workspace-utils.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..'); -const workspaceRoot = findWorkspaceRoot(rootDir) ?? rootDir; -const harnessPkgPath = path.resolve(rootDir, 'packages/harness/package.json'); -const harnessDir = path.dirname(harnessPkgPath); - -/** Minimal fs interface for dependency injection */ -export interface FsLike { - existsSync(path: string): boolean; - readFileSync(path: string, encoding: 'utf8'): string; - statSync?(path: string): { isDirectory(): boolean }; -} - -/** Result from resolveSpecifier */ -export interface ResolveResult { - specifier: string; - isFile: boolean; - installPath: string | null; - error?: string; -} - -/** Options for resolveSpecifier */ -export interface ResolveOptions { - rootDir?: string; - harnessDir?: string; - deps?: { - fs?: FsLike; - }; -} - -const WORKSPACE_SPECIFIERS = new Set(['local', 'workspace']); - -function findRepoRoot(startDir: string): string | null { - let current = path.resolve(startDir); - while (true) { - const candidate = path.join(current, 'packages', 'superdoc', 'package.json'); - if (fs.existsSync(candidate)) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -function packLocalSuperdoc(): { tarballPath: string; superdocDir: string } { - const repoRoot = findRepoRoot(rootDir); - if (!repoRoot) { - throw new Error('Could not find repo root with packages/superdoc. Use a path or npm version instead.'); - } - const superdocDir = path.join(repoRoot, 'packages', 'superdoc'); - console.log(colors.info(`Packing local superdoc from ${superdocDir}...`)); - const packResult = spawnSync('pnpm', ['--dir', superdocDir, 'run', 'pack:es'], { - stdio: 'inherit', - env: { - ...process.env, - PNPM_LOG_LEVEL: 'error', - NPM_CONFIG_LOGLEVEL: 'error', - }, - }); - if (packResult.error) { - throw new Error(`pnpm pack failed: ${packResult.error.message}`); - } - if (packResult.status !== 0) { - throw new Error(`pnpm pack exited with code ${packResult.status}`); - } - const tarballPath = path.join(superdocDir, 'superdoc.tgz'); - if (!fs.existsSync(tarballPath)) { - throw new Error(`Expected tarball not found at ${tarballPath}`); - } - return { tarballPath, superdocDir }; -} - -/** - * Convert a path to POSIX format (forward slashes). - */ -export function toPosixPath(value: string): string { - return value.split(path.sep).join('/'); -} - -/** - * Check if a directory contains the superdoc package. - */ -export function isSuperdocPackage(dir: string, deps: { fs?: FsLike } = {}): boolean { - const _fs = deps.fs || fs; - const pkgPath = path.join(dir, 'package.json'); - if (!_fs.existsSync(pkgPath)) return false; - try { - const pkg = JSON.parse(_fs.readFileSync(pkgPath, 'utf8')); - return pkg.name === 'superdoc'; - } catch { - return false; - } -} - -/** - * Resolve the input to a package specifier. - */ -export function resolveSpecifier(input: string, options: ResolveOptions = {}): ResolveResult { - const _rootDir = options.rootDir || rootDir; - const _harnessDir = options.harnessDir || harnessDir; - const _fs = options.deps?.fs || fs; - const normalizedInput = input.toLowerCase(); - - const resolvedPath = path.resolve(_rootDir, input); - // Handle explicit file: prefix - if (input.startsWith('file:')) { - const rawPath = input.slice('file:'.length); - const resolved = path.isAbsolute(rawPath) ? rawPath : path.resolve(_rootDir, rawPath); - return { - specifier: input, - isFile: true, - installPath: _fs.existsSync(resolved) ? resolved : null, - }; - } - - // Handle path that exists on filesystem - if (_fs.existsSync(resolvedPath)) { - const stat = (_fs as typeof fs).statSync(resolvedPath); - - if (stat.isDirectory()) { - // Check if this is the superdoc package directory itself - if (isSuperdocPackage(resolvedPath, { fs: _fs })) { - const relativePath = path.relative(_harnessDir, resolvedPath) || '.'; - return { - specifier: `file:${toPosixPath(relativePath)}`, - isFile: true, - installPath: resolvedPath, - }; - } - - // Check packages/superdoc subdirectory (monorepo root) - const packagesSuperdocDir = path.join(resolvedPath, 'packages', 'superdoc'); - if (isSuperdocPackage(packagesSuperdocDir, { fs: _fs })) { - const relativePath = path.relative(_harnessDir, packagesSuperdocDir) || '.'; - return { - specifier: `file:${toPosixPath(relativePath)}`, - isFile: true, - installPath: packagesSuperdocDir, - }; - } - - return { - specifier: input, - isFile: false, - installPath: null, - error: `No superdoc package found in "${resolvedPath}" or "${resolvedPath}/packages/superdoc".`, - }; - } - - // It's a file (tarball) - const relativePath = path.relative(_harnessDir, resolvedPath) || '.'; - return { - specifier: `file:${toPosixPath(relativePath)}`, - isFile: true, - installPath: resolvedPath, - }; - } - - // Assume it's an npm version string - return { - specifier: input, - isFile: false, - installPath: null, - }; -} - -/** - * Main function - runs when script is executed directly. - */ -function main(): void { - const [, , input] = process.argv; - - if (!input) { - console.error(colors.error('Usage: pnpm version ')); - console.error(colors.muted('')); - console.error(colors.muted('Examples:')); - console.error(colors.muted(' pnpm version 1.4.0')); - console.error(colors.muted(' pnpm version ../superdoc')); - console.error(colors.muted(' pnpm version local')); - console.error(colors.muted(' pnpm version ~/dev/superdoc')); - process.exit(1); - } - - if (!fs.existsSync(harnessPkgPath)) { - console.error(colors.error(`Could not find harness package.json at ${harnessPkgPath}`)); - process.exit(1); - } - - const pkg = JSON.parse(fs.readFileSync(harnessPkgPath, 'utf8')); - - // Ensure superdoc is in dependencies (move from peerDeps if needed) - if (!pkg.dependencies) { - pkg.dependencies = {}; - } - - const normalizedInput = input.toLowerCase(); - let result: ResolveResult; - if (WORKSPACE_SPECIFIERS.has(normalizedInput)) { - try { - const { tarballPath, superdocDir } = packLocalSuperdoc(); - const relativeTarballPath = path.relative(harnessDir, tarballPath) || '.'; - result = { - specifier: `file:${toPosixPath(relativeTarballPath)}`, - isFile: true, - installPath: superdocDir, - }; - console.log(colors.success(`Packed local tarball: ${tarballPath}`)); - } catch (error) { - console.error(colors.error(error instanceof Error ? error.message : String(error))); - process.exit(1); - } - } else { - result = resolveSpecifier(input); - } - - if (result.error) { - console.error(colors.error(result.error)); - console.error( - colors.warning('Ensure the path points to the superdoc package (or packages/superdoc in a monorepo).'), - ); - process.exit(1); - } - - const { specifier, isFile, installPath } = result; - - if (installPath) { - console.log(colors.info(`Found superdoc at: ${installPath}`)); - } - - // Update package.json - pkg.dependencies.superdoc = specifier; - - // Remove from peerDependencies if present (we're installing it now) - if (pkg.peerDependencies?.superdoc) { - delete pkg.peerDependencies.superdoc; - if (Object.keys(pkg.peerDependencies).length === 0) { - delete pkg.peerDependencies; - } - } - - let finalSpecifier = specifier; - if (isFile && installPath) { - const exists = fs.existsSync(installPath); - const isDir = exists && fs.statSync(installPath).isDirectory(); - const isTarball = exists && !isDir && installPath.endsWith('.tgz'); - - if (isDir) { - const tarballPath = path.join(installPath, 'superdoc.tgz'); - if (fs.existsSync(tarballPath)) { - const relativeTarballPath = path.relative(harnessDir, tarballPath); - finalSpecifier = `file:${toPosixPath(relativeTarballPath)}`; - console.log(colors.info(`Using existing tarball: ${tarballPath}`)); - } else { - console.log(colors.warning('Using local superdoc path; make sure it is already built/packed.')); - } - } else if (isTarball) { - console.log(colors.info(`Using tarball: ${installPath}`)); - } - } - - // Update package.json with final specifier (tarball path for local) - pkg.dependencies.superdoc = finalSpecifier; - fs.writeFileSync(harnessPkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); - console.log(colors.success(`Updated packages/harness superdoc dependency to "${finalSpecifier}"`)); - - if (isFile) { - const installedPath = path.join(harnessDir, 'node_modules', 'superdoc'); - if (fs.existsSync(installedPath)) { - fs.rmSync(installedPath, { recursive: true, force: true }); - console.log(colors.muted('Cleared cached superdoc install.')); - } - } - - console.log(''); - console.log(colors.info('Installing...')); - - const installResult = spawnSync( - 'pnpm', - ['--filter', '@superdoc-testing/harness', 'install', '--no-frozen-lockfile', '--loglevel', 'error'], - { - cwd: workspaceRoot, - stdio: 'inherit', - env: { - ...process.env, - PNPM_IGNORE_PEER_DEPENDENCIES: '1', - PNPM_LOG_LEVEL: 'error', - NPM_CONFIG_LOGLEVEL: 'error', - PNPM_LINK_WORKSPACE_PACKAGES: 'false', - PNPM_PREFER_WORKSPACE_PACKAGES: 'false', - }, - }, - ); - - if (installResult.error) { - console.error(colors.error(`pnpm install failed: ${installResult.error.message}`)); - process.exit(1); - } - - if (installResult.status !== 0) { - console.error(colors.error(`pnpm install exited with code ${installResult.status}`)); - process.exit(installResult.status ?? 1); - } - - console.log(''); - console.log( - colors.success(`Superdoc ${isFile ? `(local: ${finalSpecifier})` : finalSpecifier} installed successfully.`), - ); -} - -// Run main() only when executed directly (not when imported for testing) -const isMain = process.argv[1]?.includes('set-superdoc-version'); -if (isMain) { - main(); -} diff --git a/devtools/visual-testing/scripts/storage-flags.ts b/devtools/visual-testing/scripts/storage-flags.ts deleted file mode 100644 index c7fe94b308..0000000000 --- a/devtools/visual-testing/scripts/storage-flags.ts +++ /dev/null @@ -1,60 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { getBaselineLocalRoot as getCloudBaselineLocalRoot } from './r2-baselines.js'; - -export type StorageMode = 'cloud' | 'local'; - -export function parseStorageFlags(args: string[]): { mode: StorageMode; docsDir?: string } { - const mode: StorageMode = args.includes('--local') ? 'local' : 'cloud'; - const docsIndex = args.indexOf('--docs'); - const docsDir = docsIndex >= 0 ? args[docsIndex + 1] : undefined; - return { mode, docsDir }; -} - -export function buildStorageArgs(mode: StorageMode, docsDir?: string): string[] { - if (mode !== 'local') return []; - return docsDir ? ['--local', '--docs', docsDir] : ['--local']; -} - -export function resolveDocsDir(mode: StorageMode, docsDir?: string): string | undefined { - if (mode !== 'local') return undefined; - if (!docsDir) { - throw new Error('Missing --docs (required when using --local).'); - } - const resolved = path.resolve(docsDir); - if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { - throw new Error(`Docs path not found or not a directory: ${resolved}`); - } - return resolved; -} - -export function getBaselineOutputRoot(mode: StorageMode, baselineLabel: string): string { - if (mode === 'local') { - return path.join('baselines', baselineLabel); - } - const tmpRoot = process.env.R2_BASELINES_TMP_DIR ?? path.join(os.tmpdir(), 'superdoc-baselines'); - return path.join(tmpRoot, 'visual', baselineLabel); -} - -export function getBaselineLocalRoot(mode: StorageMode, prefix: string): string { - if (mode === 'local') { - return path.resolve(prefix); - } - return getCloudBaselineLocalRoot(prefix); -} - -export function findBaselineVersionsLocal(baselineRoot: string): string[] { - if (!fs.existsSync(baselineRoot)) return []; - const entries = fs.readdirSync(baselineRoot, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory() && entry.name.startsWith('v.')) - .map((entry) => entry.name) - .sort() - .reverse(); -} - -export function findLatestBaselineLocal(baselineRoot: string): string | null { - const versions = findBaselineVersionsLocal(baselineRoot); - return versions.length > 0 ? versions[0] : null; -} diff --git a/devtools/visual-testing/scripts/terminal.ts b/devtools/visual-testing/scripts/terminal.ts deleted file mode 100644 index 091ced264b..0000000000 --- a/devtools/visual-testing/scripts/terminal.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Terminal styling utilities for visual testing scripts. - * Provides consistent colored output with automatic TTY detection. - */ - -import kleur from 'kleur'; - -/** Detect if stdout is a TTY and disable colors if not or if NO_COLOR is set. */ -const isTty = Boolean(process.stdout.isTTY); -if (!isTty || process.env.NO_COLOR) { - kleur.enabled = false; -} - -/** - * Color functions for terminal output. - * Automatically disabled when stdout is not a TTY or NO_COLOR is set. - */ -export const colors = { - /** Bold cyan for section headers */ - header: (value: string) => kleur.bold().cyan(value), - /** Cyan for informational messages */ - info: (value: string) => kleur.cyan(value), - /** Green for success messages */ - success: (value: string) => kleur.green(value), - /** Yellow for warning messages */ - warning: (value: string) => kleur.yellow(value), - /** Red for error messages */ - error: (value: string) => kleur.red(value), - /** Gray for secondary/muted text */ - muted: (value: string) => kleur.gray(value), - /** Magenta for accent/highlight text */ - accent: (value: string) => kleur.magenta(value), - /** Cyan for file paths */ - path: (value: string) => kleur.cyan(value), - /** Bold for emphasized text */ - strong: (value: string) => kleur.bold(value), -}; diff --git a/devtools/visual-testing/scripts/upload-corpus-doc.ts b/devtools/visual-testing/scripts/upload-corpus-doc.ts deleted file mode 100644 index 626bc15648..0000000000 --- a/devtools/visual-testing/scripts/upload-corpus-doc.ts +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env tsx - -import path from 'node:path'; -import { spawn } from 'node:child_process'; - -const REPO_ROOT = path.resolve(import.meta.dirname, '../../..'); - -async function main(): Promise { - const passthroughArgs = process.argv.slice(2).filter((arg) => arg !== '--'); - const commandArgs = ['run', 'corpus:push', '--', ...passthroughArgs]; - - const child = spawn('pnpm', commandArgs, { - cwd: REPO_ROOT, - env: process.env, - stdio: 'inherit', - }); - - const exitCode = await new Promise((resolve) => { - child.on('close', (code) => resolve(code ?? 1)); - child.on('error', (err) => { - console.error(`Failed to spawn corpus:push: ${err.message}`); - resolve(1); - }); - }); - - process.exit(Number(exitCode)); -} - -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.error(`[upload] Fatal: ${message}`); - process.exitCode = 1; - }); -} diff --git a/devtools/visual-testing/scripts/upload-corpus.ts b/devtools/visual-testing/scripts/upload-corpus.ts deleted file mode 100644 index 7477fd12ae..0000000000 --- a/devtools/visual-testing/scripts/upload-corpus.ts +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env tsx - -import fs from 'node:fs'; -import path from 'node:path'; -import crypto from 'node:crypto'; -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; -import { colors } from './terminal.js'; - -const VALID_EXTENSIONS = new Set(['.docx']); -const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - -function normalizeSegment(value: string): string { - return value - .trim() - .replace(/[^A-Za-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^[-_.]+|[-_.]+$/g, '') - .toLowerCase(); -} - -function normalizePath(value: string): string { - return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, ''); -} - -function sha256Buffer(buffer: Buffer): string { - const hash = crypto.createHash('sha256'); - hash.update(buffer); - return `sha256:${hash.digest('hex')}`; -} - -function walk(dir: string, onFile: (filePath: string) => void): void { - if (!fs.existsSync(dir)) return; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath, onFile); - } else if (entry.isFile()) { - onFile(fullPath); - } - } -} - -async function createClient(): Promise<{ client: S3Client; bucketName: string }> { - const accountId = process.env.SD_TESTING_R2_ACCOUNT_ID ?? ''; - const bucketName = process.env.SD_TESTING_R2_BUCKET_NAME ?? ''; - const accessKeyId = process.env.SD_TESTING_R2_ACCESS_KEY_ID ?? ''; - const secretAccessKey = process.env.SD_TESTING_R2_SECRET_ACCESS_KEY ?? ''; - - if (!accountId) { - throw new Error('Missing SD_TESTING_R2_ACCOUNT_ID'); - } - - if (!bucketName) { - throw new Error('Missing SD_TESTING_R2_BUCKET_NAME'); - } - - if (!accessKeyId) { - throw new Error('Missing SD_TESTING_R2_ACCESS_KEY_ID'); - } - - if (!secretAccessKey) { - throw new Error('Missing SD_TESTING_R2_SECRET_ACCESS_KEY'); - } - - const client = new S3Client({ - region: 'auto', - endpoint: `https://${accountId}.r2.cloudflarestorage.com`, - credentials: { accessKeyId, secretAccessKey }, - }); - - return { client, bucketName }; -} - -async function main(): Promise { - const rootArg = process.argv.slice(2).find((arg) => !arg.startsWith('--')); - const rootPath = rootArg - ? path.resolve(process.cwd(), rootArg) - : process.env.SUPERDOC_CORPUS_ROOT - ? path.resolve(process.cwd(), process.env.SUPERDOC_CORPUS_ROOT) - : path.resolve(process.cwd(), 'test-docs'); - - if (!fs.existsSync(rootPath)) { - throw new Error(`Missing corpus source directory: ${rootPath}`); - } - - const { client, bucketName } = await createClient(); - - const docs: Array<{ - doc_id: string; - doc_rev: string; - filename: string; - group?: string; - relative_path: string; - }> = []; - - const files: string[] = []; - walk(rootPath, (filePath) => { - const ext = path.extname(filePath).toLowerCase(); - if (!VALID_EXTENSIONS.has(ext)) return; - const baseName = path.basename(filePath); - if (baseName.startsWith('~$')) return; - files.push(filePath); - }); - - if (files.length === 0) { - console.log(colors.warning('No .docx files found in source directory.')); - return; - } - - console.log(colors.info(`Uploading ${files.length} document(s) to R2...`)); - - for (const filePath of files) { - const buffer = fs.readFileSync(filePath); - const relative = normalizePath(path.relative(rootPath, filePath)); - const filename = path.basename(filePath); - const group = relative.includes('/') ? relative.split('/')[0] : undefined; - const relativeStem = relative.replace(path.extname(relative), ''); - const doc_id = normalizeSegment(relativeStem.replace(/\//g, '-')); - const doc_rev = sha256Buffer(buffer); - - await client.send( - new PutObjectCommand({ - Bucket: bucketName, - Key: relative, - Body: buffer, - ContentType: DOCX_CONTENT_TYPE, - }), - ); - - docs.push({ - doc_id, - doc_rev, - filename, - group, - relative_path: relative, - }); - - console.log(colors.muted(`- ${relative}`)); - } - - docs.sort((a, b) => a.relative_path.localeCompare(b.relative_path, undefined, { sensitivity: 'base' })); - - const registry = { - updated_at: new Date().toISOString(), - docs, - }; - - const registryBody = Buffer.from(`${JSON.stringify(registry, null, 2)}\n`, 'utf8'); - await client.send( - new PutObjectCommand({ - Bucket: bucketName, - Key: 'registry.json', - Body: registryBody, - ContentType: 'application/json', - }), - ); - - console.log(colors.success('βœ… Uploaded registry.json')); -} - -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - main().catch((error) => { - console.error(colors.error(`Fatal error: ${error instanceof Error ? error.message : String(error)}`)); - process.exitCode = 1; - }); -} diff --git a/devtools/visual-testing/scripts/utils.test.ts b/devtools/visual-testing/scripts/utils.test.ts deleted file mode 100644 index c121240404..0000000000 --- a/devtools/visual-testing/scripts/utils.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { normalizeDocPath } from './utils.js'; - -describe('normalizeDocPath', () => { - it('returns a simple relative path unchanged', () => { - expect(normalizeDocPath('comments-tcs/basic-comments.docx')).toBe('comments-tcs/basic-comments.docx'); - }); - - it('strips the test-docs/ prefix (case-insensitive)', () => { - expect(normalizeDocPath('test-docs/basic/simple.docx')).toBe('basic/simple.docx'); - expect(normalizeDocPath('Test-Docs/basic/simple.docx')).toBe('basic/simple.docx'); - }); - - it('strips leading ./', () => { - expect(normalizeDocPath('./basic/simple.docx')).toBe('basic/simple.docx'); - }); - - it('strips leading slashes', () => { - expect(normalizeDocPath('/basic/simple.docx')).toBe('basic/simple.docx'); - expect(normalizeDocPath('///basic/simple.docx')).toBe('basic/simple.docx'); - }); - - it('converts backslashes to forward slashes', () => { - expect(normalizeDocPath('comments-tcs\\basic-comments.docx')).toBe('comments-tcs/basic-comments.docx'); - }); - - it('handles combined prefixes', () => { - expect(normalizeDocPath('./test-docs/nested/doc.docx')).toBe('nested/doc.docx'); - }); - - it('returns empty string for empty input', () => { - expect(normalizeDocPath('')).toBe(''); - }); - - it('handles a bare filename', () => { - expect(normalizeDocPath('simple.docx')).toBe('simple.docx'); - }); -}); diff --git a/devtools/visual-testing/scripts/utils.ts b/devtools/visual-testing/scripts/utils.ts deleted file mode 100644 index 6b5b99be6f..0000000000 --- a/devtools/visual-testing/scripts/utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Shared utility functions for visual testing scripts. - */ - -/** - * Sleep for a given number of milliseconds. - * - * @param ms - Duration to sleep in milliseconds - * @returns Promise that resolves after the specified duration - */ -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Normalize a file path to use forward slashes and remove leading ./ or /. - * This ensures consistent path handling across Windows and Unix systems. - * - * @param value - Path to normalize - * @returns Normalized path string with forward slashes - */ -export function normalizePath(value: string): string { - return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, ''); -} - -/** - * Normalize a document path for comparison and filtering. - * Extends {@link normalizePath} by stripping the `test-docs/` corpus prefix - * and collapsing repeated leading slashes. - * - * @param value - Raw document path (may contain backslashes, leading ./, or test-docs/ prefix) - * @returns Cleaned path suitable for matching against corpus-relative paths - */ -export function normalizeDocPath(value: string): string { - return normalizePath(value) - .replace(/^\/+/, '') - .replace(/^test-docs\//i, ''); -} - -/** - * Creates a ring buffer for log output that keeps only the most recent content. - * - * @param limit - Maximum number of characters to retain - * @returns Object with append() to add content and dump() to retrieve buffered content - */ -export function createLogBuffer(limit: number): { - append: (chunk: Buffer | string) => void; - dump: () => string; -} { - let buffer = ''; - return { - append: (chunk: Buffer | string) => { - buffer = `${buffer}${chunk.toString()}`.slice(-limit); - }, - dump: () => buffer.trim(), - }; -} diff --git a/devtools/visual-testing/scripts/version-utils.test.ts b/devtools/visual-testing/scripts/version-utils.test.ts deleted file mode 100644 index bea22fc284..0000000000 --- a/devtools/visual-testing/scripts/version-utils.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - isPathLikeVersion, - versionLabelFromPath, - normalizeVersionLabel, - normalizeVersionSpecifier, - parseVersionInput, -} from './version-utils.js'; - -describe('isPathLikeVersion', () => { - it('should return false for empty string', () => { - expect(isPathLikeVersion('')).toBe(false); - expect(isPathLikeVersion(' ')).toBe(false); - }); - - it('should return true for file: prefix', () => { - expect(isPathLikeVersion('file:../superdoc')).toBe(true); - expect(isPathLikeVersion('file:/home/user/superdoc')).toBe(true); - }); - - it('should return true for tilde paths', () => { - expect(isPathLikeVersion('~/dev/superdoc')).toBe(true); - expect(isPathLikeVersion('~')).toBe(true); - }); - - it('should return true for relative paths', () => { - expect(isPathLikeVersion('./superdoc')).toBe(true); - expect(isPathLikeVersion('../superdoc')).toBe(true); - expect(isPathLikeVersion('path/to/superdoc')).toBe(true); - }); - - it('should return true for absolute paths', () => { - expect(isPathLikeVersion('/home/user/superdoc')).toBe(true); - }); - - it('should return false for version strings', () => { - expect(isPathLikeVersion('1.4.0')).toBe(false); - expect(isPathLikeVersion('1.4.0-next.3')).toBe(false); - expect(isPathLikeVersion('v.1.4.0')).toBe(false); - expect(isPathLikeVersion('^1.4.0')).toBe(false); - }); -}); - -describe('versionLabelFromPath', () => { - it('should extract basename from path', () => { - expect(versionLabelFromPath('/home/user/superdoc')).toBe('superdoc'); - expect(versionLabelFromPath('./superdoc')).toBe('superdoc'); - }); - - it('should handle file: prefix', () => { - expect(versionLabelFromPath('file:../superdoc')).toBe('superdoc'); - expect(versionLabelFromPath('file:/home/user/superdoc')).toBe('superdoc'); - }); - - it('should strip tarball extensions', () => { - expect(versionLabelFromPath('/path/to/superdoc-1.4.0.tgz')).toBe('superdoc-1.4.0'); - expect(versionLabelFromPath('/path/to/superdoc-1.4.0.tar.gz')).toBe('superdoc-1.4.0'); - }); - - it('should handle trailing slashes', () => { - expect(versionLabelFromPath('/home/user/superdoc/')).toBe('superdoc'); - expect(versionLabelFromPath('/home/user/superdoc//')).toBe('superdoc'); - }); - - it('should return default for empty basename', () => { - expect(versionLabelFromPath('')).toBe('local-superdoc'); - }); -}); - -describe('normalizeVersionLabel', () => { - it('should add v. prefix to version strings', () => { - expect(normalizeVersionLabel('1.4.0')).toBe('v.1.4.0'); - expect(normalizeVersionLabel('1.4.0-next.3')).toBe('v.1.4.0-next.3'); - }); - - it('should not double-prefix', () => { - expect(normalizeVersionLabel('v.1.4.0')).toBe('v.1.4.0'); - }); - - it('should extract label from path', () => { - expect(normalizeVersionLabel('/home/user/superdoc')).toBe('superdoc'); - expect(normalizeVersionLabel('file:../superdoc')).toBe('superdoc'); - }); -}); - -describe('normalizeVersionSpecifier', () => { - it('should return version without v. prefix', () => { - expect(normalizeVersionSpecifier('v.1.4.0')).toBe('1.4.0'); - expect(normalizeVersionSpecifier('1.4.0')).toBe('1.4.0'); - }); - - it('should return path as-is', () => { - expect(normalizeVersionSpecifier('/home/user/superdoc')).toBe('/home/user/superdoc'); - expect(normalizeVersionSpecifier('file:../superdoc')).toBe('file:../superdoc'); - }); -}); - -describe('parseVersionInput', () => { - it('should parse version strings', () => { - const result = parseVersionInput('1.4.0'); - expect(result.label).toBe('v.1.4.0'); - expect(result.spec).toBe('1.4.0'); - }); - - it('should handle v. prefixed versions', () => { - const result = parseVersionInput('v.1.4.0'); - expect(result.label).toBe('v.1.4.0'); - expect(result.spec).toBe('1.4.0'); - }); - - it('should parse paths', () => { - const result = parseVersionInput('/home/user/superdoc'); - expect(result.label).toBe('superdoc'); - expect(result.spec).toBe('/home/user/superdoc'); - }); - - it('should parse file: prefixed paths', () => { - const result = parseVersionInput('file:../superdoc'); - expect(result.label).toBe('superdoc'); - expect(result.spec).toBe('file:../superdoc'); - }); - - it('should parse tarball paths', () => { - const result = parseVersionInput('/path/to/superdoc-1.4.0.tgz'); - expect(result.label).toBe('superdoc-1.4.0'); - expect(result.spec).toBe('/path/to/superdoc-1.4.0.tgz'); - }); -}); diff --git a/devtools/visual-testing/scripts/version-utils.ts b/devtools/visual-testing/scripts/version-utils.ts deleted file mode 100644 index 435ba01263..0000000000 --- a/devtools/visual-testing/scripts/version-utils.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Version string utilities for visual testing scripts. - * Handles version normalization for both npm versions and local file paths. - */ - -import path from 'node:path'; - -const TAR_GZ_SUFFIX = '.tar.gz'; -const TGZ_SUFFIX = '.tgz'; - -/** - * Check if a version string appears to be a file path rather than an npm version. - * - * @param value - Version string to check - * @returns True if the value looks like a file path (starts with file:, ~, ., or contains path separators) - */ -export function isPathLikeVersion(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) return false; - if (trimmed.startsWith('file:')) return true; - if (trimmed.startsWith('~') || trimmed.startsWith('.')) return true; - if (path.isAbsolute(trimmed)) return true; - return trimmed.includes('/') || trimmed.includes('\\'); -} - -/** - * Extract a version label from a file path. - * Strips the file: prefix, trailing slashes, and tarball extensions. - * - * @param value - File path to extract label from - * @returns The base name of the path without tarball extensions, or 'local-superdoc' if empty - */ -export function versionLabelFromPath(value: string): string { - let raw = value.trim(); - if (raw.startsWith('file:')) { - raw = raw.slice('file:'.length); - } - raw = raw.replace(/[\\/]+$/, ''); - let base = path.basename(raw); - const lower = base.toLowerCase(); - if (lower.endsWith(TAR_GZ_SUFFIX)) { - base = base.slice(0, -TAR_GZ_SUFFIX.length); - } else if (lower.endsWith(TGZ_SUFFIX)) { - base = base.slice(0, -TGZ_SUFFIX.length); - } - return base || 'local-superdoc'; -} - -/** - * Normalize a version string to a display label. - * - Path-like versions: extracts base name from path - * - npm versions: ensures 'v.' prefix (e.g., '1.4.0' β†’ 'v.1.4.0') - * - * @param version - Version string to normalize - * @returns Normalized version label suitable for folder names - */ -export function normalizeVersionLabel(version: string): string { - const trimmed = version.trim(); - if (isPathLikeVersion(trimmed)) { - return versionLabelFromPath(trimmed); - } - return trimmed.startsWith('v.') ? trimmed : `v.${trimmed}`; -} - -/** - * Normalize a version string to a package specifier. - * - Path-like versions: returned as-is - * - npm versions: strips 'v.' prefix if present (e.g., 'v.1.4.0' β†’ '1.4.0') - * - * @param version - Version string to normalize - * @returns Version specifier suitable for npm install - */ -export function normalizeVersionSpecifier(version: string): string { - const trimmed = version.trim(); - if (isPathLikeVersion(trimmed)) { - return trimmed; - } - return trimmed.startsWith('v.') ? trimmed.slice(2) : trimmed; -} - -/** - * Parse a version input string into both label and specifier forms. - * - * @param version - Version string to parse - * @returns Object with `label` (display name) and `spec` (package specifier) - */ -export function parseVersionInput(version: string): { label: string; spec: string } { - return { - label: normalizeVersionLabel(version), - spec: normalizeVersionSpecifier(version), - }; -} diff --git a/devtools/visual-testing/scripts/workspace-utils.test.ts b/devtools/visual-testing/scripts/workspace-utils.test.ts deleted file mode 100644 index f5c85aa16a..0000000000 --- a/devtools/visual-testing/scripts/workspace-utils.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import path from 'path'; -import fs from 'node:fs'; -import { findWorkspaceRoot, findLocalSuperdocTarball, ensureLocalTarballInstalled } from './workspace-utils.js'; - -describe('findWorkspaceRoot', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should return null when no pnpm-workspace.yaml is found', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const result = findWorkspaceRoot('/some/deep/nested/path'); - expect(result).toBe(null); - }); - - it('should return the directory containing pnpm-workspace.yaml', () => { - const workspaceRoot = '/home/user/project'; - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - return p === path.join(workspaceRoot, 'pnpm-workspace.yaml'); - }); - - const result = findWorkspaceRoot('/home/user/project/packages/subpackage'); - expect(result).toBe(workspaceRoot); - }); - - it('should handle absolute paths', () => { - const workspaceRoot = '/workspace'; - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - return p === path.join(workspaceRoot, 'pnpm-workspace.yaml'); - }); - - const result = findWorkspaceRoot('/workspace/deep/nested'); - expect(result).toBe(workspaceRoot); - }); -}); - -describe('findLocalSuperdocTarball', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should return null when tarball does not exist', () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const result = findLocalSuperdocTarball('/some/path'); - expect(result).toBe(null); - }); - - it('should return tarball info when tarball exists in an ancestor', () => { - const workspaceRoot = '/home/user/project'; - const tarballPath = path.join(workspaceRoot, 'packages', 'superdoc', 'superdoc.tgz'); - - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - if (p === tarballPath) return true; - return false; - }); - - const result = findLocalSuperdocTarball('/home/user/project/packages/subpackage'); - expect(result).toEqual({ - root: workspaceRoot, - tarball: tarballPath, - }); - }); -}); - -describe('ensureLocalTarballInstalled', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should do nothing when no tarball is found', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - - const runVersionSwitch = vi.fn(); - await ensureLocalTarballInstalled('/some/path', runVersionSwitch); - - expect(runVersionSwitch).not.toHaveBeenCalled(); - }); - - it('should do nothing when harness package.json does not exist', async () => { - const workspaceRoot = '/home/user/project'; - const tarballPath = path.join(workspaceRoot, 'packages', 'superdoc', 'superdoc.tgz'); - - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - if (p === tarballPath) return true; - return false; - }); - - const runVersionSwitch = vi.fn(); - await ensureLocalTarballInstalled(workspaceRoot, runVersionSwitch); - - expect(runVersionSwitch).not.toHaveBeenCalled(); - }); - - it('should do nothing when already using tarball and superdoc is installed', async () => { - const workspaceRoot = '/home/user/project'; - const tarballPath = path.join(workspaceRoot, 'packages', 'superdoc', 'superdoc.tgz'); - const harnessPkgPath = path.join(workspaceRoot, 'packages', 'harness', 'package.json'); - const installedPkgPath = path.join( - workspaceRoot, - 'packages', - 'harness', - 'node_modules', - 'superdoc', - 'package.json', - ); - - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - if (p === tarballPath) return true; - if (p === harnessPkgPath) return true; - if (p === installedPkgPath) return true; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ dependencies: { superdoc: 'file:../../superdoc/superdoc.tgz' } }), - ); - - const runVersionSwitch = vi.fn(); - await ensureLocalTarballInstalled(workspaceRoot, runVersionSwitch); - - expect(runVersionSwitch).not.toHaveBeenCalled(); - }); - - it('should switch version when tarball exists but not configured', async () => { - const workspaceRoot = '/home/user/project'; - const tarballPath = path.join(workspaceRoot, 'packages', 'superdoc', 'superdoc.tgz'); - const harnessPkgPath = path.join(workspaceRoot, 'packages', 'harness', 'package.json'); - const installedPkgPath = path.join( - workspaceRoot, - 'packages', - 'harness', - 'node_modules', - 'superdoc', - 'package.json', - ); - - vi.spyOn(fs, 'existsSync').mockImplementation((p) => { - if (p === tarballPath) return true; - if (p === harnessPkgPath) return true; - if (p === installedPkgPath) return false; - return false; - }); - - vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ dependencies: { superdoc: '1.5.0' } })); - - const runVersionSwitch = vi.fn(); - const log = vi.fn(); - await ensureLocalTarballInstalled(workspaceRoot, runVersionSwitch, log); - - expect(runVersionSwitch).toHaveBeenCalledWith(path.join(workspaceRoot, 'packages', 'superdoc')); - expect(log).toHaveBeenCalledWith(expect.stringContaining('Switching to local SuperDoc tarball')); - }); -}); diff --git a/devtools/visual-testing/scripts/workspace-utils.ts b/devtools/visual-testing/scripts/workspace-utils.ts deleted file mode 100644 index 17ca7db65d..0000000000 --- a/devtools/visual-testing/scripts/workspace-utils.ts +++ /dev/null @@ -1,88 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -/** - * Find the pnpm workspace root by traversing up from a starting directory. - * - * @param startDir - Directory to start searching from - * @returns Absolute path to workspace root, or null if not found - */ -export function findWorkspaceRoot(startDir: string): string | null { - let current = path.resolve(startDir); - while (true) { - const marker = path.join(current, 'pnpm-workspace.yaml'); - if (fs.existsSync(marker)) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -/** Result from findLocalSuperdocTarball when a tarball is found. */ -export interface LocalSuperdocTarball { - /** Absolute path to the workspace root directory. */ - root: string; - /** Absolute path to the superdoc.tgz tarball. */ - tarball: string; -} - -/** - * Find a local superdoc tarball in the workspace. - * Searches for packages/superdoc/superdoc.tgz relative to the workspace root. - * - * @param startDir - Directory to start searching from - * @returns Tarball info if found, or null if not in a workspace or tarball doesn't exist - */ -export function findLocalSuperdocTarball(startDir: string): LocalSuperdocTarball | null { - let current = path.resolve(startDir); - while (true) { - const tarball = path.join(current, 'packages', 'superdoc', 'superdoc.tgz'); - if (fs.existsSync(tarball)) { - return { root: current, tarball }; - } - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -/** - * Ensure the harness uses a local superdoc tarball if available. - * Checks if a tarball exists in the workspace and if the harness is not already - * configured to use it, switches the harness to use the tarball. - * - * @param cwd - Current working directory (visual-testing root) - * @param runVersionSwitch - Function to switch superdoc version - * @param log - Optional logging function for status messages - */ -export async function ensureLocalTarballInstalled( - cwd: string, - runVersionSwitch: (version: string) => Promise, - log?: (message: string) => void, -): Promise { - const info = findLocalSuperdocTarball(cwd); - if (!info) { - return; - } - const harnessPkgPath = path.resolve(cwd, 'packages/harness/package.json'); - if (!fs.existsSync(harnessPkgPath)) { - return; - } - const harnessInstallPath = path.resolve(cwd, 'packages/harness/node_modules/superdoc'); - const hasInstalledPackage = fs.existsSync(path.join(harnessInstallPath, 'package.json')); - const pkg = JSON.parse(fs.readFileSync(harnessPkgPath, 'utf8')) as { dependencies?: Record }; - const currentSpec = pkg.dependencies?.superdoc ?? ''; - if (currentSpec.includes('superdoc.tgz') && hasInstalledPackage) { - return; - } - if (log) { - log(`Switching to local SuperDoc tarball: ${info.tarball}`); - } - await runVersionSwitch(path.join(info.root, 'packages', 'superdoc')); -} diff --git a/devtools/visual-testing/tsconfig.json b/devtools/visual-testing/tsconfig.json deleted file mode 100644 index 2ffce1ee98..0000000000 --- a/devtools/visual-testing/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "noEmit": true, - "forceConsistentCasingInFileNames": true, - "types": ["node", "vitest/globals"] - }, - "include": ["scripts/**/*.ts", "packages/**/*.ts", "packages/**/*.vue", "vitest.config.ts"], - "exclude": ["node_modules", "**/dist/**"] -} diff --git a/devtools/visual-testing/vitest.config.ts b/devtools/visual-testing/vitest.config.ts deleted file mode 100644 index f95687445b..0000000000 --- a/devtools/visual-testing/vitest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - // Run tests from all packages - include: ['packages/**/*.test.ts', 'scripts/**/*.test.ts'], - - // Exclude node_modules and dist - exclude: ['**/node_modules/**', '**/dist/**'], - - // Enable globals (describe, it, expect) without imports - globals: true, - }, -}); diff --git a/examples/README.md b/examples/README.md index 525e241510..96fc1d9c1a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -51,6 +51,7 @@ Patterns for the browser editor surface. | [comments](./editor/built-in-ui/comments) | [docs](https://docs.superdoc.dev/editor/built-in-ui/comments) | | [track-changes](./editor/built-in-ui/track-changes) | [docs](https://docs.superdoc.dev/editor/built-in-ui/track-changes) | | [toolbar](./editor/built-in-ui/toolbar) | [docs](https://docs.superdoc.dev/editor/built-in-ui/toolbar) | +| [responsive-zoom](./editor/built-in-ui/responsive-zoom) | [docs](https://docs.superdoc.dev/editor/superdoc/configuration#param-zoom) | ### Custom UI diff --git a/examples/advanced/headless-toolbar/vanilla/src/main.ts b/examples/advanced/headless-toolbar/vanilla/src/main.ts index 7ef39db908..493b3cc8c1 100644 --- a/examples/advanced/headless-toolbar/vanilla/src/main.ts +++ b/examples/advanced/headless-toolbar/vanilla/src/main.ts @@ -5,6 +5,7 @@ import { type HeadlessToolbarController, type ToolbarSnapshot, type PublicToolbarItemId, + type ToolbarPayloadMap, } from 'superdoc/headless-toolbar'; import 'superdoc/style.css'; import './style.css'; @@ -38,18 +39,67 @@ function sep(): HTMLDivElement { return el; } -function select(id: string, options: readonly { label: string; value: string }[]): HTMLSelectElement { - const el = document.createElement('select'); - el.dataset.cmd = id; +type MenuOption = { label: string; value: string }; +type ButtonToolbarItemId = { + [Id in PublicToolbarItemId]: ToolbarPayloadMap[Id] extends never ? Id : never; +}[PublicToolbarItemId]; + +function firstFontName(value: string): string { + return value.split(',')[0]?.trim().replace(/^["']|["']$/g, '') || value; +} + +function normalizedMenuValue(value: string): string { + return firstFontName(value).toLowerCase(); +} + +function menu(id: string, options: readonly MenuOption[], placeholder: string): HTMLDivElement { + const el = document.createElement('div'); + el.className = 'menu-control'; + el.dataset.menuCmd = id; + el.dataset.placeholder = placeholder; + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'menu-trigger'; + trigger.dataset.menuTrigger = 'true'; + trigger.textContent = placeholder; + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-expanded', 'false'); + el.appendChild(trigger); + + const list = document.createElement('div'); + list.className = 'menu-list'; + list.hidden = true; + list.setAttribute('role', 'listbox'); + for (const opt of options) { - const o = document.createElement('option'); - o.value = opt.value; - o.textContent = opt.label; - el.appendChild(o); + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'menu-option'; + item.dataset.menuValue = opt.value; + item.textContent = opt.label; + item.setAttribute('role', 'option'); + list.appendChild(item); } + + el.appendChild(list); return el; } +function setMenuOpen(control: HTMLElement, open: boolean) { + const list = control.querySelector('.menu-list'); + const trigger = control.querySelector('[data-menu-trigger]'); + if (!list || !trigger) return; + list.hidden = !open; + trigger.setAttribute('aria-expanded', String(open)); +} + +function closeMenus(container: HTMLElement, except?: HTMLElement) { + container.querySelectorAll('[data-menu-cmd]').forEach((control) => { + if (control !== except) setMenuOpen(control, false); + }); +} + // --- Build toolbar DOM --- function buildToolbar(container: HTMLElement) { @@ -64,8 +114,8 @@ function buildToolbar(container: HTMLElement) { // Font family & size container.append( - select('font-family', DEFAULT_FONT_FAMILY_OPTIONS), - select('font-size', DEFAULT_FONT_SIZE_OPTIONS.map(o => ({ label: o.label, value: o.value }))), + menu('font-family', DEFAULT_FONT_FAMILY_OPTIONS, 'Font'), + menu('font-size', DEFAULT_FONT_SIZE_OPTIONS.map(o => ({ label: o.label, value: o.value })), 'Size'), sep(), ); @@ -80,20 +130,12 @@ function buildToolbar(container: HTMLElement) { // Text color container.append( - select('text-color', DEFAULT_TEXT_COLOR_OPTIONS.map(o => ({ label: o.label, value: o.value }))), + menu('text-color', DEFAULT_TEXT_COLOR_OPTIONS.map(o => ({ label: o.label, value: o.value })), 'Color'), sep(), ); // Text align - const alignSelect = document.createElement('select'); - alignSelect.dataset.cmd = 'text-align'; - for (const opt of headlessToolbarConstants.DEFAULT_TEXT_ALIGN_OPTIONS) { - const o = document.createElement('option'); - o.value = opt.value; - o.textContent = opt.label; - alignSelect.appendChild(o); - } - container.append(alignSelect, sep()); + container.append(menu('text-align', headlessToolbarConstants.DEFAULT_TEXT_ALIGN_OPTIONS, 'Align'), sep()); // Image container.append(btn('image', icon(Image))); @@ -108,15 +150,33 @@ function bindEvents( container.addEventListener('click', (e) => { const target = (e.target as HTMLElement).closest('button[data-cmd]'); if (!target) return; - const cmd = target.dataset.cmd as PublicToolbarItemId; + const cmd = target.dataset.cmd as ButtonToolbarItemId; toolbar.execute(cmd); }); - container.querySelectorAll('select[data-cmd]').forEach((sel) => { - sel.addEventListener('change', () => { - toolbar.execute(sel.dataset.cmd as PublicToolbarItemId, sel.value); + container.querySelectorAll('[data-menu-cmd]').forEach((control) => { + const trigger = control.querySelector('[data-menu-trigger]'); + trigger?.addEventListener('mousedown', (event) => event.preventDefault()); + trigger?.addEventListener('click', () => { + const isOpen = control.querySelector('.menu-list')?.hidden === false; + closeMenus(container, control); + setMenuOpen(control, !isOpen); + }); + + control.querySelectorAll('[data-menu-value]').forEach((option) => { + option.addEventListener('mousedown', (event) => event.preventDefault()); + option.addEventListener('click', () => { + const cmd = control.dataset.menuCmd as PublicToolbarItemId; + const value = option.dataset.menuValue ?? ''; + toolbar.execute(cmd, value); + setMenuOpen(control, false); + }); }); }); + + document.addEventListener('mousedown', (event) => { + if (!container.contains(event.target as Node | null)) closeMenus(container); + }); } // --- Snapshot sync --- @@ -142,18 +202,28 @@ function syncUI(container: HTMLElement, snapshot: ToolbarSnapshot) { el.disabled = snapshot.commands[id]?.disabled ?? true; } - // Selects + // Menus for (const id of ['font-family', 'font-size', 'text-color', 'text-align'] as PublicToolbarItemId[]) { - const sel = container.querySelector(`select[data-cmd="${id}"]`); - if (!sel) continue; + const control = container.querySelector(`[data-menu-cmd="${id}"]`); + const trigger = control?.querySelector('[data-menu-trigger]'); + if (!control || !trigger) continue; const state = snapshot.commands[id]; - sel.disabled = state?.disabled ?? true; - if (state?.value != null) { - const val = String(state.value); - // Only set if the value matches an existing option - const hasOption = Array.from(sel.options).some((o) => o.value === val); - if (hasOption) sel.value = val; - } + trigger.disabled = state?.disabled ?? true; + const value = state?.value == null ? '' : String(state.value); + const current = normalizedMenuValue(value); + const selected = Array.from(control.querySelectorAll('[data-menu-value]')).find((option) => { + const optionValue = option.dataset.menuValue ?? ''; + const optionLabel = option.textContent ?? ''; + return ( + optionValue === value || + normalizedMenuValue(optionValue) === current || + normalizedMenuValue(optionLabel) === current + ); + }); + trigger.textContent = selected?.textContent ?? (value ? firstFontName(value) : control.dataset.placeholder ?? ''); + control.querySelectorAll('[data-menu-value]').forEach((option) => { + option.setAttribute('aria-selected', String(option === selected)); + }); } } diff --git a/examples/advanced/headless-toolbar/vanilla/src/style.css b/examples/advanced/headless-toolbar/vanilla/src/style.css index ee649a3e7b..2a58538d03 100644 --- a/examples/advanced/headless-toolbar/vanilla/src/style.css +++ b/examples/advanced/headless-toolbar/vanilla/src/style.css @@ -7,8 +7,8 @@ --btn-hover: #f1f5f9; --btn-active: #e2e8f0; --btn-disabled: #94a3b8; - --select-bg: #f8fafc; - --select-border: #cbd5e1; + --menu-bg: #f8fafc; + --menu-border: #cbd5e1; --separator-color: #e2e8f0; } @@ -77,27 +77,63 @@ html, body { height: 16px; } -#toolbar select { +.menu-control { + position: relative; +} + +#toolbar .menu-trigger { height: var(--btn-size); + min-width: 72px; padding: 0 8px; - border: 1px solid var(--select-border); + border: 1px solid var(--menu-border); border-radius: var(--btn-radius); - background: var(--select-bg); + background: var(--menu-bg); color: #334155; font-size: 13px; cursor: pointer; outline: none; + width: auto; + justify-content: flex-start; } -#toolbar select:focus { +#toolbar .menu-trigger:focus { border-color: #3b82f6; } -#toolbar select:disabled { +#toolbar .menu-trigger:disabled { opacity: 0.5; cursor: default; } +.menu-list { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + left: 0; + min-width: 150px; + padding: 4px; + border: 1px solid var(--menu-border); + border-radius: var(--btn-radius); + background: #fff; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); +} + +#toolbar .menu-option { + display: block; + width: 100%; + height: 28px; + padding: 0 8px; + text-align: left; + font-size: 13px; + font-weight: 400; +} + +#toolbar .menu-option[aria-selected='true'], +#toolbar .menu-option:hover { + background: var(--btn-hover); + color: #0f172a; +} + #editor { height: calc(100% - var(--toolbar-height)); diff --git a/examples/editor/built-in-ui/responsive-zoom/README.md b/examples/editor/built-in-ui/responsive-zoom/README.md new file mode 100644 index 0000000000..32a4a34f67 --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/README.md @@ -0,0 +1,43 @@ +# Responsive zoom + +Minimal React example for `zoom.mode: 'fit-width'`. The editor starts in fit-width mode, updates as its container changes, and exposes the current zoom and viewport metrics through callbacks. + +## What it shows + +- Configure automatic fit-width zoom with `SuperDocEditor`. +- Read applied zoom with `onZoomChange`. +- Read the latest fit target with `onViewportChange`. +- Return to fit-width mode after a manual zoom change. + +## Run it + +```bash +cd examples/editor/built-in-ui/responsive-zoom +pnpm install +pnpm build +pnpm dev +``` + +## Core pattern + +```tsx + { + console.log({ zoom, mode }); + }} + onViewportChange={({ fitZoom }) => { + console.log({ fitZoom }); + }} +/> +``` + +## Related docs + +- [SuperDoc configuration](https://docs.superdoc.dev/editor/superdoc/configuration#param-zoom) +- [SuperDoc methods](https://docs.superdoc.dev/editor/superdoc/methods#setzoommode) +- [SuperDoc events](https://docs.superdoc.dev/editor/superdoc/events#viewport-change) diff --git a/devtools/visual-testing/packages/harness/index.html b/examples/editor/built-in-ui/responsive-zoom/index.html similarity index 53% rename from devtools/visual-testing/packages/harness/index.html rename to examples/editor/built-in-ui/responsive-zoom/index.html index 7580f7079f..23b929d2e1 100644 --- a/devtools/visual-testing/packages/harness/index.html +++ b/examples/editor/built-in-ui/responsive-zoom/index.html @@ -3,10 +3,11 @@ - SuperDoc Test Harness + + SuperDoc responsive zoom -
- +
+ diff --git a/examples/editor/built-in-ui/responsive-zoom/package.json b/examples/editor/built-in-ui/responsive-zoom/package.json new file mode 100644 index 0000000000..9cce3120fd --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/package.json @@ -0,0 +1,23 @@ +{ + "name": "@superdoc-examples/responsive-zoom", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@superdoc-dev/react": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:", + "superdoc": "workspace:*" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/examples/editor/built-in-ui/responsive-zoom/public/test_file.docx b/examples/editor/built-in-ui/responsive-zoom/public/test_file.docx new file mode 100644 index 0000000000..ab62888526 Binary files /dev/null and b/examples/editor/built-in-ui/responsive-zoom/public/test_file.docx differ diff --git a/examples/editor/built-in-ui/responsive-zoom/src/App.tsx b/examples/editor/built-in-ui/responsive-zoom/src/App.tsx new file mode 100644 index 0000000000..5638df8386 --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/src/App.tsx @@ -0,0 +1,120 @@ +import { useMemo, useRef, useState } from 'react'; +import { SuperDocEditor } from '@superdoc-dev/react'; +import type { ChangeEvent } from 'react'; +import type { + SuperDocRef, + SuperDocViewportChangeEvent, + SuperDocZoomChangeEvent, +} from '@superdoc-dev/react'; +import '@superdoc-dev/react/style.css'; + +const SAMPLE_DOCUMENT = '/test_file.docx'; +const TOOLBAR_MODULES = { + toolbar: { + groups: { + left: ['zoom'], + }, + }, +}; +const FIT_WIDTH_ZOOM = { + mode: 'fit-width' as const, + fitWidth: { + min: 50, + max: 100, + padding: 32, + }, +}; + +type DocumentSource = string | File; + +export default function App() { + const editorRef = useRef(null); + const fileInputRef = useRef(null); + const [document, setDocument] = useState(SAMPLE_DOCUMENT); + const [documentName, setDocumentName] = useState('Sample document'); + const [zoom, setZoom] = useState({ zoom: 100, mode: 'fit-width' }); + const [metrics, setMetrics] = useState(null); + + const fitLabel = useMemo(() => { + if (!metrics) return 'Measuring'; + return `${Math.round(metrics.fitZoom)}% fit`; + }, [metrics]); + + const openFilePicker = () => fileInputRef.current?.click(); + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + setDocument(file); + setDocumentName(file.name); + setMetrics(null); + }; + + const restoreFitWidth = () => { + editorRef.current?.getInstance()?.setZoomMode('fit-width'); + }; + + const resetSample = () => { + setDocument(SAMPLE_DOCUMENT); + setDocumentName('Sample document'); + setMetrics(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + return ( +
+
+
+

Responsive zoom

+

{documentName}

+
+ +
+ + + + +
+
+ +
+ + Zoom {Math.round(zoom.zoom)}% + + + Mode {zoom.mode} + + + Target {fitLabel} + + {metrics ? ( + + Width {Math.round(metrics.availableWidth)}px + + ) : null} +
+ +
+ +
+
+ ); +} diff --git a/examples/editor/built-in-ui/responsive-zoom/src/main.tsx b/examples/editor/built-in-ui/responsive-zoom/src/main.tsx new file mode 100644 index 0000000000..7fabfa80ec --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './style.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/editor/built-in-ui/responsive-zoom/src/style.css b/examples/editor/built-in-ui/responsive-zoom/src/style.css new file mode 100644 index 0000000000..5fe6aa0bbe --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/src/style.css @@ -0,0 +1,156 @@ +:root { + color: var(--sd-ui-text, #1f2937); + background: var(--sd-color-gray-100, #f3f4f6); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button { + font: inherit; +} + +.app-shell { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + height: 100vh; + min-height: 0; + background: var(--sd-color-gray-100, #f3f4f6); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 64px; + padding: 12px 16px; + background: var(--sd-ui-surface-bg, #fff); + border-bottom: 1px solid var(--sd-ui-border, #d1d5db); +} + +.title-group { + min-width: 0; +} + +.title-group h1 { + margin: 0; + font-size: 16px; + font-weight: 650; + line-height: 1.3; +} + +.title-group p { + margin: 2px 0 0; + overflow: hidden; + color: var(--sd-color-gray-600, #4b5563); + font-size: 13px; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.button { + min-height: 32px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--sd-ui-border, #d1d5db); + cursor: pointer; + font-size: 13px; + font-weight: 600; +} + +.button.primary { + border-color: var(--sd-ui-action, #2563eb); + background: var(--sd-ui-action, #2563eb); + color: var(--sd-ui-action-text, #fff); +} + +.button.secondary { + background: var(--sd-ui-surface-bg, #fff); + color: var(--sd-ui-text, #1f2937); +} + +.button:hover { + filter: brightness(0.98); +} + +.status-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 16px; + background: var(--sd-color-gray-50, #f9fafb); + border-bottom: 1px solid var(--sd-ui-border, #d1d5db); + color: var(--sd-color-gray-600, #4b5563); + font-size: 12px; +} + +.status-bar span { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; + padding: 3px 8px; + border: 1px solid var(--sd-ui-border, #d1d5db); + border-radius: 6px; + background: var(--sd-ui-surface-bg, #fff); +} + +.status-bar strong { + color: var(--sd-ui-text, #1f2937); + font-weight: 650; +} + +.workspace { + min-height: 0; + padding: 12px; +} + +.responsive-editor { + overflow: hidden; + border: 1px solid var(--sd-ui-border, #d1d5db); + border-radius: 6px; + background: var(--sd-ui-surface-bg, #fff); +} + +@media (max-width: 640px) { + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .controls { + justify-content: flex-start; + width: 100%; + } + + .button { + flex: 1 1 120px; + } + + .workspace { + padding: 8px; + } +} diff --git a/examples/editor/built-in-ui/responsive-zoom/tsconfig.json b/examples/editor/built-in-ui/responsive-zoom/tsconfig.json new file mode 100644 index 0000000000..36197f5db2 --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [] +} diff --git a/examples/editor/built-in-ui/responsive-zoom/vite.config.ts b/examples/editor/built-in-ui/responsive-zoom/vite.config.ts new file mode 100644 index 0000000000..fabde1a8f5 --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/examples/editor/custom-ui/configurable-toolbar/README.md b/examples/editor/custom-ui/configurable-toolbar/README.md index 67b7fc7216..1df77b50c1 100644 --- a/examples/editor/custom-ui/configurable-toolbar/README.md +++ b/examples/editor/custom-ui/configurable-toolbar/README.md @@ -4,10 +4,12 @@ The smallest example that proves how to build your own toolbar with `superdoc/ui ## What this teaches -A custom toolbar binds buttons to commands. The same surface holds built-ins (`bold`, `italic`, `underline`, ...) and your own (`example.insertClause`). Each button subscribes per-id via `ui.commands..observe(...)`, so changes to one command don't re-render the rest of the row. Click handlers run `ui.commands.get(id).execute()`. +A custom toolbar binds controls to commands. The same surface holds built-ins (`bold`, `italic`, `underline`, `font-family`, ...) and your own (`example.insertClause`). Each button subscribes per-id via `ui.commands..observe(...)`, so changes to one command don't re-render the rest of the row. Click handlers run `ui.commands.get(id).execute()`. `ui.commands.register({ id, execute, getState })` puts a custom command on the same surface as built-ins. The example registers one and binds a button to it the same way it binds the bold button. +The font-family picker uses `ui.fonts.observe(...)` for its options and applies the chosen value with `ui.toolbar.execute('font-family', value)`. The font-size picker uses `snapshot.sizeOptions` from the same handle and applies with `ui.toolbar.execute('font-size', value)`. The family options include bundled defaults and fonts used by the active document. Both pickers use button menus so opening them does not move focus away from the editor selection. + This example shows that flow and nothing else. No threading, no resolve / reopen, no comments, no mode toggle. For the full Custom UI sidebar pattern, see [`demos/custom-ui`](../../../../../demos/custom-ui). ## Run @@ -17,10 +19,11 @@ pnpm install pnpm dev ``` -Click the buttons. Bold, Italic, Underline toggle on the current selection. Insert clause inserts a fixed snippet at the cursor. +Click the buttons. Bold, Italic, Underline toggle on the current selection. The font pickers change the selected text's font family and size. Insert clause inserts a fixed snippet at the cursor. ## See also - [Custom UI > Toolbar and commands](https://docs.superdoc.dev/editor/custom-ui/toolbar-and-commands) +- [Custom UI > API reference](https://docs.superdoc.dev/editor/custom-ui/api-reference) - [Custom UI > Custom commands](https://docs.superdoc.dev/editor/custom-ui/custom-commands) - [Custom UI > Controller setup](https://docs.superdoc.dev/editor/custom-ui/controller-setup) diff --git a/examples/editor/custom-ui/configurable-toolbar/src/main.ts b/examples/editor/custom-ui/configurable-toolbar/src/main.ts index f3ac5acec5..f440cdd95d 100644 --- a/examples/editor/custom-ui/configurable-toolbar/src/main.ts +++ b/examples/editor/custom-ui/configurable-toolbar/src/main.ts @@ -1,7 +1,7 @@ /** * The smallest example that proves how to build your own toolbar with - * `superdoc/ui`. Three built-in commands and one custom command, all - * on the same surface, no framework. + * `superdoc/ui`. Formatting buttons, a font-family picker, and one + * custom command, all on the same surface, no framework. * * Each button subscribes per-id via `ui.commands..observe(...)`, * which only fires when that command's `active` / `disabled` / @@ -33,6 +33,35 @@ const scope = ui.createScope(); const toolbar = document.querySelector('#toolbar')!; +type FontOption = { label: string; value: string; previewFamily: string }; +type SizeOption = { label: string; value: string }; + +function normalizeFontToken(value: unknown): string { + return typeof value === 'string' ? value.split(',')[0]?.trim().replace(/^["']|["']$/g, '') || '' : ''; +} + +function optionValueForCurrentFont(value: unknown, options: readonly FontOption[]): string { + const current = normalizeFontToken(value).toLowerCase(); + if (!current) return ''; + + const match = options.find((option) => { + const logical = normalizeFontToken(option.value).toLowerCase(); + const label = normalizeFontToken(option.label).toLowerCase(); + const preview = normalizeFontToken(option.previewFamily).toLowerCase(); + return current === logical || current === label || current === preview; + }); + + return match?.value ?? ''; +} + +function normalizeFontSize(value: unknown): string { + if (typeof value === 'number') return `${value}pt`; + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return /^\d+(\.\d+)?$/.test(trimmed) ? `${trimmed}pt` : trimmed; +} + // Built-in command buttons. Same shape, different ids. Each one // subscribes per-id so unrelated state changes don't re-render the // row. @@ -75,6 +104,234 @@ const sep = document.createElement('span'); sep.className = 'sep'; toolbar.appendChild(sep); +const fontPicker = document.createElement('div'); +fontPicker.className = 'font-picker'; +toolbar.appendChild(fontPicker); + +const fontButton = document.createElement('button'); +fontButton.type = 'button'; +fontButton.className = 'font-picker-trigger'; +fontButton.title = 'Font family'; +fontButton.setAttribute('aria-label', 'Font family'); +fontButton.setAttribute('aria-haspopup', 'listbox'); +fontButton.setAttribute('aria-expanded', 'false'); +fontPicker.appendChild(fontButton); + +const fontMenu = document.createElement('div'); +fontMenu.className = 'font-picker-menu'; +fontMenu.setAttribute('role', 'listbox'); +fontMenu.hidden = true; +fontPicker.appendChild(fontMenu); + +let currentFontValue = ''; +let currentFontOptions: FontOption[] = []; +let capturedFontSelection: ReturnType | null = null; + +const rememberFontSelection = () => { + const capture = ui.selection.capture(); + if (capture) capturedFontSelection = capture; +}; + +const selectedFontOption = () => { + const value = optionValueForCurrentFont(currentFontValue, currentFontOptions); + return currentFontOptions.find((font) => font.value === value) ?? null; +}; + +const setFontMenuOpen = (open: boolean) => { + fontMenu.hidden = !open; + fontButton.setAttribute('aria-expanded', String(open)); +}; + +const refreshFontButton = () => { + const selected = selectedFontOption(); + fontButton.textContent = selected?.label ?? 'Font'; + fontButton.style.fontFamily = selected?.previewFamily ?? ''; +}; + +const applyFontValue = (value: string) => { + if (capturedFontSelection) { + ui.selection.restore(capturedFontSelection); + capturedFontSelection = null; + } + setFontMenuOpen(false); + ui.toolbar.execute('font-family', value); +}; + +const renderFontOptions = () => { + const selectedValue = selectedFontOption()?.value ?? ''; + fontMenu.replaceChildren( + ...currentFontOptions.map((font) => { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'font-picker-option'; + option.textContent = font.label; + option.style.fontFamily = font.previewFamily; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', String(font.value === selectedValue)); + option.addEventListener('mousedown', (event) => { + event.preventDefault(); + rememberFontSelection(); + }); + option.addEventListener('click', () => applyFontValue(font.value)); + return option; + }), + ); +}; + +fontButton.addEventListener('mousedown', (event) => { + event.preventDefault(); + rememberFontSelection(); +}); +fontButton.addEventListener('click', () => setFontMenuOpen(fontMenu.hidden)); + +const closeFontMenuOnOutsideClick = (event: MouseEvent) => { + if (!fontPicker.contains(event.target as Node | null)) setFontMenuOpen(false); +}; +document.addEventListener('mousedown', closeFontMenuOnOutsideClick); +scope.add(() => { + document.removeEventListener('mousedown', closeFontMenuOnOutsideClick); +}); + +const fontCommand = ui.commands.get('font-family'); +if (fontCommand) { + scope.add( + fontCommand.observe((state) => { + fontButton.disabled = state.disabled; + currentFontValue = typeof state.value === 'string' ? state.value : ''; + renderFontOptions(); + refreshFontButton(); + }), + ); +} + +const fontSep = document.createElement('span'); +fontSep.className = 'sep'; +toolbar.appendChild(fontSep); + +const sizePicker = document.createElement('div'); +sizePicker.className = 'font-picker size-picker'; +toolbar.appendChild(sizePicker); + +const sizeButton = document.createElement('button'); +sizeButton.type = 'button'; +sizeButton.className = 'font-picker-trigger size-picker-trigger'; +sizeButton.title = 'Font size'; +sizeButton.setAttribute('aria-label', 'Font size'); +sizeButton.setAttribute('aria-haspopup', 'listbox'); +sizeButton.setAttribute('aria-expanded', 'false'); +sizePicker.appendChild(sizeButton); + +const sizeMenu = document.createElement('div'); +sizeMenu.className = 'font-picker-menu size-picker-menu'; +sizeMenu.setAttribute('role', 'listbox'); +sizeMenu.hidden = true; +sizePicker.appendChild(sizeMenu); + +let currentFontSizeValue = ''; +let currentFontSizeOptions: SizeOption[] = []; +let capturedFontSizeSelection: ReturnType | null = null; + +const rememberFontSizeSelection = () => { + const capture = ui.selection.capture(); + if (capture) capturedFontSizeSelection = capture; +}; + +const selectedFontSizeOption = () => { + const value = normalizeFontSize(currentFontSizeValue); + return currentFontSizeOptions.find((option) => option.value === value || option.label === value) ?? null; +}; + +const setFontSizeMenuOpen = (open: boolean) => { + sizeMenu.hidden = !open; + sizeButton.setAttribute('aria-expanded', String(open)); +}; + +const refreshFontSizeButton = () => { + const selected = selectedFontSizeOption(); + const fallback = normalizeFontSize(currentFontSizeValue).replace(/pt$/i, '') || '12'; + sizeButton.textContent = selected?.label ?? fallback; +}; + +const applyFontSizeValue = (value: string) => { + if (capturedFontSizeSelection) { + ui.selection.restore(capturedFontSizeSelection); + capturedFontSizeSelection = null; + } + setFontSizeMenuOpen(false); + ui.toolbar.execute('font-size', value); +}; + +const renderFontSizeOptions = () => { + const selectedValue = selectedFontSizeOption()?.value ?? ''; + sizeMenu.replaceChildren( + ...currentFontSizeOptions.map((size) => { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'font-picker-option size-picker-option'; + option.textContent = size.label; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', String(size.value === selectedValue)); + option.addEventListener('mousedown', (event) => { + event.preventDefault(); + rememberFontSizeSelection(); + }); + option.addEventListener('click', () => applyFontSizeValue(size.value)); + return option; + }), + ); +}; + +sizeButton.addEventListener('mousedown', (event) => { + event.preventDefault(); + rememberFontSizeSelection(); +}); +sizeButton.addEventListener('click', () => setFontSizeMenuOpen(sizeMenu.hidden)); + +const closeFontSizeMenuOnOutsideClick = (event: MouseEvent) => { + if (!sizePicker.contains(event.target as Node | null)) setFontSizeMenuOpen(false); +}; +document.addEventListener('mousedown', closeFontSizeMenuOnOutsideClick); +scope.add(() => { + document.removeEventListener('mousedown', closeFontSizeMenuOnOutsideClick); +}); + +const closePickerMenusOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + setFontMenuOpen(false); + setFontSizeMenuOpen(false); +}; +document.addEventListener('keydown', closePickerMenusOnEscape); +scope.add(() => { + document.removeEventListener('keydown', closePickerMenusOnEscape); +}); + +const fontSizeCommand = ui.commands.get('font-size'); +if (fontSizeCommand) { + scope.add( + fontSizeCommand.observe((state) => { + sizeButton.disabled = state.disabled; + currentFontSizeValue = typeof state.value === 'string' || typeof state.value === 'number' ? String(state.value) : ''; + renderFontSizeOptions(); + refreshFontSizeButton(); + }), + ); +} + +scope.add( + ui.fonts.observe((snapshot) => { + currentFontOptions = [...snapshot.options]; + currentFontSizeOptions = [...snapshot.sizeOptions]; + renderFontOptions(); + refreshFontButton(); + renderFontSizeOptions(); + refreshFontSizeButton(); + }), +); + +const sizeSep = document.createElement('span'); +sizeSep.className = 'sep'; +toolbar.appendChild(sizeSep); + // Custom command. Same surface as built-ins. The id is namespaced so // it won't collide with future built-ins. const insertClause = scope.register({ diff --git a/examples/editor/custom-ui/configurable-toolbar/src/style.css b/examples/editor/custom-ui/configurable-toolbar/src/style.css index 0eb5cb248d..dc71937454 100644 --- a/examples/editor/custom-ui/configurable-toolbar/src/style.css +++ b/examples/editor/custom-ui/configurable-toolbar/src/style.css @@ -40,6 +40,58 @@ button { font: inherit; cursor: pointer; } } .toolbar button:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; } +.font-picker { + position: relative; +} + +.font-picker-trigger { + height: 30px; + min-width: 150px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + text-align: left; +} +.font-picker-trigger:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; } + +.size-picker-trigger { + min-width: 58px; + text-align: center; +} + +.font-picker-menu { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + left: 0; + min-width: 170px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); +} + +.size-picker-menu { + min-width: 72px; +} + +.font-picker-option { + display: block; + width: 100%; + height: 28px; + padding: 0 8px; + text-align: left; + font-weight: 400; +} + +.font-picker-option[aria-selected='true'] { + background: var(--accent-soft); + color: var(--accent); +} + .toolbar button.italic { font-style: italic; } .toolbar button.underline { text-decoration: underline; } diff --git a/examples/getting-started/cdn/setup.mjs b/examples/getting-started/cdn/setup.mjs index 05d72b85fb..70a36baec9 100644 --- a/examples/getting-started/cdn/setup.mjs +++ b/examples/getting-started/cdn/setup.mjs @@ -19,7 +19,7 @@ const assets = [ [sampleSource, resolve(here, 'test_file.docx')], ]; -// The bundled metric-compatible substitutes ship as separate assets under dist/fonts/. +// The bundled reviewed fallbacks ship as separate assets under dist/fonts/. // The CDN build auto-detects `./fonts/` relative to superdoc.min.js, so the example must // serve them beside the script (here/fonts/) or every .woff2 404s. const fontsSrc = resolve(dist, 'fonts'); @@ -30,14 +30,14 @@ if (missing.length || !existsSync(fontsSrc)) { console.error('[cdn-example/setup] Build the SuperDoc bundle first:'); console.error(' pnpm --filter superdoc build'); console.error('Missing files:'); - for (const [src] of missing) console.error(' ' + src); - if (!existsSync(fontsSrc)) console.error(' ' + fontsSrc + ' (bundled font assets)'); + for (const [src] of missing) console.error(` ${src}`); + if (!existsSync(fontsSrc)) console.error(` ${fontsSrc} (bundled font assets)`); process.exit(1); } for (const [src, dst] of assets) { copyFileSync(src, dst); - console.log('[cdn-example/setup] copied', dst.replace(here + '/', '')); + console.log('[cdn-example/setup] copied', dst.replace(`${here}/`, '')); } cpSync(fontsSrc, fontsDst, { recursive: true }); diff --git a/examples/manifest.json b/examples/manifest.json index a064a902e0..e99c8e2fd6 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -179,6 +179,21 @@ "docs": "https://docs.superdoc.dev/editor/built-in-ui/toolbar", "ci": true }, + { + "id": "editor-built-in-responsive-zoom", + "section": "editor", + "subsection": "built-in-ui", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", + "title": "Responsive zoom", + "category": "Editor", + "surface": "Built-in UI", + "sourceRepo": "superdoc-dev/superdoc", + "sourcePath": "examples/editor/built-in-ui/responsive-zoom", + "docs": "https://docs.superdoc.dev/editor/superdoc/configuration#param-zoom", + "ci": true + }, { "id": "editor-custom-ui-selection-capture", "section": "editor", diff --git a/package.json b/package.json index 3243b24b45..56f27d119f 100644 --- a/package.json +++ b/package.json @@ -58,16 +58,6 @@ "release:local:cli": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-cli.mjs", "release:local:sdk": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-sdk.mjs", "prepare": "if [ -z \"$CI\" ]; then lefthook install; fi", - "test:layout": "bun scripts/test-layout.mjs", - "test:visual": "bun scripts/test-visual.mjs", - "corpus:upload": "pnpm --filter @superdoc-testing/visual docs:upload", - "layout:snapshots": "bun tests/layout/export-layout-snapshots.mjs", - "layout:snapshots:npm": "bun tests/layout/export-layout-snapshots-npm.mjs", - "layout:compare": "bun tests/layout/compare-layout-snapshots.mjs", - "corpus:pull": "node scripts/corpus/pull.mjs", - "corpus:delete": "node scripts/corpus/delete.mjs", - "corpus:push": "node scripts/corpus/push.mjs", - "corpus:update-registry": "node scripts/corpus/update-registry.mjs", "watch": "pnpm --prefix packages/superdoc run watch:es", "check:all": "pnpm run format && pnpm run lint:fix && pnpm --prefix packages/super-editor run types:build && pnpm run test", "check:examples-demos": "bun scripts/validate-examples-demos.ts", @@ -83,6 +73,7 @@ "docapi:check": "pnpm run check:public:docapi", "docapi:sync:check": "pnpm run generate:docapi && pnpm run check:public:docapi", "check:types": "tsc -b tsconfig.references.json", + "check:font-licenses": "node shared/font-system/scripts/check-bundled-font-licenses.mjs", "check:public:superdoc": "node scripts/check-public-contract.mjs", "check:public:docapi": "node scripts/check-public-docapi.mjs", "check:public": "pnpm run check:public:superdoc && pnpm run check:public:docapi", @@ -96,9 +87,7 @@ "docs:sync-engine": "pnpm exec tsx apps/docs/scripts/generate-sdk-overview.ts", "sdk:sync-version": "node packages/sdk/scripts/sync-sdk-version.mjs", "sdk:release": "node packages/sdk/scripts/sdk-release.mjs", - "sdk:release:dry": "node packages/sdk/scripts/sdk-release.mjs --dry-run", - "layout:export-one": "bun tests/layout/export-single-layout-snapshot.mjs", - "layout:screenshot-one": "pnpm --filter @superdoc-testing/visual run screenshot-one" + "sdk:release:dry": "node packages/sdk/scripts/sdk-release.mjs --dry-run" }, "devDependencies": { "@clack/prompts": "^1.0.1", @@ -128,7 +117,6 @@ "semantic-release": "catalog:", "semantic-release-ai-notes": "catalog:", "semantic-release-commit-filter": "catalog:", - "semantic-release-linear-app": "catalog:", "semantic-release-pnpm": "^1.0.2", "tsx": "catalog:", "typescript": "catalog:", diff --git a/packages/esign/.releaserc.cjs b/packages/esign/.releaserc.cjs index 8f4d5aa3cc..99641a0d19 100644 --- a/packages/esign/.releaserc.cjs +++ b/packages/esign/.releaserc.cjs @@ -24,6 +24,10 @@ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === br // stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. // Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. const shouldCommentOnRelease = !isPrerelease; +// Linear release comments are the shipped-version breadcrumb inside Linear +// itself, so keep them on for prereleases even while GitHub PR comments stay +// gated separately. +const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; @@ -51,10 +55,10 @@ if (!isPrerelease) { // Linear integration - labels issues with version on release config.plugins.push([ - 'semantic-release-linear-app', + '../../scripts/semantic-release/linear-commit-sync.cjs', { teamKeys: ['SD'], - addComment: shouldCommentOnRelease, + addComment: shouldCommentOnLinearRelease, packageName: 'esign', commentTemplate: 'shipped in {package} {releaseLink} {channel}', }, diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 2f17e5173f..dae7de893d 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -64,6 +64,40 @@ reads. AIDEV-NOTE: the v1 layout-adapter must preserve shared `SdtMetadata` object identity for sibling blocks in one id-less SDT container; see `contracts/src/sdt-container.ts` before changing SDT imports. +## Measuring β†’ Layout Ownership (SD-2845) + +Boundary between measured block geometry (`Measure[]`) and pagination/placement (`Layout`). See contract tests: +`measuring/dom/src/measuring-layout-contracts.test.ts` (`FlowBlock` β†’ `Measure`) and +`layout-engine/src/measuring-layout-ownership-contracts.test.ts` (`FlowBlock` + `Measure` β†’ `Layout`). +Further work is tracked in Linear (e.g. SD-2837, SD-2845). + +### Boundary Contract + +| Stage | Owns | Does not own | +| --- | --- | --- | +| pm-adapter | Builds `FlowBlock[]` from document state, preserves raw/source metadata, and resolves style-engine outputs into block attributes needed by measuring and rendering. | Measuring line breaks, table row heights, pagination, fragment placement, or painter-specific DOM decisions. | +| Measuring | Converts each `FlowBlock` into a same-index `Measure` for a known width/height constraint. It owns intrinsic/scaled dimensions, line breaks, line metrics, marker metrics, table cell content measurement, table columns where sizing is measurement-dependent, and zero-dimensional break measures. | Page/column placement, section scheduling, page creation, keep-next decisions, painter DOM structure, or reordering blocks. | +| Layout | Consumes `FlowBlock[]` plus same-index `Measure[]` and creates positioned `Layout` fragments. It owns pagination, section/page/column break effects, anchoring placement, float exclusions, fragment splitting, page metadata, and final page/column coordinates. | Canvas/DOM text measurement, intrinsic media measurement, table cell content measurement, or importing/resolving OOXML style cascades. | +| layout-bridge | Orchestrates conversion, constraint selection, measurement calls, cache reuse/invalidation, header/footer and footnote multi-pass layout, and calls into layout. | Reimplementing measurement or layout decisions except for explicit bridge-only orchestration needed to choose constraints or rerun a pass. | + +### Ownership Matrix + +| Area | Measuring should produce | Layout should consume or decide | layout-bridge may orchestrate | Current duplicated or unclear logic | +| --- | --- | --- | --- | --- | +| Paragraphs | `ParagraphMeasure` with line ranges, line widths, line heights, total height, marker metrics, optional drop-cap metrics, tab/segment metadata, and line max widths for the constraint used. | Place paragraph fragments into pages/columns, split by measured lines, apply spacing/keep-next/contextual spacing, apply float exclusions, and set continuation flags. | Select per-section measurement constraints, cache/reuse paragraph measures, invalidate dirty measures, and provide controlled remeasure callbacks when layout must place text in a narrower active region. | Layout still accepts `remeasureParagraph` and can attach fragment-local `lines`, so paragraph wrapping ownership is split between measuring and layout. Keep-next also reads measured heights directly from layout. | +| Lists and list items | `ListMeasure` with per-item marker width, marker text width, indent, nested `ParagraphMeasure`, and total list height. | Place each list item fragment, split item paragraph lines across pages/columns, preserve marker metrics on fragments, and apply continuation flags. | Convert list-style paragraphs into either paragraph blocks with marker attrs or list blocks consistently; measure/remeasure each item under the chosen list item constraint. | Measuring has `ListMeasure`, but `layoutDocument` currently has no `ListBlock` layout branch and an existing list layout test is skipped. Paragraphs may also carry word-layout marker data, creating two list paths. | +| Tables | `TableMeasure` with row/cell measures, total width/height, column widths, cell spacing, table border widths, row heights, and nested cell `Measure[]` for multi-block cells. | Place table fragments, split rows/partial rows, repeat headers, clamp/rescale fragment column widths when needed, position anchored/floating tables, and emit table metadata for resize boundaries. | Measure tables after selecting page/column constraints; remeasure tables in headers/footers/footnotes as needed; cache and invalidate table measures with block identity. | Table sizing logic is spread across measuring (`autofit-columns`, `fixed-table-columns`, nested cell measurement), contracts (`rescaleColumnWidths`), and layout (`layout-table`, frame/clamp logic). Some width decisions are measurement-owned while fragment clamping is layout-owned. | +| Table rows | Per-row height derived from measured cells, including row height rule effects where available, repeat-header metadata passed through block attrs. | Decide which rows fit, whether a row becomes a partial row, repeat header rows on continuation fragments, and continuation metadata. | Ensure table measures are recomputed when row content changes or available measurement width changes. | Row splitting depends on measured row/cell heights but row keep/cantSplit semantics cross table measurement and layout. | +| Table cells and nested cell content | Per-cell width/height, padding-aware content width, `blocks?: Measure[]` for nested paragraphs/images/drawings/tables, legacy `paragraph?: ParagraphMeasure`, spans, and grid column start. | Slice cell content into visible fragments for table pagination, maintain row/column boundaries, and map cell content to fragment geometry. | Recurse into measurement for each nested cell block with the cell content width; choose when nested content must be remeasured. | Nested content is measured recursively, while layout also has table cell slicing logic that interprets nested measures and block shapes. | +| Images | `ImageMeasure` with final width/height after intrinsic fallback, width/height constraints, objectFit/cover handling, and anchored negative-offset height bypass. | Place inline or anchored image fragments, compute x/y from page/column/margin anchors, set metadata and z-index, reserve flow space only when appropriate. | Provide max width/height based on page, header/footer, footnote, or table-cell context; hydrate image blocks before measuring when needed. | Scaling is measurement-owned, but layout computes placement metadata such as maxWidth/maxHeight and also has anchored/page-relative special handling. | +| Drawings | `DrawingMeasure` with drawing kind, final width/height, scale, natural size, normalized geometry, and group transform when present. | Place drawing fragments, compute anchoring and z-index, carry geometry/scale into fragments, and manage float exclusions. | Provide constraints and trigger remeasurement when drawing geometry or context changes. | Measuring handles rotation bounds/full-width shape sizing; layout handles anchored placement and pre-registration. Shape group/text content sizing boundaries need clearer documentation. | +| Section breaks | `SectionBreakMeasure` as a zero-dimensional control measure. | Apply section scheduling, page parity, page size/orientation/margin/column changes, section refs, numbering, vertical alignment, and column regions. | Preserve block order, pass break blocks through, compute per-section constraints for actual measurement, and use global-max constraints only as a compatibility check when deciding whether previous measures can be reused. | Section props are partly precomputed/looked ahead in layout and partly carried on break blocks; bridge computes both per-section constraints and a global-max compatibility constraint set from section blocks. | +| Page breaks | `PageBreakMeasure` as a zero-dimensional control measure. | Start a new page unless redundant, without producing a fragment. | Preserve the break in block/measure alignment and cache invalidation. | Page-break redundancy checks are layout-owned, but empty sectPr marker handling can interact with adjacent paragraph/break blocks. | +| Column breaks | `ColumnBreakMeasure` as a zero-dimensional control measure. | Advance to the next active column or start a new page from the last column, without producing a fragment. | Preserve alignment and recompute measurement constraints when section columns change. | Blocks are measured with per-section constraints, but layout can still trigger narrower active-region paragraph remeasurement, so wrapping ownership is still split. | +| Headers and footers | Measures for header/footer story blocks under header/footer-specific constraints; measured heights for variants, rIds, and section-aware references. | Lay out header/footer fragments per page/variant, apply header/footer heights to body page margins, and normalize fragments for render regions. | Own multi-pass header/footer measurement/layout orchestration, token resolution, variant bucketing, and cache invalidation. | Bridge has substantial header/footer orchestration; layout also consumes per-page/per-rId height maps and section refs. The height ownership boundary is functional but hard to reason about. | +| Footnotes | Measures for footnote story blocks under footnote band constraints, including nested content measures. | Reserve footnote space on body pages, place footnote fragments in footnote bands, and handle overflow across pages/columns. | Own multi-pass footnote measurement/layout, separator spacing, band overflow retries, and cache invalidation. | Footnote layout is bridge-heavy and interacts with body pagination through reserved space; ownership between reserve calculation and final placement should be explicit. | +| Nested measured content | Recursive `Measure[]` for nested blocks using the current container's content width and height rules. | Interpret nested measures only through the container layout algorithm, without remeasuring nested content directly. | Supply container constraints and invalidate nested measures when the parent container changes width or content. | Tables already recurse in measuring; future containers may duplicate this unless the recursion contract is centralized. | + ## Style Engine (`style-engine/`) Single source of truth for OOXML style cascade resolution. All property resolution flows through here. diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 2a72e902d3..cdf1311eab 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -562,6 +562,11 @@ export type ImageLuminanceAdjustment = { contrast?: number; }; +export type ImageAlphaModFix = { + /** OOXML a:alphaModFix/@amt in raw fixed-percentage units (0..100000). */ + amt: number; +}; + /** Hyperlink metadata from OOXML a:hlinkClick on a DrawingML image. */ export type ImageHyperlink = { url: string; tooltip?: string }; @@ -639,6 +644,7 @@ export type ImageRun = { // OOXML image effects grayscale?: boolean; // Apply grayscale filter to image lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum + alphaModFix?: ImageAlphaModFix; // DrawingML fixed alpha adjustment from a:alphaModFix /** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */ hyperlink?: ImageHyperlink; }; @@ -957,6 +963,7 @@ export type ImageBlock = { // OOXML image effects grayscale?: boolean; // Apply grayscale filter to image lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum + alphaModFix?: ImageAlphaModFix; // DrawingML fixed alpha adjustment from a:alphaModFix // Image transformations from OOXML a:xfrm (applies to both inline and anchored images) rotation?: number; // Rotation angle in degrees flipH?: boolean; // Horizontal flip @@ -966,7 +973,7 @@ export type ImageBlock = { sourceAnchor?: SourceAnchor; }; -export type DrawingKind = 'image' | 'vectorShape' | 'shapeGroup' | 'chart'; +export type DrawingKind = 'image' | 'vectorShape' | 'textboxShape' | 'shapeGroup' | 'chart'; export type DrawingContentSnapshot = { name: string; @@ -1147,6 +1154,7 @@ export type ShapeGroupImageChild = { src: string; alt?: string; clipPath?: string; + alphaModFix?: ImageAlphaModFix; imageId?: string; imageName?: string; }; @@ -1210,6 +1218,30 @@ export type VectorShapeDrawing = DrawingBlockBase & { }; }; +export type TextboxDrawing = DrawingBlockBase & { + drawingKind: 'textboxShape'; + geometry: DrawingGeometry; + shapeKind?: string; + customGeometry?: CustomGeometryData; + fillColor?: FillColor; + strokeColor?: StrokeColor; + strokeWidth?: number; + lineEnds?: LineEnds; + effectExtent?: EffectExtent; + textContent?: ShapeTextContent; + textAlign?: string; + textVerticalAlign?: 'top' | 'center' | 'bottom'; + textInsets?: { + top: number; + right: number; + bottom: number; + left: number; + }; + contentBlocks: ParagraphBlock[]; + /** Paragraph layout results for table-cell textboxes; populated by the layout bridge, read by the painter. */ + contentMeasures?: ParagraphMeasure[]; +}; + export type ShapeGroupDrawing = DrawingBlockBase & { drawingKind: 'shapeGroup'; geometry: DrawingGeometry; @@ -1282,7 +1314,7 @@ export type ChartDrawing = DrawingBlockBase & { chartPartPath?: string; }; -export type DrawingBlock = VectorShapeDrawing | ShapeGroupDrawing | ImageDrawing | ChartDrawing; +export type DrawingBlock = VectorShapeDrawing | TextboxDrawing | ShapeGroupDrawing | ImageDrawing | ChartDrawing; /** * Vertical alignment of content within a section/page. @@ -1778,6 +1810,8 @@ export type ParagraphAttrs = { directionContext?: ParagraphDirectionContext; isTocEntry?: boolean; tocInstruction?: string; + /** Stable id shared by every paragraph in the same TOC (docPartObj uniqueId or parent sdBlockId). */ + tocId?: string; /** Floating alignment for positioned paragraphs (from w:framePr/@w:xAlign). */ floatAlignment?: 'left' | 'right' | 'center'; /** @@ -2346,6 +2380,7 @@ export type DrawingFragment = { geometry: DrawingGeometry; scale: number; drawingContentId?: string; + contentMeasures?: ParagraphMeasure[]; pmStart?: number; pmEnd?: number; sourceAnchor?: SourceAnchor; diff --git a/packages/layout-engine/dom-contract/src/class-names.ts b/packages/layout-engine/dom-contract/src/class-names.ts index 7a7bc62464..fee653a5c8 100644 --- a/packages/layout-engine/dom-contract/src/class-names.ts +++ b/packages/layout-engine/dom-contract/src/class-names.ts @@ -48,6 +48,12 @@ export const DOM_CLASS_NAMES = { */ SDT_GROUP_HOVER: 'sdt-group-hover', + /** Paragraph fragment rendered as a Table of Contents entry. */ + TOC_ENTRY: 'superdoc-toc-entry', + + /** TOC analogue of `SDT_GROUP_HOVER`, applied to every fragment sharing a `data-toc-id`. */ + TOC_GROUP_HOVER: 'toc-group-hover', + /** Block-level image fragment (ImageBlock). */ IMAGE_FRAGMENT: 'superdoc-image-fragment', diff --git a/packages/layout-engine/layout-bridge/src/cache.ts b/packages/layout-engine/layout-bridge/src/cache.ts index f2efb115ff..ccbcd6a269 100644 --- a/packages/layout-engine/layout-bridge/src/cache.ts +++ b/packages/layout-engine/layout-bridge/src/cache.ts @@ -198,6 +198,25 @@ const hashDrawingBlock = (block: DrawingBlock): string => { ].join(':'); } + if (block.drawingKind === 'textboxShape') { + return [ + 'drawing:textbox', + hashDrawingGeometry(block.geometry), + block.shapeKind ?? '', + JSON.stringify(block.fillColor ?? null), + JSON.stringify(block.strokeColor ?? null), + block.strokeWidth ?? '', + JSON.stringify(block.customGeometry ?? null), + JSON.stringify(block.lineEnds ?? null), + JSON.stringify(block.effectExtent ?? null), + JSON.stringify(block.textContent ?? null), + block.textAlign ?? '', + block.textVerticalAlign ?? '', + JSON.stringify(block.textInsets ?? null), + block.contentBlocks.map((contentBlock) => `${contentBlock.id}:${hashRuns(contentBlock)}`).join('|'), + ].join(':'); + } + if (block.drawingKind === 'shapeGroup') { return [ 'drawing:shapeGroup', diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 6e61b0b80c..c815fd13d7 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -10,6 +10,7 @@ import type { DrawingGeometry, ShapeGroupTransform, ShapeGroupChild, + TextboxDrawing, Run, ParagraphAttrs, ParagraphSpacing, @@ -514,13 +515,29 @@ const drawingBlocksEqual = (a: DrawingBlock, b: DrawingBlock): boolean => { return imageBlocksEqual(a, b); } - if (a.drawingKind === 'vectorShape' && b.drawingKind === 'vectorShape') { + if ( + (a.drawingKind === 'vectorShape' || a.drawingKind === 'textboxShape') && + (b.drawingKind === 'vectorShape' || b.drawingKind === 'textboxShape') + ) { + const textboxContentEqual = + a.drawingKind !== 'textboxShape' || + b.drawingKind !== 'textboxShape' || + jsonEqual((a as TextboxDrawing).contentBlocks, (b as TextboxDrawing).contentBlocks); + return ( drawingGeometryEqual(a.geometry, b.geometry) && a.shapeKind === b.shapeKind && a.fillColor === b.fillColor && a.strokeColor === b.strokeColor && - a.strokeWidth === b.strokeWidth + a.strokeWidth === b.strokeWidth && + a.textAlign === b.textAlign && + a.textVerticalAlign === b.textVerticalAlign && + jsonEqual(a.textInsets, b.textInsets) && + jsonEqual(a.textContent, b.textContent) && + jsonEqual(a.customGeometry, b.customGeometry) && + jsonEqual(a.lineEnds, b.lineEnds) && + jsonEqual(a.effectExtent, b.effectExtent) && + textboxContentEqual ); } diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 988e388051..4412344af4 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -6,12 +6,15 @@ import type { HeaderFooterLayout, SectionMetadata, ParagraphBlock, + ParagraphMeasure, ColumnLayout, SectionBreakBlock, NormalizedColumnLayout, PageNumberChapterSeparator, PageNumberFormat, + TextboxDrawing, } from '@superdoc/contracts'; +import { layoutTextboxContent } from '@superdoc/layout-engine'; import { cloneColumnLayout, formatSectionPageNumberText, @@ -1011,6 +1014,8 @@ export async function incrementalLayout( 1, pageResolver, kind, + undefined, + (block, maxWidth, firstLineIndent) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), ); const layout = layouts.default?.layout; if (!layout || !(layout.height > 0)) continue; @@ -1043,6 +1048,8 @@ export async function incrementalLayout( 1, pageResolver, kind, + undefined, + (block, maxWidth, firstLineIndent) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), ); const layout = layouts.default?.layout; if (layout && layout.height > 0) { @@ -1102,6 +1109,7 @@ export async function incrementalLayout( prelayoutPageResolver, 'header', fontSignature, + (block, maxWidth, firstLineIndent) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), ); // Extract actual content heights from each variant @@ -1209,6 +1217,7 @@ export async function incrementalLayout( prelayoutPageResolver, 'footer', fontSignature, + (block, maxWidth, firstLineIndent) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), ); // Extract actual content heights from each variant @@ -1359,6 +1368,8 @@ export async function incrementalLayout( } } + hydrateTableTextboxMeasures(currentBlocks, (block, maxWidth) => remeasureParagraph(block, maxWidth)); + const pageTokenEnd = performance.now(); const totalTokenTime = pageTokenEnd - pageTokenStart; @@ -2770,7 +2781,11 @@ export async function incrementalLayout( } : undefined; + const hfRemeasure = (block: ParagraphBlock, maxWidth: number) => remeasureParagraph(block, maxWidth); if (headerFooter.headerBlocks) { + for (const blocks of Object.values(headerFooter.headerBlocks)) { + hydrateTableTextboxMeasures(blocks, hfRemeasure); + } const headerLayouts = await layoutHeaderFooterWithCache( headerFooter.headerBlocks, headerFooter.constraints, @@ -2780,10 +2795,14 @@ export async function incrementalLayout( pageResolver, // Use page resolver for section-aware numbering 'header', fontSignature, + (block, maxWidth, firstLineIndent) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), ); headers = serializeHeaderFooterResults('header', headerLayouts); } if (headerFooter.footerBlocks) { + for (const blocks of Object.values(headerFooter.footerBlocks)) { + hydrateTableTextboxMeasures(blocks, hfRemeasure); + } const footerLayouts = await layoutHeaderFooterWithCache( headerFooter.footerBlocks, headerFooter.constraints, @@ -2793,6 +2812,7 @@ export async function incrementalLayout( pageResolver, // Use page resolver for section-aware numbering 'footer', fontSignature, + (block, maxWidth, firstLineIndent) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), ); footers = serializeHeaderFooterResults('footer', footerLayouts); } @@ -2831,6 +2851,31 @@ const DEFAULT_MARGINS = { top: 72, right: 72, bottom: 72, left: 72 }; export const normalizeMargin = (value: number | undefined, fallback: number): number => Number.isFinite(value) ? (value as number) : fallback; +/** + * Walks table blocks (including nested tables) and computes `contentMeasures` for any + * `textboxShape` drawings found in table cells. Stores results directly on the block so + * the painter can read them without a `DrawingFragment` (table-cell drawings have none). + */ +export function hydrateTableTextboxMeasures( + blocks: FlowBlock[], + remeasure: (block: ParagraphBlock, maxWidth: number) => ParagraphMeasure, +): void { + for (const block of blocks) { + if (block.kind !== 'table') continue; + for (const row of block.rows ?? []) { + for (const cell of row.cells ?? []) { + for (const cellBlock of cell.blocks ?? []) { + if (cellBlock.kind === 'drawing' && cellBlock.drawingKind === 'textboxShape') { + (cellBlock as TextboxDrawing).contentMeasures = layoutTextboxContent(cellBlock, remeasure); + } else if (cellBlock.kind === 'table') { + hydrateTableTextboxMeasures([cellBlock], remeasure); + } + } + } + } + } +} + /** * Rewrites section break blocks so that `layoutDocument` uses the semantic page * dimensions instead of the per-section DOCX page sizes. Without this, each diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index e9a4976365..cbc6c6c109 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -106,6 +106,8 @@ export { clickToPositionDom, findPageElement } from './dom-mapping'; export { isListItem, getWordLayoutConfig, calculateTextStartIndent, extractParagraphIndent } from './list-indent-utils'; export type { TextIndentCalculationParams } from './list-indent-utils'; export { LayoutVersionLogger } from './instrumentation'; +export { hitTestTextboxFragment, resolveTextboxContentHit } from './position-hit.js'; +export type { TextboxHitResult } from './position-hit.js'; // Font Metrics Cache export { FontMetricsCache } from './font-metrics-cache'; @@ -571,6 +573,38 @@ const sumLineHeights = (measure: ParagraphMeasure, fromLine: number, toLine: num // calculatePageTopFallback is now in position-hit.ts and re-exported above. +/** + * SD-3328: an empty paragraph / blank line that the selection passes through is a + * zero-width slice (`pmStart === pmEnd`). `findLinesIntersectingRange` only yields + * such a line when `from < pos < to`, so it is genuinely spanned and must be + * highlighted. Emit a content-width band so the selection highlight stays + * continuous across the blank line β€” the same as selecting any text. Without this + * the band shows a gap and the highlight appears to "disappear" while a drag + * crosses a blank line (reported in body paragraphs and inside table cells). + */ +function pushEmptyLineSelectionBand( + rects: Rect[], + opts: { + x: number; + yBase: number; + width: number; + lineHeight: number; + pageIndex: number; + measure: ParagraphMeasure; + lineIndex: number; + startLineIndex: number; + }, +): void { + const lineOffset = sumLineHeights(opts.measure, opts.startLineIndex, opts.lineIndex); + rects.push({ + x: opts.x, + y: opts.yBase + lineOffset, + width: Math.max(1, opts.width), + height: opts.lineHeight, + pageIndex: opts.pageIndex, + }); +} + /** * Given a PM range [from, to), return selection rectangles for highlighting. * @@ -623,7 +657,20 @@ export function selectionToRects( if (range.pmStart == null || range.pmEnd == null) return; const sliceFrom = Math.max(range.pmStart, from); const sliceTo = Math.min(range.pmEnd, to); - if (sliceFrom >= sliceTo) return; + if (sliceFrom >= sliceTo) { + // SD-3328: blank line spanned by the selection β€” see pushEmptyLineSelectionBand. + pushEmptyLineSelectionBand(rects, { + x: fragment.x, + yBase: fragment.y + pageTopY, + width: fragment.width, + lineHeight: line.lineHeight, + pageIndex, + measure, + lineIndex: index, + startLineIndex: fragment.fromLine, + }); + return; + } // Convert PM positions to character offsets properly // (accounts for gaps in PM positions between runs) @@ -963,7 +1010,26 @@ export function selectionToRects( if (range.pmStart == null || range.pmEnd == null) return; const sliceFrom = Math.max(range.pmStart, from); const sliceTo = Math.min(range.pmEnd, to); - if (sliceFrom >= sliceTo) return; + if (sliceFrom >= sliceTo) { + // SD-3328: blank line spanned by the selection β€” see pushEmptyLineSelectionBand. + pushEmptyLineSelectionBand(rects, { + x: fragment.x + contentOffsetX + cellX + padding.left, + yBase: + fragment.y + + contentOffsetY + + rowOffset + + blockTopCursor + + effectiveSpacingBeforePx + + pageTopY, + width: cellMeasure.width - padding.left - padding.right, + lineHeight: line.lineHeight, + pageIndex, + measure: info.measure, + lineIndex: index, + startLineIndex: info.startLine, + }); + return; + } const charOffsetFrom = pmPosToCharOffset(info.block, line, sliceFrom); const charOffsetTo = pmPosToCharOffset(info.block, line, sliceTo); diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index fb2fff4bae..5c22fd6dcb 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -7,6 +7,7 @@ import type { PageNumberChapterSeparator, PageNumberFormat, ParagraphBlock, + ParagraphMeasure, TableBlock, TextRun, } from '@superdoc/contracts'; @@ -499,6 +500,7 @@ export async function layoutHeaderFooterWithCache( // The calling document's font-mapping signature, forwarded to the (cross-document) measure cache // so header/footer measures cannot leak between documents with different mappings. '' = default. fontSignature: string = '', + remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure, ): Promise { const result: HeaderFooterBatchResult = {}; @@ -516,7 +518,7 @@ export async function layoutHeaderFooterWithCache( resolveHeaderFooterTokens(clonedBlocks, 1, numPages); const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock, fontSignature); - const layout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind); + const layout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind, remeasureParagraph); result[type] = { blocks: clonedBlocks, measures, layout }; } @@ -539,7 +541,7 @@ export async function layoutHeaderFooterWithCache( const hasTokens = hasPageTokens(blocks); if (!hasTokens) { const measures = await cache.measureBlocks(blocks, constraints, measureBlock, fontSignature); - const layout = layoutHeaderFooter(blocks, measures, constraints, kind); + const layout = layoutHeaderFooter(blocks, measures, constraints, kind, remeasureParagraph); result[type] = { blocks, measures, layout }; continue; } @@ -615,7 +617,7 @@ export async function layoutHeaderFooterWithCache( // Measure and layout const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock, fontSignature); - const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind); + const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind, remeasureParagraph); const measuresById = new Map(); for (let i = 0; i < clonedBlocks.length; i += 1) { measuresById.set(clonedBlocks[i].id, measures[i]); diff --git a/packages/layout-engine/layout-bridge/src/position-hit.ts b/packages/layout-engine/layout-bridge/src/position-hit.ts index 3ed345e8ea..582da2b8c4 100644 --- a/packages/layout-engine/layout-bridge/src/position-hit.ts +++ b/packages/layout-engine/layout-bridge/src/position-hit.ts @@ -24,6 +24,7 @@ import type { TableMeasure, ParagraphBlock, ParagraphMeasure, + TextboxDrawing, } from '@superdoc/contracts'; import { adjustAvailableWidthForTextIndent, @@ -95,6 +96,29 @@ export type TableHitResult = { blockStartGlobal: number; }; +export type TextboxHitResult = { + /** The textbox drawing fragment that was hit */ + fragment: DrawingFragment; + /** The textbox drawing block from the document structure */ + block: Extract; + /** The drawing measurement data */ + measure: Measure; + /** Index of the page containing the hit */ + pageIndex: number; + /** The paragraph block inside the textbox */ + contentBlock: ParagraphBlock; + /** Measurement data for the paragraph inside the textbox */ + contentMeasure: ParagraphMeasure; + /** Paragraph index inside block.contentBlocks */ + paragraphIndex: number; + /** X coordinate relative to the textbox content area */ + localX: number; + /** Y coordinate relative to the paragraph content area */ + localY: number; + /** Cumulative line count of preceding textbox paragraphs */ + blockStartGlobal: number; +}; + type AtomicFragment = DrawingFragment | ImageFragment; export type GeometryPageHint = { @@ -700,6 +724,148 @@ export const hitTestTableFragment = ( return null; }; +/** + * Walk the paragraph content of an already-resolved textbox fragment and return the + * paragraph hit at `point`. Callers that already hold a resolved fragment/block should + * call this directly instead of {@link hitTestTextboxFragment}, which would otherwise + * re-scan every page fragment and could resolve a different textbox for overlapping shapes. + */ +export const resolveTextboxContentHit = ( + fragment: DrawingFragment, + block: TextboxDrawing, + measure: Measure, + pageIndex: number, + point: Point, +): TextboxHitResult | null => { + const fragmentWithContent = fragment as DrawingFragment & { contentMeasures?: ParagraphMeasure[] }; + const contentMeasures = Array.isArray(fragmentWithContent.contentMeasures) + ? fragmentWithContent.contentMeasures + : Array.isArray(block.contentMeasures) + ? block.contentMeasures + : []; + const contentBlocks = Array.isArray(block.contentBlocks) ? block.contentBlocks : []; + if (contentMeasures.length === 0 || contentBlocks.length === 0) return null; + + const insets = block.textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const localX = Math.max(0, point.x - fragment.x - insets.left); + const rawLocalY = point.y - fragment.y - insets.top; + + // Mirror the painter's flex justifyContent offset for center/bottom alignment + // (renderer.ts renderTextboxContent sets justifyContent on the inset container). + const totalContentHeight = contentMeasures.reduce( + (sum, m) => sum + (m?.kind === 'paragraph' ? (m.totalHeight ?? 0) : 0), + 0, + ); + const availableHeight = Math.max(0, fragment.height - insets.top - insets.bottom); + const verticalAlign = block.textVerticalAlign ?? 'top'; + let contentOffset = 0; + if (verticalAlign === 'center') { + contentOffset = Math.max(0, (availableHeight - totalContentHeight) / 2); + } else if (verticalAlign === 'bottom') { + contentOffset = Math.max(0, availableHeight - totalContentHeight); + } + const localY = rawLocalY - contentOffset; + + let paragraphStartY = 0; + let blockStartGlobal = 0; + let nearestParagraphHit: + | (Omit & { distance: number }) + | null = null; + + for (let i = 0; i < contentBlocks.length && i < contentMeasures.length; i += 1) { + const contentBlock = contentBlocks[i]; + const contentMeasure = contentMeasures[i]; + if (contentBlock?.kind !== 'paragraph' || contentMeasure?.kind !== 'paragraph') { + continue; + } + + const paragraphHeight = contentMeasure.totalHeight; + const paragraphEndY = paragraphStartY + paragraphHeight; + const isWithinParagraph = localY >= paragraphStartY && localY < paragraphEndY; + + if (isWithinParagraph) { + return { + fragment, + block, + measure, + pageIndex, + contentBlock, + contentMeasure, + paragraphIndex: i, + localX, + localY: Math.max(0, Math.min(localY - paragraphStartY, Math.max(paragraphHeight, 0))), + blockStartGlobal, + }; + } + + const distanceToParagraph = + localY < paragraphStartY ? paragraphStartY - localY : Math.max(0, localY - paragraphEndY); + if (!nearestParagraphHit || distanceToParagraph < nearestParagraphHit.distance) { + nearestParagraphHit = { + contentBlock, + contentMeasure, + paragraphIndex: i, + localX, + localY: Math.max(0, Math.min(localY - paragraphStartY, Math.max(paragraphHeight, 0))), + blockStartGlobal, + distance: distanceToParagraph, + }; + } + + paragraphStartY = paragraphEndY; + blockStartGlobal += contentMeasure.lines.length; + } + + if (nearestParagraphHit) { + return { + fragment, + block, + measure, + pageIndex, + contentBlock: nearestParagraphHit.contentBlock, + contentMeasure: nearestParagraphHit.contentMeasure, + paragraphIndex: nearestParagraphHit.paragraphIndex, + localX: nearestParagraphHit.localX, + localY: nearestParagraphHit.localY, + blockStartGlobal: nearestParagraphHit.blockStartGlobal, + }; + } + + return null; +}; + +/** + * Hit-test textbox drawing fragments to find the paragraph at a click point. + * Scans all page fragments by point β€” when the fragment and block are already resolved, + * call {@link resolveTextboxContentHit} directly to avoid resolving a different textbox + * for overlapping shapes. + */ +export const hitTestTextboxFragment = ( + pageHit: PageHit, + blocks: FlowBlock[], + measures: Measure[], + point: Point, +): TextboxHitResult | null => { + for (const fragment of pageHit.page.fragments) { + if (fragment.kind !== 'drawing' || fragment.drawingKind !== 'textboxShape') continue; + + const withinX = point.x >= fragment.x && point.x <= fragment.x + fragment.width; + const withinY = point.y >= fragment.y && point.y <= fragment.y + fragment.height; + if (!withinX || !withinY) continue; + + const blockIndex = findBlockIndexByFragmentId(blocks, fragment.blockId); + if (blockIndex === -1) continue; + + const block = blocks[blockIndex]; + const measure = measures[blockIndex]; + if (!block || block.kind !== 'drawing' || block.drawingKind !== 'textboxShape' || !measure) continue; + + return resolveTextboxContentHit(fragment as DrawingFragment, block, measure, pageHit.pageIndex, point); + } + + return null; +}; + // --------------------------------------------------------------------------- // New extracted functions // --------------------------------------------------------------------------- @@ -902,6 +1068,82 @@ export function clickToPositionGeometry( }; } + if ( + fragment.kind === 'drawing' && + fragment.drawingKind === 'textboxShape' && + block.kind === 'drawing' && + block.drawingKind === 'textboxShape' + ) { + // Use resolveTextboxContentHit directly β€” fragment and block are already resolved + // from fragmentHit, so re-scanning via hitTestTextboxFragment could pick a different + // textbox when shapes overlap. + const textboxHit = resolveTextboxContentHit( + fragment as DrawingFragment, + block, + measure, + pageIndex, + pageRelativePoint, + ); + if (textboxHit) { + const { contentBlock, contentMeasure, localX, localY, pageIndex, paragraphIndex } = textboxHit; + + const lineIndex = findLineIndexAtY(contentMeasure.lines, localY, 0, contentMeasure.lines.length); + if (lineIndex != null) { + const line = contentMeasure.lines[lineIndex]; + const isRTL = isRtlBlock(contentBlock); + const indentLeft = typeof contentBlock.attrs?.indent?.left === 'number' ? contentBlock.attrs.indent.left : 0; + const indentRight = + typeof contentBlock.attrs?.indent?.right === 'number' ? contentBlock.attrs.indent.right : 0; + const paraIndentLeft = Number.isFinite(indentLeft) ? indentLeft : 0; + const paraIndentRight = Number.isFinite(indentRight) ? indentRight : 0; + const totalIndent = paraIndentLeft + paraIndentRight; + const insets = textboxHit.block.textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + let availableWidth = Math.max(0, textboxHit.fragment.width - insets.left - insets.right - totalIndent); + + if (totalIndent > textboxHit.fragment.width) { + console.warn( + `[clickToPosition:textbox] Paragraph indents (${totalIndent}px) exceed fragment width (${textboxHit.fragment.width}px) ` + + `for block ${textboxHit.fragment.blockId}. This may indicate a layout miscalculation. ` + + `Available width clamped to 0.`, + ); + } + + // Textboxes are page-contained and never split across pages, so lineIndex === 0 + // is sufficient β€” no continuesFromPrev guard needed (unlike body paragraphs). + const isFirstLineOfParagraph = lineIndex === 0; + if (isFirstLineOfParagraph) { + const suppressFLI = (contentBlock.attrs as Record)?.suppressFirstLineIndent === true; + const firstLineOffset = getFirstLineIndentOffset(contentBlock.attrs?.indent, suppressFLI); + availableWidth = adjustAvailableWidthForTextIndent(availableWidth, firstLineOffset, line.maxWidth); + } + + const pos = mapPointToPm(contentBlock, line, localX, isRTL, availableWidth); + if (pos != null) { + return { + pos, + layoutEpoch, + blockId: textboxHit.fragment.blockId, + pageIndex, + column: determineColumn(layout, textboxHit.fragment.x, layout.pages[pageIndex], textboxHit.fragment.y), + lineIndex, + }; + } + } + + const firstRun = contentBlock.runs?.[0]; + if (firstRun && firstRun.pmStart != null) { + return { + pos: firstRun.pmStart, + layoutEpoch, + blockId: textboxHit.fragment.blockId, + pageIndex, + column: determineColumn(layout, textboxHit.fragment.x, layout.pages[pageIndex], textboxHit.fragment.y), + lineIndex: 0, + }; + } + } + } + // Handle atomic fragments (drawing, image) if (isAtomicFragment(fragment)) { const pmRange = getAtomicPmRange(fragment, block); @@ -981,11 +1223,18 @@ export function clickToPositionGeometry( } } - // Fallback: return first position in the cell + // Fallback: return first position in the cell. + // SD-3328: an EMPTY paragraph (blank line / spacer between bullets) has no runs, + // so `firstRun` is undefined and the old code fell through to `return null`. A null + // hit aborts the drag (EditorInputManager #handleDragSelectionAt early-return) and + // freezes/collapses the in-progress selection while the pointer is over the blank + // line. Derive the paragraph's own PM start from its attrs so an empty cell paragraph + // always resolves to a valid forward position inside the cell, never null. const firstRun = cellBlock.runs?.[0]; - if (firstRun && firstRun.pmStart != null) { + const fallbackPos = firstRun?.pmStart ?? blockPmRangeFromAttrs(cellBlock).pmStart; + if (fallbackPos != null) { return { - pos: firstRun.pmStart, + pos: fallbackPos, layoutEpoch, blockId: tableHit.fragment.blockId, pageIndex, diff --git a/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts b/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts index 020f11385d..9e0239727e 100644 --- a/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts +++ b/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts @@ -1,14 +1,23 @@ import { describe, it, expect } from 'vitest'; -import { clickToPosition, hitTestPage, hitTestTableFragment } from '../src/index.ts'; +import { + clickToPosition, + hitTestPage, + hitTestTableFragment, + hitTestTextboxFragment, + resolveTextboxContentHit, +} from '../src/index.ts'; import type { Layout, FlowBlock, Measure, Line, ParaFragment, + ParagraphAttrs, TableBlock, TableMeasure, TableFragment, + DrawingFragment, + ParagraphMeasure, } from '@superdoc/contracts'; import { simpleLayout, @@ -29,6 +38,81 @@ import { } from './mock-data'; describe('clickToPosition', () => { + it('hit-tests textboxShape content paragraphs with insets', () => { + const textboxParagraph: FlowBlock = { + kind: 'paragraph', + id: 'textbox-para', + runs: [{ text: 'Textbox text', fontFamily: 'Arial', fontSize: 16, pmStart: 50, pmEnd: 62 }], + }; + + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'textbox-0', + drawingKind: 'textboxShape', + geometry: { width: 140, height: 60, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [textboxParagraph], + textInsets: { top: 8, right: 10, bottom: 8, left: 12 }, + attrs: { pmStart: 49, pmEnd: 63 }, + }; + + const textboxParagraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 12, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const textboxMeasure: Measure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 140, + height: 60, + scale: 1, + naturalWidth: 140, + naturalHeight: 60, + geometry: { width: 140, height: 60, rotation: 0, flipH: false, flipV: false }, + }; + + const textboxFragment: DrawingFragment = { + kind: 'drawing', + blockId: 'textbox-0', + drawingKind: 'textboxShape', + x: 40, + y: 70, + width: 140, + height: 60, + geometry: { width: 140, height: 60, rotation: 0, flipH: false, flipV: false }, + scale: 1, + pmStart: 49, + pmEnd: 63, + contentMeasures: [textboxParagraphMeasure], + }; + + const layout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [{ number: 1, fragments: [textboxFragment] }], + }; + + const pageHit = hitTestPage(layout, { x: 64, y: 86 }); + expect(pageHit).not.toBeNull(); + + const hit = hitTestTextboxFragment(pageHit!, [textboxBlock], [textboxMeasure], { x: 64, y: 86 }); + expect(hit).not.toBeNull(); + expect(hit?.contentBlock.id).toBe('textbox-para'); + expect(hit?.localX).toBe(12); + expect(hit?.localY).toBe(8); + }); + it('maps point to PM position near start', () => { const result = clickToPosition(simpleLayout, blocks, measures, { x: 40, y: 60 }); expect(result?.pos).toBeGreaterThanOrEqual(1); @@ -52,6 +136,78 @@ describe('clickToPosition', () => { expect(result?.pos).toBe(20); }); + it('maps point inside textboxShape to paragraph PM position', () => { + const textboxParagraph: FlowBlock = { + kind: 'paragraph', + id: 'textbox-para-2', + runs: [{ text: 'Textbox text', fontFamily: 'Arial', fontSize: 16, pmStart: 80, pmEnd: 92 }], + }; + + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'textbox-1', + drawingKind: 'textboxShape', + geometry: { width: 160, height: 70, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [textboxParagraph], + textInsets: { top: 8, right: 10, bottom: 8, left: 12 }, + attrs: { pmStart: 79, pmEnd: 93 }, + }; + + const textboxParagraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 12, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const textboxMeasure: Measure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 160, + height: 70, + scale: 1, + naturalWidth: 160, + naturalHeight: 70, + geometry: { width: 160, height: 70, rotation: 0, flipH: false, flipV: false }, + }; + + const textboxFragment: DrawingFragment = { + kind: 'drawing', + blockId: 'textbox-1', + drawingKind: 'textboxShape', + x: 40, + y: 80, + width: 160, + height: 70, + geometry: { width: 160, height: 70, rotation: 0, flipH: false, flipV: false }, + scale: 1, + pmStart: 79, + pmEnd: 93, + contentMeasures: [textboxParagraphMeasure], + }; + + const layout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [{ number: 1, fragments: [textboxFragment] }], + }; + + const result = clickToPosition(layout, [textboxBlock], [textboxMeasure], { x: 76, y: 96 }); + expect(result?.blockId).toBe('textbox-1'); + expect(result?.lineIndex).toBe(0); + expect(result?.pos).toBeGreaterThanOrEqual(80); + expect(result?.pos).toBeLessThanOrEqual(92); + }); + it('uses table fragment columnIndex instead of visual x for multi-column overflow tables', () => { const cellParagraph: FlowBlock = { kind: 'paragraph', @@ -143,6 +299,68 @@ describe('clickToPosition', () => { expect(result?.column).toBe(1); }); + it('resolves an EMPTY cell paragraph to its PM position instead of null (SD-3328)', () => { + // An empty paragraph (blank line / spacer) inside a table cell has no runs and no + // measured lines, so findLineIndexAtY returns null. Before the fix the cell fallback + // (`cellBlock.runs[0].pmStart`) was undefined and the hit resolved to null β€” which + // aborts an in-progress drag and collapses the selection. The hit must resolve to the + // empty paragraph's own PM position (from its attrs) so dragging across a blank line + // keeps extending the selection. + const emptyCellPara: FlowBlock = { + kind: 'paragraph', + id: 'empty-cell-para', + runs: [], + attrs: { pmStart: 100, pmEnd: 101 } as unknown as ParagraphAttrs, + }; + + const tableBlock: TableBlock = { + kind: 'table', + id: 'empty-cell-table', + rows: [{ id: 'row-0', cells: [{ id: 'cell-0-0', blocks: [emptyCellPara] }] }], + }; + + const emptyCellMeasure: Measure = { kind: 'paragraph', lines: [], totalHeight: 20 }; + + const tableMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { blocks: [emptyCellMeasure], paragraph: emptyCellMeasure, width: 320, height: 28, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }, + ], + height: 28, + }, + ], + columnWidths: [320], + totalWidth: 320, + totalHeight: 28, + }; + + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'empty-cell-table', + fromRow: 0, + toRow: 1, + x: 30, + y: 40, + width: 320, + height: 28, + pmStart: 100, + pmEnd: 101, + }; + + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [{ number: 1, margins: { top: 0, right: 0, bottom: 0, left: 0 }, fragments: [tableFragment] }], + }; + + const result = clickToPosition(layout, [tableBlock], [tableMeasure], { x: 120, y: 54 }); + + expect(result).not.toBeNull(); + expect(result?.pos).toBe(100); + expect(result?.blockId).toBe('empty-cell-table'); + }); + it('falls back to visual x when a table fragment has no columnIndex', () => { // Legacy fragments without columnIndex should still resolve a column via fragment.x. const cellParagraph: FlowBlock = { @@ -1135,3 +1353,142 @@ describe('clickToPosition: table cells with SDT wrappers', () => { expect(result!.pos).toBeLessThanOrEqual(78); }); }); + +// --------------------------------------------------------------------------- +// resolveTextboxContentHit β€” vertical alignment offset +// +// Layout: textbox at (x:20, y:10), 100px wide, 100px tall, insets all zero. +// Two paragraphs, 15px each β†’ totalContentHeight = 30px. +// +// top: content starts at y=0 (relative to inset origin) +// center: content starts at (100-30)/2 = 35px +// bottom: content starts at 100-30 = 70px +// +// The painter renders these via CSS flex justifyContent; the hit-test must +// mirror that offset so clicks map to the correct paragraph. +// --------------------------------------------------------------------------- +describe('resolveTextboxContentHit: textVerticalAlign center and bottom', () => { + const para1: FlowBlock = { + kind: 'paragraph', + id: 'vp-para-1', + runs: [{ text: 'First', fontFamily: 'Arial', fontSize: 12, pmStart: 10, pmEnd: 15 }], + }; + const para2: FlowBlock = { + kind: 'paragraph', + id: 'vp-para-2', + runs: [{ text: 'Second', fontFamily: 'Arial', fontSize: 12, pmStart: 16, pmEnd: 22 }], + }; + + const para1Measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 40, ascent: 10, descent: 3, lineHeight: 15 }], + totalHeight: 15, + }; + const para2Measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 6, width: 48, ascent: 10, descent: 3, lineHeight: 15 }], + totalHeight: 15, + }; + + const makeFixture = (textVerticalAlign: 'top' | 'center' | 'bottom') => { + const block: FlowBlock = { + kind: 'drawing', + id: 'vp-box', + drawingKind: 'textboxShape', + geometry: { width: 100, height: 100, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [para1, para2], + textInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + textVerticalAlign, + attrs: { pmStart: 9, pmEnd: 23 }, + }; + const measure: Measure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 100, + height: 100, + scale: 1, + naturalWidth: 100, + naturalHeight: 100, + geometry: { width: 100, height: 100, rotation: 0, flipH: false, flipV: false }, + }; + const fragment: DrawingFragment = { + kind: 'drawing', + blockId: 'vp-box', + drawingKind: 'textboxShape', + x: 20, + y: 10, + width: 100, + height: 100, + geometry: { width: 100, height: 100, rotation: 0, flipH: false, flipV: false }, + scale: 1, + pmStart: 9, + pmEnd: 23, + contentMeasures: [para1Measure, para2Measure], + }; + return { block, measure, fragment }; + }; + + it('top-aligned: click at content top hits paragraph 1', () => { + const { block, measure, fragment } = makeFixture('top'); + // content starts at y=0 relative to fragment; click at fragment.y + 5 = y=15 β†’ localY=5 β†’ para1 (0-15) + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 15 }); + expect(hit?.contentBlock.id).toBe('vp-para-1'); + }); + + it('top-aligned: click at para2 region hits paragraph 2', () => { + const { block, measure, fragment } = makeFixture('top'); + // para2 starts at y=15; click at fragment.y + 20 = y=30 β†’ localY=20 β†’ para2 (15-30) + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 30 }); + expect(hit?.contentBlock.id).toBe('vp-para-2'); + }); + + it('center-aligned: click above content offset snaps to nearest (para1)', () => { + const { block, measure, fragment } = makeFixture('center'); + // contentOffset = (100-30)/2 = 35. Content: para1 at [35,50), para2 at [50,65). + // click at fragment.y + 10 = y=20 β†’ localY = 10 - 35 = -25, above all content β†’ nearest = para1 + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 20 }); + expect(hit?.contentBlock.id).toBe('vp-para-1'); + }); + + it('center-aligned: click inside para1 content area hits paragraph 1', () => { + const { block, measure, fragment } = makeFixture('center'); + // contentOffset=35. para1 spans localY [0,15). click at fragment.y + 40 = y=50 β†’ localY = 40-35 = 5 β†’ para1 + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 50 }); + expect(hit?.contentBlock.id).toBe('vp-para-1'); + }); + + it('center-aligned: click inside para2 content area hits paragraph 2', () => { + const { block, measure, fragment } = makeFixture('center'); + // contentOffset=35. para2 spans localY [15,30). click at fragment.y + 55 = y=65 β†’ localY = 55-35 = 20 β†’ para2 + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 65 }); + expect(hit?.contentBlock.id).toBe('vp-para-2'); + }); + + it('center-aligned: click below content offset snaps to nearest (para2)', () => { + const { block, measure, fragment } = makeFixture('center'); + // contentOffset=35. Content ends at localY=30 (fragment.y+65). click at fragment.y+90=y=100 β†’ localY=55 β†’ nearest=para2 + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 100 }); + expect(hit?.contentBlock.id).toBe('vp-para-2'); + }); + + it('bottom-aligned: click inside para1 content area hits paragraph 1', () => { + const { block, measure, fragment } = makeFixture('bottom'); + // contentOffset = 100-30 = 70. para1 spans localY [0,15). click at fragment.y + 75 = y=85 β†’ localY=75-70=5 β†’ para1 + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 85 }); + expect(hit?.contentBlock.id).toBe('vp-para-1'); + }); + + it('bottom-aligned: click inside para2 content area hits paragraph 2', () => { + const { block, measure, fragment } = makeFixture('bottom'); + // contentOffset=70. para2 spans localY [15,30). click at fragment.y + 90 = y=100 β†’ localY=90-70=20 β†’ para2 + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 100 }); + expect(hit?.contentBlock.id).toBe('vp-para-2'); + }); + + it('bottom-aligned: click above content offset snaps to nearest (para1)', () => { + const { block, measure, fragment } = makeFixture('bottom'); + // contentOffset=70. click at fragment.y+5=y=15 β†’ localY=5-70=-65 β†’ nearest=para1 + const hit = resolveTextboxContentHit(fragment, block as any, measure, 0, { x: 70, y: 15 }); + expect(hit?.contentBlock.id).toBe('vp-para-1'); + }); +}); diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index b06b3dde46..fc454e595d 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -218,6 +218,39 @@ describe('computeDirtyRegions', () => { expect(result.stableBlockIds.has('0-paragraph')).toBe(true); }); + it('detects textboxShape content changes as dirty', () => { + const prev = [ + drawing({ + drawingKind: 'textboxShape', + textContent: { parts: [{ text: 'Alpha', fontFamily: 'Arial', fontSize: 16 }] }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'textbox-para-1', + runs: [{ text: 'Alpha', fontFamily: 'Arial', fontSize: 16 }], + }, + ], + }), + ]; + const next = [ + drawing({ + drawingKind: 'textboxShape', + textContent: { parts: [{ text: 'Beta', fontFamily: 'Arial', fontSize: 16 }] }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'textbox-para-1', + runs: [{ text: 'Beta', fontFamily: 'Arial', fontSize: 16 }], + }, + ], + }), + ]; + + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('drawing-0')).toBe(false); + }); + it.each([ ['src', { src: 'other.png' }], ['alt', { alt: 'Diagram' }], diff --git a/packages/layout-engine/layout-bridge/test/hydrateTableTextboxMeasures.test.ts b/packages/layout-engine/layout-bridge/test/hydrateTableTextboxMeasures.test.ts new file mode 100644 index 0000000000..df19f162cf --- /dev/null +++ b/packages/layout-engine/layout-bridge/test/hydrateTableTextboxMeasures.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { FlowBlock, ParagraphBlock, ParagraphMeasure, TextboxDrawing } from '@superdoc/contracts'; +import { hydrateTableTextboxMeasures } from '../src/incrementalLayout'; + +const makeLine = (h: number) => ({ + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 100, + ascent: h * 0.8, + descent: h * 0.2, + lineHeight: h, +}); + +const makeMeasure = (lineHeights: number[]): ParagraphMeasure => ({ + kind: 'paragraph', + lines: lineHeights.map(makeLine), + totalHeight: lineHeights.reduce((a, b) => a + b, 0), +}); + +const makeTextboxBlock = (id: string): TextboxDrawing => ({ + kind: 'drawing', + drawingKind: 'textboxShape', + id, + geometry: { width: 120, height: 60, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [{ kind: 'paragraph', id: `${id}-para`, runs: [{ text: 'Hello', pmStart: 1, pmEnd: 6 }] }], + textInsets: { top: 4, right: 8, bottom: 4, left: 8 }, +}); + +const makeTable = (cellBlocks: FlowBlock[][]): FlowBlock => ({ + kind: 'table', + id: 'table-1', + rows: cellBlocks.map((blocks, ri) => ({ + cells: [{ blocks, attrs: {} }], + attrs: {}, + })), + attrs: {}, +}); + +describe('hydrateTableTextboxMeasures', () => { + it('sets contentMeasures on a textboxShape drawing in a table cell', () => { + const textbox = makeTextboxBlock('tb-1'); + const blocks: FlowBlock[] = [makeTable([[textbox]])]; + const remeasure = vi.fn((_block: ParagraphBlock, _maxWidth: number) => makeMeasure([16])); + + hydrateTableTextboxMeasures(blocks, remeasure); + + expect(remeasure).toHaveBeenCalledOnce(); + expect(textbox.contentMeasures).toHaveLength(1); + expect(textbox.contentMeasures?.[0].totalHeight).toBe(16); + }); + + it('passes contentWidth reduced by horizontal insets to remeasure', () => { + const textbox = makeTextboxBlock('tb-2'); + const blocks: FlowBlock[] = [makeTable([[textbox]])]; + const remeasure = vi.fn((_block: ParagraphBlock, maxWidth: number) => makeMeasure([16])); + + hydrateTableTextboxMeasures(blocks, remeasure); + + // geometry.width(120) - insets.left(8) - insets.right(8) = 104 + expect(remeasure).toHaveBeenCalledWith(expect.anything(), 104); + }); + + it('skips non-drawing and non-table cell blocks', () => { + const para: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] }; + const blocks: FlowBlock[] = [makeTable([[para]])]; + const remeasure = vi.fn(); + + hydrateTableTextboxMeasures(blocks, remeasure); + + expect(remeasure).not.toHaveBeenCalled(); + }); + + it('recurses into nested tables', () => { + const textbox = makeTextboxBlock('tb-nested'); + const innerTable = makeTable([[textbox]]); + const blocks: FlowBlock[] = [makeTable([[innerTable]])]; + const remeasure = vi.fn((_block: ParagraphBlock, _maxWidth: number) => makeMeasure([14])); + + hydrateTableTextboxMeasures(blocks, remeasure); + + expect(remeasure).toHaveBeenCalledOnce(); + expect(textbox.contentMeasures).toHaveLength(1); + }); + + it('skips non-table top-level blocks', () => { + const para: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] }; + const remeasure = vi.fn(); + + hydrateTableTextboxMeasures([para], remeasure); + + expect(remeasure).not.toHaveBeenCalled(); + }); + + it('handles multiple textboxes across different cells', () => { + const tb1 = makeTextboxBlock('tb-a'); + const tb2 = makeTextboxBlock('tb-b'); + const blocks: FlowBlock[] = [makeTable([[tb1], [tb2]])]; + const remeasure = vi.fn((_block: ParagraphBlock, _maxWidth: number) => makeMeasure([12])); + + hydrateTableTextboxMeasures(blocks, remeasure); + + expect(remeasure).toHaveBeenCalledTimes(2); + expect(tb1.contentMeasures).toHaveLength(1); + expect(tb2.contentMeasures).toHaveLength(1); + }); +}); diff --git a/packages/layout-engine/layout-bridge/test/mock-data.ts b/packages/layout-engine/layout-bridge/test/mock-data.ts index d6dfde3fb3..5537dbb739 100644 --- a/packages/layout-engine/layout-bridge/test/mock-data.ts +++ b/packages/layout-engine/layout-bridge/test/mock-data.ts @@ -275,6 +275,101 @@ export const tableLayout: Layout = { ], }; +// Table cell with an EMPTY paragraph between two text paragraphs (SD-3328). +// PM layout: p1 "Table text" [2,12), empty para inside pos 14, p3 "More text" [16,26). +// A selection from 2..26 passes through all three lines; the empty line is a +// zero-width slice (pmStart === pmEnd === 14) that the rect builder used to skip. +const tableEmptyParaLineP1 = { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 10, descent: 4, lineHeight: TABLE_CELL_LINE_HEIGHT } as const; +const tableEmptyParaLineEmpty = { fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 10, descent: 4, lineHeight: TABLE_CELL_LINE_HEIGHT } as const; +const tableEmptyParaLineP3 = { fromRun: 0, fromChar: 0, toRun: 0, toChar: 9, width: 70, ascent: 10, descent: 4, lineHeight: TABLE_CELL_LINE_HEIGHT } as const; + +export const tableEmptyParaBlock: FlowBlock = { + kind: 'table', + id: 'table-empty-para', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0', + attrs: { padding: { top: 2, bottom: 2, left: 4, right: 4 } }, + blocks: [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'Table text', fontFamily: 'Arial', fontSize: 14, pmStart: 2, pmEnd: 12 }] }, + { kind: 'paragraph', id: 'p-empty', runs: [{ text: '', fontFamily: 'Arial', fontSize: 14, pmStart: 14, pmEnd: 14 }] }, + { kind: 'paragraph', id: 'p3', runs: [{ text: 'More text', fontFamily: 'Arial', fontSize: 14, pmStart: 16, pmEnd: 26 }] }, + ], + }, + ], + }, + ], +}; + +export const tableEmptyParaMeasure: Measure = { + kind: 'table', + rows: [ + { + height: TABLE_CELL_LINE_HEIGHT * 3 + 4, + cells: [ + { + width: 120, + height: TABLE_CELL_LINE_HEIGHT * 3 + 4, + gridColumnStart: 0, + blocks: [ + { kind: 'paragraph', lines: [tableEmptyParaLineP1], totalHeight: TABLE_CELL_LINE_HEIGHT }, + { kind: 'paragraph', lines: [tableEmptyParaLineEmpty], totalHeight: TABLE_CELL_LINE_HEIGHT }, + { kind: 'paragraph', lines: [tableEmptyParaLineP3], totalHeight: TABLE_CELL_LINE_HEIGHT }, + ], + }, + ], + }, + ], + columnWidths: [120], + totalWidth: 120, + totalHeight: TABLE_CELL_LINE_HEIGHT * 3 + 4, +}; + +export const tableEmptyParaLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'table' as const, blockId: 'table-empty-para', fromRow: 0, toRow: 1, x: 30, y: 60, width: 120, height: TABLE_CELL_LINE_HEIGHT * 3 + 4 }, + ], + }, + ], +}; + +// Body paragraphs with an EMPTY paragraph between two text paragraphs (SD-3328). +// p1 "First line" [1,11), empty paragraph inside pos 13, p3 "Third line" [15,25). +// A selection 1..25 passes through all three; the empty line is a zero-width slice +// that the body rect builder used to skip, leaving a gap in the highlight band. +export const bodyEmptyParaBlocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'body-p1', runs: [{ text: 'First line', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 11 }] }, + { kind: 'paragraph', id: 'body-empty', runs: [{ text: '', fontFamily: 'Arial', fontSize: 16, pmStart: 13, pmEnd: 13 }] }, + { kind: 'paragraph', id: 'body-p3', runs: [{ text: 'Third line', fontFamily: 'Arial', fontSize: 16, pmStart: 15, pmEnd: 25 }] }, +]; + +export const bodyEmptyParaMeasures: Measure[] = [ + { kind: 'paragraph', lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }], totalHeight: 20 }, + { kind: 'paragraph', lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 12, descent: 4, lineHeight: 20 }], totalHeight: 20 }, + { kind: 'paragraph', lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }], totalHeight: 20 }, +]; + +export const bodyEmptyParaLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'body-p1', fromLine: 0, toLine: 1, x: 30, y: 40, width: 300, pmStart: 1, pmEnd: 11 }, + { kind: 'para', blockId: 'body-empty', fromLine: 0, toLine: 1, x: 30, y: 60, width: 300, pmStart: 13, pmEnd: 13 }, + { kind: 'para', blockId: 'body-p3', fromLine: 0, toLine: 1, x: 30, y: 80, width: 300, pmStart: 15, pmEnd: 25 }, + ], + }, + ], +}; + // Table cell spacing.before β€” selectionToRects tests (effective spacing, absorption, partial row) export const TABLE_SPACING_BEFORE = 12; export const TABLE_SPACING_FRAGMENT_Y = 50; diff --git a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts index cfce735914..0d90b212dd 100644 --- a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts +++ b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts @@ -17,6 +17,12 @@ import { tableLayout, tableBlock, tableMeasure, + tableEmptyParaLayout, + tableEmptyParaBlock, + tableEmptyParaMeasure, + bodyEmptyParaLayout, + bodyEmptyParaBlocks, + bodyEmptyParaMeasures, tableSpacingBeforeBlock, tableSpacingBeforeMeasure, tableSpacingBeforeLayout, @@ -74,6 +80,37 @@ describe('selectionToRects', () => { expect(rects[0].x).toBeGreaterThan(tableLayout.pages[0].fragments[0].x); }); + it('highlights an empty paragraph row spanned by a table-cell selection (SD-3328)', () => { + // Selection 2..26 passes through p1 -> empty paragraph -> p3. The empty line is a + // zero-width slice; before the fix the builder skipped it, leaving a gap in the + // highlight band (the reported "selection highlight disappears over empty space"). + const rects = selectionToRects(tableEmptyParaLayout, [tableEmptyParaBlock], [tableEmptyParaMeasure], 2, 26); + + // One rect per line: text, empty, text β€” the empty row must NOT be dropped. + expect(rects).toHaveLength(3); + + // The middle (empty) row sits vertically between the two text rows and is visible. + const sorted = [...rects].sort((a, b) => a.y - b.y); + const emptyRect = sorted[1]; + expect(emptyRect.y).toBeGreaterThan(sorted[0].y); + expect(emptyRect.y).toBeLessThan(sorted[2].y); + expect(emptyRect.width).toBeGreaterThan(1); + }); + + it('highlights a blank line between body paragraphs spanned by a selection (SD-3328)', () => { + // Selection 1..25 passes through p1 -> empty paragraph -> p3 in body text. + // The blank line must be highlighted just like inside a table cell. + const rects = selectionToRects(bodyEmptyParaLayout, bodyEmptyParaBlocks, bodyEmptyParaMeasures, 1, 25); + + expect(rects).toHaveLength(3); + + const sorted = [...rects].sort((a, b) => a.y - b.y); + const emptyRect = sorted[1]; + expect(emptyRect.y).toBeGreaterThan(sorted[0].y); + expect(emptyRect.y).toBeLessThan(sorted[2].y); + expect(emptyRect.width).toBeGreaterThan(1); + }); + it('accounts for visual-only prefix runs when mapping PM selections to X coordinates', () => { const blockWithoutMarker: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/layout-engine/src/anchors.test.ts b/packages/layout-engine/layout-engine/src/anchors.test.ts index b4e80d9501..7b2ed636d2 100644 --- a/packages/layout-engine/layout-engine/src/anchors.test.ts +++ b/packages/layout-engine/layout-engine/src/anchors.test.ts @@ -677,11 +677,14 @@ describe('anchors', () => { const result = collectAnchoredDrawings(blocks, measures); expect(result.size).toBe(0); + expect(result.withoutParagraph).toHaveLength(1); + expect(result.withoutParagraph[0]?.block.id).toBe('img-1'); }); it('should handle empty blocks array', () => { const result = collectAnchoredDrawings([], []); expect(result.size).toBe(0); + expect(result.withoutParagraph).toHaveLength(0); }); it('should handle anchored images with undefined vRelativeFrom (defaults to paragraph)', () => { diff --git a/packages/layout-engine/layout-engine/src/anchors.ts b/packages/layout-engine/layout-engine/src/anchors.ts index d504cfbcd7..62a6042942 100644 --- a/packages/layout-engine/layout-engine/src/anchors.ts +++ b/packages/layout-engine/layout-engine/src/anchors.ts @@ -22,6 +22,14 @@ export type AnchoredDrawing = { measure: ImageMeasure | DrawingMeasure; }; +export type AnchoredDrawingCollection = { + byParagraph: Map; + withoutParagraph: AnchoredDrawing[]; + readonly size: number; + has(index: number): boolean; + get(index: number): AnchoredDrawing[] | undefined; +}; + export type AnchoredTable = { block: TableBlock; measure: TableMeasure; @@ -130,8 +138,9 @@ export function collectPreRegisteredAnchors(blocks: FlowBlock[], measures: Measu * Collect anchored drawings (images/drawings) mapped to their anchor paragraph index. * Map of paragraph block index -> anchored images/drawings associated with that paragraph. */ -export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[]): Map { - const map = new Map(); +export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[]): AnchoredDrawingCollection { + const byParagraph = new Map(); + const withoutParagraph: AnchoredDrawing[] = []; const len = Math.min(blocks.length, measures.length); const paragraphIndexById = buildParagraphIndexById(blocks, len); @@ -159,15 +168,27 @@ export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[] typeof drawingBlock.attrs === 'object' && drawingBlock.attrs ? (drawingBlock.attrs as { anchorParagraphId?: unknown }).anchorParagraphId : undefined; + const anchoredDrawing = { block: drawingBlock, measure: drawingMeasure }; const anchorParaIndex = resolveAnchorParagraphIndex(blocks, len, paragraphIndexById, i, anchorParagraphId); - if (anchorParaIndex == null) continue; // no paragraphs at all + if (anchorParaIndex == null) { + withoutParagraph.push(anchoredDrawing); + continue; + } - const list = map.get(anchorParaIndex) ?? []; - list.push({ block: drawingBlock, measure: drawingMeasure }); - map.set(anchorParaIndex, list); + const list = byParagraph.get(anchorParaIndex) ?? []; + list.push(anchoredDrawing); + byParagraph.set(anchorParaIndex, list); } - return map; + return { + byParagraph, + withoutParagraph, + get size() { + return byParagraph.size; + }, + has: (index: number) => byParagraph.has(index), + get: (index: number) => byParagraph.get(index), + }; } /** diff --git a/packages/layout-engine/layout-engine/src/column-balancing.test.ts b/packages/layout-engine/layout-engine/src/column-balancing.test.ts index 98ffef97d1..26b02b30e4 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.test.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.test.ts @@ -555,4 +555,190 @@ describe('balanceSectionOnPage', () => { expect(result).toBeNull(); }); + + // SD-3359: Word balances a continuous multi-column section by flowing content + // line-by-line β€” a paragraph that straddles the column boundary SPLITS at a line + // boundary (the IT-1150 complaint). Atomic per-fragment assignment leaves the + // columns lumpy whenever one fragment is large relative to the section. + describe('paragraph line splitting across columns (SD-3359)', () => { + type SplitFragment = TestFragment & { + fromLine?: number; + toLine?: number; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; + }; + const LINE = 20; + const TOP = 96; + const COL1_X = 96 + 288 + 48; + + /** A (5 lines) + B (3 lines) + C (14 lines): atomic best is 160 | 280 (120px lumpy); + * line-balanced is 220 | 220 with C split across the boundary. */ + function straddleFixture(cLines = 14): { + fragments: SplitFragment[]; + measureMap: Map }>; + blockSectionMap: Map; + } { + const mk = (id: string, y: number): SplitFragment => ({ + blockId: id, + x: 96, + y, + width: 624, + kind: 'para', + }); + const fragments = [mk('A', TOP), mk('B', TOP + 100), mk('C', TOP + 160)]; + const measureMap = new Map }>([ + ['A', createMeasure('paragraph', Array(5).fill(LINE))], + ['B', createMeasure('paragraph', Array(3).fill(LINE))], + ['C', createMeasure('paragraph', Array(cLines).fill(LINE))], + ]); + const blockSectionMap = new Map([ + ['A', 1], + ['B', 1], + ['C', 1], + ]); + return { fragments, measureMap, blockSectionMap }; + } + + const balance = ( + fragments: SplitFragment[], + measureMap: Map }>, + blockSectionMap: Map, + extra: Record = {}, + ) => + balanceSectionOnPage({ + fragments, + sectionIndex: 1, + sectionColumns: { count: 2, gap: 48, width: 288 }, + sectionHasExplicitColumnBreak: false, + blockSectionMap, + margins: { left: 96 }, + topMargin: TOP, + columnWidth: 288, + availableHeight: 720, + measureMap, + ...extra, + }); + + it('splits a straddling paragraph at a line boundary so columns balance', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + + const result = balance(fragments, measureMap, blockSectionMap); + + expect(result).not.toBeNull(); + // C was split into two fragments. + const cFrags = fragments.filter((f) => f.blockId === 'C') as SplitFragment[]; + expect(cFrags.length).toBe(2); + const [c1, c2] = cFrags.sort((a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0)); + // The halves partition C's lines contiguously. + expect(c1.toLine).toBe(c2.fromLine!); + expect(c2.toLine).toBe(14); + // First half continues in col 0 below A+B; second half tops col 1. + expect(c1.x).toBe(96); + expect(c2.x).toBe(COL1_X); + expect(c2.y).toBe(TOP); + expect(c1.continuesOnNext).toBe(true); + expect(c2.continuesFromPrev).toBe(true); + // Column bottoms balance within one line height (vs 120px atomic lumpiness). + const bottom = (f: SplitFragment): number => { + const from = f.fromLine ?? 0; + const to = f.toLine ?? measureMap.get(f.blockId)!.lines.length; + return f.y + (to - from) * LINE; + }; + const col0Bottom = Math.max(...fragments.filter((f) => f.x === 96).map(bottom)); + const col1Bottom = Math.max(...fragments.filter((f) => f.x === COL1_X).map(bottom)); + expect(Math.abs(col0Bottom - col1Bottom)).toBeLessThanOrEqual(LINE); + // The balanced bottom beats the atomic assignment (TOP + 280). + expect(result!.maxY).toBeLessThan(TOP + 280); + expect(result!.maxY).toBe(Math.max(col0Bottom, col1Bottom)); + }); + + it('does not split a paragraph with keepLines (author intent wins)', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + + const result = balance(fragments, measureMap, blockSectionMap, { + keepLinesBlockIds: new Set(['C']), + }); + + expect(result).not.toBeNull(); + // C stays whole β€” no extra fragment, no partial line range. + expect(fragments.filter((f) => f.blockId === 'C').length).toBe(1); + const c = fragments.find((f) => f.blockId === 'C')! as SplitFragment; + expect(c.fromLine ?? 0).toBe(0); + expect(c.toLine ?? 14).toBe(14); + }); + + it('balances a single tall paragraph alone in the section by splitting it', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + const only = [{ ...fragments[2], y: TOP }]; // C alone (14 lines = 280px) + + const result = balance(only, measureMap, blockSectionMap); + + // Previously skipped (single atomic block can't distribute); a breakable + // paragraph CAN balance β€” Word splits it across the columns. + expect(result).not.toBeNull(); + expect(only.length).toBe(2); + const [c1, c2] = (only as SplitFragment[]).sort((a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0)); + expect(c1.toLine).toBe(c2.fromLine!); + expect(c2.toLine).toBe(14); + expect(result!.maxY).toBeLessThan(TOP + 280); + }); + + it('slices remeasured fragment.lines across the split (no duplicated halves)', () => { + // A fragment remeasured for a narrower column carries its own `lines`, and + // resolveParagraph renders that array INSTEAD of measure.lines[fromLine..toLine]. + // The split must slice `lines` for each half, or both columns render the whole + // paragraph. The remeasured heights (22px) also differ from the stale measure + // (20px), so the break point and cursors must come from the remeasured lines. + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + const REMEASURED = 22; + const c = fragments[2] as SplitFragment & { lines?: Array<{ lineHeight: number }> }; + c.lines = Array.from({ length: 14 }, () => ({ lineHeight: REMEASURED })); + + const result = balance(fragments, measureMap, blockSectionMap); + + expect(result).not.toBeNull(); + const cFrags = ( + fragments.filter((f) => f.blockId === 'C') as Array }> + ).sort((a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0)); + expect(cFrags.length).toBe(2); + const [c1, c2] = cFrags; + // Each half carries ONLY its own remeasured lines, partitioning the original 14. + expect(c1.lines).toBeDefined(); + expect(c2.lines).toBeDefined(); + expect(c1.lines!.length + c2.lines!.length).toBe(14); + expect(c1.lines!.length).toBe((c1.toLine ?? 0) - (c1.fromLine ?? 0)); + expect(c2.lines!.length).toBe(c2.toLine! - c2.fromLine!); + // Cursors advanced by the remeasured heights: the second column's bottom is + // its line count at 22px, not at the stale 20px measure. + const col1Frags = fragments.filter((f) => f.x === COL1_X) as Array< + SplitFragment & { lines?: Array<{ lineHeight: number }> } + >; + const col1Bottom = Math.max( + ...col1Frags.map((f) => f.y + (f.lines ? f.lines.reduce((s, l) => s + l.lineHeight, 0) : 0)), + ); + expect(col1Bottom).toBe(result!.maxY); + }); + + it('offsets the split by the fragment fromLine when pagination already split the paragraph', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + // C is the tail of a 16-line paragraph: this page renders lines [2, 16). + measureMap.set('C', createMeasure('paragraph', Array(16).fill(LINE))); + const c = fragments[2]; + c.fromLine = 2; + c.toLine = 16; + + const result = balance(fragments, measureMap, blockSectionMap); + + expect(result).not.toBeNull(); + const cFrags = (fragments.filter((f) => f.blockId === 'C') as SplitFragment[]).sort( + (a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0), + ); + expect(cFrags.length).toBe(2); + const [c1, c2] = cFrags; + expect(c1.fromLine).toBe(2); + expect(c1.toLine).toBe(c2.fromLine!); + expect(c2.toLine).toBe(16); + expect(c2.fromLine!).toBeGreaterThan(2); + }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/column-balancing.ts b/packages/layout-engine/layout-engine/src/column-balancing.ts index e330fb4026..96e89bcb88 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.ts @@ -158,9 +158,18 @@ export function calculateBalancedColumnHeight( }; } - // Calculate total content height and block-height extremes + // Calculate total content height and block-height extremes. A column can + // never be shorter than its tallest INDIVISIBLE chunk: the full height for + // an unbreakable block, but only the tallest LINE for a breakable paragraph + // (SD-3359 β€” flooring at a breakable paragraph's full height pinned the + // search above the balanced height and packed the overflow lines into the + // first column instead of splitting evenly). const totalHeight = ctx.contentBlocks.reduce((sum, b) => sum + b.measuredHeight, 0); - const maxBlockHeight = ctx.contentBlocks.reduce((m, b) => Math.max(m, b.measuredHeight), 0); + const maxBlockHeight = ctx.contentBlocks.reduce((m, b) => { + const indivisible = + b.canBreak && b.lineHeights && b.lineHeights.length > 1 ? Math.max(...b.lineHeights) : b.measuredHeight; + return Math.max(m, indivisible); + }, 0); // Early exit: content is very small, no need to balance if (totalHeight < config.minColumnHeight * ctx.columnCount) { @@ -506,6 +515,13 @@ export interface BalancingFragment { fromLine?: number; /** Ending line index (exclusive) for partial paragraph fragments */ toLine?: number; + /** + * Remeasured lines carried by the fragment itself (set when a paragraph measured at one + * width is placed in a narrower column or beside a float). When present, the resolve + * stage renders THIS array and ignores fromLine/toLine into measure.lines - so balancing + * must source heights from it and slice it when splitting a fragment across columns. + */ + lines?: Array<{ lineHeight: number }>; /** Pre-computed height for non-paragraph fragments */ height?: number; } @@ -549,6 +565,11 @@ interface FragmentInfo { */ function getFragmentHeight(fragment: BalancingFragment, measureMap: Map): number { if (fragment.kind === 'para') { + // A fragment remeasured for a narrower column carries its own lines; the resolve + // stage renders (and sizes) from THAT array, so balancing must agree with it. + if (fragment.lines && fragment.lines.length > 0) { + return fragment.lines.reduce((sum, l) => sum + (l.lineHeight ?? 0), 0); + } const measure = measureMap.get(fragment.blockId); if (!measure || measure.kind !== 'paragraph' || !measure.lines) { return 0; @@ -665,6 +686,12 @@ export interface BalanceSectionOnPageArgs { * Optional; when omitted no fragment is treated as a marker. */ sectPrMarkerBlockIds?: Set; + /** + * Block IDs of paragraphs with `w:keepLines` β€” the author asked Word not to + * split these, so they stay atomic during balancing. Optional; when omitted + * every multi-line paragraph is splittable. (SD-3359) + */ + keepLinesBlockIds?: Set; } /** @@ -784,13 +811,42 @@ export function balanceSectionOnPage(args: BalanceSectionOnPageArgs): { maxY: nu // Use `getBalancingHeight` so empty sectPr-marker paragraphs contribute 0 // to their column's cursor β€” matching Word's behavior of not rendering a // blank line for such markers. - const contentBlocks: BalancingBlock[] = ordered.map((f, i) => ({ - blockId: `${f.blockId}#${i}`, - measuredHeight: getBalancingHeight(f, args.measureMap, args.sectPrMarkerBlockIds), - canBreak: false, - keepWithNext: false, - keepTogether: true, - })); + // + // SD-3359: multi-line paragraphs additionally expose their per-line heights so + // the balancer can SPLIT a paragraph that straddles the column boundary (Word + // flows content line-by-line when balancing a continuous section, ECMA-376 + // Β§17.18.77 β€” atomic assignment leaves the columns lumpy whenever one + // paragraph is large relative to the section). sectPr markers, `w:keepLines` + // paragraphs, non-paragraph fragments, and single-line paragraphs stay atomic. + const lineHeightsFor = (f: BalancingFragment): number[] | undefined => { + if (f.kind !== 'para') return undefined; + if (args.sectPrMarkerBlockIds?.has(f.blockId)) return undefined; + if (args.keepLinesBlockIds?.has(f.blockId)) return undefined; + // A remeasured fragment renders its own `lines` (resolveParagraph ignores + // fromLine/toLine then), so break points must be computed against that array. + if (f.lines && f.lines.length > 0) { + if (f.lines.length <= 1) return undefined; + return f.lines.map((l) => l.lineHeight); + } + const measure = args.measureMap.get(f.blockId); + if (!measure || measure.kind !== 'paragraph' || !Array.isArray(measure.lines)) return undefined; + const fromLine = f.fromLine ?? 0; + const toLine = f.toLine ?? measure.lines.length; + if (toLine - fromLine <= 1) return undefined; + return measure.lines.slice(fromLine, toLine).map((l) => l.lineHeight); + }; + + const contentBlocks: BalancingBlock[] = ordered.map((f, i) => { + const lineHeights = lineHeightsFor(f); + return { + blockId: `${f.blockId}#${i}`, + measuredHeight: getBalancingHeight(f, args.measureMap, args.sectPrMarkerBlockIds), + canBreak: lineHeights !== undefined, + keepWithNext: false, + keepTogether: lineHeights === undefined, + lineHeights, + }; + }); if ( shouldSkipBalancing({ @@ -831,6 +887,49 @@ export function balanceSectionOnPage(args: BalanceSectionOnPageArgs): { maxY: nu f.x = columnX(col); f.y = colCursors[col]; f.width = columnWidth; + // SD-3359: apply a line-boundary split chosen by the balancer. The first + // half keeps the leading lines in this column; a cloned second half carries + // the remaining lines to the top of the next column β€” the same + // fromLine/toLine + continuation-flag surgery pagination uses when a + // paragraph splits across pages. The simulation assigns the block to the + // column of its FIRST half and flows the remainder into the next column, + // so the cursors advance by the split heights it computed. + const bp = result.blockBreakPoints?.get(block.blockId); + if (bp && bp.heightAfterBreak > 0 && col < columnCount - 1) { + const fromLine = f.fromLine ?? 0; + const splitLine = fromLine + bp.breakAfterLine + 1; + const measureLineCount = args.measureMap.get(f.blockId)?.lines?.length ?? splitLine; + const originalToLine = f.toLine ?? measureLineCount; + const originalContinuesOnNext = (f as { continuesOnNext?: boolean }).continuesOnNext ?? false; + const secondHalf = { + ...f, + fromLine: splitLine, + toLine: originalToLine, + x: columnX(col + 1), + y: colCursors[col + 1], + width: columnWidth, + continuesFromPrev: true, + continuesOnNext: originalContinuesOnNext, + } as BalancingFragment; + // Remeasured fragments render their own `lines` wholesale (fromLine/toLine are + // ignored by the resolve stage then), so the halves must each carry ONLY their + // slice or both columns render the entire paragraph. + if (f.lines && f.lines.length > 0) { + secondHalf.lines = f.lines.slice(bp.breakAfterLine + 1); + f.lines = f.lines.slice(0, bp.breakAfterLine + 1); + } + f.toLine = splitLine; + (f as { continuesOnNext?: boolean }).continuesOnNext = true; + colCursors[col] += bp.heightBeforeBreak; + colCursors[col + 1] += bp.heightAfterBreak; + // Insert right after the first half so document order is preserved for + // any later consumer that walks the page fragments. + const fragIdx = fragments.indexOf(f); + if (fragIdx >= 0) fragments.splice(fragIdx + 1, 0, secondHalf); + if (colCursors[col] > maxY) maxY = colCursors[col]; + if (colCursors[col + 1] > maxY) maxY = colCursors[col + 1]; + continue; + } colCursors[col] += block.measuredHeight; if (colCursors[col] > maxY) maxY = colCursors[col]; } diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index bde926ca2f..208db77358 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -15,6 +15,7 @@ import type { ColumnBreakBlock, PageBreakBlock, TableBlock, + TableFragment, TableMeasure, } from '@superdoc/contracts'; import { layoutDocument, layoutHeaderFooter, type LayoutOptions } from './index.js'; @@ -989,6 +990,47 @@ describe('layoutDocument', () => { expect(fragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 15); }); + it('renders paragraphless anchored drawings and floating tables on the same fallback page', () => { + const imageBlock: ImageBlock = { + kind: 'image', + id: 'paragraphless-floating-image', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 24, + offsetV: 36, + }, + wrap: { type: 'Square' }, + }; + const imageMeasure: ImageMeasure = { + kind: 'image', + width: 80, + height: 40, + }; + const floatingTable = makeParagraphlessFloatingTable('paragraphless-floating-table'); + const floatingTableMeasure = makeTableMeasure([220], [60]); + + const layout = layoutDocument([floatingTable, imageBlock], [floatingTableMeasure, imageMeasure], DEFAULT_OPTIONS); + + expect(layout.pages).toHaveLength(1); + + const imageFragment = layout.pages[0].fragments.find( + (candidate) => candidate.kind === 'image' && candidate.blockId === 'paragraphless-floating-image', + ) as ImageFragment | undefined; + const tableFragment = layout.pages[0].fragments.find( + (candidate) => candidate.kind === 'table' && candidate.blockId === 'paragraphless-floating-table', + ) as TableFragment | undefined; + + expect(imageFragment).toBeTruthy(); + expect(tableFragment).toBeTruthy(); + expect(imageFragment?.x).toBe(DEFAULT_OPTIONS.margins!.left + 24); + expect(imageFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 36); + expect(tableFragment?.x).toBe(120); + expect(tableFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 15); + }); + it('renders a floating table after pruning a leading empty page', () => { const leadingPageBreak: PageBreakBlock = { kind: 'pageBreak', @@ -2431,6 +2473,214 @@ describe('layoutDocument', () => { expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true); expect(layout.pages[1].fragments).toHaveLength(0); }); + + // SD-3366: Word collapses a style/direct pageBreakBefore when the paragraph + // directly follows an explicit page break. Shapes A-E below are Word-verified + // (see the SD-3366 fixture matrix): A and E suppress; B, C and D fire. + describe('pageBreakBefore after an explicit page break (SD-3366)', () => { + const explicitBreak = (id: string): PageBreakBlock => + ({ kind: 'pageBreak', id, attrs: { lineBreakType: 'page' } }) as PageBreakBlock; + const styleBreak = (id: string): PageBreakBlock => + ({ kind: 'pageBreak', id, attrs: { source: 'pageBreakBefore' } }) as PageBreakBlock; + const emptyParagraph = (id: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [{ text: '', fontFamily: 'Arial', fontSize: 16 }], + }); + const textParagraph = (id: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [{ text: 'content', fontFamily: 'Arial', fontSize: 16 }], + }); + + it('suppresses pageBreakBefore directly after an explicit page break (shape E)', () => { + const blocks: FlowBlock[] = [ + textParagraph('p1'), + explicitBreak('pb-explicit'), + styleBreak('pb-style'), + textParagraph('p2'), + ]; + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + { kind: 'pageBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(2); + expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true); + }); + + it('suppresses pageBreakBefore after an explicit break and its empty remnant paragraph (shape A)', () => { + const blocks: FlowBlock[] = [ + textParagraph('p1'), + explicitBreak('pb-explicit'), + emptyParagraph('remnant'), + styleBreak('pb-style'), + textParagraph('p2'), + ]; + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + makeMeasure([20]), + { kind: 'pageBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(2); + expect(pageContainsBlock(layout.pages[1], 'remnant')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true); + }); + + it('still fires pageBreakBefore after a plain empty paragraph with no explicit break (shape B)', () => { + const blocks: FlowBlock[] = [ + textParagraph('p1'), + emptyParagraph('plain-empty'), + styleBreak('pb-style'), + textParagraph('p2'), + ]; + const measures: Measure[] = [makeMeasure([40]), makeMeasure([20]), { kind: 'pageBreak' }, makeMeasure([40])]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(2); + expect(pageContainsBlock(layout.pages[0], 'p1')).toBe(true); + expect(pageContainsBlock(layout.pages[0], 'plain-empty')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true); + }); + + it('still fires pageBreakBefore when text follows the explicit break on the fresh page (shape C)', () => { + const blocks: FlowBlock[] = [ + textParagraph('p1'), + explicitBreak('pb-explicit'), + textParagraph('after-break'), + styleBreak('pb-style'), + textParagraph('p2'), + ]; + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + makeMeasure([40]), + { kind: 'pageBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(3); + expect(pageContainsBlock(layout.pages[1], 'after-break')).toBe(true); + expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true); + }); + + it('still fires pageBreakBefore when an extra empty paragraph follows the break remnant (shape D)', () => { + const blocks: FlowBlock[] = [ + textParagraph('p1'), + explicitBreak('pb-explicit'), + emptyParagraph('remnant'), + emptyParagraph('extra-empty'), + styleBreak('pb-style'), + textParagraph('p2'), + ]; + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + makeMeasure([20]), + makeMeasure([20]), + { kind: 'pageBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(3); + expect(pageContainsBlock(layout.pages[1], 'remnant')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'extra-empty')).toBe(true); + expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true); + }); + + it('still fires pageBreakBefore when the remnant paragraph carries a list marker (numberingProperties)', () => { + // An empty list item renders a visible marker ("1.", "β€’") painted from + // paragraph attrs, not runs β€” that is page content, so the break re-arms. + const blocks: FlowBlock[] = [ + textParagraph('p1'), + explicitBreak('pb-explicit'), + { + kind: 'paragraph', + id: 'list-remnant', + runs: [{ text: '', fontFamily: 'Arial', fontSize: 16 }], + attrs: { numberingProperties: { numId: 1, ilvl: 0 } }, + }, + styleBreak('pb-style'), + textParagraph('p2'), + ]; + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + makeMeasure([20]), + { kind: 'pageBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(3); + expect(pageContainsBlock(layout.pages[1], 'list-remnant')).toBe(true); + expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true); + }); + + it('still fires pageBreakBefore when the remnant paragraph carries a wordLayout marker', () => { + const blocks: FlowBlock[] = [ + textParagraph('p1'), + explicitBreak('pb-explicit'), + { + kind: 'paragraph', + id: 'marker-remnant', + runs: [{ text: '', fontFamily: 'Arial', fontSize: 16 }], + attrs: { wordLayout: { marker: { markerText: '1.' } } }, + } as FlowBlock, + styleBreak('pb-style'), + textParagraph('p2'), + ]; + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + makeMeasure([20]), + { kind: 'pageBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(3); + expect(pageContainsBlock(layout.pages[1], 'marker-remnant')).toBe(true); + expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true); + }); + + it('does not suppress an explicit page break that follows another explicit page break', () => { + // Two manual breaks in a row intentionally produce a blank page. + const blocks: FlowBlock[] = [ + textParagraph('p1'), + explicitBreak('pb-1'), + explicitBreak('pb-2'), + textParagraph('p2'), + ]; + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + { kind: 'pageBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(3); + expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true); + }); + }); }); describe('Phase 4: Column Breaks', () => { @@ -3038,7 +3288,7 @@ describe('layoutDocument', () => { const TWO_COL_RIGHT_X = LEFT_MARGIN + TWO_COL_WIDTH + COLUMN_GAP; // 330 /** Build a 2-col section ending with a section break, surrounded by single-column context. */ - function buildTwoColumnSection(paragraphCount: number, lineHeight = 20) { + function buildTwoColumnSection(paragraphCount: number, lineHeight = 20, endBreakType = 'continuous') { const blocks: FlowBlock[] = [ { kind: 'sectionBreak', @@ -3059,7 +3309,7 @@ describe('layoutDocument', () => { blocks.push({ kind: 'sectionBreak', id: 'sb-end', - type: 'continuous', + type: endBreakType, columns: { count: 1, gap: 0 }, margins: {}, attrs: { source: 'sectPr', sectionIndex: 1 }, @@ -3112,6 +3362,88 @@ describe('layoutDocument', () => { expect(afterSectionPara!.y).toBe(expectedBalancedBottom); }); + it('splits a paragraph straddling the column boundary so the section balances (SD-3359)', () => { + // IT-1150 / SD-3359 repro shape: two 1-line paragraphs plus one 14-line + // paragraph (280px) in a 2-col continuous section followed by continuous + // single-column content. Atomic assignment can only reach 40 | 280; + // Word flows line-by-line, splitting the long paragraph at the boundary + // so both columns reach the minimum section height (Β§17.18.77): + // col0 = 20 + 20 + 6 lines (160), col1 = 8 lines (160). + const blocks: FlowBlock[] = [ + { + kind: 'sectionBreak', + id: 'sb-start', + type: 'continuous', + columns: { count: 2, gap: COLUMN_GAP }, + margins: {}, + attrs: { source: 'sectPr', sectionIndex: 0, isFirstSection: true }, + } as FlowBlock, + { kind: 'paragraph', id: 'p0', runs: [], attrs: { sectionIndex: 0 } } as FlowBlock, + { kind: 'paragraph', id: 'p1', runs: [], attrs: { sectionIndex: 0 } } as FlowBlock, + { kind: 'paragraph', id: 'p-long', runs: [], attrs: { sectionIndex: 0 } } as FlowBlock, + { + kind: 'sectionBreak', + id: 'sb-end', + type: 'continuous', + columns: { count: 1, gap: 0 }, + margins: {}, + attrs: { source: 'sectPr', sectionIndex: 1 }, + } as FlowBlock, + { kind: 'paragraph', id: 'p-after', runs: [], attrs: { sectionIndex: 1 } } as FlowBlock, + ]; + const measures: Measure[] = [ + { kind: 'sectionBreak' }, + makeMeasure([20]), + makeMeasure([20]), + makeMeasure(Array(14).fill(20)), + { kind: 'sectionBreak' }, + makeMeasure([20]), + ]; + + const layout = layoutDocument(blocks, measures, PAGE); + + const longFrags = layout.pages[0].fragments + .filter((f): f is ParaFragment => f.kind === 'para' && f.blockId === 'p-long') + .sort((a, b) => a.fromLine - b.fromLine); + // The straddling paragraph split into two fragments partitioning its lines. + expect(longFrags).toHaveLength(2); + expect(longFrags[0].toLine).toBe(longFrags[1].fromLine); + expect(longFrags[1].toLine).toBe(14); + expect(longFrags[0].x).toBe(LEFT_MARGIN); + expect(longFrags[1].x).toBe(TWO_COL_RIGHT_X); + // Both columns reach the same minimum height (320 / 2 = 160). + const pAfter = layout.pages[0].fragments.find( + (f): f is ParaFragment => f.kind === 'para' && f.blockId === 'p-after', + ); + expect(pAfter).toBeDefined(); + expect(pAfter!.y).toBe(72 + 160); + }); + + it('does NOT balance a 2-col section whose END break is nextPage (Β§17.18.77, SD-3359)', () => { + // Per the Β§17.18.77 note only a CONTINUOUS break balances the previous + // section. A 2-col section that merely STARTS continuous but is ended by + // a nextPage break fills column-by-column instead (Word behavior), and + // the following section starts on a fresh page. Regression for the + // post-layout gate that previously keyed off the section's own begin + // type instead of the break that ends it. + const { blocks, measures } = buildTwoColumnSection(6, 20, 'nextPage'); + + const layout = layoutDocument(blocks, measures, PAGE); + + const sectionFragments = layout.pages[0].fragments.filter( + (f): f is ParaFragment => f.kind === 'para' && f.blockId.startsWith('p') && f.blockId !== 'p-after', + ); + // Unbalanced column-by-column fill: all 6 short paragraphs stay in col 0. + expect(sectionFragments.filter((f) => f.x === LEFT_MARGIN)).toHaveLength(6); + expect(sectionFragments.filter((f) => f.x === TWO_COL_RIGHT_X)).toHaveLength(0); + // The nextPage break pushes the following section to page 2. + expect(layout.pages.length).toBe(2); + const pAfter = layout.pages[1].fragments.find( + (f): f is ParaFragment => f.kind === 'para' && f.blockId === 'p-after', + ); + expect(pAfter).toBeDefined(); + }); + it('fills BOTH columns on every page of a multi-page 2-col continuous section', () => { // ECMA-376 Β§17.18.77 (ST_SectionMark): a continuous section break // "balances content of the previous section." Word's observable behavior @@ -4309,6 +4641,145 @@ describe('layoutHeaderFooter', () => { expect(paragraphFragment.x).toBe(0); expect(paragraphFragment.width).toBe(200); }); + + it('computes contentMeasures for textboxShape blocks when remeasureParagraph is provided', () => { + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'footer-textbox-1', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 40, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'footer-textbox-para-1', + runs: [{ text: 'Footer text', pmStart: 1, pmEnd: 12 }], + }, + ], + textInsets: { top: 4, right: 8, bottom: 4, left: 8 }, + }; + const textboxMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 40, + scale: 1, + naturalWidth: 120, + naturalHeight: 40, + geometry: { width: 120, height: 40, rotation: 0, flipH: false, flipV: false }, + }; + + const layout = layoutHeaderFooter( + [textboxBlock], + [textboxMeasure], + { width: 400, height: 80 }, + 'footer', + (_block, _maxWidth) => makeMeasure([16]), + ); + + const fragment = layout.pages[0].fragments[0] as DrawingFragment; + expect(fragment.kind).toBe('drawing'); + expect(fragment.drawingKind).toBe('textboxShape'); + expect(Array.isArray(fragment.contentMeasures)).toBe(true); + expect(fragment.contentMeasures).toHaveLength(1); + }); + + it('places paragraphless anchored textboxShape blocks in header/footer layout', () => { + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'header-textbox-1', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 40, rotation: 0, flipH: false, flipV: false }, + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: -10, + offsetV: -6, + }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'header-textbox-para-1', + runs: [{ text: 'Header text', pmStart: 1, pmEnd: 12 }], + }, + ], + textInsets: { top: 4, right: 8, bottom: 4, left: 8 }, + }; + const textboxMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 40, + scale: 1, + naturalWidth: 120, + naturalHeight: 40, + geometry: { width: 120, height: 40, rotation: 0, flipH: false, flipV: false }, + }; + + const layout = layoutHeaderFooter( + [textboxBlock], + [textboxMeasure], + { width: 400, height: 80 }, + 'header', + (_block, _maxWidth) => makeMeasure([16]), + ); + + expect(layout.pages).toHaveLength(1); + const fragment = layout.pages[0].fragments[0] as DrawingFragment; + expect(fragment.kind).toBe('drawing'); + expect(fragment.blockId).toBe('header-textbox-1'); + expect(fragment.isAnchored).toBe(true); + expect(fragment.x).toBe(-10); + expect(fragment.y).toBe(-6); + expect(fragment.contentMeasures).toHaveLength(1); + }); + + it('places paragraphless page-relative behindDoc header images at page offsets', () => { + const imageBlock: FlowBlock = { + kind: 'image', + id: 'header-bg-image', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + vRelativeFrom: 'page', + offsetH: 1387 * (96 / 914400), + offsetV: 254000 * (96 / 914400), + behindDoc: true, + }, + wrap: { type: 'None', behindDoc: true }, + }; + const imageMeasure: ImageMeasure = { + kind: 'image', + width: 7557463 * (96 / 914400), + height: 10723880 * (96 / 914400), + }; + + const layout = layoutHeaderFooter( + [imageBlock], + [imageMeasure], + { + width: 602, + height: 648, + pageWidth: 816, + pageHeight: 1056, + margins: { left: 107, right: 107, top: 72, bottom: 72, header: 36 }, + }, + 'header', + ); + + expect(layout.pages).toHaveLength(1); + const fragment = layout.pages[0].fragments[0] as ImageFragment; + expect(fragment.kind).toBe('image'); + expect(fragment.blockId).toBe('header-bg-image'); + expect(fragment.isAnchored).toBe(true); + expect(fragment.behindDoc).toBe(true); + expect(fragment.zIndex).toBe(0); + expect(fragment.x).toBeCloseTo(0.15, 2); + expect(fragment.y).toBeCloseTo(26.67, 2); + expect(layout.height).toBe(0); + expect(layout.renderHeight).toBeGreaterThan(1000); + }); }); describe('requirePageBoundary edge cases', () => { @@ -4628,9 +5099,7 @@ describe('requirePageBoundary edge cases', () => { const layout = layoutDocument(blocks, measures, options); const page = layout.pages[0]; - const contentWidth = options.pageSize!.w - options.margins!.left - options.margins!.right; - const totalGap = 48 * 2; - const expectedSecondColumnX = 50 + (100 * (contentWidth - totalGap)) / (100 + 100 + 300) + 48; + const expectedSecondColumnX = 50 + 100 + 48; const p2 = page.fragments.find((f) => f.blockId === 'p2') as ParaFragment; const p3 = page.fragments.find((f) => f.blockId === 'p3') as ParaFragment; @@ -5053,6 +5522,126 @@ describe('requirePageBoundary edge cases', () => { }); }); + describe('textbox content measures', () => { + it('stores contentMeasures for inline textbox drawings when remeasureParagraph is available', () => { + const drawingBlock: FlowBlock = { + kind: 'drawing', + id: 'textbox-inline', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'textbox-para-1', + runs: [{ text: 'Textbox text', pmStart: 10, pmEnd: 22 }], + }, + ], + textInsets: { top: 4, right: 8, bottom: 4, left: 12 }, + }; + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 80, + scale: 1, + naturalWidth: 120, + naturalHeight: 80, + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + }; + + const remeasureParagraph: NonNullable = (_block, maxWidth) => { + expect(maxWidth).toBe(100); + return makeMeasure([18]); + }; + + const layout = layoutDocument([drawingBlock], [drawingMeasure], { + ...DEFAULT_OPTIONS, + remeasureParagraph, + }); + + const fragment = layout.pages[0].fragments[0] as DrawingFragment; + expect(fragment.kind).toBe('drawing'); + expect(fragment.drawingKind).toBe('textboxShape'); + expect(fragment.contentMeasures).toHaveLength(1); + expect(fragment.contentMeasures?.[0]?.totalHeight).toBe(18); + }); + + it('stores contentMeasures for anchored textbox drawings on pre-registered pages', () => { + const firstPageParagraph: FlowBlock = { + kind: 'paragraph', + id: 'para-page-1', + runs: [], + }; + const forcedBreak: FlowBlock = { + kind: 'pageBreak', + id: 'pb-before-textbox', + }; + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'textbox-pre-reg-page', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'textbox-para-anchored', + runs: [{ text: 'Anchored textbox', pmStart: 30, pmEnd: 45 }], + }, + ], + textInsets: { top: 0, right: 10, bottom: 0, left: 10 }, + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'page', + alignH: 'left', + alignV: 'top', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + distLeft: 0, + distRight: 10, + }, + }; + const paragraphMeasure = makeMeasure([20]); + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 80, + scale: 1, + naturalWidth: 120, + naturalHeight: 80, + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + }; + + const remeasureWidths: number[] = []; + const remeasureParagraph: NonNullable = (_block, maxWidth) => { + remeasureWidths.push(maxWidth); + return makeMeasure([16, 16]); + }; + + const layout = layoutDocument( + [firstPageParagraph, forcedBreak, textboxBlock], + [paragraphMeasure, { kind: 'pageBreak' }, drawingMeasure], + { + ...DEFAULT_OPTIONS, + remeasureParagraph, + }, + ); + + const page2 = layout.pages[1]; + const fragment = page2.fragments.find((frag) => frag.blockId === 'textbox-pre-reg-page') as DrawingFragment; + expect(fragment).toBeTruthy(); + expect(fragment.drawingKind).toBe('textboxShape'); + expect(fragment.contentMeasures).toHaveLength(1); + expect(fragment.contentMeasures?.[0]?.lines).toHaveLength(2); + expect(remeasureWidths).toContain(100); + }); + }); + describe('tables in columns/pages', () => { it('moves table to next column when not enough vertical space', () => { const table: TableBlock = { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 64fd88708b..71d14836b6 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -58,6 +58,7 @@ import { import { layoutParagraphBlock, type FootnoteAnchorRef } from './layout-paragraph.js'; import { layoutImageBlock } from './layout-image.js'; import { layoutDrawingBlock } from './layout-drawing.js'; +import { layoutTextboxContent } from './layout-textbox.js'; import { layoutTableBlock, createAnchoredTableFragment, isAnchoredTableFullWidth } from './layout-table.js'; import { collectAnchoredDrawings, @@ -597,6 +598,7 @@ export type LayoutOptions = { * overlay behavior in paragraph-free header/footer regions. */ allowParagraphlessAnchoredTableFallback?: boolean; + allowParagraphlessAnchoredDrawingFallback?: boolean; /** * Allow body layout to synthesize page 1 when section metadata exists but no * renderable body blocks survive conversion. @@ -691,6 +693,47 @@ const shouldSkipRedundantPageBreakBefore = (block: PageBreakBlock, state: PageSt return isAtTopOfFreshPage; }; +/** An explicit page break (manual `w:br w:type="page"`), as opposed to a style/direct pageBreakBefore. */ +const isExplicitPageBreakBlock = (block: FlowBlock | undefined): boolean => { + return block?.kind === 'pageBreak' && (block as PageBreakBlock).attrs?.source !== 'pageBreakBefore'; +}; + +/** + * A paragraph that renders no content: every run is a text run with empty + * text, and the paragraph paints no list marker. List markers ("1.", "β€’") + * come from paragraph attrs (`numberingProperties` / `wordLayout.marker`), + * not runs, so an empty-text list item is still visible page content. + */ +const isEmptyParagraphBlock = (block: FlowBlock | undefined): boolean => { + if (block?.kind !== 'paragraph') return false; + const paragraph = block as ParagraphBlock; + if (paragraph.attrs?.numberingProperties || paragraph.attrs?.wordLayout?.marker) return false; + const runs = paragraph.runs ?? []; + return runs.every((run) => (run.kind === undefined || run.kind === 'text') && run.text === ''); +}; + +/** + * Word collapses a style/direct pageBreakBefore when the paragraph directly + * follows an explicit page break. The break paragraph's own empty remnant + * (its paragraph mark, emitted as an empty paragraph block right after the + * break) does not re-arm the break β€” but any other content does: one extra + * empty paragraph, or text after the break in the same paragraph, and Word + * renders the second page break again. Verified against Word renders of the + * SD-3366 fixture matrix (shapes A-E). + * + * The directly-adjacent case (break at the end of a paragraph with content, + * which emits no remnant) is already covered by the fresh-page geometric + * guard above; this structural check covers the remnant case, where the + * remnant fragment makes the fresh page non-empty. + */ +const isPageBreakBeforeSatisfiedByExplicitBreak = (blocks: readonly FlowBlock[], index: number): boolean => { + const block = blocks[index]; + if (block?.kind !== 'pageBreak' || (block as PageBreakBlock).attrs?.source !== 'pageBreakBefore') { + return false; + } + return isEmptyParagraphBlock(blocks[index - 1]) && isExplicitPageBreakBlock(blocks[index - 2]); +}; + const hasOnlySectionBreakBlocks = (blocks: readonly FlowBlock[]): boolean => { return blocks.length > 0 && blocks.every((block) => block.kind === 'sectionBreak'); }; @@ -944,6 +987,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options let activeColumns = cloneColumnLayout(options.columns); let pendingColumns: ColumnLayout | null = null; const allowParagraphlessAnchoredTableFallback = options.allowParagraphlessAnchoredTableFallback !== false; + const allowParagraphlessAnchoredDrawingFallback = options.allowParagraphlessAnchoredDrawingFallback !== false; const allowSectionBreakOnlyPageFallback = options.allowSectionBreakOnlyPageFallback !== false; // Track active and pending orientation @@ -1875,13 +1919,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const blockSectionMap = new Map(); const sectionColumnsMap = new Map(); const sectionHasExplicitColumnBreak = new Set(); - // sectionIndex -> type of the section break that ENDS this section (per - // pm-adapter end-tagged semantics, ECMA-376 Β§17.6.17: a paragraph's sectPr - // describes the section ENDING at that paragraph, so SectionBreakBlock.type - // here is the type of the break that closes the section). Per ECMA-376 - // Β§17.18.77 only `continuous` breaks trigger column balancing β€” `nextPage`, - // `evenPage`, `oddPage` do not. Tracked here so the post-layout pass can - // skip the wrong section types. + // sectionIndex -> the section's own sectPr `w:type` (ECMA-376 Β§17.6.22): + // how the section BEGINS relative to its predecessor β€” i.e. the type of the + // break that closes the PREVIOUS section. The break that ENDS section N is + // therefore `get(N + 1)`. Per ECMA-376 Β§17.18.77 only a `continuous` break + // balances the section BEFORE it β€” `nextPage`, `evenPage`, `oddPage` do + // not. (The earlier comment here claimed end-break semantics, which led the + // post-layout gate to key off the wrong section β€” SD-3359.) const sectionEndBreakType = new Map(); // sectionIndex -> whether `` was EXPLICIT in the source sectPr. // Body sectPrs default to `continuous` when w:type is omitted; Word does @@ -1906,6 +1950,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // the older `line.width === 0` heuristic, which incorrectly collapsed normal // blank paragraphs and caused overlap on the next paragraph. const sectPrMarkerBlockIds = new Set(); + // Block IDs of paragraphs with `w:keepLines` (ECMA-376 Β§17.3.1.14): the + // author asked Word not to split these, so column balancing must keep them + // atomic instead of breaking them at a line boundary. (SD-3359) + const keepLinesBlockIds = new Set(); // True if any block in the document is a column break. Used as a guard for // the document-wide balancing fallback (Nick comment 2): when callers use // LayoutOptions.columns without section metadata, we still want Word's @@ -1970,10 +2018,18 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options ) { sectPrMarkerBlockIds.add(block.id); } + if ( + block.kind === 'paragraph' && + (blockWithAttrs as { attrs?: { keepLines?: boolean } }).attrs?.keepLines === true + ) { + keepLinesBlockIds.add(block.id); + } }); // Collect anchored drawings mapped to their anchor paragraphs - const anchoredByParagraph = collectAnchoredDrawings(blocks, measures); + const anchoredDrawings = collectAnchoredDrawings(blocks, measures); + const anchoredByParagraph = anchoredDrawings.byParagraph; + const paragraphlessAnchoredDrawings = anchoredDrawings.withoutParagraph; // PASS 1C: collect anchored/floating tables mapped to their anchor paragraphs. // Tables without any anchor paragraph need explicit fallback placement so // floating-only documents still produce a page and render their content. @@ -2006,6 +2062,36 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options }); }; + const resolveParagraphlessAnchoredDrawingY = ( + block: ImageBlock | DrawingBlock, + measure: ImageMeasure | DrawingMeasure, + state: PageState, + ): number => + resolveAnchoredGraphicY({ + anchor: block.anchor, + objectHeight: measure.height ?? 0, + contentTop: state.topMargin, + contentBottom: state.contentBottom, + pageBottomMargin: state.page.margins?.bottom ?? activeBottomMargin, + preRegisteredFallbackToContentTop: true, + }); + + const resolveParagraphlessAnchoredDrawingX = ( + block: ImageBlock | DrawingBlock, + measure: ImageMeasure | DrawingMeasure, + state: PageState, + ): number => + block.anchor + ? computeAnchorX( + block.anchor, + state.columnIndex, + normalizeColumns(activeColumns, activePageSize.w - (activeLeftMargin + activeRightMargin)), + measure.width, + { left: activeLeftMargin, right: activeRightMargin }, + activePageSize.w, + ) + : columnX(state); + for (const entry of preRegisteredAnchors) { // Ensure first page exists const state = paginator.ensurePage(); @@ -2310,6 +2396,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options availableHeight, measureMap: balancingMeasureMap, sectPrMarkerBlockIds, + keepLinesBlockIds, }); if (balanceResult) { // Collapse both cursors to the balanced section bottom so the new @@ -2700,6 +2787,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const state = paginator.ensurePage(); const drawBlock = block as DrawingBlock; const drawMeasure = measure as DrawingMeasure; + const contentMeasures = + drawBlock.drawingKind === 'textboxShape' && typeof options.remeasureParagraph === 'function' + ? layoutTextboxContent(drawBlock, options.remeasureParagraph) + : undefined; const fragment: DrawingFragment = { kind: 'drawing', @@ -2718,6 +2809,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options sourceAnchor: drawBlock.sourceAnchor, }; + if (contentMeasures) { + fragment.contentMeasures = contentMeasures; + } + const attrs = drawBlock.attrs as Record | undefined; if (attrs?.pmStart != null) fragment.pmStart = attrs.pmStart as number; if (attrs?.pmEnd != null) fragment.pmEnd = attrs.pmEnd as number; @@ -2734,6 +2829,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options ensurePage: paginator.ensurePage, advanceColumn: paginator.advanceColumn, columnX, + textboxContentMeasures: + block.drawingKind === 'textboxShape' && typeof options.remeasureParagraph === 'function' + ? layoutTextboxContent(block, options.remeasureParagraph) + : undefined, }); continue; } @@ -2761,7 +2860,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options throw new Error(`layoutDocument: expected pageBreak measure for block ${block.id}`); } const currentState = states[states.length - 1]; - if (shouldSkipRedundantPageBreakBefore(block as PageBreakBlock, currentState)) { + if ( + shouldSkipRedundantPageBreakBefore(block as PageBreakBlock, currentState) || + isPageBreakBeforeSatisfiedByExplicitBreak(blocks, index) + ) { continue; } paginator.startNewPage(); @@ -2801,15 +2903,98 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options sectionFirstPageNumbers.clear(); }; + const shouldUseBlankPageFallback = pages.length === 0; + if ( - pages.length === 0 && + shouldUseBlankPageFallback && ((allowParagraphlessAnchoredTableFallback && paragraphlessAnchoredTables.length > 0) || + (allowParagraphlessAnchoredDrawingFallback && paragraphlessAnchoredDrawings.length > 0) || (allowSectionBreakOnlyPageFallback && hasOnlySectionBreakBlocks(blocks))) ) { resetPaginationStateForBlankPageFallback(); } - if (allowParagraphlessAnchoredTableFallback && pages.length === 0 && paragraphlessAnchoredTables.length > 0) { + if ( + allowParagraphlessAnchoredDrawingFallback && + shouldUseBlankPageFallback && + paragraphlessAnchoredDrawings.length > 0 + ) { + const state = paginator.ensurePage(); + + for (const { block, measure } of paragraphlessAnchoredDrawings) { + if (placedAnchoredIds.has(block.id)) continue; + + const anchorX = resolveParagraphlessAnchoredDrawingX(block, measure, state); + const anchorY = resolveParagraphlessAnchoredDrawingY(block, measure, state); + + if (block.kind === 'image' && measure.kind === 'image') { + const pageContentHeight = Math.max(0, state.contentBottom - state.topMargin); + const aspectRatio = measure.width > 0 && measure.height > 0 ? measure.width / measure.height : 1.0; + const minWidth = 20; + const minHeight = minWidth / aspectRatio; + const fragment: ImageFragment = { + kind: 'image', + blockId: block.id, + x: anchorX, + y: anchorY, + width: measure.width, + height: measure.height, + isAnchored: true, + behindDoc: block.anchor?.behindDoc === true, + zIndex: getFragmentZIndex(block), + metadata: { + originalWidth: measure.width, + originalHeight: measure.height, + maxWidth: activePageSize.w - (activeLeftMargin + activeRightMargin), + maxHeight: pageContentHeight, + aspectRatio, + minWidth, + minHeight, + }, + sourceAnchor: block.sourceAnchor, + }; + const attrs = block.attrs as Record | undefined; + if (attrs?.pmStart != null) fragment.pmStart = attrs.pmStart as number; + if (attrs?.pmEnd != null) fragment.pmEnd = attrs.pmEnd as number; + state.page.fragments.push(fragment); + placedAnchoredIds.add(block.id); + continue; + } + + if (block.kind === 'drawing' && measure.kind === 'drawing') { + const contentMeasures = + block.drawingKind === 'textboxShape' && typeof options.remeasureParagraph === 'function' + ? layoutTextboxContent(block, options.remeasureParagraph) + : undefined; + const fragment: DrawingFragment = { + kind: 'drawing', + blockId: block.id, + drawingKind: block.drawingKind, + x: anchorX, + y: anchorY, + width: measure.width, + height: measure.height, + geometry: measure.geometry, + scale: measure.scale, + isAnchored: true, + behindDoc: block.anchor?.behindDoc === true, + zIndex: getFragmentZIndex(block), + drawingContentId: block.drawingContentId, + sourceAnchor: block.sourceAnchor, + }; + if (contentMeasures) { + fragment.contentMeasures = contentMeasures; + } + const attrs = block.attrs as Record | undefined; + if (attrs?.pmStart != null) fragment.pmStart = attrs.pmStart as number; + if (attrs?.pmEnd != null) fragment.pmEnd = attrs.pmEnd as number; + state.page.fragments.push(fragment); + placedAnchoredIds.add(block.id); + } + } + } + + if (allowParagraphlessAnchoredTableFallback && shouldUseBlankPageFallback && paragraphlessAnchoredTables.length > 0) { const state = paginator.ensurePage(); for (const { block: tableBlock, measure: tableMeasure } of paragraphlessAnchoredTables) { @@ -2829,7 +3014,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } - if (allowSectionBreakOnlyPageFallback && pages.length === 0 && hasOnlySectionBreakBlocks(blocks)) { + if (allowSectionBreakOnlyPageFallback && shouldUseBlankPageFallback && hasOnlySectionBreakBlocks(blocks)) { paginator.ensurePage(); } @@ -3046,7 +3231,19 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // (two_column_two_page-arial 2 p17 keeps its 3+2 split). if (isMultiPage && !isLast) continue; - const allowedByMidDocContinuous = endBreakType === 'continuous' && !isLast; + // The per-section type is the type of the break that BEGINS the section + // (its own sectPr `w:type`, Β§17.6.22) β€” i.e. the break that closes the + // PREVIOUS section. The break that ends section N is therefore section + // N+1's begin type. Keying rule 1 off the section's OWN type balanced a + // 2-col section that merely STARTED continuous even when it ended at a + // nextPage break β€” Word only balances when the break AFTER the section + // is continuous (Β§17.18.77 note, SD-3359 V6 repro). The next-is-body + // case is excluded here: a body sectPr defaults to `continuous` when + // `` is omitted and Word does NOT balance then (sd-1655) β€” + // rule 2 below owns that boundary and demands explicitness. + const nextSectionBeginType = sectionEndBreakType.get(sectionIdx + 1); + const nextIsBody = lastSectionIdx !== null && sectionIdx + 1 === lastSectionIdx; + const allowedByMidDocContinuous = !isLast && !nextIsBody && nextSectionBeginType === 'continuous'; // Body-explicit-continuous balances the section IT ENDS, which is the // section immediately preceding the body. No doc-wide flag. const allowedByBodyExplicitContinuous = @@ -3094,6 +3291,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options availableHeight: sectionAvailableHeight, measureMap: balancingMeasureMap, sectPrMarkerBlockIds, + keepLinesBlockIds, }); } @@ -3332,6 +3530,7 @@ export function layoutHeaderFooter( measures: Measure[], constraints: HeaderFooterConstraints, kind?: 'header' | 'footer', + remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure, ): HeaderFooterLayout { if (blocks.length !== measures.length) { throw new Error( @@ -3355,6 +3554,7 @@ export function layoutHeaderFooter( margins: { top: 0, right: 0, bottom: 0, left: 0 }, allowParagraphlessAnchoredTableFallback: false, allowSectionBreakOnlyPageFallback: false, + remeasureParagraph, }); // Post-normalize page-relative anchored fragment Y positions for footers. @@ -3646,5 +3846,6 @@ export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTok // Table utilities consumed by layout-bridge and cross-package sync tests export { getCellLines, getEmbeddedRowLines, resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; +export { layoutTextboxContent } from './layout-textbox.js'; export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js'; diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index 0ba5888187..e86f7d784f 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -140,6 +140,25 @@ describe('layoutDrawingBlock', () => { }); }); + describe('Textbox content measures', () => { + it('should attach textbox content measures to drawing fragments when provided', () => { + const contentMeasures = [{ kind: 'paragraph', lines: [], totalHeight: 24 }]; + const context = { + ...createMockContext({ + drawingKind: 'textboxShape', + }), + textboxContentMeasures: contentMeasures, + }; + + layoutDrawingBlock(context); + + const state = context.ensurePage(); + expect(state.page.fragments).toHaveLength(1); + expect(state.page.fragments[0]?.kind).toBe('drawing'); + expect((state.page.fragments[0] as DrawingFragment).contentMeasures).toEqual(contentMeasures); + }); + }); + describe('Basic inline placement', () => { it('should place drawing at current cursor position with no margins', () => { const context = createMockContext(); diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index 8d3fa42b52..67ffa21cb2 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -1,4 +1,4 @@ -import type { DrawingBlock, DrawingMeasure, DrawingFragment } from '@superdoc/contracts'; +import type { DrawingBlock, DrawingMeasure, DrawingFragment, ParagraphMeasure } from '@superdoc/contracts'; import type { NormalizedColumns } from './layout-image.js'; import type { PageState } from './paginator.js'; import { extractBlockPmRange } from './layout-utils.js'; @@ -24,6 +24,8 @@ export type DrawingLayoutContext = { advanceColumn: (state: PageState) => PageState; /** Computes the X coordinate for a column in the given page state (SD-2629). */ columnX: (state: PageState, columnIndex?: number) => number; + /** Optional textbox paragraph measurements carried alongside textbox drawings. */ + textboxContentMeasures?: ParagraphMeasure[]; }; /** @@ -66,6 +68,7 @@ export function layoutDrawingBlock({ ensurePage, advanceColumn, columnX, + textboxContentMeasures, }: DrawingLayoutContext): void { if (block.anchor?.isAnchored) { return; @@ -139,6 +142,10 @@ export function layoutDrawingBlock({ sourceAnchor: block.sourceAnchor, }; + if (textboxContentMeasures) { + (fragment as DrawingFragment & { contentMeasures?: ParagraphMeasure[] }).contentMeasures = textboxContentMeasures; + } + state.page.fragments.push(fragment); state.cursorY += requiredHeight; state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts index 73eef8d711..dfbeefbbc0 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, mock } from 'bun:test'; -import type { ParagraphBlock, ParagraphMeasure, Line } from '@superdoc/contracts'; +import type { + DrawingFragment, + ParagraphBlock, + ParagraphMeasure, + Line, + TextboxDrawing, + DrawingMeasure, +} from '@superdoc/contracts'; import { layoutParagraphBlock, type ParagraphLayoutContext } from './layout-paragraph.js'; import type { PageState } from './paginator.js'; import type { FloatingObjectManager } from './floating-objects.js'; @@ -1286,6 +1293,82 @@ describe('layoutParagraphBlock - contextualSpacing', () => { }); }); +describe('layoutParagraphBlock - anchored textbox drawings', () => { + it('attaches textbox content measures for anchored textbox fragments', () => { + const pageState = makePageState(); + const ensurePage = mock(() => pageState); + const remeasureParagraph = mock((_block: ParagraphBlock, _maxWidth: number) => ({ + kind: 'paragraph' as const, + lines: [], + totalHeight: 18, + })); + + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'anchor-paragraph', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 12 }], + }; + + const measure = makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 150 }]); + const textboxParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'textbox-paragraph', + runs: [{ text: 'Textbox text', fontFamily: 'Arial', fontSize: 10 }], + pmStart: 21, + pmEnd: 33, + }; + const drawingBlock: TextboxDrawing = { + kind: 'drawing', + id: 'drawing-1', + drawingKind: 'textboxShape', + geometry: { width: 143, height: 45, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [textboxParagraph], + textInsets: { top: 10, right: 10, bottom: 10, left: 10 }, + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + }, + }; + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + width: 143, + height: 45, + geometry: drawingBlock.geometry, + scale: 1, + }; + + const ctx: ParagraphLayoutContext = { + block, + measure, + columnWidth: 150, + ensurePage, + advanceColumn: mock((state) => state), + columnX: mock(() => 50), + floatManager: makeFloatManager(), + remeasureParagraph, + }; + + layoutParagraphBlock(ctx, { + anchoredDrawings: [{ block: drawingBlock, measure: drawingMeasure }], + anchoredTables: [], + columnWidth: 150, + pageWidth: 600, + pageMargins: { top: 50, right: 50, bottom: 50, left: 50 }, + columns: { width: 150, gap: 20, count: 1 }, + placedAnchoredIds: new Set(), + }); + + expect(remeasureParagraph).toHaveBeenCalledWith(textboxParagraph, 123); + expect(pageState.page.fragments).toHaveLength(2); + expect(pageState.page.fragments[0]?.kind).toBe('drawing'); + const fragment = pageState.page.fragments[0] as DrawingFragment; + expect(fragment.contentMeasures).toEqual([{ kind: 'paragraph', lines: [], totalHeight: 18 }]); + }); +}); + describe('layoutParagraphBlock - keepLines', () => { it('advances to next page when keepLines is true and paragraph does not fit', () => { const block: ParagraphBlock = { diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index 1fe9fa6fac..22d98b83ee 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -26,6 +26,7 @@ import { rewindPreviousParagraphTrailing, computeParagraphLayoutStartY, } from './layout-utils.js'; +import { layoutTextboxContent } from './layout-textbox.js'; import { resolveAnchoredGraphicY, resolveAnchoredGraphicX, getFragmentZIndex } from '@superdoc/contracts'; import { createAnchoredTableFragment, isAnchoredTableFullWidth } from './layout-table.js'; import type { AnchoredTable } from './anchors.js'; @@ -595,6 +596,10 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para if (pmRange.pmEnd != null) fragment.pmEnd = pmRange.pmEnd; state.page.fragments.push(fragment); } else if (entry.block.kind === 'drawing' && entry.measure.kind === 'drawing') { + const contentMeasures = + entry.block.drawingKind === 'textboxShape' && typeof remeasureParagraph === 'function' + ? layoutTextboxContent(entry.block, remeasureParagraph) + : undefined; const fragment: DrawingFragment = { kind: 'drawing', blockId: entry.block.id, @@ -611,6 +616,9 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para drawingContentId: entry.block.drawingContentId, sourceAnchor: entry.block.sourceAnchor, }; + if (contentMeasures) { + (fragment as DrawingFragment & { contentMeasures?: ParagraphMeasure[] }).contentMeasures = contentMeasures; + } if (pmRange.pmStart != null) fragment.pmStart = pmRange.pmStart; if (pmRange.pmEnd != null) fragment.pmEnd = pmRange.pmEnd; state.page.fragments.push(fragment); diff --git a/packages/layout-engine/layout-engine/src/layout-textbox.test.ts b/packages/layout-engine/layout-engine/src/layout-textbox.test.ts new file mode 100644 index 0000000000..74c2ec12b9 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/layout-textbox.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'bun:test'; +import type { ParagraphBlock, ParagraphMeasure, TextboxDrawing } from '@superdoc/contracts'; +import { layoutTextboxContent } from './layout-textbox.js'; + +describe('layoutTextboxContent', () => { + it('remeasures textbox paragraphs with width reduced by horizontal insets', () => { + const paragraphA: ParagraphBlock = { kind: 'paragraph', id: 'p1', runs: [] }; + const paragraphB: ParagraphBlock = { kind: 'paragraph', id: 'p2', runs: [] }; + const block: TextboxDrawing = { + kind: 'drawing', + id: 'drawing-1', + drawingKind: 'textboxShape', + geometry: { width: 200, height: 100, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [paragraphA, paragraphB], + textInsets: { top: 4, right: 12, bottom: 4, left: 8 }, + }; + + const calls: Array<{ id: string; maxWidth: number }> = []; + const remeasureParagraph = (paragraph: ParagraphBlock, maxWidth: number): ParagraphMeasure => { + calls.push({ id: paragraph.id, maxWidth }); + return { kind: 'paragraph', lines: [], totalHeight: 10 }; + }; + + const result = layoutTextboxContent(block, remeasureParagraph); + + expect(result).toHaveLength(2); + expect(calls).toEqual([ + { id: 'p1', maxWidth: 180 }, + { id: 'p2', maxWidth: 180 }, + ]); + }); + + it('returns an empty array when textbox has no content blocks', () => { + const block: TextboxDrawing = { + kind: 'drawing', + id: 'drawing-1', + drawingKind: 'textboxShape', + geometry: { width: 200, height: 100, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [], + }; + + expect(layoutTextboxContent(block, () => ({ kind: 'paragraph', lines: [], totalHeight: 10 }))).toEqual([]); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/layout-textbox.ts b/packages/layout-engine/layout-engine/src/layout-textbox.ts new file mode 100644 index 0000000000..7a2c23c9e4 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/layout-textbox.ts @@ -0,0 +1,15 @@ +import type { ParagraphMeasure, TextboxDrawing } from '@superdoc/contracts'; + +export function layoutTextboxContent( + block: TextboxDrawing, + remeasureParagraph: (block: TextboxDrawing['contentBlocks'][number], maxWidth: number) => ParagraphMeasure, +): ParagraphMeasure[] { + if (!Array.isArray(block.contentBlocks) || block.contentBlocks.length === 0) { + return []; + } + + const insets = block.textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const contentWidth = Math.max(1, block.geometry.width - insets.left - insets.right); + + return block.contentBlocks.map((paragraphBlock) => remeasureParagraph(paragraphBlock, contentWidth)); +} diff --git a/packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts b/packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts new file mode 100644 index 0000000000..7d00655a8c --- /dev/null +++ b/packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import type { + ColumnBreakBlock, + DrawingBlock, + DrawingMeasure, + FlowBlock, + ImageBlock, + ImageMeasure, + Line, + Measure, + PageBreakBlock, + ParagraphMeasure, + SectionBreakBlock, + TableBlock, + TableMeasure, +} from '@superdoc/contracts'; +import { layoutDocument, type LayoutOptions } from './index.js'; + +const DEFAULT_OPTIONS: LayoutOptions = { + pageSize: { w: 500, h: 500 }, + margins: { top: 50, right: 50, bottom: 50, left: 50 }, +}; + +const line = (lineHeight: number, width = 100): Line => ({ + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width, + ascent: lineHeight * 0.8, + descent: lineHeight * 0.2, + lineHeight, + maxWidth: width, +}); + +const paragraphMeasure = (heights: number[]): ParagraphMeasure => ({ + kind: 'paragraph', + lines: heights.map((height) => line(height)), + totalHeight: heights.reduce((sum, height) => sum + height, 0), +}); + +const paragraphBlock = (id: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [], +}); + +const tableBlock = (id: string): TableBlock => ({ + kind: 'table', + id, + rows: [ + { + id: `${id}-row-1`, + cells: [ + { + id: `${id}-cell-1`, + paragraph: { + kind: 'paragraph', + id: `${id}-cell-paragraph`, + runs: [], + }, + }, + ], + }, + ], +}); + +const tableMeasure = (width: number, height: number): TableMeasure => ({ + kind: 'table', + columnWidths: [width], + rows: [ + { + height, + cells: [ + { + width, + height, + paragraph: paragraphMeasure([height]), + }, + ], + }, + ], + totalWidth: width, + totalHeight: height, +}); + +describe('Measuring to Layout ownership contracts', () => { + it('consumes paragraph measure lines for fragment line ranges and pagination', () => { + const layout = layoutDocument([paragraphBlock('paragraph-contract')], [paragraphMeasure([120, 120, 120, 120])], { + pageSize: { w: 400, h: 300 }, + margins: { top: 30, right: 30, bottom: 30, left: 30 }, + }); + + expect(layout.pages).toHaveLength(2); + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'para', + blockId: 'paragraph-contract', + fromLine: 0, + toLine: 2, + continuesOnNext: true, + }); + expect(layout.pages[1].fragments[0]).toMatchObject({ + kind: 'para', + blockId: 'paragraph-contract', + fromLine: 2, + toLine: 4, + continuesFromPrev: true, + }); + }); + + it('uses image measures, not block dimensions, for image fragment size', () => { + const block: ImageBlock = { + kind: 'image', + id: 'image-contract', + src: 'image.png', + width: 999, + height: 999, + }; + const measure: ImageMeasure = { kind: 'image', width: 120, height: 80 }; + + const layout = layoutDocument([block], [measure], DEFAULT_OPTIONS); + + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'image', + blockId: 'image-contract', + width: 120, + height: 80, + }); + }); + + it('uses drawing measures for drawing fragment geometry and size', () => { + const block: DrawingBlock = { + kind: 'drawing', + id: 'drawing-contract', + drawingKind: 'vectorShape', + geometry: { width: 999, height: 999 }, + }; + const measure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 140, + height: 70, + scale: 0.5, + naturalWidth: 280, + naturalHeight: 140, + geometry: { width: 280, height: 140 }, + }; + + const layout = layoutDocument([block], [measure], DEFAULT_OPTIONS); + + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'drawing', + blockId: 'drawing-contract', + drawingKind: 'vectorShape', + width: 140, + height: 70, + scale: 0.5, + geometry: { width: 280, height: 140 }, + }); + }); + + it('uses table measures for table fragment dimensions and row range', () => { + const block = tableBlock('table-contract'); + const measure = tableMeasure(180, 40); + + const layout = layoutDocument([block], [measure], DEFAULT_OPTIONS); + + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'table', + blockId: 'table-contract', + fromRow: 0, + toRow: 1, + width: 180, + height: 40, + }); + }); + + it('consumes page and column break measures as layout control flow without fragments', () => { + const pageBreak: PageBreakBlock = { kind: 'pageBreak', id: 'page-break' }; + const columnBreak: ColumnBreakBlock = { kind: 'columnBreak', id: 'column-break' }; + const blocks: FlowBlock[] = [ + paragraphBlock('p1'), + columnBreak, + paragraphBlock('p2'), + pageBreak, + paragraphBlock('p3'), + ]; + const measures: Measure[] = [ + paragraphMeasure([20]), + { kind: 'columnBreak' }, + paragraphMeasure([20]), + { kind: 'pageBreak' }, + paragraphMeasure([20]), + ]; + + const layout = layoutDocument(blocks, measures, { + ...DEFAULT_OPTIONS, + columns: { count: 2, gap: 20 }, + }); + + expect(layout.pages).toHaveLength(2); + expect(layout.pages[0].fragments.map((fragment) => fragment.blockId)).toEqual(['p1', 'p2']); + expect(layout.pages[1].fragments.map((fragment) => fragment.blockId)).toEqual(['p3']); + expect(layout.pages[0].fragments[1].x).toBeGreaterThan(layout.pages[0].fragments[0].x); + }); + + it('consumes section break measures as layout control flow without fragments', () => { + const firstSection: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb-first', + attrs: { isFirstSection: true }, + margins: { top: 50, right: 50, bottom: 50, left: 50 }, + }; + const nextPageSection: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb-next', + type: 'nextPage', + margins: { top: 50, right: 50, bottom: 50, left: 50 }, + }; + const blocks: FlowBlock[] = [firstSection, paragraphBlock('p1'), nextPageSection, paragraphBlock('p2')]; + const measures: Measure[] = [ + { kind: 'sectionBreak' }, + paragraphMeasure([20]), + { kind: 'sectionBreak' }, + paragraphMeasure([20]), + ]; + + const layout = layoutDocument(blocks, measures, DEFAULT_OPTIONS); + + expect(layout.pages.length).toBeGreaterThanOrEqual(2); + const allBlockIds = layout.pages.flatMap((p) => p.fragments.map((f) => f.blockId)); + expect(allBlockIds).toEqual(['p1', 'p2']); + expect(allBlockIds).not.toContain('sb-first'); + expect(allBlockIds).not.toContain('sb-next'); + expect(layout.pages[0].fragments[0]).toMatchObject({ kind: 'para', blockId: 'p1' }); + expect(layout.pages[1].fragments[0]).toMatchObject({ kind: 'para', blockId: 'p2' }); + }); + + it('fails fast for mismatched FlowBlock and Measure kinds', () => { + expect(() => + layoutDocument([paragraphBlock('paragraph-contract')], [{ kind: 'pageBreak' }], DEFAULT_OPTIONS), + ).toThrow(/expected paragraph measure/); + }); + + // Today layoutDocument throws for ListBlock; when list layout lands, implement real + // assertions (e.g. list-item fragments, marker metrics) and drop this todo. + it.todo('consumes ListBlock + ListMeasure in layoutDocument (list-item fragments, marker widths, pagination)'); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 9f4d8e2bf6..d5b9e5b9ff 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TabRun, TextRun } from '@superdoc/contracts'; +import type { + FlowBlock, + ImageBlock, + ImageRun, + ParagraphBlock, + SourceAnchor, + TableBlock, + TabRun, + TextRun, +} from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -188,6 +197,13 @@ describe('deriveBlockVersion - table image content', () => { expect(filtered).not.toBe(plain); }); + it('changes when a table image fixed alpha changes', () => { + const plain = deriveBlockVersion(makeTableWithImage(baseImage)); + const transparent = deriveBlockVersion(makeTableWithImage({ ...baseImage, alphaModFix: { amt: 9000 } })); + + expect(transparent).not.toBe(plain); + }); + it('changes when a table image hyperlink changes', () => { const unlinked = deriveBlockVersion(makeTableWithImage(baseImage)); const linked = deriveBlockVersion( @@ -218,6 +234,43 @@ describe('deriveBlockVersion - table image content', () => { }); }); +describe('deriveBlockVersion - textboxShape content', () => { + const makeTextboxParagraph = (text: string): ParagraphBlock => ({ + kind: 'paragraph', + id: 'textbox-para-1', + runs: [{ text, fontFamily: 'Arial', fontSize: 16, pmStart: 10, pmEnd: 10 + text.length }], + }); + + const makeTextbox = (text: string): FlowBlock => ({ + kind: 'drawing', + id: 'textbox-1', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 40, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect', + contentBlocks: [makeTextboxParagraph(text)], + textContent: { + parts: [{ text, fontFamily: 'Arial', fontSize: 16 }], + }, + textInsets: { top: 4, right: 6, bottom: 4, left: 6 }, + textVerticalAlign: 'top', + }); + + it('produces a different version when textbox text changes', () => { + const first = deriveBlockVersion(makeTextbox('Alpha')); + const second = deriveBlockVersion(makeTextbox('Beta')); + expect(second).not.toBe(first); + }); + + it('produces a different version when textbox insets change', () => { + const first = deriveBlockVersion(makeTextbox('Alpha')); + const second = deriveBlockVersion({ + ...makeTextbox('Alpha'), + textInsets: { top: 8, right: 6, bottom: 4, left: 6 }, + }); + expect(second).not.toBe(first); + }); +}); + describe('deriveBlockVersion - inline image runs', () => { const baseImageRun: ImageRun = { kind: 'image', @@ -257,6 +310,13 @@ describe('deriveBlockVersion - inline image runs', () => { expect(filtered).not.toBe(plain); }); + it('changes when an inline image fixed alpha changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const transparent = deriveBlockVersion(makeParagraphWithImageRun({ ...baseImageRun, alphaModFix: { amt: 9000 } })); + + expect(transparent).not.toBe(plain); + }); + it('changes when an inline image transform changes', () => { const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); const transformed = deriveBlockVersion(makeParagraphWithImageRun({ ...baseImageRun, rotation: 45, flipH: true })); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 5e3f2ee76c..7262a52762 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -18,6 +18,7 @@ import { type TableAttrs, type TableBlock, type TableCellAttrs, + type TextboxDrawing, type TrackedChangeMeta, type TextRun, type VectorShapeDrawing, @@ -119,6 +120,26 @@ const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => { return [lum.bright ?? '', lum.contrast ?? ''].join(':'); }; +const drawingTextVersion = (block: VectorShapeDrawing | TextboxDrawing): string => { + const textboxContentBlocks = + 'contentBlocks' in block && Array.isArray(block.contentBlocks) + ? block.contentBlocks.map((contentBlock: ParagraphBlock) => deriveBlockVersion(contentBlock)).join(';') + : ''; + + return JSON.stringify([ + block.textAlign ?? '', + block.textVerticalAlign ?? '', + block.textInsets ?? null, + block.textContent ?? null, + textboxContentBlocks, + ]); +}; + +const imageAlphaModFixVersion = (alphaModFix: ImageBlock['alphaModFix'] | undefined): string => { + if (!alphaModFix) return ''; + return String(alphaModFix.amt ?? ''); +}; + const renderedBlockImageVersion = (image: ImageBlock | ImageDrawing): string => [ image.src ?? '', @@ -132,6 +153,7 @@ const renderedBlockImageVersion = (image: ImageBlock | ImageDrawing): string => image.blacklevel ?? '', image.grayscale ? 1 : 0, imageLuminanceVersion(image.lum), + imageAlphaModFixVersion(image.alphaModFix), image.rotation ?? '', image.flipH ? 1 : 0, image.flipV ? 1 : 0, @@ -157,6 +179,7 @@ const renderedInlineImageRunVersion = (image: ImageRun): string => image.blacklevel ?? '', image.grayscale ? 1 : 0, imageLuminanceVersion(image.lum), + imageAlphaModFixVersion(image.alphaModFix), image.rotation ?? '', image.flipH ? 1 : 0, image.flipV ? 1 : 0, @@ -434,10 +457,10 @@ export const deriveBlockVersion = (block: FlowBlock): string => { const imageLike = block as ImageDrawing; return ['drawing:image', renderedBlockImageVersion(imageLike)].join('|'); } - if (block.drawingKind === 'vectorShape') { + if (block.drawingKind === 'vectorShape' || block.drawingKind === 'textboxShape') { const vector = block as VectorShapeDrawing; return [ - 'drawing:vector', + block.drawingKind === 'textboxShape' ? 'drawing:textbox' : 'drawing:vector', vector.shapeKind ?? '', vector.fillColor ?? '', vector.strokeColor ?? '', @@ -447,6 +470,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { vector.geometry.rotation ?? 0, vector.geometry.flipH ? 1 : 0, vector.geometry.flipV ? 1 : 0, + drawingTextVersion(vector), ].join('|'); } if (block.drawingKind === 'shapeGroup') { diff --git a/packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts b/packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts new file mode 100644 index 0000000000..90238635ea --- /dev/null +++ b/packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { measureBlock } from './index.js'; +import type { DrawingBlock, FlowBlock, ListBlock, Measure, TableBlock } from '@superdoc/contracts'; + +const textRun = (text: string, fontSize = 16) => ({ + kind: 'text' as const, + text, + fontFamily: 'Arial', + fontSize, +}); + +const expectMeasureKind = ( + measure: Measure, + kind: TKind, +): Extract => { + expect(measure.kind).toBe(kind); + return measure as Extract; +}; + +describe('Measuring to Layout contract', () => { + it('produces paragraph line geometry and total height for layout', async () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'paragraph-contract', + runs: [textRun('SuperDoc wraps this paragraph into measured lines.')], + }; + + const measure = expectMeasureKind(await measureBlock(block, 120), 'paragraph'); + + expect(measure.lines.length).toBeGreaterThan(0); + expect(measure.totalHeight).toBe(measure.lines.reduce((sum, line) => sum + line.lineHeight, 0)); + for (const line of measure.lines) { + expect(line.width).toBeGreaterThanOrEqual(0); + expect(line.lineHeight).toBeGreaterThan(0); + expect(line.maxWidth).toBeGreaterThan(0); + } + }); + + it('produces list item marker metrics and nested paragraph measures', async () => { + const block: ListBlock = { + kind: 'list', + id: 'list-contract', + listType: 'number', + items: [ + { + id: 'item-1', + marker: { kind: 'number', text: '1.', level: 0, order: 1 }, + paragraph: { + kind: 'paragraph', + id: 'item-1-paragraph', + runs: [textRun('A list item paragraph measured under the item content width.')], + attrs: { indent: { left: 24, hanging: 18 } }, + }, + }, + ], + }; + + const measure = expectMeasureKind(await measureBlock(block, 220), 'list'); + + expect(measure.items).toHaveLength(1); + expect(measure.items[0]).toMatchObject({ + itemId: 'item-1', + indentLeft: 24, + paragraph: { kind: 'paragraph' }, + }); + expect(measure.items[0].markerTextWidth).toBeGreaterThan(0); + expect(measure.items[0].markerWidth).toBeGreaterThanOrEqual(measure.items[0].markerTextWidth); + expect(measure.totalHeight).toBe(measure.items[0].paragraph.totalHeight); + }); + + it('produces table row, cell, column, and nested content measures', async () => { + const block: TableBlock = { + kind: 'table', + id: 'table-contract', + columnWidths: [120], + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [ + { + kind: 'paragraph', + id: 'cell-paragraph', + runs: [textRun('Nested cell paragraph')], + }, + { + kind: 'image', + id: 'cell-image', + src: 'image.png', + width: 40, + height: 20, + }, + ], + }, + ], + }, + ], + }; + + const measure = expectMeasureKind(await measureBlock(block, 240), 'table'); + + expect(measure.columnWidths).toHaveLength(1); + expect(measure.totalWidth).toBeGreaterThan(0); + expect(measure.totalHeight).toBeGreaterThan(0); + expect(measure.rows).toHaveLength(1); + expect(measure.rows[0].cells).toHaveLength(1); + expect(measure.rows[0].cells[0].blocks?.map((nested) => nested.kind)).toEqual(['paragraph', 'image']); + }); + + it('produces final image dimensions after measurement constraints', async () => { + const block: FlowBlock = { + kind: 'image', + id: 'image-contract', + src: 'image.png', + width: 400, + height: 200, + }; + + const measure = expectMeasureKind(await measureBlock(block, { maxWidth: 100, maxHeight: 80 }), 'image'); + + expect(measure.width).toBe(100); + expect(measure.height).toBe(50); + }); + + it('produces drawing geometry, scale, and natural size for layout', async () => { + const block: DrawingBlock = { + kind: 'drawing', + id: 'drawing-contract', + drawingKind: 'vectorShape', + geometry: { width: 200, height: 100 }, + }; + + const measure = expectMeasureKind(await measureBlock(block, { maxWidth: 100, maxHeight: 100 }), 'drawing'); + + expect(measure).toMatchObject({ + drawingKind: 'vectorShape', + width: 100, + height: 50, + scale: 0.5, + naturalWidth: 200, + naturalHeight: 100, + geometry: { width: 200, height: 100 }, + }); + }); + + it('produces zero-dimensional control measures for break blocks', async () => { + await expect(measureBlock({ kind: 'sectionBreak', id: 'section-break', margins: {} }, 500)).resolves.toEqual({ + kind: 'sectionBreak', + }); + await expect(measureBlock({ kind: 'pageBreak', id: 'page-break' }, 500)).resolves.toEqual({ kind: 'pageBreak' }); + await expect(measureBlock({ kind: 'columnBreak', id: 'column-break' }, 500)).resolves.toEqual({ + kind: 'columnBreak', + }); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/images/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts index 70f72c7429..3b48085f4d 100644 --- a/packages/layout-engine/painters/dom/src/images/drawing-image.ts +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -1,13 +1,8 @@ -import type { - DrawingBlock, - ImageDrawing, - PositionedDrawingGeometry, - ShapeGroupChild, - TextPart, -} from '@superdoc/contracts'; +import type { DrawingBlock, ImageDrawing, ShapeGroupChild, ShapeGroupImageChild, TextPart } from '@superdoc/contracts'; import { applyImageClipPath } from './image-clip-path.js'; import { createBlockImageContent } from './image-block.js'; import type { BuildImageHyperlinkAnchor } from './types.js'; +import { resolveImageOpacity } from '../runs/image-run.js'; export const createDrawingImageElement = ( doc: Document, @@ -25,17 +20,17 @@ export const createDrawingImageElement = ( }; export const createShapeGroupImageElement = (doc: Document, child: ShapeGroupChild): HTMLElement => { - const attrs = child.attrs as PositionedDrawingGeometry & { - src: string; - alt?: string; - clipPath?: string; - }; + const attrs = (child as ShapeGroupImageChild).attrs; const img = doc.createElement('img'); img.src = attrs.src; img.alt = attrs.alt ?? ''; img.style.objectFit = 'contain'; img.style.display = 'block'; applyImageClipPath(img, attrs.clipPath); + const opacity = resolveImageOpacity(attrs); + if (opacity != null) { + img.style.opacity = opacity; + } return img; }; diff --git a/packages/layout-engine/painters/dom/src/images/image-block.test.ts b/packages/layout-engine/painters/dom/src/images/image-block.test.ts index 30f63e7da7..259ac84e90 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.test.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { DrawingBlock } from '@superdoc/contracts'; -import { createDrawingImageElement } from './drawing-image.js'; +import type { DrawingBlock, ShapeGroupImageChild } from '@superdoc/contracts'; +import { createDrawingImageElement, createShapeGroupImageElement } from './drawing-image.js'; import { buildImageHyperlinkAnchor } from './hyperlink.js'; import { resolveBlockImageClipPath } from './image-block.js'; @@ -35,6 +35,7 @@ describe('createDrawingImageElement', () => { src: 'data:image/png;base64,AAA', grayscale: true, gain: 2, + alphaModFix: { amt: 9000 }, } as DrawingBlock; const imgEl = createDrawingImageElement(doc, drawing, (imageEl) => imageEl) as HTMLImageElement; @@ -42,6 +43,7 @@ describe('createDrawingImageElement', () => { expect(imgEl.style.display).toBe('block'); expect(imgEl.style.filter).toContain('grayscale(100%)'); expect(imgEl.style.filter).toContain('contrast(2)'); + expect(imgEl.style.opacity).toBe('0.09'); }); it('wraps drawing images with unified hyperlink anchors', () => { @@ -65,3 +67,28 @@ describe('createDrawingImageElement', () => { expect(anchor.querySelector('img.superdoc-drawing-image')).toBeTruthy(); }); }); + +describe('createShapeGroupImageElement', () => { + const createDoc = (): Document => document.implementation.createHTMLDocument('shape-group-image'); + + it('applies DrawingML fixed alpha to grouped images', () => { + const doc = createDoc(); + const child: ShapeGroupImageChild = { + shapeType: 'image', + attrs: { + x: 0, + y: 0, + width: 120, + height: 80, + src: 'data:image/png;base64,AAA', + alphaModFix: { amt: 9000 }, + }, + }; + + const imgEl = createShapeGroupImageElement(doc, child) as HTMLImageElement; + + expect(imgEl.src).toBe('data:image/png;base64,AAA'); + expect(imgEl.style.display).toBe('block'); + expect(imgEl.style.opacity).toBe('0.09'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts index 0541294831..84cb3784a7 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -1,5 +1,5 @@ import type { ImageBlock, ImageDrawing } from '@superdoc/contracts'; -import { buildImageFilters } from '../runs/image-run.js'; +import { buildImageFilters, resolveImageOpacity } from '../runs/image-run.js'; import { applyImageClipPath, readImageClipPathValue } from './image-clip-path.js'; import type { BuildImageHyperlinkAnchor } from './types.js'; @@ -57,6 +57,10 @@ export const createBlockImageContent = ({ if (filters.length > 0) { img.style.filter = filters.join(' '); } + const opacity = resolveImageOpacity(block); + if (opacity != null) { + img.style.opacity = opacity; + } return buildImageHyperlinkAnchor?.(img, block.hyperlink, hyperlinkDisplay) ?? img; }; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index bbc6207eb3..00a0ce9ec7 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -3703,6 +3703,93 @@ describe('DomPainter', () => { expect(wrapperAfter?.style.fontSize).toBe('10px'); }); + it('keeps segment-positioned inline SDT text aligned with adjacent plain runs', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'segment-positioned-inline-sdt-baseline', + runs: [ + { text: 'KvK', fontFamily: 'Arial, sans-serif', fontSize: 16, pmStart: 0, pmEnd: 3 }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16 }, + { + text: 'KvK_number', + fontFamily: 'Arial, sans-serif', + fontSize: 16, + pmStart: 4, + pmEnd: 14, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'header-value-sdt', + tag: 'inline_text_sdt', + alias: 'Header value', + lockMode: 'unlocked', + }, + }, + ], + attrs: {}, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 2, + toChar: 10, + width: 140, + maxWidth: 300, + ascent: 12, + descent: 4, + lineHeight: 20, + segments: [ + { runIndex: 0, fromChar: 0, toChar: 3, width: 28 }, + { runIndex: 2, fromChar: 0, toChar: 10, width: 92, x: 80 }, + ], + }, + ], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'segment-positioned-inline-sdt-baseline', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 0, + pmEnd: 14, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const wrapper = mount.querySelector( + '.superdoc-structured-content-inline[data-sdt-id="header-value-sdt"]', + ) as HTMLElement | null; + expect(wrapper).toBeTruthy(); + expect(wrapper?.style.padding).toBe('0px'); + expect(wrapper?.style.borderWidth).toBe('0px'); + expect(wrapper?.style.lineHeight).toBe('20px'); + + const value = wrapper?.querySelector('[data-pm-start="4"]') as HTMLElement | null; + expect(value).toBeTruthy(); + expect(value?.style.left).toBe('0px'); + expect(value?.style.top).toBe('0px'); + }); + it('uses first run font-size for inline SDT wrapper when a field has mixed run sizes', () => { const mixedSizeBlock: FlowBlock = { kind: 'paragraph', @@ -6263,7 +6350,7 @@ describe('DomPainter', () => { expect(behindDocEl).toBeTruthy(); expect(behindDocEl.style.top).toBe('40px'); - expect(behindDocEl.style.left).toBe('42px'); + expect(behindDocEl.style.left).toBe('12px'); }); it('renders footer page-relative media using normalized band-local coordinates', () => { @@ -6868,6 +6955,261 @@ describe('DomPainter', () => { expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page 2'); }); + it('renders textboxShape contentMeasures as line-based DOM with pm datasets', () => { + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'drawing-textbox-lines', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect', + fillColor: '#ffffff', + strokeColor: '#000000', + strokeWidth: 1, + textInsets: { top: 4, right: 8, bottom: 4, left: 12 }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'textbox-paragraph-lines', + runs: [{ text: 'Textbox line', fontFamily: 'Arial', fontSize: 16, pmStart: 10, pmEnd: 22 }], + }, + ], + textContent: { + parts: [{ text: 'Fallback textbox line' }], + }, + }; + + const textboxMeasure: Measure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 80, + scale: 1, + naturalWidth: 120, + naturalHeight: 80, + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + }; + + const textboxLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'drawing', + drawingKind: 'textboxShape', + blockId: 'drawing-textbox-lines', + x: 30, + y: 40, + width: 120, + height: 80, + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + scale: 1, + contentMeasures: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 12, + width: 80, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [textboxBlock], measures: [textboxMeasure] }); + painter.paint(textboxLayout, mount); + + const shapeEl = mount.querySelector('.superdoc-vector-shape') as HTMLElement | null; + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement | null; + + expect(shapeEl).toBeTruthy(); + expect(lineEl).toBeTruthy(); + expect(lineEl?.dataset.pmStart).toBe('10'); + expect(lineEl?.dataset.pmEnd).toBe('22'); + expect(shapeEl?.textContent).toContain('Textbox line'); + expect(shapeEl?.textContent).not.toContain('Fallback textbox line'); + }); + + it('renders textboxShape fallback text inside table cells', () => { + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'table-textbox-drawing', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect', + fillColor: '#ffffff', + strokeColor: '#000000', + strokeWidth: 1, + textInsets: { top: 4, right: 8, bottom: 4, left: 12 }, + textContent: { + parts: [{ text: 'Contract ACC' }], + }, + contentBlocks: [], + }; + + const tableBlock: TableBlock = { + kind: 'table', + id: 'table-with-textbox', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0', + blocks: [textboxBlock], + attrs: {}, + }, + ], + }, + ], + }; + + const textboxMeasure: Measure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 80, + scale: 1, + naturalWidth: 120, + naturalHeight: 80, + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + }; + + const tableMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 80, + cells: [ + { + width: 140, + height: 80, + gridColumnStart: 0, + blocks: [textboxMeasure], + }, + ], + }, + ], + columnWidths: [140], + totalWidth: 140, + totalHeight: 80, + }; + + const tableLayout: Layout = { + pageSize: { w: 300, h: 300 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'table', + blockId: 'table-with-textbox', + x: 0, + y: 0, + width: 140, + height: 80, + fromRow: 0, + toRow: 1, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [tableBlock], measures: [tableMeasure] }); + painter.paint(tableLayout, mount); + + const shapeEl = mount.querySelector('.superdoc-vector-shape') as HTMLElement | null; + expect(shapeEl).toBeTruthy(); + expect(shapeEl?.textContent).toContain('Contract ACC'); + }); + + it('renders textboxShape with contentMeasures as superdoc-line DOM inside table cell', () => { + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'table-cell-textbox-lines', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect', + textInsets: { top: 4, right: 8, bottom: 4, left: 12 }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'cell-textbox-para', + runs: [{ text: 'Cell text', fontFamily: 'Arial', fontSize: 14, pmStart: 5, pmEnd: 14 }], + }, + ], + contentMeasures: [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 9, width: 80, ascent: 12, descent: 4, lineHeight: 18 }], + totalHeight: 18, + }, + ], + }; + + const tableBlock: TableBlock = { + kind: 'table', + id: 'table-cell-textbox', + rows: [{ id: 'row-0', cells: [{ id: 'cell-0', blocks: [textboxBlock], attrs: {} }] }], + }; + + const textboxMeasure: Measure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 80, + scale: 1, + naturalWidth: 120, + naturalHeight: 80, + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + }; + const tableMeasure: TableMeasure = { + kind: 'table', + rows: [{ height: 80, cells: [{ width: 140, height: 80, gridColumnStart: 0, blocks: [textboxMeasure] }] }], + columnWidths: [140], + totalWidth: 140, + totalHeight: 80, + }; + + const tableLayout: Layout = { + pageSize: { w: 300, h: 300 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'table', blockId: 'table-cell-textbox', x: 0, y: 0, width: 140, height: 80, fromRow: 0, toRow: 1 }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [tableBlock], measures: [tableMeasure] }); + painter.paint(tableLayout, mount); + + const wrapper = mount.querySelector('[data-block-id="table-cell-textbox-lines"]') as HTMLElement | null; + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement | null; + + expect(wrapper).toBeTruthy(); + expect(lineEl).toBeTruthy(); + expect(lineEl?.dataset.pmStart).toBe('5'); + expect(lineEl?.dataset.pmEnd).toBe('14'); + expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Cell text'); + }); + it('renders formatted PAGE fields in drawing text', () => { const vectorShapeBlock: FlowBlock = { kind: 'drawing', @@ -7347,7 +7689,8 @@ describe('DomPainter', () => { const dropCapEl = mount.querySelector('.superdoc-drop-cap') as HTMLElement; expect(dropCapEl.textContent).toBe('H'); - expect(dropCapEl.style.fontFamily).toBe('Georgia'); + // Drop caps paint the physical render family: Georgia's bundled substitute is Gelasio. + expect(dropCapEl.style.fontFamily).toBe('Gelasio'); expect(dropCapEl.style.fontSize).toBe('72px'); expect(dropCapEl.style.fontWeight).toBe('bold'); expect(dropCapEl.style.width).toBe('50px'); @@ -8604,6 +8947,20 @@ describe('DomPainter', () => { expect((img as HTMLElement).style.filter).toContain('brightness(0)'); }); + it('renders DrawingML fixed alpha as image opacity', () => { + renderInlineImageRun({ + kind: 'image', + src: inlineImageSrc, + width: 100, + height: 100, + alphaModFix: { amt: 9000 }, + }); + + const img = mount.querySelector('img'); + expect(img).toBeTruthy(); + expect((img as HTMLElement).style.opacity).toBe('0.09'); + }); + it('renders VML gain and blacklevel using fixed-fraction units', () => { const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; @@ -13360,6 +13717,81 @@ describe('applyRunDataAttributes', () => { expect(fragment.dataset.pmEnd).toBe('10'); }); + it('renders a trailing lineBreak continuation as a bare PM-ranged line', () => { + const lineBreakBlock: FlowBlock = { + kind: 'paragraph', + id: 'linebreak-continuation', + runs: [ + { text: 'Text', fontFamily: 'Arial', fontSize: 16, pmStart: 5, pmEnd: 9 }, + { kind: 'lineBreak', pmStart: 9, pmEnd: 10 }, + ], + }; + + const lineBreakMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 4, + width: 40, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 1, + fromChar: 0, + toRun: 1, + toChar: 0, + width: 0, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const lineBreakLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'linebreak-continuation', + fromLine: 0, + toLine: 2, + x: 20, + y: 20, + width: 300, + pmStart: 5, + pmEnd: 10, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [lineBreakBlock], + measures: [lineBreakMeasure], + }); + + painter.paint(lineBreakLayout, mount); + + const lines = mount.querySelectorAll('.superdoc-line'); + expect(lines).toHaveLength(2); + expect(lines[1].dataset.pmStart).toBe('9'); + expect(lines[1].dataset.pmEnd).toBe('10'); + expect(lines[1].children).toHaveLength(0); + expect(lines[1].querySelector('.superdoc-empty-run')).toBeNull(); + expect(lines[1].textContent).toBe(''); + }); + it('handles multiple consecutive lineBreak runs', () => { const multiLineBreakBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts index 8684ce3a0a..aedc328567 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -11,6 +11,7 @@ import { type ResolvedListMarkerGeometry, } from '@superdoc/common/list-marker-utils'; import { applySourceAnchorDataset } from '../utils/source-anchor.js'; +import { allowFontSynthesis } from '../runs/font-synthesis.js'; type PainterListTextStartParams = { wordLayout: MinimalWordLayout | undefined; @@ -113,6 +114,7 @@ export const createListMarkerElement = ( if (run.fontSize != null) { markerEl.style.fontSize = `${run.fontSize}px`; } + allowFontSynthesis(markerEl); markerEl.style.fontWeight = run.bold ? 'bold' : ''; markerEl.style.fontStyle = run.italic ? 'italic' : ''; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts index 27dd466e3f..2a4b99b983 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -9,8 +9,10 @@ import type { import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import { resolvePhysicalFamily, type ResolvePhysicalFamily } from '@superdoc/font-system'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; +import { allowFontSynthesis } from '../runs/font-synthesis.js'; import type { BetweenBorderInfo } from './borders/index.js'; import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; @@ -101,7 +103,11 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): } if (isTocEntry) { - fragmentEl.classList.add('superdoc-toc-entry'); + fragmentEl.classList.add(DOM_CLASS_NAMES.TOC_ENTRY); + const tocId = block.attrs?.tocId; + if (typeof tocId === 'string' && tocId.length > 0) { + fragmentEl.dataset.tocId = tocId; + } } if (paraContinuesFromPrev) { @@ -174,6 +180,7 @@ const renderDropCap = ( style: run.italic ? 'italic' : 'normal', }); dropCapEl.style.fontSize = `${run.fontSize}px`; + allowFontSynthesis(dropCapEl); if (run.bold) { dropCapEl.style.fontWeight = 'bold'; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index e5ef3ea5ca..5409c760c5 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -29,6 +29,7 @@ import type { TableBlock, TableFragment, TableMeasure, + TextboxDrawing, VectorShapeDrawing, VectorShapeStyle, ResolvedLayout, @@ -134,7 +135,7 @@ type EffectExtent = { bottom: number; }; -type VectorShapeDrawingWithEffects = VectorShapeDrawing & { +type ShapeTextDrawingWithEffects = (VectorShapeDrawing | TextboxDrawing) & { lineEnds?: LineEnds; effectExtent?: EffectExtent; }; @@ -336,7 +337,7 @@ function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean { } } else if (block.kind === 'drawing') { const drawing = block as DrawingBlock; - if (drawing.drawingKind === 'vectorShape') { + if (drawing.drawingKind === 'vectorShape' || drawing.drawingKind === 'textboxShape') { return hasPageContextTokenInShapeText(drawing.textContent); } if (drawing.drawingKind === 'shapeGroup') { @@ -2248,7 +2249,7 @@ export class DomPainter { } fragEl.style.top = `${pageY}px`; - fragEl.style.left = `${marginLeft + fragment.x}px`; + fragEl.style.left = `${isPageRelative ? fragment.x : marginLeft + fragment.x}px`; fragEl.style.zIndex = '0'; // Same level as page, but inserted first so renders behind fragEl.dataset.behindDocSection = kind; // Track for cleanup on re-render // Insert at beginning of page so it renders behind body content due to DOM order @@ -2869,8 +2870,8 @@ export class DomPainter { if (block.drawingKind === 'image') { return createDrawingImageElement(this.doc, block, this.buildImageHyperlinkAnchor.bind(this)); } - if (block.drawingKind === 'vectorShape') { - return this.createVectorShapeElement(block, fragment.geometry, false, 1, 1, context); + if (block.drawingKind === 'vectorShape' || block.drawingKind === 'textboxShape') { + return this.createVectorShapeElement(block, fragment.geometry, false, 1, 1, context, fragment); } if (block.drawingKind === 'shapeGroup') { return this.createShapeGroupElement(block, context); @@ -2882,15 +2883,19 @@ export class DomPainter { } private createVectorShapeElement( - block: VectorShapeDrawingWithEffects, + block: ShapeTextDrawingWithEffects, geometry?: DrawingGeometry, applyTransforms = false, groupScaleX = 1, groupScaleY = 1, context?: FragmentRenderContext, + fragment?: DrawingFragment, ): HTMLElement { const container = this.doc!.createElement('div'); container.classList.add('superdoc-vector-shape'); + if (block.drawingKind === 'textboxShape') { + container.classList.add('superdoc-textbox-shape'); + } container.style.width = '100%'; container.style.height = '100%'; container.style.position = 'relative'; @@ -2933,15 +2938,11 @@ export class DomPainter { this.applyLineEnds(svgElement, block); contentContainer.appendChild(svgElement); - if (this.hasShapeTextContent(block.textContent)) { - const textElement = this.createShapeTextElement( - block, - innerWidth, - innerHeight, - groupScaleX, - groupScaleY, - context, - ); + if (block.drawingKind === 'textboxShape' || this.hasShapeTextContent(block.textContent)) { + const textElement = + block.drawingKind === 'textboxShape' + ? this.createTextboxContentElement(block, fragment, innerWidth, innerHeight, context) + : this.createShapeTextElement(block, innerWidth, innerHeight, groupScaleX, groupScaleY, context); contentContainer.appendChild(textElement); } @@ -2953,15 +2954,11 @@ export class DomPainter { // Fallback rendering when no preset shape SVG is available this.applyFallbackShapeStyle(contentContainer, block); - if (this.hasShapeTextContent(block.textContent)) { - const textElement = this.createShapeTextElement( - block, - innerWidth, - innerHeight, - groupScaleX, - groupScaleY, - context, - ); + if (block.drawingKind === 'textboxShape' || this.hasShapeTextContent(block.textContent)) { + const textElement = + block.drawingKind === 'textboxShape' + ? this.createTextboxContentElement(block, fragment, innerWidth, innerHeight, context) + : this.createShapeTextElement(block, innerWidth, innerHeight, groupScaleX, groupScaleY, context); contentContainer.appendChild(textElement); } @@ -2972,7 +2969,7 @@ export class DomPainter { /** * Apply fill and stroke styles to a fallback shape container */ - private applyFallbackShapeStyle(container: HTMLElement, block: VectorShapeDrawing): void { + private applyFallbackShapeStyle(container: HTMLElement, block: ShapeTextDrawingWithEffects): void { // Handle fill color if (block.fillColor === null) { container.style.background = 'none'; @@ -3009,7 +3006,7 @@ export class DomPainter { } private createShapeTextElement( - block: VectorShapeDrawing, + block: VectorShapeDrawing | TextboxDrawing, width: number, height: number, groupScaleX = 1, @@ -3043,7 +3040,60 @@ export class DomPainter { ); } - private shouldUseWordArtTextRenderer(block: VectorShapeDrawing): boolean { + private createTextboxContentElement( + block: TextboxDrawing, + fragment: DrawingFragment | undefined, + width: number, + height: number, + context?: FragmentRenderContext, + ): Element { + const contentMeasures = fragment?.contentMeasures ?? block.contentMeasures; + if (!Array.isArray(contentMeasures) || contentMeasures.length === 0) { + return this.hasShapeTextContent(block.textContent) + ? this.createShapeTextElement(block, width, height, 1, 1, context) + : this.doc!.createElement('div'); + } + + const contentRoot = this.doc!.createElement('div'); + contentRoot.style.position = 'absolute'; + contentRoot.style.top = '0'; + contentRoot.style.left = '0'; + contentRoot.style.width = '100%'; + contentRoot.style.height = '100%'; + contentRoot.style.display = 'flex'; + contentRoot.style.flexDirection = 'column'; + contentRoot.style.boxSizing = 'border-box'; + contentRoot.style.overflow = 'hidden'; + + const insets = block.textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + contentRoot.style.padding = `${insets.top}px ${insets.right}px ${insets.bottom}px ${insets.left}px`; + + const verticalAlign = block.textVerticalAlign ?? 'top'; + contentRoot.style.justifyContent = + verticalAlign === 'bottom' ? 'flex-end' : verticalAlign === 'center' ? 'center' : 'flex-start'; + + const linesHost = this.doc!.createElement('div'); + linesHost.style.display = 'flex'; + linesHost.style.flexDirection = 'column'; + linesHost.style.minWidth = '0'; + linesHost.style.width = '100%'; + + const renderContext = context ?? this.defaultFragmentRenderContext(); + const availableWidth = Math.max(1, width - insets.left - insets.right); + + block.contentBlocks.forEach((paragraphBlock, paragraphIndex) => { + const measure = contentMeasures[paragraphIndex]; + if (!measure?.lines) return; + measure.lines.forEach((line, lineIndex) => { + linesHost.appendChild(this.renderLine(paragraphBlock, line, renderContext, availableWidth, lineIndex)); + }); + }); + + contentRoot.appendChild(linesHost); + return contentRoot; + } + + private shouldUseWordArtTextRenderer(block: VectorShapeDrawing | TextboxDrawing): boolean { return block.attrs?.isWordArt === true && this.hasShapeTextContent(block.textContent); } @@ -3334,7 +3384,7 @@ export class DomPainter { } private tryCreatePresetSvg( - block: VectorShapeDrawing, + block: ShapeTextDrawingWithEffects, widthOverride?: number, heightOverride?: number, ): string | null { @@ -3385,7 +3435,7 @@ export class DomPainter { * Each path in the custom geometry has its own coordinate space (w Γ— h) which is * mapped to the shape's actual dimensions via the SVG viewBox. */ - private tryCreateCustomGeometrySvg(block: VectorShapeDrawing, width: number, height: number): string | null { + private tryCreateCustomGeometrySvg(block: ShapeTextDrawingWithEffects, width: number, height: number): string | null { const custGeom = block.customGeometry; if (!custGeom?.paths?.length) return null; @@ -3476,7 +3526,7 @@ export class DomPainter { } private getEffectExtentMetrics( - block: VectorShapeDrawingWithEffects, + block: ShapeTextDrawingWithEffects, geometry?: DrawingGeometry, ): { offsetX: number; @@ -3496,7 +3546,7 @@ export class DomPainter { return { offsetX: left, offsetY: top, innerWidth, innerHeight }; } - private applyLineEnds(svgElement: SVGElement, block: VectorShapeDrawingWithEffects): void { + private applyLineEnds(svgElement: SVGElement, block: ShapeTextDrawingWithEffects): void { const lineEnds = block.lineEnds; if (!lineEnds) return; if (block.strokeColor === null) return; @@ -3736,7 +3786,7 @@ export class DomPainter { flipH: attrs.flipH ?? false, flipV: attrs.flipV ?? false, }; - const vectorChild: VectorShapeDrawingWithEffects = { + const vectorChild: ShapeTextDrawingWithEffects = { drawingKind: 'vectorShape', kind: 'drawing', id: `${attrs.shapeId ?? child.shapeType}`, @@ -3872,7 +3922,7 @@ export class DomPainter { if (block.drawingKind === 'shapeGroup') { return this.createShapeGroupElement(block, context); } - if (block.drawingKind === 'vectorShape') { + if (block.drawingKind === 'vectorShape' || block.drawingKind === 'textboxShape') { return this.createVectorShapeElement(block, block.geometry, false, 1, 1, context); } if (block.drawingKind === 'chart') { @@ -3988,6 +4038,14 @@ export class DomPainter { return runContext; } + private defaultFragmentRenderContext(): FragmentRenderContext { + return { + pageNumber: 1, + totalPages: 1, + section: 'body', + }; + } + /** * Updates an existing fragment element's position and dimensions in place. * Used during incremental updates to efficiently reposition fragments without full re-render. diff --git a/packages/layout-engine/painters/dom/src/runs/field-annotation-run.ts b/packages/layout-engine/painters/dom/src/runs/field-annotation-run.ts index 6e483150cb..b9919e0cf1 100644 --- a/packages/layout-engine/painters/dom/src/runs/field-annotation-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/field-annotation-run.ts @@ -5,6 +5,7 @@ import { DOM_CLASS_NAMES } from '../constants.js'; import { assertPmPositions } from '../pm-position-validation.js'; import type { RunRenderContext } from './types.js'; import { BROWSER_DEFAULT_FONT_SIZE } from './text-run.js'; +import { allowFontSynthesis } from './font-synthesis.js'; /** * Renders a FieldAnnotationRun as an inline "pill" element matching super-editor's visual appearance. @@ -109,6 +110,7 @@ export const renderFieldAnnotationRun = (run: FieldAnnotationRun, context: RunRe if (run.textColor) { annotation.style.color = run.textColor; } + allowFontSynthesis(annotation); if (run.bold) { annotation.style.fontWeight = 'bold'; } diff --git a/packages/layout-engine/painters/dom/src/runs/font-synthesis.ts b/packages/layout-engine/painters/dom/src/runs/font-synthesis.ts new file mode 100644 index 0000000000..fafc99edb0 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/runs/font-synthesis.ts @@ -0,0 +1,5 @@ +export function allowFontSynthesis(element: HTMLElement): void { + // Host resets commonly disable synthesis globally. SuperDoc text must still honor Word-style + // synthetic bold/italic when DocFonts authorizes a real source face for a styled fallback. + element.style.setProperty('font-synthesis', 'weight style'); +} diff --git a/packages/layout-engine/painters/dom/src/runs/image-run.ts b/packages/layout-engine/painters/dom/src/runs/image-run.ts index 23245a113a..42929f125f 100644 --- a/packages/layout-engine/painters/dom/src/runs/image-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/image-run.ts @@ -26,6 +26,7 @@ const FALLBACK_MAX_DIMENSION = 1000; const MIN_IMAGE_DIMENSION = 20; type ImageFilterSource = Pick; +type ImageOpacitySource = Pick; const clampLumUnit = (value: number): number => { return Math.max(-100000, Math.min(100000, value)); @@ -100,6 +101,16 @@ export const buildImageFilters = (source: ImageFilterSource): string[] => { return filters; }; +export const resolveImageOpacity = (source: ImageOpacitySource): string | null => { + const amt = source.alphaModFix?.amt; + if (typeof amt !== 'number' || !Number.isFinite(amt)) { + return null; + } + + const opacity = Math.max(0, Math.min(100000, amt)) / 100000; + return opacity === 1 ? null : String(opacity); +}; + /** * Renders an ImageRun as an inline element. * @@ -262,6 +273,10 @@ export const renderImageRun = (run: ImageRun, context: RunRenderContext): HTMLEl if (filters.length > 0) { img.style.filter = filters.join(' '); } + const opacity = resolveImageOpacity(run); + if (opacity != null) { + img.style.opacity = opacity; + } // Assert PM positions are present for cursor fallback assertPmPositions(run, 'inline image run'); diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index 339454977f..b9f7ceb943 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -697,12 +697,17 @@ const renderExplicitlyPositionedRuns = ({ geoSdtWrapper.style.left = `${elemLeftPx}px`; geoSdtWrapper.style.top = '0px'; geoSdtWrapper.style.height = `${line.lineHeight}px`; + geoSdtWrapper.style.padding = '0px'; + geoSdtWrapper.style.borderWidth = '0px'; + geoSdtWrapper.style.lineHeight = `${line.lineHeight}px`; } if (isImageRun(runForSdt)) { geoSdtWrapper.dataset.containsInlineImage = 'true'; } runContext.syncInlineSdtWrapperTypography(geoSdtWrapper, runForSdt); + geoSdtWrapper.style.lineHeight = `${line.lineHeight}px`; elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; + elem.style.top = '0px'; geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); runContext.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); geoSdtWrapper.appendChild(elem); diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts index 784ecb8d1c..1cb6dc4a65 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts @@ -69,6 +69,8 @@ describe('tab underline alignment (SD-3330)', () => { it('does not draw a border on a plain (non-underlined) inline tab', () => { const el = renderInlineTabRun(plainTab(), LINE, document, 0); expect(el.style.borderBottom).toBe(''); + expect(el.style.height).toBe(`${LINE.lineHeight}px`); + expect(el.style.verticalAlign).toBe('bottom'); }); it('keeps a plain positioned tab invisible with no border', () => { diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts index b67dca92d5..59f9724bfd 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { TextRun } from '@superdoc/contracts'; import type { FragmentRenderContext } from '../renderer.js'; import { textRunMergeSignature } from './hash.js'; -import { resolveRunText } from './text-run.js'; +import { applyRunStyles, resolveRunText } from './text-run.js'; describe('resolveRunText', () => { const context: FragmentRenderContext = { @@ -81,3 +81,23 @@ describe('resolveRunText', () => { expect(textRunMergeSignature(baseRun)).not.toBe(textRunMergeSignature(formattedRun)); }); }); + +describe('applyRunStyles', () => { + it('opts painted text into browser font synthesis for reviewed synthetic fallback faces', () => { + const element = document.createElement('span'); + const run: TextRun = { + text: 'Bold text', + fontFamily: 'Cooper Black', + fontSize: 16, + bold: true, + italic: true, + }; + + applyRunStyles(element, run, false, () => 'Caprasimo'); + + expect(element.style.fontFamily).toBe('Caprasimo'); + expect(element.style.fontWeight).toBe('bold'); + expect(element.style.fontStyle).toBe('italic'); + expect(element.style.getPropertyValue('font-synthesis')).toBe('weight style'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.ts b/packages/layout-engine/painters/dom/src/runs/text-run.ts index bc81c29bf5..7b19328948 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -14,6 +14,7 @@ import type { RunRenderContext, TrackedChangesRenderConfig } from './types.js'; import { applyRunDataAttributes } from './hash.js'; import { applyLinkAttributes, applyLinkDataset, buildLinkRenderData, enhanceAccessibility } from './links.js'; import { setTextContentWithFormattingSpaceMarks } from './formatting-marks.js'; +import { allowFontSynthesis } from './font-synthesis.js'; import { normalizeRtlDateTokenForWordParity, resolveRunDirectionAttribute, @@ -110,6 +111,7 @@ export const applyRunStyles = ( style: run.italic ? 'italic' : 'normal', }); element.style.fontSize = `${run.fontSize}px`; + allowFontSynthesis(element); if (run.bold) element.style.fontWeight = 'bold'; if (run.italic) element.style.fontStyle = 'italic'; diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index c3d5f4a109..f84115e6f6 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -241,6 +241,9 @@ const LINK_AND_TOC_STYLES = ` color: inherit !important; text-decoration: none !important; cursor: default; + /* Disable native link drag so our pointer loop can run text-selection. */ + -webkit-user-drag: none; + user-drag: none; } .superdoc-toc-entry .superdoc-link:hover { @@ -252,6 +255,31 @@ const LINK_AND_TOC_STYLES = ` outline: none; } +/* TOC hover. .toc-group-hover is set by PresentationEditor on every entry + sharing a data-toc-id so the whole TOC greys out together. The ::after + stripe (height set via --toc-gap-below) fills the paragraph-spacing gap + between adjacent entries so the hover reads as one continuous block. */ +.superdoc-toc-entry:hover, +.superdoc-toc-entry.toc-group-hover { + background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + +/* Pointer-events stay on (default) so the stripe extends the parent entry's + hit-test area through the paragraph-spacing gap. Without this, moving the + cursor between two adjacent entries fires mouseout on the upper entry with + relatedTarget = the page (not a TOC entry), the coordinator drops the + group-hover class, and the grey disappears for a frame before the next + entry's mouseover restores it β€” visible as a flicker. */ +.superdoc-toc-entry.toc-group-hover::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: var(--toc-gap-below, 0px); + background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + /* Remove focus outlines from layout engine elements */ .superdoc-layout, .superdoc-page, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c59888ebb6..ff51462e4f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -918,6 +918,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen drawingWrapper.style.flexShrink = '0'; drawingWrapper.style.maxWidth = '100%'; drawingWrapper.style.boxSizing = 'border-box'; + drawingWrapper.dataset.blockId = (block as DrawingBlock).id; applySdtDataset(drawingWrapper, (block as DrawingBlock).attrs as SdtMetadata | undefined); const drawingInner = doc.createElement('div'); @@ -1134,6 +1135,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen drawingWrapper.style.maxWidth = '100%'; drawingWrapper.style.boxSizing = 'border-box'; drawingWrapper.style.zIndex = String(zIndex); + drawingWrapper.dataset.blockId = anchoredBlock.id; applySdtDataset(drawingWrapper, anchoredBlock.attrs as SdtMetadata | undefined); const drawingInner = doc.createElement('div'); diff --git a/packages/react/.releaserc.cjs b/packages/react/.releaserc.cjs index f1a076283a..0914f533f1 100644 --- a/packages/react/.releaserc.cjs +++ b/packages/react/.releaserc.cjs @@ -36,6 +36,10 @@ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === br // stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. // Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. const shouldCommentOnRelease = !isPrerelease; +// Linear release comments are the shipped-version breadcrumb inside Linear +// itself, so keep them on for prereleases even while GitHub PR comments stay +// gated separately. +const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; @@ -74,8 +78,8 @@ if (!isPrerelease) { // Linear integration - labels issues with version on release config.plugins.push([ - 'semantic-release-linear-app', - { teamKeys: ['SD'], addComment: shouldCommentOnRelease, packageName: 'react' }, + '../../scripts/semantic-release/linear-commit-sync.cjs', + { teamKeys: ['SD'], addComment: shouldCommentOnLinearRelease, packageName: 'react' }, ]); config.plugins.push([ diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index d40ff03b64..94d697d35f 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -162,6 +162,72 @@ describe('SuperDocEditor', () => { }, SUPERDOC_READY_TEST_TIMEOUT, ); + + it( + 'should call onZoomChange when zoom changes through the core wiring', + async () => { + const ref = createRef(); + const onReady = vi.fn(); + const onZoomChange = vi.fn(); + + render(); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); + + const instance = ref.current?.getInstance(); + expect(instance).toBeTruthy(); + + instance?.setZoom(150); + + expect(onZoomChange).toHaveBeenCalledWith({ zoom: 150, mode: 'manual' }); + expect(instance?.getZoom()).toBe(150); + expect(instance?.getZoomState().mode).toBe('manual'); + }, + SUPERDOC_READY_TEST_TIMEOUT, + ); + + it( + 'should route onZoomChange through the latest callback after rerender', + async () => { + const ref = createRef(); + const onReady = vi.fn(); + const firstOnZoomChange = vi.fn(); + const secondOnZoomChange = vi.fn(); + + const { rerender } = render(); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); + + const instance = ref.current?.getInstance(); + expect(instance).toBeTruthy(); + + rerender(); + + // Same instance (callback identity changes must not rebuild) and the + // fresh callback receives the event. + expect(ref.current?.getInstance()).toBe(instance); + instance?.setZoom(80); + + expect(firstOnZoomChange).not.toHaveBeenCalled(); + expect(secondOnZoomChange).toHaveBeenCalledWith({ zoom: 80, mode: 'manual' }); + }, + SUPERDOC_READY_TEST_TIMEOUT, + ); + + it( + 'should apply zoom.initial through config passthrough', + async () => { + const ref = createRef(); + const onReady = vi.fn(); + + render(); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); + + expect(ref.current?.getInstance()?.getZoom()).toBe(50); + }, + SUPERDOC_READY_TEST_TIMEOUT, + ); }); describe('onEditorDestroy', () => { diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index b022552456..5ebe4c9f1d 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -20,6 +20,8 @@ import type { SuperDocTransactionEvent, SuperDocContentErrorEvent, SuperDocExceptionEvent, + SuperDocZoomChangeEvent, + SuperDocViewportChangeEvent, } from './types'; /** @@ -50,6 +52,8 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null); @@ -211,6 +229,16 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef { + if (!destroyed) { + callbacksRef.current.onZoomChange?.(event); + } + }, + onViewportChange: (event: SuperDocViewportChangeEvent) => { + if (!destroyed) { + callbacksRef.current.onViewportChange?.(event); + } + }, }; instance = new SuperDoc(superdocConfig) as SuperDocInstance; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index eba3573a70..d62c9b0933 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -27,4 +27,6 @@ export type { SuperDocTransactionEvent, SuperDocContentErrorEvent, SuperDocExceptionEvent, + SuperDocZoomChangeEvent, + SuperDocViewportChangeEvent, } from './types'; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index a0a378cd24..ccf299f802 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -104,6 +104,20 @@ export type SuperDocContentErrorEvent = Parameters>[0]; + +/** + * Event passed to onViewportChange callback. Re-derived from the core + * `Config.onViewportChange` parameter so the React wrapper cannot + * drift from the core contract. + */ +export type SuperDocViewportChangeEvent = Parameters>[0]; + // ============================================================================= // React Component Types // ============================================================================= @@ -131,7 +145,9 @@ type ExplicitCallbackProps = | 'onEditorUpdate' | 'onTransaction' | 'onContentError' - | 'onException'; + | 'onException' + | 'onZoomChange' + | 'onViewportChange'; /** * Explicitly typed callback props to ensure proper TypeScript inference. @@ -158,6 +174,12 @@ export interface CallbackProps { /** Callback when an exception is thrown */ onException?: (event: SuperDocExceptionEvent) => void; + + /** Callback when the zoom level changes (setZoom, toolbar, or fit-width mode) */ + onZoomChange?: (event: SuperDocZoomChangeEvent) => void; + + /** Callback when the implied fit changes (rounded fit zoom or base page width); see the core viewport-change event */ + onViewportChange?: (event: SuperDocViewportChangeEvent) => void; } /** diff --git a/packages/sdk/.releaserc.cjs b/packages/sdk/.releaserc.cjs index 53a8253b8a..d051242492 100644 --- a/packages/sdk/.releaserc.cjs +++ b/packages/sdk/.releaserc.cjs @@ -36,6 +36,10 @@ const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === br // stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest. // Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments. const shouldCommentOnRelease = !isPrerelease; +// Linear release comments are the shipped-version breadcrumb inside Linear +// itself, so keep them on for prereleases even while GitHub PR comments stay +// gated separately. +const shouldCommentOnLinearRelease = true; // Use AI-powered notes for stable releases, conventional generator for prereleases const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }]; @@ -103,10 +107,10 @@ if (!isPrerelease) { // Linear integration config.plugins.push([ - 'semantic-release-linear-app', + '../../scripts/semantic-release/linear-commit-sync.cjs', { teamKeys: ['SD'], - addComment: shouldCommentOnRelease, + addComment: shouldCommentOnLinearRelease, packageName: 'superdoc-sdk', commentTemplate: 'shipped in {package} {releaseLink} {channel}', }, diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 22e1b658d2..8c082ab52f 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -178,7 +178,6 @@ "@types/mdast": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "@types/uuid": "catalog:", "@vitejs/plugin-vue": "catalog:", "@vue/test-utils": "catalog:", "canvas": "catalog:", diff --git a/packages/super-editor/src/editors/v1/assets/styles/elements/toolbar.css b/packages/super-editor/src/editors/v1/assets/styles/elements/toolbar.css index ec02262b57..e5809e665b 100644 --- a/packages/super-editor/src/editors/v1/assets/styles/elements/toolbar.css +++ b/packages/super-editor/src/editors/v1/assets/styles/elements/toolbar.css @@ -15,3 +15,92 @@ width: 12px; height: 12px; } + +.sd-toolbar-split-field { + padding: 0; + gap: 0; +} + +.sd-toolbar-split-field .sd-toolbar-split-field__main, +.sd-toolbar-split-field .sd-toolbar-split-field__caret { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + box-sizing: border-box; + position: relative; + z-index: 1; +} + +.sd-toolbar-split-field .sd-toolbar-split-field__main { + padding: 0 3px 0 var(--sd-ui-toolbar-item-padding, 5px); + border-top-left-radius: var(--sd-ui-radius, 6px); + border-bottom-left-radius: var(--sd-ui-radius, 6px); + cursor: text; +} + +.sd-toolbar-split-field .sd-toolbar-split-field__caret { + align-self: stretch; + width: 20px; + padding: 0 4px 0 2px; + border: 0; + background: transparent; + color: inherit; + border-top-right-radius: var(--sd-ui-radius, 6px); + border-bottom-right-radius: var(--sd-ui-radius, 6px); + cursor: pointer; +} + +.sd-toolbar-split-field:hover { + background-color: var(--sd-ui-toolbar-button-hover-bg, var(--sd-ui-hover-bg, #dbdbdb)); +} + +.sd-toolbar-split-field.high-contrast:hover { + background-color: #000; + color: #fff; +} + +.sd-toolbar-split-field .sd-toolbar-split-field__main:hover, +.sd-toolbar-split-field .sd-toolbar-split-field__caret:hover { + background-color: var(--sd-ui-toolbar-button-active-bg, var(--sd-ui-active-bg, #c8d0d8)); +} + +.sd-toolbar-split-field.high-contrast .sd-toolbar-split-field__main:hover, +.sd-toolbar-split-field.high-contrast .sd-toolbar-split-field__caret:hover { + background-color: #000; + color: #fff; +} + +.sd-toolbar-split-field .sd-toolbar-split-field__caret::before { + content: ''; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 1px; + background-color: transparent; + transition: background-color 0.15s ease-out; +} + +.sd-toolbar-split-field:hover .sd-toolbar-split-field__caret::before { + background-color: var(--sd-ui-border, rgba(71, 72, 74, 0.2)); +} + +.sd-toolbar-split-field.sd-disabled, +.sd-toolbar-split-field.sd-disabled:hover { + background-color: initial; +} + +.sd-toolbar-split-field.sd-disabled .sd-toolbar-split-field__main, +.sd-toolbar-split-field.sd-disabled .sd-toolbar-split-field__caret { + cursor: default; +} + +.sd-toolbar-split-field.sd-disabled .sd-toolbar-split-field__main:hover, +.sd-toolbar-split-field.sd-disabled .sd-toolbar-split-field__caret:hover { + background-color: initial; +} + +.sd-toolbar-split-field.sd-disabled .sd-toolbar-split-field__caret::before { + background-color: transparent; +} diff --git a/packages/super-editor/src/editors/v1/components/SuperEditor.test.js b/packages/super-editor/src/editors/v1/components/SuperEditor.test.js index 243aac2ea2..b0a6e9f175 100644 --- a/packages/super-editor/src/editors/v1/components/SuperEditor.test.js +++ b/packages/super-editor/src/editors/v1/components/SuperEditor.test.js @@ -1414,6 +1414,160 @@ describe('SuperEditor.vue', () => { vi.useRealTimers(); }); + it('should not show image resize overlay for a behind-doc header image outside header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-hover-body', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'body'; + + const imageEl = document.createElement('div'); + imageEl.classList.add('superdoc-image-fragment'); + imageEl.dataset.behindDocSection = 'header'; + imageEl.dataset.sdBlockId = 'header-image'; + imageEl.setAttribute('data-image-metadata', '{"width":100}'); + + const event = new MouseEvent('mousemove'); + Object.defineProperty(event, 'target', { value: imageEl }); + + wrapper.vm.updateImageResizeOverlay(event); + + expect(wrapper.vm.imageResizeState.visible).toBe(false); + expect(wrapper.vm.imageResizeState.imageElement).toBe(null); + expect(wrapper.vm.imageResizeState.blockId).toBe(null); + + wrapper.unmount(); + vi.useRealTimers(); + }); + + it('should show image resize overlay for a behind-doc header image in header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-hover-header', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'header'; + + const imageEl = document.createElement('div'); + imageEl.classList.add('superdoc-image-fragment'); + imageEl.dataset.behindDocSection = 'header'; + imageEl.dataset.sdBlockId = 'header-image'; + imageEl.setAttribute('data-image-metadata', '{"width":100}'); + + const event = new MouseEvent('mousemove'); + Object.defineProperty(event, 'target', { value: imageEl }); + + wrapper.vm.updateImageResizeOverlay(event); + + expect(wrapper.vm.imageResizeState.visible).toBe(true); + expect(wrapper.vm.imageResizeState.imageElement).toBe(imageEl); + expect(wrapper.vm.imageResizeState.blockId).toBe('header-image'); + + wrapper.unmount(); + vi.useRealTimers(); + }); + + it('should not apply image selection outline for a behind-doc header image outside header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-selection-body', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'body'; + + const imageEl = document.createElement('div'); + imageEl.dataset.behindDocSection = 'header'; + + wrapper.vm.setSelectedImage(imageEl, 'header-image', 42); + + expect(imageEl.classList.contains('superdoc-image-selected')).toBe(false); + expect(wrapper.vm.selectedImageState.element).toBe(null); + expect(wrapper.vm.selectedImageState.blockId).toBe(null); + expect(wrapper.vm.selectedImageState.pmStart).toBe(null); + + wrapper.unmount(); + vi.useRealTimers(); + }); + + it('should apply image selection outline for a behind-doc header image in header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-selection-header', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'header'; + + const imageEl = document.createElement('div'); + imageEl.dataset.behindDocSection = 'header'; + + wrapper.vm.setSelectedImage(imageEl, 'header-image', 42); + + expect(imageEl.classList.contains('superdoc-image-selected')).toBe(true); + expect(wrapper.vm.selectedImageState.element).toBe(imageEl); + expect(wrapper.vm.selectedImageState.blockId).toBe('header-image'); + expect(wrapper.vm.selectedImageState.pmStart).toBe(42); + + wrapper.unmount(); + vi.useRealTimers(); + }); + it('should clear image selection when props switch to viewing mode', async () => { vi.useFakeTimers(); EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); diff --git a/packages/super-editor/src/editors/v1/components/SuperEditor.vue b/packages/super-editor/src/editors/v1/components/SuperEditor.vue index 84c3827a6a..349aba7332 100644 --- a/packages/super-editor/src/editors/v1/components/SuperEditor.vue +++ b/packages/super-editor/src/editors/v1/components/SuperEditor.vue @@ -101,6 +101,8 @@ const currentZoom = ref(1); */ let zoomChangeHandler = null; let documentModeChangeHandler = null; +let headerFooterModeChangeHandler = null; +const activeHeaderFooterMode = ref('body'); // Watch for changes in options.rulers with deep option to catch nested changes watch( @@ -655,6 +657,16 @@ const onTableResizeEnd = () => { tableResizeState.tableElement = null; }; +const getBehindDocSection = (element: Element | null): string | null => { + return element?.closest?.('[data-behind-doc-section]')?.getAttribute('data-behind-doc-section') ?? null; +}; + +const canInteractWithHeaderFooterOwnedMedia = (element: Element | null): boolean => { + const section = getBehindDocSection(element); + if (!section) return true; + return section === activeHeaderFooterMode.value; +}; + /** * Update image resize overlay visibility based on mouse position. * Shows overlay when hovering over images with data-image-metadata attribute. @@ -699,6 +711,10 @@ const updateImageResizeOverlay = (event: MouseEvent): void => { // Check for standalone image fragments (ImageBlock) if (target.classList?.contains(DOM_CLASS_NAMES.IMAGE_FRAGMENT) && target.hasAttribute('data-image-metadata')) { + if (!canInteractWithHeaderFooterOwnedMedia(target)) { + hideImageResizeOverlay(); + return; + } imageResizeState.visible = true; imageResizeState.imageElement = target as HTMLElement; imageResizeState.blockId = target.getAttribute('data-sd-block-id'); @@ -710,6 +726,10 @@ const updateImageResizeOverlay = (event: MouseEvent): void => { target.classList?.contains(DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER) && target.querySelector?.('[data-image-metadata]') ) { + if (!canInteractWithHeaderFooterOwnedMedia(target)) { + hideImageResizeOverlay(); + return; + } imageResizeState.visible = true; imageResizeState.imageElement = target as HTMLElement; imageResizeState.blockId = target.getAttribute('data-pm-start'); @@ -718,6 +738,10 @@ const updateImageResizeOverlay = (event: MouseEvent): void => { // Check for inline images (ImageRun inside paragraphs). When image has clipPath it is wrapped; // use the wrapper so the resizer works on the cropped portion's box. if (target.classList?.contains(DOM_CLASS_NAMES.INLINE_IMAGE) && target.hasAttribute('data-image-metadata')) { + if (!canInteractWithHeaderFooterOwnedMedia(target)) { + hideImageResizeOverlay(); + return; + } imageResizeState.visible = true; const wrapper = target.closest?.(`.${DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER}`) as HTMLElement | null; imageResizeState.imageElement = (wrapper ?? target) as HTMLElement; @@ -770,6 +794,12 @@ const setSelectedImage = (element, blockId, pmStart) => { return; } + if (element && !canInteractWithHeaderFooterOwnedMedia(element)) { + clearSelectedImage(); + hideImageResizeOverlay(); + return; + } + // Remove selection from the previously selected element if (selectedImageState.element && selectedImageState.element !== element) { selectedImageState.element.classList.remove('superdoc-image-selected'); @@ -785,6 +815,15 @@ const setSelectedImage = (element, blockId, pmStart) => { } }; +const cleanupInactiveHeaderFooterOwnedImageUi = () => { + if (imageResizeState.imageElement && !canInteractWithHeaderFooterOwnedMedia(imageResizeState.imageElement)) { + hideImageResizeOverlay(); + } + if (selectedImageState.element && !canInteractWithHeaderFooterOwnedMedia(selectedImageState.element)) { + clearSelectedImage(); + } +}; + /** * Combined handler to update both table and image resize overlays */ @@ -1034,6 +1073,11 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } = presentationEditor.on('imageDeselected', () => { clearSelectedImage(); }); + headerFooterModeChangeHandler = ({ mode } = {}) => { + activeHeaderFooterMode.value = mode ?? 'body'; + cleanupInactiveHeaderFooterOwnedImageUi(); + }; + presentationEditor.on('headerFooterModeChanged', headerFooterModeChangeHandler); layoutUpdatedHandler = () => { if (imageResizeState.visible && imageResizeState.blockId) { @@ -1047,7 +1091,11 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } = newElement = editorElem.value?.querySelector(buildInlineImagePmSelector(escapedBlockId)); } if (newElement) { - imageResizeState.imageElement = newElement as HTMLElement; + if (canInteractWithHeaderFooterOwnedMedia(newElement)) { + imageResizeState.imageElement = newElement as HTMLElement; + } else { + hideImageResizeOverlay(); + } } else { imageResizeState.visible = false; imageResizeState.imageElement = null; @@ -1275,6 +1323,10 @@ onBeforeUnmount(() => { editor.value.off('zoomChange', zoomChangeHandler); zoomChangeHandler = null; } + if (editor.value instanceof PresentationEditor && headerFooterModeChangeHandler) { + editor.value.off('headerFooterModeChanged', headerFooterModeChangeHandler); + headerFooterModeChangeHandler = null; + } if (editor.value instanceof PresentationEditor && layoutUpdatedHandler) { editor.value.off('layoutUpdated', layoutUpdatedHandler); layoutUpdatedHandler = null; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.test.js b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.test.js index 3d858d3b2c..df1d066063 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.test.js @@ -1,4 +1,4 @@ -import { afterEach, describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { mount, shallowMount } from '@vue/test-utils'; import { h, nextTick, ref } from 'vue'; import ButtonGroup from './ButtonGroup.vue'; @@ -213,14 +213,16 @@ describe('ButtonGroup dropdown trigger keyboard activation (codex P2 regression) // Real ToolbarButton (no stub) inside the dropdown branch so we exercise // the actual @keydown.enter handler + allowEnterPropagation plumbing. - const mountWithDropdownItem = (item) => + const mountWithDropdownItem = (item, globalOverrides = {}) => mount(ButtonGroup, { props: { toolbarItems: [item], overflowItems: [] }, attachTo: document.body, global: { + ...globalOverrides, stubs: { // Render SdTooltip's trigger slot so the real ToolbarButton mounts. SdTooltip: { name: 'SdTooltip', template: '
' }, + ...(globalOverrides.stubs ?? {}), }, }, }); @@ -314,4 +316,236 @@ describe('ButtonGroup dropdown trigger keyboard activation (codex P2 regression) expect(events[0][0].item.command).toBe('toggleBulletList'); expect(events[0][0].argument).toBeNull(); }); + + it('does not open the font size dropdown when clicking inside the size input', async () => { + const item = { + ...createFullDropdownItem('12pt'), + name: ref('fontSize'), + label: ref('12'), + selectedValue: ref('12pt'), + inlineTextInputVisible: ref(true), + hasInlineTextInput: ref(true), + nestedOptions: ref([{ key: '12pt', label: '12', props: { 'data-item': 'btn-fontSize-option' } }]), + }; + wrapper = mountWithDropdownItem(item); + + await wrapper.get('#inlineTextInput-fontSize').trigger('click'); + expect(item.expand.value).toBe(false); + + const caret = wrapper.get('[data-item="btn-fontSize-caret"]'); + expect(caret.attributes('aria-label')).toBe('Test dropdown options'); + + await caret.trigger('click'); + expect(item.expand.value).toBe(true); + }); + + it('wraps the font family combobox in the toolbar tooltip', () => { + const item = { + ...createFullDropdownItem('Arial'), + command: 'setFontFamily', + id: ref('font-family'), + name: ref('fontFamily'), + label: ref('Arial'), + selectedValue: ref('Arial'), + nestedOptions: ref([ + { key: 'Arial', label: 'Arial', props: { style: { fontFamily: 'Arial' } } }, + { key: 'Helvetica', label: 'Helvetica', props: { style: { fontFamily: 'Helvetica' } } }, + ]), + }; + wrapper = mountWithDropdownItem(item); + + expect(wrapper.findComponent({ name: 'SdTooltip' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'FontFamilyCombobox' }).exists()).toBe(true); + }); + + it('does not render the font family combobox when the font options list is empty', () => { + const item = { + ...createFullDropdownItem('Arial'), + command: 'setFontFamily', + id: ref('font-family'), + name: ref('fontFamily'), + label: ref('Arial'), + selectedValue: ref('Arial'), + nestedOptions: ref([]), + }; + wrapper = mountWithDropdownItem(item); + + expect(wrapper.findComponent({ name: 'FontFamilyCombobox' }).exists()).toBe(false); + }); + + it('opens the font family combobox from roving keyboard activation', async () => { + const item = { + ...createFullDropdownItem('Arial'), + command: 'setFontFamily', + id: ref('font-family'), + name: ref('fontFamily'), + label: ref('Arial'), + selectedValue: ref('Arial'), + nestedOptions: ref([ + { key: 'Arial', label: 'Arial', props: { style: { fontFamily: 'Arial' } } }, + { key: 'Helvetica', label: 'Helvetica', props: { style: { fontFamily: 'Helvetica' } } }, + ]), + }; + wrapper = mountWithDropdownItem(item); + + const ctn = wrapper.find('.sd-toolbar-item-ctn').element; + ctn.focus(); + ctn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await nextTick(); + await nextTick(); + + const input = wrapper.get('[data-item="btn-fontFamily"] input').element; + expect(item.expand.value).toBe(true); + expect(document.body.querySelector('[role="listbox"]')).not.toBeNull(); + expect(document.activeElement).toBe(input); + }); + + it('moves from font family to font size on Tab', async () => { + const flushPendingMarkCommands = vi.fn(() => true); + const fontFamily = { + ...createFullDropdownItem('Arial'), + command: 'setFontFamily', + id: ref('font-family'), + name: ref('fontFamily'), + label: ref('Arial'), + selectedValue: ref('Arial'), + nestedOptions: ref([ + { key: 'Arial', label: 'Arial', props: { style: { fontFamily: 'Arial' } } }, + { key: 'Helvetica', label: 'Helvetica', props: { style: { fontFamily: 'Helvetica' } } }, + ]), + }; + const separator = { + type: 'separator', + id: ref('separator'), + disabled: ref(false), + isNarrow: ref(false), + isWide: ref(false), + }; + const fontSize = { + ...createFullDropdownItem('12pt'), + command: 'setFontSize', + id: ref('font-size'), + name: ref('fontSize'), + label: ref('12'), + selectedValue: ref('12pt'), + inlineTextInputVisible: ref(true), + hasInlineTextInput: ref(true), + nestedOptions: ref([{ key: '12pt', label: '12', props: { 'data-item': 'btn-fontSize-option' } }]), + }; + // Mount the trio up front: production toolbar rebuilds re-mount ButtonGroup + // with fresh props (Toolbar bumps its render key), so a mid-test setProps + // swap does not model anything real. Rebuild survival is covered by the + // browser-level Tab-flow behavior spec. + wrapper = mount(ButtonGroup, { + props: { toolbarItems: [fontFamily, separator, fontSize], overflowItems: [] }, + attachTo: document.body, + global: { + // Matches production: $toolbar is installed via app.config.globalProperties, + // which is what getCurrentInstance().proxy can actually see. + config: { + globalProperties: { + $toolbar: { + flushPendingMarkCommands, + }, + }, + }, + stubs: { + SdTooltip: { name: 'SdTooltip', template: '
' }, + }, + }, + }); + + const input = wrapper.get('[data-item="btn-fontFamily"] input'); + await input.trigger('focus'); + await input.setValue('hel'); + const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + input.element.dispatchEvent(event); + await nextTick(); + await waitForAnimationFrame(); + await nextTick(); + + expect(event.defaultPrevented).toBe(true); + expect(flushPendingMarkCommands).toHaveBeenCalledTimes(1); + expect(wrapper.emitted('command')?.[0]?.[0].argument).toBe('Helvetica'); + const fontSizeInput = wrapper.get('#inlineTextInput-fontSize').element; + expect(document.activeElement).toBe(fontSizeInput); + await nextTick(); + expect(fontSizeInput.selectionStart).toBe(0); + expect(fontSizeInput.selectionEnd).toBe(fontSizeInput.value.length); + }); + + it('moves from font size to the editor on Tab', async () => { + const flushPendingMarkCommands = vi.fn(() => true); + const item = { + ...createFullDropdownItem('12pt'), + command: 'setFontSize', + id: ref('font-size'), + name: ref('fontSize'), + label: ref('12'), + selectedValue: ref('12pt'), + inlineTextInputVisible: ref(true), + hasInlineTextInput: ref(true), + nestedOptions: ref([{ key: '12pt', label: '12', props: { 'data-item': 'btn-fontSize-option' } }]), + }; + const focusEditor = vi.fn(); + wrapper = mountWithDropdownItem(item, { + config: { + globalProperties: { + $toolbar: { + flushPendingMarkCommands, + activeEditor: { + focus: focusEditor, + }, + }, + }, + }, + }); + + const input = wrapper.get('#inlineTextInput-fontSize'); + await input.trigger('focus'); + await input.setValue('18'); + const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + input.element.dispatchEvent(event); + await nextTick(); + + expect(event.defaultPrevented).toBe(true); + expect(wrapper.emitted('command')?.[0]?.[0].argument).toBe('18'); + expect(flushPendingMarkCommands).toHaveBeenCalledTimes(1); + expect(focusEditor).toHaveBeenCalledTimes(1); + }); + + it('flushes pending font marks when the font family combobox hands focus back to the editor', async () => { + const flushPendingMarkCommands = vi.fn(() => true); + const focusEditor = vi.fn(); + const item = { + ...createFullDropdownItem('Arial'), + command: 'setFontFamily', + id: ref('font-family'), + name: ref('fontFamily'), + label: ref('Arial'), + selectedValue: ref('Arial'), + nestedOptions: ref([ + { key: 'Arial', label: 'Arial', props: { style: { fontFamily: 'Arial' } } }, + { key: 'Helvetica', label: 'Helvetica', props: { style: { fontFamily: 'Helvetica' } } }, + ]), + }; + wrapper = mountWithDropdownItem(item, { + config: { + globalProperties: { + $toolbar: { + flushPendingMarkCommands, + activeEditor: { + focus: focusEditor, + }, + }, + }, + }, + }); + + wrapper.findComponent({ name: 'FontFamilyCombobox' }).vm.$emit('editor-handoff'); + await nextTick(); + + expect(flushPendingMarkCommands).toHaveBeenCalledTimes(1); + expect(focusEditor).toHaveBeenCalledTimes(1); + }); }); 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 2c9ae33188..3442d48c97 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue @@ -1,14 +1,17 @@