diff --git a/AGENTS.md b/AGENTS.md index 3a29083be5..cc02b5afed 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`. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index ea41944e0b..49a769b7a2 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -1,6 +1,65 @@ -# 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, metric-compatible substitute fonts so that documents +referencing a non-embedded Microsoft core font still render with correct line +breaks and pagination. + +### 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`). Distribute that notice set together with the font files. + +SPDX license expression for this bundled font set: `OFL-1.1 AND Apache-2.0`. + +### 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 Serif | Times New Roman | OFL-1.1 | none declared | 2.1.5 | +| Liberation Mono | Courier New | OFL-1.1 | none declared | 2.1.5 | + +### 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 registered Red Hat trademark. The v2.1.5 files declare no OFL Reserved Font Name. SuperDoc names the unmodified fonts. + +### Format conversion + +The bundled faces are format-only TrueType-to-WOFF2 conversions (`fontTools`, +`flavor="woff2"`, Brotli; no subsetting; WOFF2 metadata omitted). No design, +metric, glyph, `cmap`, or `name`-table change. Verified for this ship set: +20 / 20 faces have a WOFF2 `name` table byte-identical to their source TTF with +identical glyph count and `cmap`, and all metrics are preserved. Under OFL FAQ +2.2.1 these 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`. + +The fonts remain under their own OFL-1.1 / Apache-2.0 terms and are not +relicensed under SuperDoc's terms (AGPLv3 community build or commercial). --- @@ -12,7 +71,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/docs/advanced/headless-toolbar.mdx b/apps/docs/advanced/headless-toolbar.mdx index 0be39ee79a..2ccfd7670b 100644 --- a/apps/docs/advanced/headless-toolbar.mdx +++ b/apps/docs/advanced/headless-toolbar.mdx @@ -313,6 +313,7 @@ Snapshot values match the format you pass to `execute()`. What you read is what | `redo` | — | — | | `ruler` | — | — | | `zoom` | number (e.g. `125`) | number | +| `zoom-fit-width` | none | none | | `document-mode` | `'editing'` \| `'suggesting'` \| `'viewing'` | mode string | ### Track changes diff --git a/apps/docs/editor/custom-ui/api-reference.mdx b/apps/docs/editor/custom-ui/api-reference.mdx index b77ad697c4..b17a6bd77b 100644 --- a/apps/docs/editor/custom-ui/api-reference.mdx +++ b/apps/docs/editor/custom-ui/api-reference.mdx @@ -187,6 +187,19 @@ await ui.document.export({ exportType: ['docx'], commentsType: 'external', trigg 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. diff --git a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx index 4c307f97dc..113f37fc09 100644 --- a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx +++ b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx @@ -117,7 +117,7 @@ Common ids you'll wire to buttons: | 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` | +| 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` | 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..1a9416a6a0 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -440,12 +440,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 +458,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/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/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/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/examples/editor/built-in-ui/responsive-zoom/index.html b/examples/editor/built-in-ui/responsive-zoom/index.html new file mode 100644 index 0000000000..23b929d2e1 --- /dev/null +++ b/examples/editor/built-in-ui/responsive-zoom/index.html @@ -0,0 +1,13 @@ + + + + + + + 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/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..92e25ff3c9 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,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", diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index bde926ca2f..8468d1d76f 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -4628,9 +4628,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; 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/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/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index cb7339e376..888ffc46ba 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -104,12 +104,18 @@ function decode(params) { return null; } - // ECMA-376 renames w:t → w:delText inside . Other inline content — - // w:noBreakHyphen, w:tab, w:br, etc. — stays as-is; the deletion is - // conveyed by the wrapper alone. Guard the rename so non-text - // atoms inside don't crash. - const textNode = translatedTextNode.elements.find((n) => n.name === 'w:t'); - if (textNode) textNode.name = 'w:delText'; + // ECMA-376 (17.3.3.7) requires w:delText for ALL text runs inside . A + // single run can now hold multiple siblings, because the newline export + // safety net splits text around (e.g. AlphaBeta), + // so rename every direct w:t, not just the first; a leftover inside + // would not be treated as deleted. Other inline content + // (w:noBreakHyphen, w:tab, w:br, etc.) stays as-is; the wrapper alone + // conveys the deletion. + (translatedTextNode.elements || []) + .filter((n) => n.name === 'w:t') + .forEach((n) => { + n.name = 'w:delText'; + }); return { name: 'w:del', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 4aceb41c3d..643fc19441 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -177,6 +177,37 @@ describe('w:del translator', () => { expect(result.elements[0].elements[0].name).toBe('w:delText'); }); + it('renames every in a multi-segment run to (newline split)', () => { + const mockTrackedMark = { + type: 'trackDelete', + attrs: { + id: '789', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + + // The newline export safety net produces one run with interleaved w:t/w:br; + // every w:t inside must become w:delText, not just the first. + exportSchemaToJson.mockReturnValue({ + name: 'w:r', + elements: [ + { name: 'w:t', elements: [{ text: 'Alpha', type: 'text' }] }, + { name: 'w:br' }, + { name: 'w:t', elements: [{ text: 'Beta', type: 'text' }] }, + ], + }); + + const node = { type: 'text', text: 'Alpha\nBeta', marks: [mockTrackedMark] }; + const result = config.decode({ node }); + + const run = result.elements[0]; + expect(run.elements.map((n) => n.name)).toEqual(['w:delText', 'w:br', 'w:delText']); + expect(run.elements.some((n) => n.name === 'w:t')).toBe(false); + }); + it('writes sourceId to w:id for round-trip fidelity', () => { const mockTrackedMark = { type: 'trackDelete', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js index e9eee7aca8..0586bc1b6c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js @@ -44,11 +44,38 @@ export function getTextNodeForExport(text, marks, params) { partPath: resolveExportPartPath(params), }); - textNodes.push({ - name: 'w:t', - elements: [{ text, type: 'text' }], - attributes: nodeAttrs, - }); + const textValue = typeof text === 'string' ? text : ''; + // Normalize CRLF/CR to LF so Windows line endings export Word-native breaks + // too, rather than leaving a stray carriage return inside . + const normalizedText = textValue.includes('\r') ? textValue.replace(/\r\n?/g, '\n') : textValue; + if (normalizedText.includes('\n')) { + // Export safety net: a raw newline inside is whitespace that Word + // collapses on open (it is not the OOXML representation of a line break), + // while SuperDoc still renders it as a break: the SD-3278 + // divergence. Emit a Word-native between + // segments instead. Everything stays inside this single run so the + // surrounding / wrappers keep wrapping exactly one run. + const segments = normalizedText.split('\n'); + segments.forEach((segment, index) => { + if (segment.length > 0) { + const segmentNeedsSpace = /^\s|\s$/.test(segment); + textNodes.push({ + name: 'w:t', + elements: [{ text: segment, type: 'text' }], + attributes: segmentNeedsSpace ? { 'xml:space': 'preserve' } : null, + }); + } + if (index < segments.length - 1) { + textNodes.push({ name: 'w:br' }); + } + }); + } else { + textNodes.push({ + name: 'w:t', + elements: [{ text: normalizedText, type: 'text' }], + attributes: nodeAttrs, + }); + } // For custom mark export, we need to add a bookmark start and end tag // And store attributes in the bookmark name diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js index 75f0a66550..979c3b9b37 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js @@ -134,4 +134,80 @@ describe('getTextNodeForExport', () => { const runPropertiesChange = runProperties.elements.find((element) => element.name === 'w:rPrChange'); expect(runPropertiesChange.attributes['w:id']).toBe('7'); }); + + // SD-3278 export safety net: a raw newline left inside a PM text + // node (e.g. from an imported .docx that stored breaks as literal '\n') must + // export as a Word-native , not a collapsed newline inside . + describe('raw newline export safety net', () => { + const contentElements = (result) => result.elements.filter((el) => el.name === 'w:t' || el.name === 'w:br'); + + it('exports a single newline as // within one run', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + expect(result.name).toBe('w:r'); + const content = contentElements(result); + expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']); + expect(content[0].elements[0].text).toBe('Alpha'); + expect(content[2].elements[0].text).toBe('Beta'); + }); + + it('never leaves a raw newline inside a ', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts.some((el) => el.elements[0].text.includes('\n'))).toBe(false); + }); + + it('emits a soft break (no w:type="page") for the ', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + const br = result.elements.find((el) => el.name === 'w:br'); + expect(br).toBeDefined(); + expect(br.attributes?.['w:type']).toBeUndefined(); + }); + + it('leaves newline-free text as a single (unchanged)', () => { + const result = getTextNodeForExport('hello world', [], buildParams()); + const content = contentElements(result); + expect(content).toHaveLength(1); + expect(content[0].name).toBe('w:t'); + expect(content[0].elements[0].text).toBe('hello world'); + }); + + it('emits a for each newline including leading, trailing, and consecutive newlines', () => { + const result = getTextNodeForExport('\nA\n\nB\n', [], buildParams()); + const content = contentElements(result); + expect(content.map((el) => el.name)).toEqual(['w:br', 'w:t', 'w:br', 'w:br', 'w:t', 'w:br']); + const texts = content.filter((el) => el.name === 'w:t').map((el) => el.elements[0].text); + expect(texts).toEqual(['A', 'B']); + }); + + it('sets xml:space="preserve" only on segments with edge whitespace', () => { + const result = getTextNodeForExport('Alpha \n Beta', [], buildParams()); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts[0].elements[0].text).toBe('Alpha '); + expect(texts[0].attributes).toEqual({ 'xml:space': 'preserve' }); + expect(texts[1].elements[0].text).toBe(' Beta'); + expect(texts[1].attributes).toEqual({ 'xml:space': 'preserve' }); + }); + + it('does not set xml:space on segments without edge whitespace', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts[0].attributes).toBeNull(); + expect(texts[1].attributes).toBeNull(); + }); + + it('normalizes CRLF to a on export', () => { + const content = contentElements(getTextNodeForExport('Alpha\r\nBeta', [], buildParams())); + expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']); + expect(content[0].elements[0].text).toBe('Alpha'); + expect(content[2].elements[0].text).toBe('Beta'); + }); + + it('normalizes a bare CR to a without leaving a stray carriage return in ', () => { + const result = getTextNodeForExport('Alpha\rBeta', [], buildParams()); + const content = contentElements(result); + expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts.some((el) => el.elements[0].text.includes('\r'))).toBe(false); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts index eb1374ccc8..1ab66b302b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts @@ -9,6 +9,7 @@ import { type NodeOptions = { text?: string; marks?: Array<{ type: { name: string } }>; + leafText?: (node: ProseMirrorNode) => string; isInline?: boolean; isBlock?: boolean; isLeaf?: boolean; @@ -28,7 +29,7 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; return { - type: { name: typeName }, + type: { name: typeName, spec: options.leafText ? { leafText: options.leafText } : {} }, text: isText ? text : undefined, nodeSize, isText, @@ -138,6 +139,25 @@ describe('resolveTextRangeInBlock', () => { expect(result).toEqual({ from: 6, to: 7 }); }); + + it('maps visible offsets across tracked deleted leaf nodes without counting them', () => { + const textA = createNode('text', [], { text: 'A' }); + const deletedBreak = createNode('lineBreak', [], { + isInline: true, + isLeaf: true, + marks: [{ type: { name: 'trackDelete' } }], + leafText: () => '\n', + }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deletedBreak, textB], { + isBlock: true, + inlineContent: true, + }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 1, end: 2 }, { textModel: 'visible' }); + + expect(result).toEqual({ from: 3, to: 4 }); + }); }); describe('computeTextContentLength', () => { @@ -204,6 +224,26 @@ describe('computeTextContentLength', () => { expect(computeTextContentLength(paragraph, { textModel: 'visible' })).toBe(2); expect(textContentInBlock(paragraph, { textModel: 'visible' })).toBe('AB'); }); + + it('excludes tracked deleted leaf nodes in the visible text model', () => { + const textA = createNode('text', [], { text: 'A' }); + const deletedBreak = createNode('lineBreak', [], { + isInline: true, + isLeaf: true, + marks: [{ type: { name: 'trackDelete' } }], + leafText: () => '\n', + }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deletedBreak, textB], { + isBlock: true, + inlineContent: true, + }); + + expect(computeTextContentLength(paragraph)).toBe(3); + expect(computeTextContentLength(paragraph, { textModel: 'visible' })).toBe(2); + expect(textContentInBlock(paragraph)).toBe('A\nB'); + expect(textContentInBlock(paragraph, { textModel: 'visible' })).toBe('AB'); + }); }); describe('pmPositionToTextOffset', () => { @@ -268,4 +308,23 @@ describe('pmPositionToTextOffset', () => { expect(pmPositionToTextOffset(paragraph, 0, 6, { textModel: 'visible' })).toBe(1); expect(pmPositionToTextOffset(paragraph, 0, 7, { textModel: 'visible' })).toBe(2); }); + + it('keeps PM positions inside tracked deleted leaf nodes at the surrounding visible offset', () => { + const textA = createNode('text', [], { text: 'A' }); + const deletedBreak = createNode('lineBreak', [], { + isInline: true, + isLeaf: true, + marks: [{ type: { name: 'trackDelete' } }], + leafText: () => '\n', + }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deletedBreak, textB], { + isBlock: true, + inlineContent: true, + }); + + expect(pmPositionToTextOffset(paragraph, 0, 2, { textModel: 'visible' })).toBe(1); + expect(pmPositionToTextOffset(paragraph, 0, 3, { textModel: 'visible' })).toBe(1); + expect(pmPositionToTextOffset(paragraph, 0, 4, { textModel: 'visible' })).toBe(2); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts index 5395f05ce1..17e95eb01d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts @@ -29,6 +29,10 @@ function shouldSkipTextNode(node: ProseMirrorNode, options?: TextOffsetOptions): return isVisibleTextModel(options) && hasTrackDeleteMark(node); } +function shouldSkipLeafNode(node: ProseMirrorNode, options?: TextOffsetOptions): boolean { + return isVisibleTextModel(options) && hasTrackDeleteMark(node); +} + function resolveSegmentPosition( targetOffset: number, segmentStart: number, @@ -87,6 +91,10 @@ export function pmPositionToTextOffset( if (node.isLeaf) { const endPos = docPos + node.nodeSize; + if (shouldSkipLeafNode(node, options)) { + if (pmPos < endPos) done = true; + return; + } if (pmPos >= endPos) { offset += 1; } else { @@ -141,6 +149,7 @@ export function computeTextContentLength(blockNode: ProseMirrorNode, options?: T return; } if (node.isLeaf) { + if (shouldSkipLeafNode(node, options)) return; length += 1; return; } @@ -229,6 +238,7 @@ export function resolveTextRangeInBlock( } if (node.isLeaf) { + if (shouldSkipLeafNode(node, options)) return; advanceSegment(1, docPos, docPos + node.nodeSize); return; } @@ -262,7 +272,14 @@ export function textContentInBlock(blockNode: ProseMirrorNode, options?: TextOff } if (node.isLeaf) { - text += '\ufffc'; + if (shouldSkipLeafNode(node, options)) return; + // Honor a leaf's declared visible text (e.g. lineBreak -> '\n', + // noBreakHyphen -> U+2011) so this content model agrees with the visible + // document and with the offset model. All leafText values are one + // character, matching the 1-per-leaf length used by the offset helpers + // above; other leaves fall back to the U+FFFC placeholder. + const leafText = (node.type?.spec as { leafText?: (n: ProseMirrorNode) => string } | undefined)?.leafText; + text += typeof leafText === 'function' ? leafText(node) : '\ufffc'; return; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts index 8f5c99b9a8..a63576cf03 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import { Fragment, Schema } from 'prosemirror-model'; import { buildTextWithTabs, parentAllowsNodeAt, textBetweenWithTabs } from './text-with-tabs.js'; -function makeRealSchema(options: { hasTab?: boolean; hasNoBreakHyphen?: boolean; hasGenericLeaf?: boolean } = {}) { +function makeRealSchema( + options: { hasTab?: boolean; hasLineBreak?: boolean; hasNoBreakHyphen?: boolean; hasGenericLeaf?: boolean } = {}, +) { const nodes: Record = { doc: { content: 'paragraph+' }, paragraph: { group: 'block', content: 'inline*' }, @@ -13,6 +15,11 @@ function makeRealSchema(options: { hasTab?: boolean; hasNoBreakHyphen?: boolean; // Tab is non-leaf, which is why `textBetweenWithTabs` (not PM's built-in textBetween) is needed. nodes.tab = { group: 'inline', inline: true, atom: true, content: 'inline*' }; } + if (options.hasLineBreak) { + // Mirrors the real extensions/line-break/line-break.js shape: inline atom + // that disallows marks (`marks: ''`) and renders to
/ exports to . + nodes.lineBreak = { group: 'inline', inline: true, atom: true, marks: '' }; + } if (options.hasNoBreakHyphen) { // Mirrors the real extensions/no-break-hyphen schema: inline leaf atom with leafText. nodes.noBreakHyphen = { group: 'inline', inline: true, atom: true, leafText: () => '‑' }; @@ -93,12 +100,114 @@ describe('buildTextWithTabs', () => { expect(result.child(0).text).toBe('x'); expect(result.child(0).marks.some((m: any) => m.type.name === 'bold')).toBe(true); expect(result.child(1).type.name).toBe('tab'); - // Tab carries the run's marks — the OOXML exporter reads node.marks on tab + // Tab carries the run's marks. The OOXML exporter reads node.marks on tab // nodes (tab-translator.js:53) to emit matching around . expect(result.child(1).marks.some((m: any) => m.type.name === 'bold')).toBe(true); expect(result.child(2).text).toBe('y'); expect(result.child(2).marks.some((m: any) => m.type.name === 'bold')).toBe(true); }); + + // SD-3278: a literal '\n' inside a text node exports as raw newline + // inside , which Word collapses. It must become a `lineBreak` node so the + // exporter emits a Word-native . + it('splits text around a single newline into text + lineBreak + text', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined); + expect(result).toBeInstanceOf(Fragment); + const fragment = result as Fragment; + expect(fragment.childCount).toBe(3); + expect(fragment.child(0).text).toBe('Alpha'); + expect(fragment.child(1).type.name).toBe('lineBreak'); + expect(fragment.child(2).text).toBe('Beta'); + }); + + it('emits a lineBreak node even when the schema has no tab node type', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined) as Fragment; + expect(result).toBeInstanceOf(Fragment); + expect(result.childCount).toBe(3); + expect(result.child(1).type.name).toBe('lineBreak'); + }); + + it('keeps the raw newline in a single text node when the schema has no lineBreak node type', () => { + const schema = makeRealSchema({ hasTab: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined); + expect((result as any).isText).toBe(true); + expect((result as any).text).toBe('Alpha\nBeta'); + }); + + it('creates the lineBreak node bare in the creation path', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const boldMark = schema.marks.bold.create(); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', [boldMark]) as Fragment; + expect(result.child(0).marks.some((m: any) => m.type.name === 'bold')).toBe(true); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(1).marks.length).toBe(0); + expect(result.child(2).marks.some((m: any) => m.type.name === 'bold')).toBe(true); + }); + + it('omits empty segments around leading, trailing, and consecutive newlines', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const lead = buildTextWithTabs(schema, '\nfoo', undefined) as Fragment; + expect(lead.childCount).toBe(2); + expect(lead.child(0).type.name).toBe('lineBreak'); + expect(lead.child(1).text).toBe('foo'); + + const doubled = buildTextWithTabs(schema, 'a\n\nb', undefined) as Fragment; + expect(doubled.childCount).toBe(4); + expect(doubled.child(0).text).toBe('a'); + expect(doubled.child(1).type.name).toBe('lineBreak'); + expect(doubled.child(2).type.name).toBe('lineBreak'); + expect(doubled.child(3).text).toBe('b'); + }); + + it('interleaves tab and lineBreak nodes when both control characters are present', () => { + const schema = makeRealSchema({ hasTab: true, hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'a\tb\nc', undefined) as Fragment; + expect(result.childCount).toBe(5); + expect(result.child(0).text).toBe('a'); + expect(result.child(1).type.name).toBe('tab'); + expect(result.child(2).text).toBe('b'); + expect(result.child(3).type.name).toBe('lineBreak'); + expect(result.child(4).text).toBe('c'); + }); + + it('keeps the raw tab literal but still splits the newline when tabs are disallowed', () => { + const schema = makeRealSchema({ hasTab: true, hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'a\tb\nc', undefined, { parentAllowsTab: false }) as Fragment; + expect(result.childCount).toBe(3); + expect(result.child(0).text).toBe('a\tb'); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(2).text).toBe('c'); + }); + + // Generated/SDK text often uses CRLF; normalize CRLF and bare CR to + // line breaks so no stray carriage return survives in a text segment. + it('normalizes CRLF to a lineBreak node', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\r\nBeta', undefined) as Fragment; + expect(result.childCount).toBe(3); + expect(result.child(0).text).toBe('Alpha'); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(2).text).toBe('Beta'); + }); + + it('normalizes a bare CR to a lineBreak node without leaving a stray carriage return', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\rBeta', undefined) as Fragment; + expect(result.childCount).toBe(3); + expect(result.child(0).text).toBe('Alpha'); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(2).text).toBe('Beta'); + }); + + // A `text*`-only parent (e.g. total-page-number) rejects lineBreak. + it('keeps the raw newline in a single text node when parentAllowsLineBreak is false', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined, { parentAllowsLineBreak: false }); + expect((result as any).isText).toBe(true); + expect((result as any).text).toBe('Alpha\nBeta'); + }); }); describe('parentAllowsNodeAt', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts index 3a44a8582f..82e9bbf64f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts @@ -10,38 +10,77 @@ import type { Transaction } from 'prosemirror-state'; import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js'; /** - * Build a text-or-fragment suitable for insertion, splitting on '\t' and - * inserting schema `tab` nodes at each split. + * Build a text-or-fragment suitable for insertion, splitting on '\t' and '\n' + * and inserting schema `tab` / `lineBreak` nodes at each split. * - * Returns a plain text node when the schema has no `tab` node type, when the - * parent disallows tab nodes (see {@link parentAllowsTabAt}), or when the text - * contains no tab characters. The raw '\t' is preserved inside the text node - * so exporters and readers still see the character. + * A tab split produces a `tab` node; a newline split produces a `lineBreak` + * node. Each is only performed when the schema exposes the matching node type + * (and, for tabs, when the parent admits them; see {@link parentAllowsNodeAt}). + * A control character that cannot be materialized stays literal inside the + * surrounding text node so exporters and readers still see it. * - * Callers are responsible for ensuring `text` is non-empty (ProseMirror's - * `schema.text` throws on empty input). + * Newlines must become `lineBreak` nodes (not raw '\n' inside a text node): + * a literal newline inside `` is whitespace that Word collapses on open + * (it is not the OOXML representation of a line break), so it would drop the + * visible break while SuperDoc still renders it. The `lineBreak` node + * round-trips to a Word-native ``. See SD-3278. + * + * Returns a plain text node when the text contains no splittable control + * character (the common case). Callers are responsible for ensuring `text` is + * non-empty (ProseMirror's `schema.text` throws on empty input). */ export function buildTextWithTabs( schema: Schema, text: string, marks: readonly ProseMirrorMark[] | undefined, - opts: { parentAllowsTab?: boolean } = {}, + opts: { parentAllowsTab?: boolean; parentAllowsLineBreak?: boolean } = {}, ): ProseMirrorNode | ProseMirrorFragment { - // Check the cheapest/most selective predicate first — most calls carry no '\t'. - if (!text.includes('\t')) return schema.text(text, marks); + // Normalize CRLF/CR to LF up front so Windows line endings (common in + // generated/SDK text) are treated as line breaks too. Only allocate when a + // carriage return is actually present. + const normalized = text.includes('\r') ? text.replace(/\r\n?/g, '\n') : text; + + // Check the cheapest/most selective predicate first; most calls carry neither. + const hasTab = normalized.includes('\t'); + const hasNewline = normalized.includes('\n'); + if (!hasTab && !hasNewline) return schema.text(normalized, marks); + + // `lineBreak` is a normal inline node, but some textblocks restrict their + // content to `text*` (e.g. total-page-number) and reject it. Gate on parent + // admission the same way tabs do: callers that target restrictive parents + // pass the probe result; others default to allowed. When disallowed or absent + // we fall back to literal text, and the export safety net still converts any + // raw newline to `` on export. + const tabNodeType = hasTab && opts.parentAllowsTab !== false ? schema.nodes?.tab : undefined; + const lineBreakNodeType = hasNewline && opts.parentAllowsLineBreak !== false ? schema.nodes?.lineBreak : undefined; + if (!tabNodeType && !lineBreakNodeType) return schema.text(normalized, marks); - const tabNodeType = schema.nodes?.tab; - if (!tabNodeType || opts.parentAllowsTab === false) return schema.text(text, marks); + // `NodeType.create` takes `readonly Mark[] | undefined` (not null) for marks. + const tabMarks: readonly ProseMirrorMark[] | undefined = marks ?? undefined; + + // Split only on the control characters we can replace with a node; any other + // control character stays literal inside the surrounding text segments. + const splitPattern = [tabNodeType ? '\\t' : null, lineBreakNodeType ? '\\n' : null].filter(Boolean).join('|'); + const parts = normalized.split(new RegExp(`(${splitPattern})`)); - const tabMarks = (marks ?? null) as ProseMirrorMark[] | null; - const parts = text.split('\t'); const nodes: ProseMirrorNode[] = []; - for (let i = 0; i < parts.length; i++) { - if (parts[i]) nodes.push(schema.text(parts[i], marks)); - // Carry the surrounding marks onto the tab node so the OOXML exporter - // wraps `` in a matching `` — keeps formatting unbroken - // across the tab (bold-run | tab | bold-run rather than bold | plain | bold). - if (i < parts.length - 1) nodes.push(tabNodeType.create(null, null, tabMarks)); + for (const part of parts) { + if (part === '') continue; // schema.text throws on empty input + if (part === '\t' && tabNodeType) { + // Carry the surrounding marks onto the tab node so the OOXML exporter + // wraps `` in a matching ``, keeping formatting unbroken + // across the tab (bold-run | tab | bold-run rather than bold | plain | bold). + nodes.push(tabNodeType.create(null, null, tabMarks)); + } else if (part === '\n' && lineBreakNodeType) { + // Create the break bare: a soft line break carries no run formatting. + // (Tracking an inserted break so it exports inside is a separate + // concern: br-translator does not yet route node.marks the way + // noBreakHyphen's translator does; see SD-3371. It is not a schema limit: + // a leaf atom can carry marks, governed by the parent run/paragraph.) + nodes.push(lineBreakNodeType.create()); + } else { + nodes.push(schema.text(part, marks)); + } } return Fragment.from(nodes); } @@ -128,8 +167,14 @@ export function textBetweenWithTabs( } if (node.isLeaf) { if (node.isInline) { + if ( + options.textModel === 'visible' && + node.marks?.some((mark: ProseMirrorMark) => mark.type.name === TrackDeleteMarkName) + ) { + return false; + } // Honor PM's `leafText` NodeSpec contract: an inline leaf can declare - // its visible text representation (e.g. noBreakHyphen → U+2011) so + // its visible text representation (e.g. noBreakHyphen -> U+2011) so // flattened reads match the rendered glyph instead of producing the // generic placeholder. Falls back to `leafFallback` when undefined. const leafTextFn = node.type?.spec?.leafText; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts index 82ab9e11ff..4deabd771e 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts @@ -997,7 +997,10 @@ function insertTextAroundSdt( const { tr } = editor.state; const tabType = editor.schema.nodes?.tab; const parentAllowsTab = tabType && content.includes('\t') ? parentAllowsNodeAt(tr, pos, tabType) : false; - tr.insert(pos, buildTextWithTabs(editor.schema, content, undefined, { parentAllowsTab })); + const lineBreakType = editor.schema.nodes?.lineBreak; + const parentAllowsLineBreak = + lineBreakType && /[\r\n]/.test(content) ? parentAllowsNodeAt(tr, pos, lineBreakType) : false; + tr.insert(pos, buildTextWithTabs(editor.schema, content, undefined, { parentAllowsTab, parentAllowsLineBreak })); dispatchTransaction(editor, tr); return true; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index cf2c46d86b..0ec53f6615 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -886,6 +886,16 @@ export function executeTextRewrite( const replacementText = getReplacementText(step.args.replacement); const marks = resolveMarksForRange(editor, target, step); + // A rewrite range can start in a normal parent (a run) and span into a + // `text*`-only inline container (e.g. total-page-number) that rejects + // lineBreak. The edits below land at remapped positions whose parent may + // differ from `absFrom`, so probe admission at each actual edit position + // rather than once at the range start: a newline that lands in a restrictive + // parent falls back to literal text (the export safety net still emits + // ). Probe on the current `tr` so prior in-loop steps are reflected. + const lineBreakNodeType = editor.state.schema.nodes?.lineBreak; + const parentAllowsLineBreakAt = (pos: number): boolean => + lineBreakNodeType ? parentAllowsNodeAt(tr, pos, lineBreakNodeType) : false; const structuralRewrite = resolveStructuralRangeRewrite(tr.doc, absFrom, absTo, step); if (structuralRewrite) { @@ -935,7 +945,9 @@ export function executeTextRewrite( tr.delete(absFrom, absTo); return { changed: target.text.length > 0 }; } - const content = buildTextWithTabs(editor.state.schema, replacementText, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, replacementText, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(absFrom), + }); tr.replaceWith(absFrom, absTo, content); return { changed: replacementText !== target.text }; } @@ -991,10 +1003,14 @@ export function executeTextRewrite( if (change.type === 'delete') { tr.delete(remap(change.docFrom), remap(change.docTo)); } else if (change.type === 'insert') { - const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(remap(change.docPos)), + }); tr.insert(remap(change.docPos), content); } else { - const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(remap(change.docFrom)), + }); tr.replaceWith(remap(change.docFrom), remap(change.docTo), content); } } @@ -1002,12 +1018,15 @@ export function executeTextRewrite( // Pure deletion after trimming: a non-empty replacement whose new text is // fully contained in the old text's common prefix + suffix collapses to an // empty delta (e.g. "best endeavours to:" → "endeavours to:" leaves - // trimmedNew === ""). Delete the removed range rather than building - // schema.text('') — ProseMirror rejects empty text nodes. + // trimmedNew === ""). This also covers removing a lineBreak, now that + // lineBreak counts as "\n" in the diff. Delete the removed range rather than + // building schema.text(''), which ProseMirror rejects for empty text. tr.delete(trimmedFrom, trimmedTo); } else { // 0 or 1 word change: replace just the trimmed range. - const content = buildTextWithTabs(editor.state.schema, trimmedNew, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, trimmedNew, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(trimmedFrom), + }); tr.replaceWith(trimmedFrom, trimmedTo, content); } @@ -1138,7 +1157,12 @@ export function executeTextInsert( const tabNodeType = editor.state.schema.nodes?.tab; const parentAllowsTab = tabNodeType && text.includes('\t') ? parentAllowsNodeAt(tr, absPos, tabNodeType) : false; - tr.insert(absPos, buildTextWithTabs(editor.state.schema, text, marks, { parentAllowsTab })); + // Restrictive parents like total-page-number are `text*` and reject lineBreak; + // probe admission so newline-bearing inserts fall back to literal text there. + const lineBreakNodeType = editor.state.schema.nodes?.lineBreak; + const parentAllowsLineBreak = + lineBreakNodeType && /[\r\n]/.test(text) ? parentAllowsNodeAt(tr, absPos, lineBreakNodeType) : false; + tr.insert(absPos, buildTextWithTabs(editor.state.schema, text, marks, { parentAllowsTab, parentAllowsLineBreak })); return { changed: true }; } @@ -1323,7 +1347,13 @@ export function executeSpanTextRewrite( // For single replacement block, use flat replacement into the span if (replacementBlocks.length === 1) { const marks = resolveSpanMarks(editor, target, policy, step.id); - const content = buildTextWithTabs(editor.state.schema, replacementBlocks[0], asProseMirrorMarks(marks)); + // Probe parent admission so a newline replacement into a `text*`-only span + // parent falls back to literal text (the export safety net handles the rest). + const lineBreakNodeType = editor.state.schema.nodes?.lineBreak; + const parentAllowsLineBreak = lineBreakNodeType ? parentAllowsNodeAt(tr, absFrom, lineBreakNodeType) : false; + const content = buildTextWithTabs(editor.state.schema, replacementBlocks[0], asProseMirrorMarks(marks), { + parentAllowsLineBreak, + }); tr.replaceWith(absFrom, absTo, content); return { changed: true }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/newline-handling.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/newline-handling.integration.test.ts new file mode 100644 index 0000000000..3d69098ae7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/newline-handling.integration.test.ts @@ -0,0 +1,548 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { executeSpanTextRewrite, executeTextInsert, executeTextRewrite } from './executor.ts'; + +/** + * SD-3278: multi-line text forwarded into text-mode mutations (e.g. + * an agentic SDK workflow) must export Word-native line breaks, not a raw '\n' + * inside one that Word collapses. + * + * Two paths are covered: + * - Creation path: doc.replace / text.rewrite with a single '\n' must build a + * `lineBreak` PM node (not a literal '\n' in a text node). + * - Export safety net: a text node that already holds a raw '\n' (e.g. from an + * imported .docx that stored breaks as literal newlines) must still export a + * Word-native . + */ + +function makeSchemaEditor(paragraphs: string[] = ['hello world']) { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: paragraphs.map((text) => ({ + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text }] }], + })), + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; +} + +function getFirstMatchRef(editor: any, pattern: string): string { + const match = editor.doc.query.match({ select: { type: 'text', pattern }, require: 'first' }); + const ref = match?.items?.[0]?.handle?.ref; + if (!ref) throw new Error(`Could not resolve ref for pattern "${pattern}"`); + return ref; +} + +function hasNodeOfType(editor: any, name: string): boolean { + let found = false; + editor.state.doc.descendants((node: any) => { + if (found) return false; + if (node.type.name === name) { + found = true; + return false; + } + return true; + }); + return found; +} + +function paragraphCount(editor: any): number { + let count = 0; + editor.state.doc.forEach((node: any) => { + if (node.type.name === 'paragraph') count += 1; + }); + return count; +} + +function countNodeType(editor: any, name: string): number { + let count = 0; + editor.state.doc.descendants((node: any) => { + if (node.type.name === name) count += 1; + }); + return count; +} + +describe('text-mode mutations: single newline handling (SD-3278)', () => { + let editor: any | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('direct doc.replace with a single newline builds a lineBreak node inside one paragraph', () => { + editor = makeSchemaEditor(['hello world']); + + const receipt = editor.doc.replace( + { ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + // A single '\n' stays within the paragraph (only '\n\n+' splits paragraphs). + expect(paragraphCount(editor)).toBe(1); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(true); + // It must NOT become a page break. + expect(hasNodeOfType(editor, 'hardBreak')).toBe(false); + }); + + it('tracked doc.replace with a single newline builds a lineBreak node', () => { + editor = makeSchemaEditor(['hello world']); + + const receipt = editor.doc.replace( + { ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(true); + expect(hasNodeOfType(editor, 'hardBreak')).toBe(false); + }); + + // Read-model consistency (SD-3278): a lineBreak created on the write side must + // read back as '\n' on the diff/search paths, or query.match cannot find + // break-bearing content and an identical rewrite duplicates the break. + it('finds break-bearing content by \\n and is idempotent under an identical rewrite (no duplicate break)', () => { + editor = makeSchemaEditor(['hello world']); + editor.doc.replace({ ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, { changeMode: 'direct' }); + expect(countNodeType(editor, 'lineBreak')).toBe(1); + + // query.match must see the break as '\n' to resolve a ref to the content. + const ref = getFirstMatchRef(editor, 'Alpha\nBeta'); + editor.doc.replace({ ref, text: 'Alpha\nBeta' }, { changeMode: 'direct' }); + // The identical rewrite is a no-op: still exactly one break, not two. + expect(countNodeType(editor, 'lineBreak')).toBe(1); + }); + + it('rewriting break-bearing content to single-line text removes the break', () => { + editor = makeSchemaEditor(['hello world']); + editor.doc.replace({ ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, { changeMode: 'direct' }); + expect(countNodeType(editor, 'lineBreak')).toBe(1); + + const ref = getFirstMatchRef(editor, 'Alpha\nBeta'); + editor.doc.replace({ ref, text: 'AlphaBeta' }, { changeMode: 'direct' }); + expect(countNodeType(editor, 'lineBreak')).toBe(0); + expect(editor.state.doc.firstChild?.textContent).toBe('AlphaBeta'); + }); + + // Proves the bot P1 (rewrite char-diff counts the break) DIRECTLY, without + // query.match: rewriting `AlphaBeta` to the same visible text must + // be a no-op (the diff reads the break as '\n' via leafText), so it neither + // duplicates nor strands the break. + it('an identical rewrite over an existing lineBreak node is a no-op (direct target)', () => { + // paragraph > run > [ text 'Alpha', lineBreak, text 'Beta' ] + editor = initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [{ type: 'text', text: 'Alpha' }, { type: 'lineBreak' }, { type: 'text', text: 'Beta' }], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + expect(countNodeType(editor, 'lineBreak')).toBe(1); + + // Span the inline content: first text/lineBreak start .. last node end. + let absFrom = -1; + let absTo = -1; + editor.state.doc.descendants((node: any, pos: number) => { + if (node.isText || node.type.name === 'lineBreak') { + if (absFrom === -1) absFrom = pos; + absTo = pos + node.nodeSize; + } + }); + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'idem', + op: 'text.rewrite', + blockId: '__selection__', + from: 0, + to: 0, + absFrom, + absTo, + text: 'Alpha\nBeta', + capturedStyle: undefined, + } as any; + const step = { + id: 'idem-rewrite', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'Alpha\nBeta' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any); + editor.dispatch(tr); + + // Still exactly one break: not duplicated (the bot P1 regression) and not removed. + expect(countNodeType(editor, 'lineBreak')).toBe(1); + expect(editor.state.doc.firstChild?.textContent).toBe('Alpha\nBeta'); + }); +}); + +// lineBreak must not be forced into a parent that rejects it. The +// total-page-number node is `content: 'text*'`, so a newline insert there must +// fall back to literal text rather than throwing (mirrors the tab guard). +describe('newline insert into a restrictive (text*) parent', () => { + let editor: any | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + function makeEditorWithTotalPageCount() { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [{ type: 'total-page-number', attrs: {}, content: [{ type: 'text', text: '7' }] }], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + } + + function findTotalPageNumberPos(ed: any): number { + let pos: number | undefined; + ed.state.doc.descendants((node: any, nodePos: number) => { + if (pos !== undefined) return false; + if (node.type.name === 'total-page-number') { + pos = nodePos; + return false; + } + return true; + }); + if (pos === undefined) throw new Error('total-page-number node not found'); + return pos; + } + + it('inserts a\\nb into total-page-number without throwing and without creating a lineBreak node', () => { + editor = makeEditorWithTotalPageCount(); + const nodePos = findTotalPageNumberPos(editor); + const innerPos = nodePos + 1; // inside the total-page-number, before its '7' + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'step-1', + op: 'text.insert', + blockId: 'total-page-number-1', + from: 0, + to: 0, + absFrom: innerPos, + absTo: innerPos, + text: '', + marks: [], + } as any; + const step = { + id: 'insert-newline-into-total-page-number', + op: 'text.insert', + where: { by: 'ref', ref: 'ignored' }, + args: { position: 'before', content: { text: 'a\nb' } }, + } as any; + + expect(() => executeTextInsert(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + const totalPageNumber = editor.state.doc.nodeAt(nodePos); + expect(totalPageNumber?.type.name).toBe('total-page-number'); + expect(totalPageNumber?.textContent).toBe('a\nb7'); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + }); + + // The fix also threads the parent-admission probe into the rewrite path + // (executeTextRewrite / executeSpanTextRewrite), not just text.insert. Rewriting + // text INSIDE a text*-only parent with a newline must not throw or inject a + // lineBreak; it falls back to literal text because the field is `text*` only. + it('rewrites text inside total-page-number with a\\nb without throwing or creating a lineBreak node', () => { + editor = makeEditorWithTotalPageCount(); + const nodePos = findTotalPageNumberPos(editor); + const innerPos = nodePos + 1; // the '7' text node sits at [innerPos, innerPos + 1] + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'rewrite-step', + op: 'text.rewrite', + // '__selection__' makes resolveMarksForRange skip style capture (no block). + blockId: '__selection__', + from: 0, + to: 1, + absFrom: innerPos, + absTo: innerPos + 1, + text: '7', + capturedStyle: undefined, + } as any; + const step = { + id: 'rewrite-newline-into-total-page-number', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'a\nb' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + expect(() => executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + const totalPageNumber = editor.state.doc.nodeAt(nodePos); + expect(totalPageNumber?.type.name).toBe('total-page-number'); + // No lineBreak node was forced into the text*-only parent. + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + }); + + // The probe must be taken at the actual edit position, not once at the range + // start: a rewrite can start in a normal run (which allows lineBreak) and the + // newline edit can land inside a total-page-number (text*-only). A single + // probe at the start would mint a lineBreak that the field parent rejects. + it('does not force a lineBreak into total-page-number when a spanning rewrite lands the newline inside the field', () => { + // paragraph > run > [ text 'AB', total-page-number > text '7' ] + editor = initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [ + { type: 'text', text: 'AB' }, + { type: 'total-page-number', attrs: {}, content: [{ type: 'text', text: '7' }] }, + ], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + + const nodePos = findTotalPageNumberPos(editor); // total-page-number opens here; 'B' is at nodePos-1 + const tr = editor.state.tr; + // Range 'B7' starts in the run ('B') and spans into the field's text ('7'). + // The replacement appends '\n', whose edit lands at the end of the field's + // text (a text*-only parent). + const target = { + kind: 'range', + stepId: 'span-rewrite', + op: 'text.rewrite', + blockId: '__selection__', + from: 0, + to: 0, + absFrom: nodePos - 1, // 'B' in the run + absTo: nodePos + 2, // just after '7' inside the field + text: 'B7', + capturedStyle: undefined, + } as any; + const step = { + id: 'span-rewrite-into-field', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'B7\n' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + expect(() => executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + expect(editor.state.doc.nodeAt(nodePos)?.type.name).toBe('total-page-number'); + // The newline landed in the text*-only field, so it falls back to literal + // text rather than forcing a (schema-invalid) lineBreak there. + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + }); + + // executeSpanTextRewrite has its own single-block replacement path with a + // separate parentAllowsLineBreak probe. The rewrite/insert paths above cover + // their probes, but the span path's was unexercised even though its source + // comment claims it is covered. A single inline '\n' stays in one replacement + // block (split is on \n{2,}), so it reaches this single-block path. + it('span rewrite with a single newline builds one lineBreak in a normal parent', () => { + editor = makeSchemaEditor(['hello world']); // paragraph > run > text 'hello world' + const tr = editor.state.tr; + + // A real two-segment span over the run text ('hello' + ' world'). The run + // admits a lineBreak, so the single '\n' must mint exactly one. + const target = { + kind: 'span', + stepId: 'span-newline-normal', + op: 'text.rewrite', + matchId: 'm:span-normal', + segments: [ + { blockId: 'p1', from: 0, to: 5, absFrom: 2, absTo: 7 }, + { blockId: 'p1', from: 5, to: 11, absFrom: 7, absTo: 13 }, + ], + text: 'hello world', + marks: [], + capturedStyleBySegment: [], + } as any; + const step = { + id: 'span-newline-normal', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'Alpha\nBeta' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + expect(() => executeSpanTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + expect(countNodeType(editor, 'lineBreak')).toBe(1); + expect(hasNodeOfType(editor, 'hardBreak')).toBe(false); + + // The break is a real node, never a raw '\n' baked into a text node. + let rawNewlineText = false; + editor.state.doc.descendants((node: any) => { + if (node.isText && typeof node.text === 'string' && node.text.includes('\n')) rawNewlineText = true; + }); + expect(rawNewlineText).toBe(false); + }); + + it('span rewrite with a newline into total-page-number falls back to literal text, no lineBreak', () => { + editor = makeEditorWithTotalPageCount(); // paragraph > run > total-page-number > text '7' + const nodePos = findTotalPageNumberPos(editor); + const tr = editor.state.tr; + + // The span sits entirely inside the field's text ('7' at [nodePos+1, nodePos+2]). + // total-page-number is text*-only, so the probe at the edit position rejects a + // lineBreak and the replacement falls back to literal text (the export safety + // net turns it into a later). + const target = { + kind: 'span', + stepId: 'span-newline-field', + op: 'text.rewrite', + matchId: 'm:span-field', + segments: [{ blockId: 'p1', from: 0, to: 1, absFrom: nodePos + 1, absTo: nodePos + 2 }], + text: '7', + marks: [], + capturedStyleBySegment: [], + } as any; + const step = { + id: 'span-newline-field', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'Alpha\nBeta' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + expect(() => executeSpanTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + expect(editor.state.doc.nodeAt(nodePos)?.type.name).toBe('total-page-number'); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + }); +}); + +describe('docx export: newline becomes (SD-3278)', () => { + let docData: Awaited>; + let editor: any | undefined; + + beforeAll(async () => { + docData = await loadTestDataForEditorTests('blank-doc.docx'); + }); + + const makeBlankDocEditor = () => + initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + // Tracked mutations require an author/user on the editor instance. + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('exports a Word-native for a text node that already holds a raw newline', async () => { + editor = makeBlankDocEditor(); + + // Seed a raw '\n' directly via the core command (tr.insertText keeps the + // literal newline) to reproduce an imported .docx whose held a raw + // newline (this exercises the export safety net, not the creation path). + editor.dispatch(editor.state.tr.insertText('Alpha\nBeta', 1)); + expect(editor.state.doc.textContent).toContain('Alpha\nBeta'); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + + const xml = await editor.exportDocx({ exportXmlOnly: true }); + expect(xml).toContain(' around (no leftover )', async () => { + editor = makeBlankDocEditor(); + + // Seed "Alpha\nBeta" as one text node, then mark the whole range as a tracked + // deletion. On export the run is split around ; every segment must + // become inside , never a stray . + editor.dispatch(editor.state.tr.insertText('Alpha\nBeta', 1)); + const delMark = editor.schema.marks.trackDelete.create({ + id: 'del-1', + author: 'Reviewer', + authorEmail: 'reviewer@example.com', + date: '2026-01-01T00:00:00.000Z', + }); + editor.dispatch(editor.state.tr.addMark(1, 1 + 'Alpha\nBeta'.length, delMark)); + + const xml = await editor.exportDocx({ exportXmlOnly: true }); + expect(xml).toContain(' survives inside the deletion (it would be ignored by Word). + expect(/]*>[\s\S]*?<\/w:del>/.test(xml)).toBe(false); + }); + + it('tracked doc.replace with a newline exports valid tracked OOXML (inserted text in , break preserved as )', async () => { + editor = makeBlankDocEditor(); + + // Seed text to target, then tracked-replace it with multi-line content. + editor.dispatch(editor.state.tr.insertText('hello world', 1)); + const match = editor.doc.query.match({ select: { type: 'text', pattern: 'hello world' }, require: 'first' }); + const ref = match?.items?.[0]?.handle?.ref; + expect(ref).toBeTruthy(); + + editor.doc.replace({ ref, text: 'Alpha\nBeta' }, { changeMode: 'tracked' }); + + const xml = await editor.exportDocx({ exportXmlOnly: true }); + // Inserted text is tracked and the newline survives as a Word-native break. + expect(xml).toContain('/ the way noBreakHyphen's translator does, so the break + // exports as its own run rather than inside . The output is valid + // OOXML; on reject the orphan break remains. This is an export-routing gap, + // not a schema limit (a leaf atom can carry marks). Tracked deletes of an + // existing raw-newline node keep the break inside via the single-run + // path above. + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts index 6b038a2210..1d1af2b13b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts @@ -917,7 +917,15 @@ function materializeTab(schema: Schema): ProseMirrorNode { } function materializeLineBreak(schema: Schema): ProseMirrorNode { - const nodeType = schema.nodes.hardBreak ?? schema.nodes.lineBreak; + // A `kind: 'lineBreak'` item is a soft break and must materialize to `lineBreak` + // (exports ``), not `hardBreak` (exports ``, a page + // break). Block-level page breaks use the distinct `kind: 'break'` path. + // KNOWN AMBIGUITY: structural projection (sd-projection) maps BOTH hardBreak + // and lineBreak to `kind: 'lineBreak'` with no discriminator, so a structural + // read/write echo of an *inline* page break is demoted to a soft break here. + // That projection ambiguity predates this change and needs a discriminator to + // round-trip inline page breaks (separate follow-up). + const nodeType = schema.nodes.lineBreak ?? schema.nodes.hardBreak; if (!nodeType) return schema.text('\n'); return nodeType.create(); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts index 6559632baa..d3e3085c80 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts @@ -895,6 +895,74 @@ describe('materializeFragment — capability gates', () => { // Extension nodes bypass capability gates — should fall through to fallback materializer expect(() => materializeFragment(editor.state.schema, fragment, new Set(), 'insert')).not.toThrow(); }); + + // SD-3278: a `kind: 'lineBreak'` inline must become a soft `lineBreak` + // node (exports to ), NOT a `hardBreak` node (exports to a page break). + // This is the bug that made the structural.replace workaround fail. + it('materializes a structural lineBreak as a soft lineBreak node, not a hardBreak', () => { + const fragment: SDFragment = { + kind: 'paragraph', + paragraph: { + inlines: [ + { kind: 'run', run: { text: 'Alpha' } }, + { kind: 'lineBreak' }, + { kind: 'run', run: { text: 'Beta' } }, + ], + }, + } as any; + + const materialized = materializeFragment(editor.state.schema, fragment, new Set(), 'insert'); + const roots = Array.isArray(materialized) ? materialized : [materialized]; + const typeNames = new Set(); + roots.forEach((root: any) => root.descendants((node: any) => typeNames.add(node.type.name))); + + expect(typeNames.has('lineBreak')).toBe(true); + expect(typeNames.has('hardBreak')).toBe(false); + }); + + // SD-3278: a structural run whose text carries a raw newline (a natural shape + // for SDK/agent-built `structural.replace` content) must split into lineBreak + // nodes, not keep the literal '\n' in a text node. + it('splits a newline inside structural run text into a lineBreak node, not raw text', () => { + const fragment: SDFragment = { + kind: 'paragraph', + paragraph: { + inlines: [{ kind: 'run', run: { text: 'left\nright' } }], + }, + } as any; + + const materialized = materializeFragment(editor.state.schema, fragment, new Set(), 'insert'); + const roots = Array.isArray(materialized) ? materialized : [materialized]; + const typeCounts: Record = {}; + let text = ''; + roots.forEach((root: any) => + root.descendants((node: any) => { + typeCounts[node.type.name] = (typeCounts[node.type.name] ?? 0) + 1; + if (node.isText) text += node.text ?? ''; + }), + ); + + expect(typeCounts.lineBreak).toBe(1); + expect(typeCounts.hardBreak ?? 0).toBe(0); + // No raw newline survives inside a text node. + expect(text.includes('\n')).toBe(false); + expect(text).toContain('left'); + expect(text).toContain('right'); + // The break reads back as '\n' via leafText, so structural reads that + // flatten PM content preserve it on round-trip (SD-3278). + const readableText = roots + .map((root: any) => { + if (typeof root.textBetween !== 'function') return root.textContent ?? ''; + const leafText = (node: any) => { + const specLeafText = node?.type?.spec?.leafText; + return typeof specLeafText === 'function' ? specLeafText(node) : ''; + }; + const size = root.content?.size ?? root.size ?? 0; + return root.textBetween(0, size, '', leafText); + }) + .join(''); + expect(readableText).toBe('left\nright'); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js b/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js index 4fbc2a148c..e141dcc41f 100644 --- a/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js +++ b/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js @@ -32,6 +32,17 @@ export const LineBreak = Node.create({ content: '', atom: true, + // The visible text representation of this leaf. Without it, flattening APIs + // see the break as nothing or a U+FFFC placeholder, so the rewrite char-diff + // and the doc-api text model (query.match, structural projection) disagree + // with the visible document. For example, an idempotent text.rewrite over + // `Alpha\nBeta` would duplicate the break, and query.match could not find it. + // Read by PM's built-in `Node.textBetween` (so `node.textContent` too), + // SuperDoc's `textBetweenWithTabs` / `charOffsetToDocPos`, SearchIndex, and + // `text-offset-resolver`. Mirrors the `noBreakHyphen` leaf (U+2011). See + // SD-3278. + leafText: () => '\n', + addOptions() { return {}; }, diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js index 593f599a5f..94a02b611c 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js @@ -386,14 +386,16 @@ export const Paragraph = OxmlNode.create({ // We avoid `findParentNode(isList)` here because `isList` depends on // `getResolvedParagraphProperties`, a WeakMap cache keyed by node // identity. After the numbering plugin's `appendTransaction` sets - // `listRendering`, the paragraph node object is replaced, leaving - // the new node uncached — causing `isList` to return false. + // `listRendering`, the paragraph node object is replaced, so the + // new node is uncached and `isList` can return false. const { $from } = selection; let paragraph = null; + let paragraphDepth = null; for (let d = $from.depth; d >= 0; d--) { const node = $from.node(d); if (node.type.name === 'paragraph') { paragraph = node; + paragraphDepth = d; break; } } @@ -404,7 +406,18 @@ export const Paragraph = OxmlNode.create({ if (!isListParagraph) return false; if (!isVisuallyEmptyParagraph(paragraph) && !hasOnlyBreakContent(paragraph)) return false; - const tr = state.tr.insertText(event.data); + let tr = state.tr; + if (hasOnlyBreakContent(paragraph) && paragraphDepth != null) { + const contentStart = $from.start(paragraphDepth); + const contentEnd = $from.end(paragraphDepth); + tr = tr.delete(contentStart, contentEnd).insertText(event.data, contentStart); + // insertText at an explicit position leaves the caret before the inserted + // text, so subsequent native keystrokes prepend instead of append (typing + // "abcdef" lands as "bcdefa"). Place the caret after the inserted text. + tr = tr.setSelection(TextSelection.create(tr.doc, contentStart + event.data.length)); + } else { + tr = tr.insertText(event.data); + } view.dispatch(tr); event.preventDefault(); return true; diff --git a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js index a830c3d298..162d6e5289 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js +++ b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js @@ -28,6 +28,12 @@ const DELETION_BARRIER = '\u0000'; const DEFAULT_SEARCH_MODEL = 'raw'; const hasTrackDeleteMark = (node) => node?.marks?.some((mark) => mark?.type?.name === 'trackDelete') ?? false; +const readLeafText = (node) => { + const leafText = node?.type?.spec?.leafText; + if (typeof leafText === 'function') return leafText(node); + if (typeof leafText === 'string') return leafText; + return ATOM_PLACEHOLDER; +}; /** * SearchIndex provides a lazily-built, cached index for searching across @@ -69,7 +75,7 @@ export class SearchIndex { this.#buildVisible(doc); } else { // Get the flattened text using ProseMirror's optimized textBetween - this.text = doc.textBetween(0, doc.content.size, BLOCK_SEPARATOR, ATOM_PLACEHOLDER); + this.text = doc.textBetween(0, doc.content.size, BLOCK_SEPARATOR, readLeafText); } this.segments = []; @@ -141,7 +147,12 @@ export class SearchIndex { } if (node.isLeaf) { - parts.push(ATOM_PLACEHOLDER); + if (hasTrackDeleteMark(node)) { + appendDeletionBarrier(); + return; + } + + parts.push(readLeafText(node)); emittedDeletionBarrier = false; return; } @@ -240,29 +251,48 @@ export class SearchIndex { } if (node.isLeaf) { + if (searchModel === 'visible' && hasTrackDeleteMark(node)) { + if (context?.deletionBarrierActive) { + return offset; + } + addSegment({ + offsetStart: offset, + offsetEnd: offset + 1, + docFrom: docPos, + docTo: docPos + node.nodeSize, + kind: 'atom', + }); + if (context) { + context.deletionBarrierActive = true; + } + return offset + 1; + } + if (context && searchModel === 'visible') { context.deletionBarrierActive = false; } + const leafText = readLeafText(node); + if (leafText.length === 0) return offset; // Leaf node (atom): check if it's a hard_break or other atom if (node.type.name === 'hard_break') { addSegment({ offsetStart: offset, - offsetEnd: offset + 1, + offsetEnd: offset + leafText.length, docFrom: docPos, docTo: docPos + node.nodeSize, kind: 'hardBreak', }); - return offset + 1; + return offset + leafText.length; } - // Other atoms get the replacement character + // Other atoms use their declared leaf text or the replacement character. addSegment({ offsetStart: offset, - offsetEnd: offset + 1, + offsetEnd: offset + leafText.length, docFrom: docPos, docTo: docPos + node.nodeSize, kind: 'atom', }); - return offset + 1; + return offset + leafText.length; } // For non-leaf nodes, recurse into content @@ -310,6 +340,14 @@ export class SearchIndex { */ offsetRangeToDocRanges(start, end) { const ranges = []; + // A single search hit is gapless in offset space, so consecutive segments + // (text and inline-leaf atoms like lineBreak) belong to one contiguous + // match. Coalesce them into one doc range — otherwise a hit spanning + // `text + lineBreak + text` yields discontiguous text ranges that the + // downstream D5 contiguity guard rejects (SD-3278). A block separator is a + // real split between blocks and ends the current range. The D5 guard still + // catches genuinely separate edits, which are not offset-contiguous. + let current = null; for (const segment of this.segments) { // Skip segments entirely before our range @@ -317,25 +355,49 @@ export class SearchIndex { // Stop if we're past our range if (segment.offsetStart >= end) break; - // Only include text segments in the result - if (segment.kind !== 'text') continue; + // Block separators split blocks; never coalesce across them. + if (segment.kind === 'blockSep') { + if (current) { + ranges.push({ from: current.from, to: current.to }); + current = null; + } + continue; + } - // Calculate the overlap const overlapStart = Math.max(start, segment.offsetStart); const overlapEnd = Math.min(end, segment.offsetEnd); + if (overlapStart >= overlapEnd) continue; + + let from; + let to; + if (segment.kind === 'text') { + from = segment.docFrom + (overlapStart - segment.offsetStart); + to = segment.docFrom + (overlapEnd - segment.offsetStart); + } else { + // Inline leaf atom (lineBreak, hardBreak, image, ...): occupies its + // whole node span and is part of the contiguous match, not a gap. + from = segment.docFrom; + to = segment.docTo; + } - if (overlapStart < overlapEnd) { - // Map the overlap back to document positions - const startInSegment = overlapStart - segment.offsetStart; - const endInSegment = overlapEnd - segment.offsetStart; - - ranges.push({ - from: segment.docFrom + startInSegment, - to: segment.docFrom + endInSegment, - }); + // Coalesce only when the next segment is BOTH offset-contiguous (same + // search hit) AND PM-contiguous (`from === current.to`, i.e. immediately + // adjacent in the document). This merges `text + lineBreak + text` within + // one run into a single range, but never bridges a document gap — a + // skipped/tracked-deleted leaf, a run boundary, or any content the match + // does not actually cover. A non-coalesced segment becomes its own range; + // since it stays offset-contiguous, the downstream block coalescing still + // sees no gap, while the D5 guard keeps rejecting genuinely separate edits. + if (current && segment.offsetStart === current.offsetEnd && from === current.to) { + current.to = to; + current.offsetEnd = overlapEnd; + } else { + if (current) ranges.push({ from: current.from, to: current.to }); + current = { from, to, offsetEnd: overlapEnd }; } } + if (current) ranges.push({ from: current.from, to: current.to }); return ranges; } diff --git a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js index bde782ccf8..6b92f0f791 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js +++ b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; import { SearchIndex } from './SearchIndex.js'; describe('SearchIndex.stripDiacritics', () => { @@ -142,6 +143,66 @@ describe('SearchIndex.searchIgnoringDiacritics', () => { }); }); +describe('SearchIndex lineBreak leaf text', () => { + const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { group: 'block', content: 'inline*' }, + text: { group: 'inline' }, + lineBreak: { + group: 'inline', + inline: true, + atom: true, + leafText: () => '\n', + }, + }, + marks: {}, + }); + + function buildLineBreakDoc() { + return schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create(null, [schema.text('Alpha'), schema.nodes.lineBreak.create(), schema.text('Beta')]), + ]); + } + + it('indexes lineBreak using its declared leafText', () => { + const index = new SearchIndex(); + + index.ensureValid(buildLineBreakDoc()); + + expect(index.text).toBe('Alpha\nBeta'); + expect(index.search('Alpha\nBeta')).toHaveLength(1); + }); + + it('coalesces a hit spanning text + lineBreak + text into one contiguous doc range', () => { + const index = new SearchIndex(); + index.ensureValid(buildLineBreakDoc()); + + // The full 'Alpha\nBeta' span (text + lineBreak + text, all PM-adjacent in + // one block) must map to a single contiguous range, not discontiguous text + // ranges that the downstream D5 contiguity guard would reject. + const ranges = index.offsetRangeToDocRanges(0, index.text.length); + expect(ranges).toHaveLength(1); + }); + + it('does NOT coalesce across a block separator (negative)', () => { + const index = new SearchIndex(); + index.ensureValid( + schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create(null, [schema.text('Alpha')]), + schema.nodes.paragraph.create(null, [schema.text('Beta')]), + ]), + ); + + // 'Alpha\nBeta' here is two paragraphs joined by a block separator, not an + // inline break. The separator is a real split: the span must stay two ranges + // (one per block), never a single editable text range. + expect(index.text).toBe('Alpha\nBeta'); + const ranges = index.offsetRangeToDocRanges(0, index.text.length); + expect(ranges).toHaveLength(2); + }); +}); + describe('SearchIndex searchModel: visible', () => { function textNode(text, { deleted = false } = {}) { return { @@ -155,6 +216,19 @@ describe('SearchIndex searchModel: visible', () => { }; } + function leafNode(typeName, { deleted = false, leafText = undefined } = {}) { + return { + isText: false, + isLeaf: true, + isInline: true, + isBlock: false, + type: { name: typeName, spec: leafText ? { leafText } : {} }, + nodeSize: 1, + marks: deleted ? [{ type: { name: 'trackDelete' } }] : [], + forEach: () => {}, + }; + } + function containerNode(children, { isBlock = false, textBetween = '' } = {}) { return { isText: false, @@ -204,6 +278,23 @@ describe('SearchIndex searchModel: visible', () => { return { doc }; } + function buildDocWithTrackedDeletedLineBreak() { + const paragraph = containerNode( + [textNode('before'), leafNode('lineBreak', { deleted: true, leafText: () => '\n' }), textNode('after')], + { + isBlock: true, + textBetween: 'before\nafter', + }, + ); + + const doc = containerNode([paragraph], { + isBlock: false, + textBetween: 'before\nafter', + }); + + return { doc }; + } + it('excludes pending tracked deletions in visible model', () => { const { doc } = buildDocWithTrackedDeletion(); const index = new SearchIndex(); @@ -250,4 +341,14 @@ describe('SearchIndex searchModel: visible', () => { expect(ranges[0].to - ranges[0].from).toBe('after'.length); expect(ranges[0]).toEqual({ from: 13, to: 18 }); }); + + it('excludes pending tracked deletions on leaf nodes in visible model', () => { + const { doc } = buildDocWithTrackedDeletedLineBreak(); + const index = new SearchIndex(); + + index.ensureValid(doc, { searchModel: 'visible' }); + + expect(index.search('\n')).toHaveLength(0); + expect(index.search('beforeafter')).toHaveLength(0); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/search/search.js b/packages/super-editor/src/editors/v1/extensions/search/search.js index 8679aa08e3..e3fcbb196b 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/search.js +++ b/packages/super-editor/src/editors/v1/extensions/search/search.js @@ -49,11 +49,12 @@ const mapIndexMatchesToDocMatches = ({ searchIndex, indexMatches, doc, positionT if (ranges.length === 0) continue; const matchTexts = ranges.map((r) => doc.textBetween(r.from, r.to)); + const matchText = typeof indexMatch.text === 'string' ? indexMatch.text : matchTexts.join(''); const match = { from: ranges[0].from, to: ranges[ranges.length - 1].to, - text: matchTexts.join(''), + text: matchText, id: uuidv4(), ranges, trackerIds: [], diff --git a/packages/super-editor/src/headless-toolbar/helpers/document.ts b/packages/super-editor/src/headless-toolbar/helpers/document.ts index 958f90e669..ff0023a1d0 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/document.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/document.ts @@ -144,6 +144,16 @@ export const createZoomStateDeriver = }; }; +export const createZoomFitWidthStateDeriver = + () => + ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => { + const mode = typeof superdoc?.getZoomState === 'function' ? superdoc.getZoomState()?.mode : undefined; + return { + active: mode === 'fit-width', + disabled: !context || typeof superdoc?.setZoomMode !== 'function', + }; + }; + export const createDocumentModeStateDeriver = () => ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => { @@ -182,6 +192,18 @@ export const createZoomExecute = return true; }; +// Toggle fit-width mode. A second activation returns to manual at the +// current value, matching toolbar toggle conventions; numeric zoom stays +// on the separate `zoom` command. +export const createZoomFitWidthExecute = + () => + ({ superdoc }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => { + if (typeof superdoc?.setZoomMode !== 'function') return false; + const mode = typeof superdoc.getZoomState === 'function' ? superdoc.getZoomState()?.mode : undefined; + superdoc.setZoomMode(mode === 'fit-width' ? 'manual' : 'fit-width'); + return true; + }; + export const createDocumentModeExecute = () => ({ superdoc, payload }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => { diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 541e886596..dedc6fe3b2 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -986,6 +986,75 @@ describe('createToolbarRegistry', () => { }); }); + it('derives zoom-fit-width active state from the host zoom mode', () => { + const registry = createToolbarRegistry(); + + const fitActive = registry['zoom-fit-width']?.state({ + context: createContext(), + superdoc: { + getZoomState: vi.fn(() => ({ mode: 'fit-width', value: 84, fitZoom: 84, min: 10, max: 100 })), + setZoomMode: vi.fn(), + }, + }); + expect(fitActive).toEqual({ active: true, disabled: false }); + + const manual = registry['zoom-fit-width']?.state({ + context: createContext(), + superdoc: { + getZoomState: vi.fn(() => ({ mode: 'manual', value: 100, fitZoom: null, min: 10, max: 100 })), + setZoomMode: vi.fn(), + }, + }); + expect(manual).toEqual({ active: false, disabled: false }); + }); + + it('disables zoom-fit-width without a context or a setZoomMode host bridge', () => { + const registry = createToolbarRegistry(); + + const noContext = registry['zoom-fit-width']?.state({ + context: null, + superdoc: { setZoomMode: vi.fn(), getZoomState: vi.fn(() => ({ mode: 'manual' })) }, + }); + expect(noContext?.disabled).toBe(true); + + const noBridge = registry['zoom-fit-width']?.state({ + context: createContext(), + superdoc: {}, + }); + expect(noBridge?.disabled).toBe(true); + }); + + it('zoom-fit-width execute toggles between fit-width and manual', () => { + const registry = createToolbarRegistry(); + + const setZoomMode = vi.fn(); + const fromManual = registry['zoom-fit-width']?.execute?.({ + context: createContext(), + superdoc: { + setZoomMode, + getZoomState: vi.fn(() => ({ mode: 'manual', value: 100, fitZoom: null, min: 10, max: 100 })), + }, + }); + expect(fromManual).toBe(true); + expect(setZoomMode).toHaveBeenCalledWith('fit-width'); + + const setZoomModeBack = vi.fn(); + registry['zoom-fit-width']?.execute?.({ + context: createContext(), + superdoc: { + setZoomMode: setZoomModeBack, + getZoomState: vi.fn(() => ({ mode: 'fit-width', value: 84, fitZoom: 84, min: 10, max: 100 })), + }, + }); + expect(setZoomModeBack).toHaveBeenCalledWith('manual'); + + const noBridge = registry['zoom-fit-width']?.execute?.({ + context: createContext(), + superdoc: {}, + }); + expect(noBridge).toBe(false); + }); + it('enables track-changes accept-selection when selection contains tracked changes and action is allowed', () => { collectTrackedChangesMock.mockReturnValueOnce([{ id: 'tc-1' }]); isTrackedChangeActionAllowedMock.mockReturnValueOnce(true); diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts index 3ea56a72d4..bd96c6a229 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts @@ -8,6 +8,8 @@ import { createRulerExecute, createRulerStateDeriver, createZoomExecute, + createZoomFitWidthExecute, + createZoomFitWidthStateDeriver, createZoomStateDeriver, } from './helpers/document.js'; import { @@ -187,6 +189,11 @@ export const createToolbarRegistry = (): Partial { expect(ok).toHaveBeenCalledTimes(2); }); }); + +describe('ui.zoom', () => { + let teardown: Array<() => void> = []; + + afterEach(() => { + teardown.forEach((fn) => fn()); + teardown = []; + }); + + const attachZoomSurface = (superdoc: ReturnType) => { + const zoomHost = { + state: { + mode: 'manual' as 'manual' | 'fit-width', + value: 100, + fitZoom: null as number | null, + min: 10, + max: 100, + }, + metrics: null as { availableWidth: number; documentWidth: number; fitZoom: number } | null, + setZoom: vi.fn(), + setZoomMode: vi.fn(), + }; + superdoc.getZoomState = vi.fn(() => ({ ...zoomHost.state })); + superdoc.getViewportMetrics = vi.fn(() => zoomHost.metrics); + superdoc.setZoom = zoomHost.setZoom; + superdoc.setZoomMode = zoomHost.setZoomMode; + return zoomHost; + }; + + it('degrades to a static manual/100 snapshot when the host lacks the zoom surface', () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + expect(ui.zoom.getSnapshot()).toEqual({ + mode: 'manual', + value: 100, + fitZoom: null, + min: 10, + max: 100, + metrics: null, + }); + // Mutations are no-ops, not crashes. + expect(() => ui.zoom.set(150)).not.toThrow(); + expect(() => ui.zoom.setMode('fit-width')).not.toThrow(); + }); + + it('snapshots the host zoom state and metrics', () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + zoomHost.state = { mode: 'fit-width', value: 84, fitZoom: 84, min: 25, max: 100 }; + zoomHost.metrics = { availableWidth: 685, documentWidth: 816, fitZoom: 84 }; + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + expect(ui.zoom.getSnapshot()).toEqual({ + mode: 'fit-width', + value: 84, + fitZoom: 84, + min: 25, + max: 100, + metrics: { availableWidth: 685, documentWidth: 816, fitZoom: 84 }, + }); + }); + + it('observes mode-only transitions via zoomChange', async () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + teardown.push(ui.zoom.observe(cb)); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0].mode).toBe('manual'); + + zoomHost.state = { ...zoomHost.state, mode: 'fit-width' }; + superdoc.fireSuperdoc('zoomChange', { zoom: 100, mode: 'fit-width' }); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb.mock.calls[1][0].mode).toBe('fit-width'); + expect(cb.mock.calls[1][0].value).toBe(100); + }); + + it('observes viewport metric updates via viewport-change', async () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + teardown.push(ui.zoom.observe(cb)); + expect(cb).toHaveBeenCalledTimes(1); + + zoomHost.state = { ...zoomHost.state, fitZoom: 74 }; + zoomHost.metrics = { availableWidth: 600, documentWidth: 816, fitZoom: 74 }; + superdoc.fireSuperdoc('viewport-change', zoomHost.metrics); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb.mock.calls[1][0].fitZoom).toBe(74); + expect(cb.mock.calls[1][0].metrics).toEqual({ availableWidth: 600, documentWidth: 816, fitZoom: 74 }); + }); + + it('does not re-fire observers when zoom state is unchanged', async () => { + const superdoc = makeSuperdocStub(); + attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + teardown.push(ui.zoom.observe(cb)); + expect(cb).toHaveBeenCalledTimes(1); + + // Unrelated recompute trigger with identical zoom state. + superdoc.fireSuperdoc('document-mode-change', { documentMode: 'viewing' }); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('routes set and setMode to the host zoom surface', () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + ui.zoom.set(125); + expect(zoomHost.setZoom).toHaveBeenCalledWith(125); + + ui.zoom.setMode('fit-width'); + expect(zoomHost.setZoomMode).toHaveBeenCalledWith('fit-width'); + }); +}); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index e577285f62..84e46a51b1 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -72,6 +72,9 @@ import type { ContentControlFocusResult, ViewportRect, ViewportRectResult, + ZoomHandle, + ZoomMode, + ZoomSlice, } from './types.js'; /** @@ -110,7 +113,7 @@ const EDITOR_EVENTS = [ */ const LIST_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded', 'tracked-changes-changed'] as const; -const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const; +const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange', 'viewport-change'] as const; /** * Presentation-editor events the controller listens to. These signal @@ -701,6 +704,64 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { * either field, but they do trigger computeState rebuilds). */ let documentMemo: { slice: DocumentSlice } | null = null; + let zoomMemo: { slice: ZoomSlice } | null = null; + + // Static fallback for hosts without the zoom surface (older builds, + // minimal stubs): manual mode at 100% with no metrics. + const FALLBACK_ZOOM_SLICE: ZoomSlice = Object.freeze({ + mode: 'manual', + value: 100, + fitZoom: null, + min: 10, + max: 100, + metrics: null, + }); + + // Read the host zoom state + metrics into one slice. Memoized on the + // field values. Metrics compare by reference, which is equivalent to a + // field-wise compare because the host's viewport-fit store replaces the + // (frozen) metrics object only when a field actually changed; if that + // invariant moves, switch this to field-wise. `shallowEqual` on + // `state.zoom` then short-circuits `ui.zoom.observe` while nothing + // zoom-related changes. + const computeZoomSlice = (): ZoomSlice => { + if (typeof superdoc.getZoomState !== 'function') return FALLBACK_ZOOM_SLICE; + let state: ReturnType> | null = null; + try { + state = superdoc.getZoomState(); + } catch { + state = null; + } + if (!state) return FALLBACK_ZOOM_SLICE; + let metrics: ZoomSlice['metrics'] = null; + try { + metrics = superdoc.getViewportMetrics?.() ?? null; + } catch { + metrics = null; + } + const prev = zoomMemo?.slice; + if ( + prev && + prev.mode === state.mode && + prev.value === state.value && + prev.fitZoom === state.fitZoom && + prev.min === state.min && + prev.max === state.max && + prev.metrics === metrics + ) { + return prev; + } + const slice: ZoomSlice = { + mode: state.mode, + value: state.value, + fitZoom: state.fitZoom, + min: state.min, + max: state.max, + metrics, + }; + zoomMemo = { slice }; + return slice; + }; /** * Internal dirty flag. Flipped to `true` by any editor transaction @@ -937,6 +998,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { documentMode, document: documentSlice, selection: selectionSlice, + zoom: computeZoomSlice(), toolbar: { context: toolbarSnapshot.context, commands: builtInCommands } as ToolbarSnapshotSlice, comments: { total: commentsListCache.total, @@ -1034,7 +1096,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; // zoomChange fires *before* the re-render, so notifying then would hand // consumers stale rects. Tag the next post-paint layout flush as 'zoom'. - const onGeometryZoom = () => { + // Only a changed VALUE schedules a repaint; mode-only transitions + // (setZoomMode with an unchanged value) would latch a tag no flush ever + // consumes, mis-labeling the next unrelated layout notification. + let lastGeometryZoomValue: number | null = (() => { + try { + return superdoc.getZoomState?.().value ?? null; + } catch { + return null; + } + })(); + const onGeometryZoom = (...args: unknown[]) => { + const payload = args[0] as { zoom?: number } | undefined; + const nextZoom = typeof payload?.zoom === 'number' ? payload.zoom : null; + if (nextZoom !== null && nextZoom === lastGeometryZoomValue) return; + lastGeometryZoomValue = nextZoom; zoomPending = true; }; const onGeometryLayout = () => { @@ -2328,6 +2404,42 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // ---- ui.zoom ----------------------------------------------------------- + // One slice for zoom UIs (mode, value, fit zoom, bounds, metrics) plus + // the two mutations. Recomputes on the host's zoomChange (which now + // includes mode-only transitions) and viewport-change events; both are + // in SUPERDOC_EVENTS. + const zoom: ZoomHandle = { + getSnapshot: () => computeState().zoom, + observe(listener) { + return select((state) => state.zoom, shallowEqual).subscribe((snapshot) => { + try { + listener(snapshot); + } catch { + // see scheduleNotify + } + }); + }, + set(percent: number) { + const setter = superdoc.setZoom; + if (typeof setter !== 'function') return; + try { + setter.call(superdoc, percent); + } catch (err) { + console.error('[superdoc/ui] ui.zoom.set failed:', err); + } + }, + setMode(mode: ZoomMode) { + const setter = superdoc.setZoomMode; + if (typeof setter !== 'function') return; + try { + setter.call(superdoc, mode); + } catch (err) { + console.error('[superdoc/ui] ui.zoom.setMode failed:', err); + } + }, + }; + // Live scopes created via `ui.createScope()`. The controller's // `destroy()` cascades into every entry before tearing down its own // resources, so consumers do not need to call `scope.destroy()` @@ -2597,6 +2709,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { selection, viewport, document, + zoom, createScope: createScopeFn, destroy, }; diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 68e778013e..e3594e6773 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -148,4 +148,8 @@ export type { DocumentExportInput, DocumentHandle, DocumentSlice, + ZoomHandle, + ZoomMode, + ZoomSlice, + ZoomViewportMetrics, } from './types.js'; diff --git a/packages/super-editor/src/ui/react/hooks.ts b/packages/super-editor/src/ui/react/hooks.ts index 7beba5ca28..aa92e0d9fd 100644 --- a/packages/super-editor/src/ui/react/hooks.ts +++ b/packages/super-editor/src/ui/react/hooks.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { shallowEqual } from '../equality.js'; import type { CommentsSlice, @@ -8,6 +8,8 @@ import type { SelectionSlice, ToolbarSnapshotSlice, UIToolbarCommandState, + ZoomMode, + ZoomSlice, } from '../types.js'; import { useSuperDocSlice, useSuperDocUI } from './provider.js'; @@ -87,6 +89,61 @@ export function useSuperDocDocument(): DocumentSlice { return useSuperDocSlice((ui) => ui.select((state) => state.document, shallowEqual), EMPTY_DOCUMENT); } +const EMPTY_ZOOM: ZoomSlice = { + mode: 'manual', + value: 100, + fitZoom: null, + min: 10, + max: 100, + metrics: null, +}; + +/** + * Zoom state + actions for custom zoom UIs. The snapshot updates on + * value changes, mode-only transitions, and viewport metric updates; + * `set(percent)` switches the host to manual mode by contract, + * `setMode('fit-width')` re-enters automatic fitting. + * + * ```tsx + * const zoom = useSuperDocZoom(); + * return ( + * <> + * {zoom.value}% + * + * + * ); + * ``` + */ +export function useSuperDocZoom(): ZoomSlice & { + set: (percent: number) => void; + setMode: (mode: ZoomMode) => void; +} { + const ui = useSuperDocUI(); + const slice = useSuperDocSlice((controller) => controller.select((state) => state.zoom, shallowEqual), EMPTY_ZOOM); + const set = useCallback( + (percent: number) => { + ui?.zoom.set(percent); + }, + [ui], + ); + const setMode = useCallback( + (mode: ZoomMode) => { + ui?.zoom.setMode(mode); + }, + [ui], + ); + // Memoized so the returned object keeps its identity while the slice and + // actions are unchanged; the controller-side slice memo makes `slice` + // reference-stable, and effects keyed on this hook's result must not + // re-run on unrelated parent renders. + return useMemo(() => ({ ...slice, set, setMode }), [slice, set, setMode]); +} + const FALLBACK_COMMAND_STATE: UIToolbarCommandState = { active: false, disabled: true, diff --git a/packages/super-editor/src/ui/react/index.ts b/packages/super-editor/src/ui/react/index.ts index a285bb6c15..3f0ea99465 100644 --- a/packages/super-editor/src/ui/react/index.ts +++ b/packages/super-editor/src/ui/react/index.ts @@ -38,4 +38,5 @@ export { useSuperDocToolbar, useSuperDocCommand, useSuperDocDocument, + useSuperDocZoom, } from './hooks.js'; diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 2169b66ef9..16e7077842 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -36,12 +36,12 @@ export interface Subscribable { /** * Event names the UI controller (`createSuperDocUI`) subscribes to on - * a SuperDoc-like host. Narrower than - * `HeadlessToolbarSuperdocHostEvent` (which adds - * `formatting-marks-change`); a custom UI host stub only has to - * support the three events the UI controller actually consumes. + * a SuperDoc-like host. Differs from `HeadlessToolbarSuperdocHostEvent` + * (which adds `formatting-marks-change` but not `viewport-change`); a + * custom UI host stub only has to support the four events the UI + * controller actually consumes. */ -export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange'; +export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'viewport-change'; /** * Structural typing for the SuperDoc instance. Keeps the UI controller @@ -82,6 +82,42 @@ export interface SuperDocLike { * browser test stubs stay valid without a host implementation. */ export?(options?: DocumentExportInput): Promise; + /** + * Optional zoom bridge consumed by `ui.zoom`. Mirrors the superdoc + * package's zoom surface (`setZoom` switches the mode to manual, + * `setZoomMode` toggles fitting, the getters snapshot state and + * viewport metrics). All optional so stubs and older hosts stay + * valid; `ui.zoom` degrades to a static manual/100 snapshot. + */ + setZoom?(percent: number): unknown; + setZoomMode?(mode: ZoomMode): unknown; + getZoomState?(): { + mode: ZoomMode; + value: number; + fitZoom: number | null; + min: number; + max: number; + }; + getViewportMetrics?(): ZoomViewportMetrics | null; +} + +/** + * Zoom mode mirrored from the superdoc package: `manual` holds the + * last-set value, `fit-width` continuously re-fits to the container. + */ +export type ZoomMode = 'manual' | 'fit-width'; + +/** + * Pure viewport measurements mirrored from the superdoc package's + * `viewport-change` payload / `getViewportMetrics()`. + */ +export interface ZoomViewportMetrics { + /** Width available to the document in pixels (container minus the comments sidebar). */ + availableWidth: number; + /** Widest document page width in pixels at 100% zoom. */ + documentWidth: number; + /** Unclamped zoom percentage that fits the document in the available width. */ + fitZoom: number; } export interface SuperDocEditorLike { @@ -309,6 +345,33 @@ export interface SuperDocUIState { * document transactions; `activeIds` derives from the selection. */ contentControls: ContentControlsSlice; + /** + * Zoom slice. Sourced from the host's `getZoomState()` / + * `getViewportMetrics()` and recomputed on `zoomChange` / + * `viewport-change`. Hosts without the zoom surface yield a static + * manual/100 snapshot. + */ + zoom: ZoomSlice; +} + +/** + * Zoom snapshot exposed on `state.zoom` and through `ui.zoom`. Combines + * the host's zoom state (mode, value, fit bounds) with the latest + * viewport metrics so a zoom UI renders from one slice. + */ +export interface ZoomSlice { + /** Current zoom mode. */ + mode: ZoomMode; + /** Current zoom value as a percentage. */ + value: number; + /** Latest unclamped fit zoom, or `null` before the first viewport measurement. */ + fitZoom: number | null; + /** Effective lower bound of the fit-width policy. */ + min: number; + /** Effective upper bound of the fit-width policy. */ + max: number; + /** Latest viewport measurements, or `null` before editors mount. */ + metrics: ZoomViewportMetrics | null; } /** @@ -811,6 +874,17 @@ export interface SuperDocUI { */ document: DocumentHandle; + /** + * Zoom domain. One slice for zoom UIs (mode, value, fit zoom, + * bounds, viewport metrics) plus the two mutations: `set(percent)` + * (numeric zoom, switches the host to manual mode) and + * `setMode('fit-width' | 'manual')`. Sugar over `state.zoom` and + * passthroughs to the host's `setZoom` / `setZoomMode`; the slice + * recomputes on the host's `zoomChange` and `viewport-change` + * events, including mode-only transitions. + */ + zoom: ZoomHandle; + /** * Create a {@link SuperDocUIScope} for collecting subscriptions, * custom-command registrations, and DOM listeners under one @@ -1048,6 +1122,36 @@ export interface DocumentHandle { replaceFile(file: File): Promise; } +/** + * Zoom domain handle (`ui.zoom`). Read / observe the {@link ZoomSlice} + * and mutate through the host's zoom surface. Hosts without zoom + * methods (older builds, minimal stubs) degrade gracefully: the slice + * is a static manual/100 snapshot and the mutations are no-ops. + */ +export interface ZoomHandle { + /** Current zoom snapshot. */ + getSnapshot(): ZoomSlice; + /** + * Subscribe to zoom snapshots. Fires on value changes, mode-only + * transitions, and fit-relevant viewport metric updates (the host's + * deduped `viewport-change`); `getSnapshot()` always reads the + * latest stored metrics. Returns the unsubscribe function; pair + * with `scope.add(...)` for lifecycle handling. + */ + observe(listener: (snapshot: ZoomSlice) => void): () => void; + /** + * Set a numeric zoom percentage. Routes through `superdoc.setZoom`, + * which switches the mode to `manual` by contract. + */ + set(percent: number): void; + /** + * Switch the zoom mode. Routes through `superdoc.setZoomMode`; + * `'fit-width'` applies the fit immediately when viewport metrics + * are available. + */ + setMode(mode: ZoomMode): void; +} + /** * Selection domain handle exposed on `ui.selection`. Same shape as * `CommentsHandle` / `TrackChangesHandle`: snapshot + subscription. Mirrors diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 0484dc8387..5c2648d27f 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -193,7 +193,6 @@ "@hocuspocus/provider": "catalog:", "@hocuspocus/server": "catalog:", "@superdoc-dev/superdoc-yjs-collaboration": "workspace:*", - "@types/uuid": "catalog:", "@superdoc/common": "workspace:*", "@superdoc/contracts": "workspace:*", "@superdoc/super-editor": "workspace:*", diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index 446c9a5758..c54ca5a41e 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -54,6 +54,7 @@ but have separate script chains because the validation needs differ. | `check:public` | `check:public:superdoc` + `check:public:docapi` | Both public interfaces. The umbrella to run before merging. | | `check:public:superdoc` | `check:public-contract` (legacy alias) | SuperDoc package: vite build + postbuild chain, consumer typecheck matrix, deep-type audit. | | `check:public:docapi` | `docapi:check` (legacy alias) | Document API: contract parity, generated outputs are not stale, examples compile, overview alignment. Clean-checkout safe: gitignored outputs (`packages/document-api/generated/`) are built in memory; tracked outputs (`apps/docs/document-api/reference/`, overview block) are still compared byte-for-byte. | +| `check:font-licenses` | `node shared/font-system/scripts/check-bundled-font-licenses.mjs` | Bundled font compliance: every shipped WOFF2 has legal metadata, stable hash, notices, and a runtime manifest entry. Also runs inside `check:public:superdoc`. | | `report:public:superdoc` | `report:public-contract` (legacy alias) | Read-only tier metadata (supported / legacy / legacy-raw / asset / deprecated). Not a gate. | ### TypeScript compiler @@ -152,7 +153,9 @@ what an actual consumer would see — not the workspace source. Seven of these run as wrapper stages of `check:public:superdoc`. `public-method-coverage` runs alongside the cheap policy gates (`contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, -`jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`) before `build`. The other +`jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`). `font-license-gate` +runs after those cheap gates and before `build`, so a new bundled font +without a legal manifest row or notices fails before packaging. The other six run after `build`: `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`, diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 700a1aa1c2..dd72c75a1d 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { h, defineComponent, ref, shallowRef, reactive, nextTick } from 'vue'; -import { DOCX } from '@superdoc/common'; +import { DOCX, PDF } from '@superdoc/common'; import { Schema } from 'prosemirror-model'; import { EditorState, TextSelection } from 'prosemirror-state'; import { Mapping, StepMap } from 'prosemirror-transform'; @@ -104,6 +104,15 @@ const CommentsLayerStub = stubComponent('CommentsLayer'); const HrbrFieldsLayerStub = stubComponent('HrbrFieldsLayer'); const AiLayerStub = stubComponent('AiLayer'); const HtmlViewerStub = stubComponent('HtmlViewer'); +const PdfViewerStub = defineComponent({ + name: 'PdfViewer', + props: ['file', 'fileId', 'config', 'initialScale'], + emits: ['page-rendered', 'document-ready', 'selection-raw', 'bypass-selection'], + setup(_props, { expose }) { + expose({ updateScale: vi.fn() }); + return () => h('div', { class: 'sd-pdf-viewer' }); + }, +}); const createTrackedChangeIndexStub = () => ({ subscribe: vi.fn(() => () => {}), @@ -145,6 +154,16 @@ vi.mock('./components/HtmlViewer/HtmlViewer.vue', () => ({ default: HtmlViewerStub, })); +vi.mock('./components/PdfViewer/PdfViewer.vue', () => ({ + // SuperDoc.vue loads PdfViewer through defineAsyncComponent, so Vue + // receives this module namespace and interop-probes it (__isTeleport + // etc.); vitest's strict mock proxy throws on undeclared exports. The + // __esModule flag makes Vue's resolver take `default` immediately, + // before any probing. + __esModule: true, + default: PdfViewerStub, +})); + vi.mock('@superdoc/components/CommentsLayer/CommentDialog.vue', () => ({ default: CommentDialogStub, })); @@ -189,6 +208,8 @@ const buildSuperdocStore = () => { selectionPosition: ref(null), activeSelection: ref(null), activeZoom: ref(100), + zoomMode: ref('manual'), + viewportMetrics: ref(null), modules: reactive({ comments: { readOnly: false }, ai: {}, 'hrbr-fields': [] }), handlePageReady: vi.fn(), user: { name: 'Ada', email: 'ada@example.com' }, @@ -338,6 +359,7 @@ const mountComponent = async ( const createSuperdocStub = () => { const toolbar = { config: { aiApiKey: 'abc' }, setActiveEditor: vi.fn(), updateToolbarState: vi.fn() }; const runtimeMap = new Map(); + const eventHandlers = new Map(); return { config: { modules: { comments: {}, ai: {}, toolbar: {}, pdf: {} }, @@ -365,8 +387,24 @@ const createSuperdocStub = () => { getActiveRuntime: vi.fn(() => null), activateRuntimeFromEventTarget: vi.fn(() => false), lockSuperdoc: vi.fn(), - emit: vi.fn(), - listeners: vi.fn(), + on: vi.fn((eventName, handler) => { + const handlers = eventHandlers.get(eventName) ?? new Set(); + handlers.add(handler); + eventHandlers.set(eventName, handlers); + return undefined; + }), + off: vi.fn((eventName, handler) => { + eventHandlers.get(eventName)?.delete(handler); + return undefined; + }), + emit: vi.fn((eventName, payload) => { + const handlers = eventHandlers.get(eventName); + if (handlers) { + for (const handler of [...handlers]) handler(payload); + } + return true; + }), + listeners: vi.fn((eventName) => [...(eventHandlers.get(eventName) ?? [])]), captureLayoutPipelineEvent: vi.fn(), canPerformPermission: vi.fn(() => true), }; @@ -2914,4 +2952,462 @@ describe('SuperDoc.vue', () => { const styleVars = wrapper.vm.superdocStyleVars; expect(styleVars['--sd-comments-highlight-hover']).toBe('#abcdef88'); }); + + describe('viewport-change + fit-to-container', () => { + // Letter page: 8.5in * 96 = 816px base width through the page-styles path. + const stubPageStylesEditor = (superdocStub) => { + superdocStub.activeEditor = { + getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })), + }; + }; + + const setContainerWidth = (wrapper, width) => { + const rootEl = wrapper.find('.superdoc').element; + const parentEl = rootEl.parentElement; + Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: width }); + if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: width }); + }; + + const viewportChangeCalls = (superdocStub) => + superdocStub.emit.mock.calls.filter(([name]) => name === 'viewport-change'); + + it('does not emit viewport-change before isReady', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.isReady.value = false; + await nextTick(); + + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + + expect(viewportChangeCalls(superdocStub).length).toBe(0); + }); + + it('emits viewport-change with page-styles-derived widths when ready', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1]).toEqual({ + availableWidth: 1200, + documentWidth: 816, + fitZoom: 147, + }); + }); + + it('keeps documentWidth zoom-independent (page styles win over scaled DOM)', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + // A zoom applied before the first measurement must not corrupt the base. + superdocStoreStub.activeZoom.value = 50; + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1].documentWidth).toBe(816); + expect(calls[0][1].fitZoom).toBe(147); + }); + + it('falls back to page styles when laid-out pages are unavailable', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.activeEditor = { + getPages: vi.fn(() => { + throw new Error('layout not ready'); + }), + getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })), + }; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1]).toEqual({ + availableWidth: 1200, + documentWidth: 816, + fitZoom: 147, + }); + }); + + it('dedupes width changes that round to the same fit', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // 1199 / 816 rounds to the same fitZoom (147) as 1200 / 816. + setContainerWidth(wrapper, 1199); + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + await nextTick(); + + expect(viewportChangeCalls(superdocStub).length).toBe(1); + // The event dedupes, but stored metrics stay latest: reads must see + // the 1199 measurement the deduped event skipped. + expect(superdocStoreStub.viewportMetrics.value.availableWidth).toBe(1199); + + // A materially different width emits again. + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(2); + expect(calls[1][1].fitZoom).toBe(74); + }); + + it('skips emission entirely while no document width is measurable', async () => { + const superdocStub = createSuperdocStub(); + // No activeEditor and no laid-out DOM: base width unresolvable. + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(viewportChangeCalls(superdocStub).length).toBe(0); + }); + + it('does not scan PDF page DOM for DOCX-only documents', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + const rootEl = wrapper.find('.superdoc').element; + const querySelectorAllSpy = vi.spyOn(rootEl, 'querySelectorAll'); + + const pageEl = document.createElement('div'); + pageEl.className = 'sd-pdf-viewer-page'; + rootEl.appendChild(pageEl); + + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(querySelectorAllSpy).not.toHaveBeenCalledWith('.sd-pdf-viewer-page'); + expect(viewportChangeCalls(superdocStub)[0][1].documentWidth).toBe(816); + }); + + const zoomChangeCalls = (superdocStub) => superdocStub.emit.mock.calls.filter(([name]) => name === 'zoomChange'); + + it('stores the latest metrics for getViewportMetrics()', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(superdocStoreStub.viewportMetrics.value).toEqual({ + availableWidth: 1200, + documentWidth: 816, + fitZoom: 147, + }); + }); + + it('fit-width mode applies the fit with default clamping (max 100)', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width' }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // fitZoom = 600 / 816 = 74; within [10, 100] so applied as-is. The + // fit writes the store directly (setZoom would flip mode to manual). + expect(superdocStoreStub.activeZoom.value).toBe(74); + expect(zoomChangeCalls(superdocStub)).toEqual([['zoomChange', { zoom: 74, mode: 'fit-width' }]]); + expect(viewportChangeCalls(superdocStub)[0][1].fitZoom).toBe(74); + }); + + it('clamps the applied fit but emits the raw fitZoom', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width', fitWidth: { min: 80 } }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + setContainerWidth(wrapper, 408); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // Raw fit is 50 (408 / 816); applied value clamps to min 80. + expect(viewportChangeCalls(superdocStub)[0][1].fitZoom).toBe(50); + expect(superdocStoreStub.activeZoom.value).toBe(80); + }); + + it('padding shapes the applied fit only, never the metrics', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width', fitWidth: { padding: 96 } }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + superdocStoreStub.activeZoom.value = 50; + setContainerWidth(wrapper, 912); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // Metrics are policy-free: availableWidth stays 912 and fitZoom is + // the raw ratio. The applied fit reserves the padding: (912 - 96) / + // 816 = 100. + expect(viewportChangeCalls(superdocStub)[0][1]).toEqual({ + availableWidth: 912, + documentWidth: 816, + fitZoom: 112, + }); + expect(superdocStoreStub.activeZoom.value).toBe(100); + }); + + it('subtracts the comments sidebar width through the owned template ref', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + commentsStoreStub.pendingComment.value = { commentId: 'pending-1', selection: { selectionBounds: {} } }; + await nextTick(); + + const sidebarEl = wrapper.find('.superdoc__right-sidebar').element; + Object.defineProperty(sidebarEl, 'offsetWidth', { configurable: true, value: 240 }); + + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1]).toEqual({ + availableWidth: 960, + documentWidth: 816, + fitZoom: 118, + }); + }); + + it('does not re-apply zoom when the target equals the current zoom', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width' }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + superdocStoreStub.activeZoom.value = 74; + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // Same-value guard: target 74 equals current zoom, so no write and + // no zoomChange, while the viewport-change event still emits. + expect(superdocStoreStub.activeZoom.value).toBe(74); + expect(zoomChangeCalls(superdocStub).length).toBe(0); + expect(viewportChangeCalls(superdocStub).length).toBe(1); + }); + + it('manual mode never applies the fit (metrics still emit)', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { fitWidth: { min: 25 } }; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(viewportChangeCalls(superdocStub).length).toBe(1); + expect(superdocStoreStub.activeZoom.value).toBe(100); + expect(zoomChangeCalls(superdocStub).length).toBe(0); + }); + + it('switching zoomMode to fit-width applies the fit immediately', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = {}; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(superdocStoreStub.activeZoom.value).toBe(100); + + superdocStoreStub.zoomMode.value = 'fit-width'; + await nextTick(); + + expect(superdocStoreStub.activeZoom.value).toBe(74); + expect(zoomChangeCalls(superdocStub)).toEqual([['zoomChange', { zoom: 74, mode: 'fit-width' }]]); + }); + + it('resolves documentWidth as the widest page across documents', async () => { + const superdocStub = createSuperdocStub(); + + const wrapper = await mountComponent(superdocStub); + // Two DOCX documents: portrait letter (8.5in) and landscape (11in). + // Zoom is global, so the fit must target the widest page. + superdocStoreStub.documents.value = [ + { + id: 'doc-portrait', + type: DOCX, + editorMountNonce: ref(0), + getEditor: vi.fn(() => ({ getPageStyles: () => ({ pageSize: { width: 8.5, height: 11 } }) })), + setEditor: vi.fn(), + }, + { + id: 'doc-landscape', + type: DOCX, + editorMountNonce: ref(0), + getEditor: vi.fn(() => ({ getPageStyles: () => ({ pageSize: { width: 11, height: 8.5 } }) })), + setEditor: vi.fn(), + }, + ]; + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + // 11in * 96 = 1056: the widest page wins over 816. + expect(calls[0][1].documentWidth).toBe(1056); + expect(calls[0][1].fitZoom).toBe(114); + }); + + it('prefers the widest laid-out page over body page styles (landscape sections)', async () => { + const superdocStub = createSuperdocStub(); + // Portrait body section (8.5in) but a laid-out interior landscape + // page: the fit must target what the renderer paints (getPages max), + // exactly like SuperEditor's own container sizing. + superdocStub.activeEditor = { + getPages: vi.fn(() => [{ size: { w: 816 } }, { size: { w: 1056 } }]), + getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })), + }; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1].documentWidth).toBe(1056); + expect(calls[0][1].fitZoom).toBe(114); + }); + + it('re-evaluates document width after pagination updates', async () => { + const superdocStub = createSuperdocStub(); + let pages = [{ size: { w: 816 } }]; + superdocStub.activeEditor = { + getPages: vi.fn(() => pages), + getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })), + }; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(viewportChangeCalls(superdocStub)[0][1].documentWidth).toBe(816); + + pages = [{ size: { w: 816 } }, { size: { w: 1056 } }]; + superdocStub.emit.mockClear(); + superdocStub.emit('pagination-update', { totalPages: 2, superdoc: superdocStub }); + + expect(viewportChangeCalls(superdocStub).length).toBe(0); + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1]).toEqual({ + availableWidth: 1200, + documentWidth: 1056, + fitZoom: 114, + }); + }); + + it('resolves PDF page width scale-relatively (zoom-sync state cannot corrupt the base)', async () => { + const superdocStub = createSuperdocStub(); + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.documents.value = [ + { + id: 'pdf-1', + type: PDF, + data: { name: 'doc.pdf' }, + editorMountNonce: ref(0), + setEditor: vi.fn(), + getEditor: vi.fn(() => null), + }, + ]; + await nextTick(); + + // A rendered 612pt PDF page at 50% viewer zoom measures 408px with + // --scale-factor 2/3 (zoom * 96/72). The store zoom claims 100% (a + // seeded zoom the viewer has not applied yet); the resolver must + // trust the page's actual scale factor and report 816 regardless. + const pageEl = document.createElement('div'); + pageEl.className = 'sd-pdf-viewer-page'; + Object.defineProperty(pageEl, 'clientWidth', { configurable: true, value: 408 }); + wrapper.find('.superdoc').element.appendChild(pageEl); + + const originalGetComputedStyle = window.getComputedStyle.bind(window); + const computedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudo) => { + if (el === pageEl) { + return { getPropertyValue: (prop) => (prop === '--scale-factor' ? `${2 / 3}` : '') }; + } + return originalGetComputedStyle(el, pseudo); + }); + + try { + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1].documentWidth).toBeCloseTo(816, 6); + expect(calls[0][1].fitZoom).toBe(147); + } finally { + computedStyleSpy.mockRestore(); + } + }); + }); }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 0328040cf2..33262f0dbe 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -50,6 +50,7 @@ import { useAi } from './composables/use-ai'; import { useHighContrastMode } from './composables/use-high-contrast-mode'; import { useCommentSmallScreen } from './composables/use-comment-small-screen.js'; import { useCompactCommentPopover } from './composables/use-compact-comment-popover.js'; +import { useViewportFit } from './composables/use-viewport-fit.js'; import { getVisibleThreadAnchorClientY } from './helpers/comment-focus.js'; import { useUiFontFamily } from './composables/useUiFontFamily.js'; import { usePasswordPrompt } from './composables/use-password-prompt.js'; @@ -81,6 +82,8 @@ const { selectionPosition, activeSelection, activeZoom, + zoomMode, + viewportMetrics, } = storeToRefs(superdocStore); const { handlePageReady, modules, user, getDocument } = superdocStore; @@ -221,6 +224,7 @@ const superdocStyleVars = computed(() => { // Refs const superdocRoot = ref(null); const layers = ref(null); +const rightSidebarRef = ref(null); const pdfViewerRef = ref(null); const pendingReplayTrackedChangeSync = ref(false); const toolsMenuPosition = reactive({ top: null, right: '-25px', zIndex: 101 }); @@ -349,6 +353,7 @@ const handleDocumentReady = (documentId, container) => { if (!proxy.$superdoc.config.collaboration) isReady.value = true; } + ensureInitialFallbackZoom(); isFloatingCommentsReady.value = true; hasInitializedLocations.value = true; proxy.$superdoc.broadcastPdfDocumentReady(); @@ -495,6 +500,9 @@ const onEditorCreate = ({ editor }) => { * @param {PresentationEditor} payload.presentationEditor - The PresentationEditor wrapper */ const onEditorReady = ({ editor, presentationEditor }) => { + // Legacy (non-layout-engine) editors return early below; the seeded + // initial zoom for their CSS-fallback transform must apply first. + ensureInitialFallbackZoom(); if (!presentationEditor) return; // Store presentationEditor reference for mode changes @@ -1432,6 +1440,21 @@ watch(showCommentsSidebar, (value) => { proxy.$superdoc.broadcastSidebarToggle(value); }); +// Viewport fit tracking: maintains viewport metrics, emits `viewport-change`, +// and applies the fit-width zoom policy. See composables/use-viewport-fit.js. +useViewportFit({ + getSuperdoc: () => proxy.$superdoc, + superdocContainerWidth, + isReady, + activeZoom, + zoomMode, + viewportMetrics, + showCommentsSidebar, + rightSidebarRef, + superdocRoot, + documents, +}); + /** * Scroll the page to a given commentId * @@ -1760,6 +1783,43 @@ const handlePdfSelectionRaw = ({ selectionBounds, documentId, page }) => { handleSelectionChange(selection); }; +// Web layout without layout engine - apply CSS transform directly +// to non-PDF sub-document containers so zoom works for PM fallback rendering. +// PDF documents are excluded because pdfViewer.updateScale() handles their zoom +// separately; applying both would result in double-zoom. +const applyFallbackZoomStyles = (zoomFactor) => { + const subDocs = layers.value?.querySelectorAll('.superdoc__sub-document'); + subDocs?.forEach((el) => { + if (el.querySelector('.sd-pdf-viewer')) return; + if (zoomFactor === 1) { + el.style.transformOrigin = ''; + el.style.transform = ''; + el.style.width = ''; + } else { + el.style.transformOrigin = 'top left'; + el.style.transform = `scale(${zoomFactor})`; + el.style.width = `${100 / zoomFactor}%`; + } + }); +}; + +// One-time initial application for surfaces that only consume zoom +// imperatively. A seeded `zoom.initial` never fires the activeZoom watcher +// (the ref starts at the seeded value), and the fallback transform targets +// elements that do not exist until documents render - so apply once from +// the per-document ready hooks. PresentationEditor and PdfViewer take +// their initial value at creation (layoutEngineOptions.zoom / +// :initial-scale) and need nothing here. +let initialFallbackZoomApplied = false; +const ensureInitialFallbackZoom = () => { + if (initialFallbackZoomApplied) return; + if (proxy.$superdoc.config.useLayoutEngine !== false) return; + const zoomFactor = (activeZoom.value ?? 100) / 100; + if (zoomFactor === 1) return; + initialFallbackZoomApplied = true; + nextTick(() => applyFallbackZoomStyles(zoomFactor)); +}; + watch( () => activeZoom.value, (zoom) => { @@ -1768,23 +1828,8 @@ watch( if (proxy.$superdoc.config.useLayoutEngine !== false) { PresentationEditor.setGlobalZoom(zoomFactor); } else { - // Web layout without layout engine — apply CSS transform directly - // to non-PDF sub-document containers so zoom works for PM fallback rendering. - // PDF documents are excluded because pdfViewer.updateScale() handles their zoom - // separately below; applying both would result in double-zoom. - const subDocs = layers.value?.querySelectorAll('.superdoc__sub-document'); - subDocs?.forEach((el) => { - if (el.querySelector('.sd-pdf-viewer')) return; - if (zoomFactor === 1) { - el.style.transformOrigin = ''; - el.style.transform = ''; - el.style.width = ''; - } else { - el.style.transformOrigin = 'top left'; - el.style.transform = `scale(${zoomFactor})`; - el.style.width = `${100 / zoomFactor}%`; - } - }); + initialFallbackZoomApplied = true; + applyFallbackZoomStyles(zoomFactor); } const pdfViewer = getPDFViewer(); @@ -1926,6 +1971,7 @@ const getPDFViewer = () => { v-if="doc.type === PDF" :file="doc.data" :file-id="doc.id" + :initial-scale="(activeZoom ?? 100) / 100" :config="pdfConfig" @selection-raw="handlePdfSelectionRaw" @bypass-selection="handlePdfClick" @@ -1956,7 +2002,7 @@ const getPDFViewer = () => { -