diff --git a/.gitignore b/.gitignore
index 47686d6..1d58dfc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@ dist-demo
dist-ssr
test-results
playwright-report
+# Generated benchmark reports and local archives
+/docs/benchmarks/
+/benchmark-results/
*.local
# Editor directories and files
diff --git a/CLAUDE.md b/CLAUDE.md
index 9a13b52..5d06a69 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,7 +8,7 @@
## WASM Files
When updating docxodus:
-1. Install the explicitly targeted engine version; this integration pins `docxodus@12.6.1` and optional `@docxodus/export@12.6.1`.
+1. Install the explicitly targeted engine version; this integration pins `docxodus@12.6.2` and optional `@docxodus/export@12.6.2`.
2. Update matching pins and run `npm run sync:wasm`. Never patch the digest-verified runtime files.
3. Run `npm run check`, `npm run test:browser`, and the eligible-host PDF test.
4. Keep the complete API audit passing; React UI imports `docxodus/core`, not the upstream editor or `docxodus/react`.
diff --git a/README.md b/README.md
index b65275a..9d88cca 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# react-docxodus-viewer
React components and hooks for viewing, editing, reviewing, comparing, verifying,
-and exporting Word documents with **Docxodus 12.6.1**. The viewer uses the core
+and exporting Word documents with **Docxodus 12.6.2**. The viewer uses the core
engine and a React-owned pagination component; it does not load the upstream
browser editor or ribbon.
@@ -14,7 +14,7 @@ browser editor or ribbon.
Requires React 18+ and an ESM-capable application bundler.
```sh
-npm install react-docxodus-viewer docxodus@12.6.1
+npm install react-docxodus-viewer docxodus@12.6.2
npx rdv-copy-runtime public/docxodus
```
@@ -159,7 +159,7 @@ continue to style the viewer controls and panels.
- `/export-browser`: complete upstream browser export contracts/helpers. Keep the
materializer bundle static; use `loadBrowserExporter()` for actual bundled apps.
- `/server`: complete `@docxodus/export` Node/PDF API. Install the optional companion
- with `npm install @docxodus/export@12.6.1`.
+ with `npm install @docxodus/export@12.6.2`.
- `/assets`: Node build helper `copyDocxodusRuntime(directory)`.
- `/styles.css`: viewer, editor, and feature-panel styles.
@@ -192,7 +192,7 @@ for each opened file. Downloads retain the document name.
npm install
npm run dev # sync verified assets, then start Vite
npm run check # lint, unit tests, types, library/demo builds, API audit
-npm run test:browser # actual 12.6.1 WASM workflows
+npm run test:browser # actual 12.6.2 WASM workflows
npm run test:performance # NVCA opening, zoom, typing and layout benchmark
npm run test:stress # NVCA editing and independent DOCX integrity checks
npm run test:package # packed consumer imports, types and runtime-copy command
@@ -203,7 +203,7 @@ Set `DOCXODUS_REQUIRE_PDF=1` to make an unavailable PDF environment fail that te
Set `RDV_TEST_PORT` to run browser checks on a different local port. Use
`RDV_TEST_PREVIEW=1` after building to exercise the production demo. This runs the
studio and module examples; source-only API harness tests remain development checks.
-The API audit compares every published export against the installed 12.6.1
+The API audit compares every published export against the installed 12.6.2
TypeScript declarations and checks identity of the built runtime exports.
See the [performance campaign](docs/performance.md) for measurements, regression
guards, and the next optimization targets.
diff --git a/WASM_CDN_ISSUES.md b/WASM_CDN_ISSUES.md
index 06a5f1c..65db0d9 100644
--- a/WASM_CDN_ISSUES.md
+++ b/WASM_CDN_ISSUES.md
@@ -1,13 +1,13 @@
-# Docxodus 12.6.1 runtime deployment
+# Docxodus 12.6.2 runtime deployment
Deploy runtime files from the same package version as the JavaScript engine. The
-React package pins 12.6.1; earlier .NET 8 assets cannot be mixed with its .NET 10
+React package pins 12.6.2; earlier .NET 8 assets cannot be mixed with its .NET 10
runtime. This document supersedes the older 3.x CDN workarounds.
## Copy verified static assets
```sh
-npm install docxodus@12.6.1
+npm install docxodus@12.6.2
npx rdv-copy-runtime public/docxodus
```
@@ -42,7 +42,7 @@ available for custom hosting layouts.
## Export bundles must remain byte-identical
-The 12.6.1 browser materializer fetches its own module and verifies its length and
+The 12.6.2 browser materializer fetches its own module and verifies its length and
SHA-256 against `export-assets.json`. Rebundling, minifying, rewriting imports or
appending a source-map comment changes those bytes and causes an explicit runtime
verification failure. Runtime WASM and worker files are also verified.
@@ -94,7 +94,7 @@ Check the actual response headers, requested URLs and matching package version.
## Node/PDF export
-Install `@docxodus/export@12.6.1` and import `/server` only in Node. The companion
+Install `@docxodus/export@12.6.2` and import `/server` only in Node. The companion
owns its runtime deployment and browser materialization pipeline. It requires a
non-root host with Chromium's sandbox and, on Linux, permitted user namespaces.
`checkExportEnvironment()` reports deployment findings without rendering a file.
diff --git a/demo/App.tsx b/demo/App.tsx
index 35636bc..103d22f 100644
--- a/demo/App.tsx
+++ b/demo/App.tsx
@@ -13,7 +13,7 @@ import '../src/styles/DocumentViewer.css';
import './App.css';
const WASM_BASE_PATH = import.meta.env.BASE_URL + 'wasm/';
-const FINGERPRINT = 'react-studio-v12.6.1';
+const FINGERPRINT = 'react-studio-v12.6.2';
const snapshotBytes = (session: DocxSession) => session.save();
const docxMime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
type Panel = 'edit' | 'review' | 'comments' | 'annotations' | 'history' | 'verify' | 'export';
@@ -136,7 +136,7 @@ function Workspace() {
}}>
{tab === 'document' ? (ready ? filename.replace(/\.docx$/i, '') : 'A little space to think.') : tab === 'compare' ? 'Every difference, in context.' : 'Give your document meaning.'}
All 12.6.1 comparison settings are supported, including move detection, formatting policy, granularity, headers and footers, and cross-paragraph differences.
+ Advanced comparison options
All 12.6.2 comparison settings are supported, including move detection, formatting policy, granularity, headers and footers, and cross-paragraph differences.
{(error || comparison.error) &&
{error ?? comparison.error?.message}
}
{comparison.batchResults &&
{comparison.batchResults.map((result, index) =>
{result.error ? {result.name}: {result.error} : }
)}
}
diff --git a/src/components/PaginatedDocument.tsx b/src/components/PaginatedDocument.tsx
index 602c55a..7bd03a8 100644
--- a/src/components/PaginatedDocument.tsx
+++ b/src/components/PaginatedDocument.tsx
@@ -42,8 +42,8 @@ function adaptRootSelectors(rules: CSSRuleList) {
}
/** A viewer may stay mounted inside a hidden tab while conversion finishes. */
-function waitForLayout(element: HTMLElement, signal: AbortSignal): Promise {
- return new Promise((resolve, reject) => {
+async function waitForLayout(element: HTMLElement, signal: AbortSignal): Promise {
+ await new Promise((resolve, reject) => {
let observer: ResizeObserver | undefined;
let frame: number | undefined;
const cleanup = () => {
@@ -52,21 +52,27 @@ function waitForLayout(element: HTMLElement, signal: AbortSignal): Promise
signal.removeEventListener('abort', abort);
};
const abort = () => { cleanup(); reject(new DOMException('Pagination cancelled', 'AbortError')); };
- const check = () => {
- if (element.getBoundingClientRect().width <= 0) return false;
- cleanup(); resolve(); return true;
- };
if (signal.aborted) { abort(); return; }
- if (check()) return;
signal.addEventListener('abort', abort, { once: true });
if (typeof ResizeObserver !== 'undefined') {
- observer = new ResizeObserver(check);
+ // Reading bounds here forces the entire incoming document to lay out in
+ // the same task that parsed it. Observe the browser's next layout instead.
+ observer = new ResizeObserver(entries => {
+ if (entries.some(entry => entry.target === element && entry.contentRect.width > 0)) { cleanup(); resolve(); }
+ });
observer.observe(element);
} else {
- const poll = () => { if (!check()) frame = requestAnimationFrame(poll); };
+ const poll = () => {
+ if (element.getBoundingClientRect().width > 0) { cleanup(); resolve(); }
+ else frame = requestAnimationFrame(poll);
+ };
frame = requestAnimationFrame(poll);
}
});
+ // Pagination writes must not run in a ResizeObserver delivery's microtasks.
+ // That can resize the viewer again before the observer cycle completes.
+ await yieldToBrowser();
+ if (signal.aborted) throw new DOMException('Pagination cancelled', 'AbortError');
}
/** Zoom changes presentation, never the document's page breaks. */
@@ -186,76 +192,91 @@ export function PaginatedDocument({ html, canvasEditor, canvasOwner, liveBlocks,
}
}
}
- const shadow = element.shadowRoot ?? element.attachShadow({ mode: 'open' });
- const parsed = new DOMParser().parseFromString(html, 'text/html');
- const wrapper = document.createElement('div');
- for (const attribute of Array.from(parsed.documentElement.attributes)) wrapper.setAttribute(attribute.name, attribute.value);
- wrapper.classList.add('rdv-document-html');
- const documentBody = document.createElement('div');
- for (const attribute of Array.from(parsed.body.attributes)) documentBody.setAttribute(attribute.name, attribute.value);
- documentBody.classList.add('rdv-document-body');
- wrapper.append(...Array.from(parsed.head.querySelectorAll('style')), documentBody);
- documentBody.append(...Array.from(parsed.body.childNodes));
- // Converted HTML sometimes contains a legacy bootstrap script. React owns execution.
- wrapper.querySelectorAll('script, iframe, object, embed').forEach(node => node.remove());
- for (const node of [wrapper, ...wrapper.querySelectorAll('*')]) {
- for (const attribute of Array.from(node.attributes)) {
- if (/^on/i.test(attribute.name) || (/^(href|src|xlink:href)$/i.test(attribute.name) && /^\s*javascript:/i.test(attribute.value))) node.removeAttribute(attribute.name);
- }
- }
- // Measure the incoming pages while the current editing surface retains focus.
- // The actual DOM handoff happens synchronously after fonts/images are ready.
- if (activeLayout.current) Object.assign(wrapper.style, { opacity: '0', position: 'absolute', top: '0', left: '0', width: '100%', pointerEvents: 'none' });
- wrapper.inert = true;
- shadow.append(wrapper);
- const selectionStyles = document.createElement('style');
- selectionStyles.textContent = '[data-rdv-selected="true"] { outline: 1.5px solid var(--rdv-selection-color, #93aa79); outline-offset: 5px; border-radius: 1px; }';
- wrapper.append(selectionStyles);
- for (const stylesheet of wrapper.querySelectorAll('style')) {
- if (stylesheet.sheet) adaptRootSelectors(stylesheet.sheet.cssRules);
- }
- const onClick = (event: MouseEvent) => {
- if (canvasEditor && event.target instanceof Element && event.target.closest('[data-rdv-editable="true"]')) return;
- const target = event.target instanceof Element ? event.target : null;
- // Inline comment/revision wrappers have their own anchors. Editing a passage
- // should select its paragraph, rather than the discussion attached to it.
- const selection = shadowSelection(documentBody);
- if (callbacksRef.current.onTextSelectionChange) {
- if (target?.closest('a[href]')) event.preventDefault();
- if (selection && !selection.isCollapsed) return;
- }
- const block = target?.closest(paragraphSelector);
- const anchor = (block ?? target?.closest('[data-source-anchor-id]'))?.getAttribute('data-source-anchor-id');
- if (anchor) callbacksRef.current.onAnchorSelect?.(anchor);
- const link = target?.closest('a[href^="#"]');
- if (link) {
- const id = link.getAttribute('href')!.slice(1);
- const destination = Array.from(documentBody.querySelectorAll('[id]')).find(node => node.id === id);
- if (destination) { event.preventDefault(); destination.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
+ let discardPending = () => {};
+ void run(async signal => {
+ const check = () => {
+ if (signal.aborted || (canvasEditor && !canvasEditor.acceptsLayout(canvasOwner ?? null, documentVersion))) throw new DOMException('Editing superseded this layout', 'AbortError');
+ };
+ const idle = async () => { if (canvasEditor) await canvasEditor.whenIdle(signal); check(); };
+ await yieldToBrowser();
+ await idle();
+ const shadow = element.shadowRoot ?? element.attachShadow({ mode: 'open' });
+ const parsed = new DOMParser().parseFromString(html, 'text/html');
+ const wrapper = document.createElement('div');
+ discardPending = () => wrapper.remove();
+ for (const attribute of Array.from(parsed.documentElement.attributes)) wrapper.setAttribute(attribute.name, attribute.value);
+ wrapper.classList.add('rdv-document-html');
+ const documentBody = document.createElement('div');
+ for (const attribute of Array.from(parsed.body.attributes)) documentBody.setAttribute(attribute.name, attribute.value);
+ documentBody.classList.add('rdv-document-body');
+ wrapper.append(...Array.from(parsed.head.querySelectorAll('style')), documentBody);
+ documentBody.append(...Array.from(parsed.body.childNodes));
+ // Converted HTML sometimes contains a legacy bootstrap script. React owns execution.
+ wrapper.querySelectorAll('script, iframe, object, embed').forEach(node => node.remove());
+ let preparationDeadline = performance.now() + 8;
+ for (const node of [wrapper, ...wrapper.querySelectorAll('*')]) {
+ for (const attribute of Array.from(node.attributes)) {
+ if (/^on/i.test(attribute.name) || (/^(href|src|xlink:href)$/i.test(attribute.name) && /^\s*javascript:/i.test(attribute.value))) node.removeAttribute(attribute.name);
+ }
+ if (performance.now() >= preparationDeadline) {
+ await yieldToBrowser(); await idle(); preparationDeadline = performance.now() + 8;
+ }
}
- };
- documentBody.addEventListener('click', onClick);
- const onSelection = () => {
- if (canvasEditor) return;
- const selection = shadowSelection(documentBody);
- if (callbacksRef.current.onTextSelectionChange && selection && !selection.isCollapsed) {
- callbacksRef.current.onTextSelectionChange(readTextSelection(documentBody, activeLayout.current?.documentVersion));
+ // Measure the incoming pages while the current editing surface retains focus.
+ // The actual DOM handoff happens synchronously after fonts/images are ready.
+ if (activeLayout.current) Object.assign(wrapper.style, { opacity: '0', position: 'absolute', top: '0', left: '0', width: '100%', pointerEvents: 'none' });
+ wrapper.inert = true;
+ shadow.append(wrapper);
+ const selectionStyles = document.createElement('style');
+ selectionStyles.textContent = '[data-rdv-selected="true"] { outline: 1.5px solid var(--rdv-selection-color, #93aa79); outline-offset: 5px; border-radius: 1px; }';
+ wrapper.append(selectionStyles);
+ for (const stylesheet of wrapper.querySelectorAll('style')) {
+ if (stylesheet.sheet) adaptRootSelectors(stylesheet.sheet.cssRules);
}
- };
- documentBody.addEventListener('mouseup', onSelection);
- documentBody.addEventListener('keyup', onSelection);
- let observer: IntersectionObserver | null = null;
- let detachEditor: (() => void) | undefined;
- const dispose = () => {
- detachEditor?.();
- observer?.disconnect();
- documentBody.removeEventListener('click', onClick);
- documentBody.removeEventListener('mouseup', onSelection);
- documentBody.removeEventListener('keyup', onSelection);
- if (body.current === documentBody) { body.current = null; callbacksRef.current.onRootChange?.(null); }
- wrapper.remove();
- };
- void run(async signal => {
+ const onClick = (event: MouseEvent) => {
+ if (canvasEditor && event.target instanceof Element && event.target.closest('[data-rdv-editable="true"]')) return;
+ const target = event.target instanceof Element ? event.target : null;
+ // Inline comment/revision wrappers have their own anchors. Editing a passage
+ // should select its paragraph, rather than the discussion attached to it.
+ const selection = shadowSelection(documentBody);
+ if (callbacksRef.current.onTextSelectionChange) {
+ if (target?.closest('a[href]')) event.preventDefault();
+ if (selection && !selection.isCollapsed) return;
+ }
+ const block = target?.closest(paragraphSelector);
+ const anchor = (block ?? target?.closest('[data-source-anchor-id]'))?.getAttribute('data-source-anchor-id');
+ if (anchor) callbacksRef.current.onAnchorSelect?.(anchor);
+ const link = target?.closest('a[href^="#"]');
+ if (link) {
+ const id = link.getAttribute('href')!.slice(1);
+ const destination = Array.from(documentBody.querySelectorAll('[id]')).find(node => node.id === id);
+ if (destination) { event.preventDefault(); destination.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
+ }
+ };
+ documentBody.addEventListener('click', onClick);
+ const onSelection = () => {
+ if (canvasEditor) return;
+ const selection = shadowSelection(documentBody);
+ if (callbacksRef.current.onTextSelectionChange && selection && !selection.isCollapsed) {
+ callbacksRef.current.onTextSelectionChange(readTextSelection(documentBody, activeLayout.current?.documentVersion));
+ }
+ };
+ documentBody.addEventListener('mouseup', onSelection);
+ documentBody.addEventListener('keyup', onSelection);
+ let observer: IntersectionObserver | null = null;
+ let detachEditor: (() => void) | undefined = undefined;
+ const dispose = () => {
+ detachEditor?.();
+ observer?.disconnect();
+ documentBody.removeEventListener('click', onClick);
+ documentBody.removeEventListener('mouseup', onSelection);
+ documentBody.removeEventListener('keyup', onSelection);
+ if (body.current === documentBody) { body.current = null; callbacksRef.current.onRootChange?.(null); }
+ wrapper.remove();
+ };
+ discardPending = () => { if (activeLayout.current?.wrapper !== wrapper) dispose(); };
+ await yieldToBrowser();
+ await idle();
await waitForLayout(element, signal);
documentBody.getBoundingClientRect(); // Start font requests before awaiting readiness.
await document.fonts?.ready;
@@ -273,16 +294,12 @@ export function PaginatedDocument({ html, canvasEditor, canvasOwner, liveBlocks,
// The converter supplies its own default canvas color inside the ShadowRoot.
// Apply the host's theme to the canvas without changing the document pages.
container.style.backgroundColor = backgroundColor;
- // Docxodus 12.6.1 measures note/header reserves while creating pages. Zooming
+ // Docxodus 12.6.2 measures note/header reserves while creating pages. Zooming
// those pages during measurement mixes scaled pixels with document points,
// clipping body paragraphs in documents with substantial footnotes.
const engine = new PaginationEngine(staging, container, {
scale: 1, showPageNumbers, pageGap, cssPrefix, fragmentParagraphs,
});
- const check = () => {
- if (signal.aborted || (canvasEditor && !canvasEditor.acceptsLayout(canvasOwner ?? null, documentVersion))) throw new DOMException('Editing superseded this layout', 'AbortError');
- };
- const idle = async () => { if (canvasEditor) await canvasEditor.whenIdle(signal); };
const cooperative = cooperativePagination(engine, check, idle);
const result = await cooperative.paginate();
await idle(); check();
@@ -324,12 +341,12 @@ export function PaginatedDocument({ html, canvasEditor, canvasOwner, liveBlocks,
}
return result;
}).catch(error => {
- if (activeLayout.current?.wrapper !== wrapper) dispose();
+ discardPending();
if (error?.name !== 'AbortError') callbacksRef.current.onError?.(error);
});
return () => {
cancel();
- if (activeLayout.current?.wrapper !== wrapper) dispose();
+ discardPending();
};
}, [html, canvasEditor, canvasOwner, liveBlocks, showPageNumbers, pageGap, cssPrefix, fragmentParagraphs, documentVersion, rendererFingerprint, backgroundColor, layoutSettings, run, cancel]);
diff --git a/src/editing/CanvasEditor.ts b/src/editing/CanvasEditor.ts
index d3d023f..10f3562 100644
--- a/src/editing/CanvasEditor.ts
+++ b/src/editing/CanvasEditor.ts
@@ -34,7 +34,6 @@ export class CanvasEditor {
private listeners = new Set<() => void>();
private selectionGuards = new Set<() => boolean>();
private baselines = new Map();
- private anchorCache: { owner: DocxSession | null; version: number; ids: Map } | null = null;
private root: HTMLElement | null = null;
private renderedOwner: DocxSession | null = null;
private renderedVersion = 0;
@@ -77,13 +76,13 @@ export class CanvasEditor {
}
private text(anchorId: string) { return editableText(this.controller.getFormatting(anchorId)); }
private canonical(anchorId: string) {
- const { session: owner, version } = this.controller.getSnapshot();
- if (!this.anchorCache || this.anchorCache.owner !== owner || this.anchorCache.version !== version) {
- const ids = Object.keys(this.controller.getAnchorIndex());
- this.anchorCache = { owner, version, ids: new Map(ids.filter(id => /^(p|h|li):/.test(id)).map(id => [id.slice(id.indexOf(':')), id])) };
- }
+ // The formatting read needed to prepare/edit a paragraph already resolves its
+ // canonical ID. Avoid rebuilding the document-wide anchor inventory on Enter.
+ const canonical = this.controller.getFormatting(anchorId)?.anchorId;
const identity = anchorId.slice(anchorId.indexOf(':'));
- return this.anchorCache.ids.get(identity);
+ // Native formatting can resolve an ID across stories by UNID. The canvas
+ // must retain its stronger story identity check, including for stale kinds.
+ return canonical && /^(p|h|li):/.test(canonical) && canonical.slice(canonical.indexOf(':')) === identity ? canonical : undefined;
}
private ownsFocus() {
if (!this.root) return false;
diff --git a/src/editing/text.ts b/src/editing/text.ts
index 0614928..e0539f8 100644
--- a/src/editing/text.ts
+++ b/src/editing/text.ts
@@ -1,7 +1,7 @@
import type { CharSpan, DocxSession, EditResult, FormatOp, FormattingInspection } from 'docxodus/core';
/**
- * ExactVisibleText in Docxodus 12.6.1 is descendant w:t text plus native list
+ * ExactVisibleText in Docxodus 12.6.2 is descendant w:t text plus native list
* numbering for body paragraphs. Keep that contract without Markdown projection.
* Requires a canonical anchor; unknown XML retains the native metadata fallback.
*/
@@ -107,9 +107,9 @@ export function paragraphTextSteps(session: DocxSession, anchorId: string, befor
const changes = selected ? [borrowInsertion(before, selected)] : textChanges(before, after);
if (!changes.length) return [];
const exact = selected ?? textChange(before, after, false)!;
- // 12.6.1 inserts inside ordinary text runs and formats exactly the replacement
- // in one undo/version unit. Let native code decide whether a particular run's
- // field/container structure allows this operation.
+ // 12.6.2 inserts inside text runs, including runs with leading tabs, and formats
+ // exactly the replacement in one undo/version unit. Let native code decide
+ // whether a particular run's field/container structure allows this operation.
const atomicFormat = atomicTyping && typingFormat && exact.inserted.length && changes.length === 1
? typingFormat : undefined;
if (atomicFormat) changes.splice(0, changes.length, exact);
@@ -123,7 +123,7 @@ export function paragraphTextSteps(session: DocxSession, anchorId: string, befor
const steps: Array[0][number]> = changes.reverse().map(change => ({ tool: 'ParagraphEditor', action: 'replace text', mutation: () => {
const current = editableText(session.getFormatting(anchorId));
if (current.slice(change.start, change.start + change.removed.length) !== change.removed) throw new Error('This text changed before the edit could be applied.');
- // 12.6.1 replaceMatch addresses only enclosingAnchor.id + span (its
+ // 12.6.2 replaceMatch addresses only enclosingAnchor.id + span (its
// ReplaceTextAtSpan bridge). We already have those verified native offsets.
// Searching the entire package for a borrowed space or period produces
// thousands of irrelevant matches and stalls large-document typing.
diff --git a/src/engine.ts b/src/engine.ts
index a4d8b68..f36f190 100644
--- a/src/engine.ts
+++ b/src/engine.ts
@@ -1,2 +1,2 @@
-/** The complete 12.6.1 engine API, without React or the upstream web editor. */
+/** The complete 12.6.2 engine API, without React or the upstream web editor. */
export * from 'docxodus/core';
diff --git a/src/hooks/useDocumentEditor.ts b/src/hooks/useDocumentEditor.ts
index 408c6ae..3916278 100644
--- a/src/hooks/useDocumentEditor.ts
+++ b/src/hooks/useDocumentEditor.ts
@@ -54,11 +54,19 @@ export function useDocumentEditor(controller: DocxSessionController, options: Us
const previous = selectionRef.current;
if (selectionOwner.current === snapshot.session && previous?.version === snapshot.version && previous.anchorId === anchorId && previous.source === source &&
previous.span?.start === span?.start && previous.span?.length === span?.length && !!previous.span === !!span) return true;
- const index = controller.getAnchorIndex();
- const id = index[anchorId] ? anchorId : Object.keys(index).find(id => index[id].unid === anchorId.split(':').at(-1));
- const info = id ? index[id] : undefined;
- if (!info || !['p', 'h', 'li'].includes(info.kind)) throw new Error('Choose a paragraph or heading to edit.');
- const text = editableText(controller.getFormatting(id!));
+ let id = anchorId;
+ let formatting = /^(p|h|li):/.test(id) ? controller.getFormatting(id) : null;
+ if (!formatting || formatting.anchorId !== id) {
+ // Preserve shorthand/stale-ID selection for hosts. Canvas selections
+ // already carry a canonical ID and need no document-wide inventory.
+ const index = controller.getAnchorIndex();
+ const resolved = index[id] ? id : Object.keys(index).find(key => index[key].unid === id.split(':').at(-1));
+ const info = resolved ? index[resolved] : undefined;
+ if (!info || !['p', 'h', 'li'].includes(info.kind)) throw new Error('Choose a paragraph or heading to edit.');
+ id = resolved!;
+ formatting = controller.getFormatting(id);
+ }
+ const text = editableText(formatting);
if (span && (span.start < 0 || span.length < 0 || span.start + span.length > text.length)) throw new Error('Select text inside this paragraph.');
const next = { anchorId: id!, span, text, version: snapshot.version, source };
if (selectionOwner.current === snapshot.session && JSON.stringify(selectionRef.current) === JSON.stringify(next)) return true;
@@ -83,9 +91,8 @@ export function useDocumentEditor(controller: DocxSessionController, options: Us
const selectedAnchorId = selection?.anchorId;
const query = useCallback((session: DocxSession) => {
if (!selectedAnchorId) return null;
- const info = controller.getAnchorIndex()[selectedAnchorId];
- if (!info) return null;
const formatting = controller.getFormatting(selectedAnchorId);
+ if (!formatting || formatting.anchorId !== selectedAnchorId) return null;
// Enriched metadata includes projection/hash work. Preserve the public detail
// accessor for hosts that need it without paying for it to move the caret.
const version = session.getVersion();
diff --git a/src/hooks/useDocumentExport.ts b/src/hooks/useDocumentExport.ts
index e051d7a..a3cb778 100644
--- a/src/hooks/useDocumentExport.ts
+++ b/src/hooks/useDocumentExport.ts
@@ -23,7 +23,7 @@ export function downloadDocument(data: Uint8Array | string, filename: string, mi
export interface UseDocumentExportOptions {
wasmBasePath?: string;
pdfExporter?: PdfExporter;
- /** Static, unmodified dist/export-browser.bundle.js from Docxodus 12.6.1. */
+ /** Static, unmodified dist/export-browser.bundle.js from Docxodus 12.6.2. */
browserModuleUrl?: string;
}
diff --git a/src/rendering/anchors.ts b/src/rendering/anchors.ts
index 4fa202d..fdd6b85 100644
--- a/src/rendering/anchors.ts
+++ b/src/rendering/anchors.ts
@@ -1,6 +1,6 @@
/** Align converter paragraph kinds with the exact native snapshot being rendered. */
export function reconcileSourceAnchors(html: string, anchors: readonly string[]) {
- // Docxodus 12.6.1 can stamp a numbered paragraph as p while its session uses li.
+ // Docxodus 12.6.2 can stamp a numbered paragraph as p while its session uses li.
// Scope + Unid is stable across paragraph/heading/list kind changes.
const canonical = new Map(anchors.filter(id => /^(p|h|li):/.test(id)).map(id => [id.slice(id.indexOf(':')), id]));
const parsed = new DOMParser().parseFromString(html, 'text/html');
diff --git a/src/rendering/cooperativePagination.generated.js b/src/rendering/cooperativePagination.generated.js
index 7023cbd..e3c0d47 100644
--- a/src/rendering/cooperativePagination.generated.js
+++ b/src/rendering/cooperativePagination.generated.js
@@ -23,7 +23,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
// Generated by scripts/generate-cooperative-pagination.mjs. Do not edit.
-// Docxodus 12.6.1 pagination.js SHA-256: 2b67cf77f5383db0871647e8c40ca367e1c79cfb3f996120edbf6d7c707e31c0
+// Docxodus 12.6.2 pagination.js SHA-256: 2b67cf77f5383db0871647e8c40ca367e1c79cfb3f996120edbf6d7c707e31c0
// Only async calls and cooperative loop checkpoints differ from upstream.
import { parseSectionDimensions, pxToPt } from 'docxodus/core';
/**
diff --git a/src/rendering/sessionPageMap.ts b/src/rendering/sessionPageMap.ts
index 126658f..b5418c4 100644
--- a/src/rendering/sessionPageMap.ts
+++ b/src/rendering/sessionPageMap.ts
@@ -1,6 +1,6 @@
import type { DocxSession, PageCitation, PageCitationRequest, PageMap, PageMapStatus } from 'docxodus/core';
-// Portable constraints mirror Docxodus 12.6.1's MIT-licensed PageMapContract
+// Portable constraints mirror Docxodus 12.6.2's MIT-licensed PageMapContract
// (Copyright John Scrudato IV). A failed/unknown case always uses native validation.
// Native validation additionally proves anchor/story/table ownership. That proof
// survives only journaled paragraph text/run edits with no created/removed anchors.
diff --git a/src/session.ts b/src/session.ts
index d365614..8196d4a 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -118,7 +118,13 @@ export class DocxSessionController {
const changes = start < 0 ? null : this.journal.slice(start);
const changed = changes && covered(changes, this.cacheVersion, version);
const stableDefinitions = !!changes && definitionsCovered(changes, this.cacheVersion, version);
- if (changed) changed.forEach(id => { this.formattingCache.delete(id); this.catalogChanges.add(id); });
+ if (changed) {
+ const ids = new Set(changed);
+ for (const [id, formatting] of this.formattingCache) {
+ if (ids.has(id) || (formatting && ids.has(formatting.anchorId))) this.formattingCache.delete(id);
+ }
+ changed.forEach(id => this.catalogChanges.add(id));
+ }
else { this.formattingCache.clear(); this.catalogCache = null; this.catalogChanges.clear(); }
if (!stableDefinitions) this.stylesCache = null;
this.anchorsCache = null;
@@ -137,7 +143,13 @@ export class DocxSessionController {
const native = this.refreshReadCache();
// An atomic shadow reports its base version until commit; never cache its reads.
if (this.depth || this.transactionDepth) return native.getFormatting(anchorId);
- if (!this.formattingCache.has(anchorId)) this.formattingCache.set(anchorId, native.getFormatting(anchorId));
+ if (!this.formattingCache.has(anchorId)) {
+ const formatting = native.getFormatting(anchorId);
+ this.formattingCache.set(anchorId, formatting);
+ // A former paragraph ID may now resolve to a heading. Share that native
+ // result with its canonical ID, and invalidate both aliases on later edits.
+ if (formatting?.anchorId) this.formattingCache.set(formatting.anchorId, formatting);
+ }
return this.formattingCache.get(anchorId)!;
}
/** Text/run edits and paragraph splits preserve definitions. Unknown edits invalidate them. */
diff --git a/tests/browser/canvas.spec.ts b/tests/browser/canvas.spec.ts
index 4ef367b..5d47e9b 100644
--- a/tests/browser/canvas.spec.ts
+++ b/tests/browser/canvas.spec.ts
@@ -78,7 +78,17 @@ test('collapsed Enter uses one native undo unit and one batch render', async ({
const original = bridge.BeginTransaction;
const renderOne = bridge.RenderBlockHtml;
const renderMany = bridge.RenderEditorBlocksHtml;
- if (!renderMany) throw new Error('The pinned native batch renderer is unavailable');
+ const listAnchors = bridge.ListAnchors;
+ if (!renderMany || !listAnchors) throw new Error('The pinned native editor bridge is unavailable');
+ let measuring = false, scans = 0;
+ document.addEventListener('beforeinput', () => {
+ measuring = true;
+ requestAnimationFrame(() => { measuring = false; Reflect.set(window, 'splitAnchorScansBeforePaint', scans); });
+ }, { once: true, capture: true });
+ bridge.ListAnchors = handle => {
+ if (measuring) scans++;
+ return listAnchors(handle);
+ };
Reflect.set(window, 'splitTransactions', 0);
Reflect.set(window, 'splitSingleRenders', 0);
Reflect.set(window, 'splitBatchRenders', 0);
@@ -100,6 +110,7 @@ test('collapsed Enter uses one native undo unit and one batch render', async ({
expect(await page.evaluate(() => Reflect.get(window, 'splitTransactions'))).toBe(0);
expect(await page.evaluate(() => Reflect.get(window, 'splitSingleRenders'))).toBe(0);
expect(await page.evaluate(() => Reflect.get(window, 'splitBatchRenders'))).toBe(1);
+ await expect.poll(() => page.evaluate(() => Reflect.get(window, 'splitAnchorScansBeforePaint'))).toBe(0);
await page.keyboard.press('Control+z');
await expect.poll(() => nativeText(page)).toEqual(['Hello world.']);
await page.keyboard.press('Control+y');
@@ -144,14 +155,32 @@ for (const sample of [
{ name: 'paragraph start', text: 'Plain text.', offset: 0, fast: true },
{ name: 'empty paragraph', text: '', offset: 0, fast: true },
{ name: 'inside a run', text: 'Plain text.', offset: 3, fast: true },
+ { name: 'inside a run with one leading tab', text: 'Plain text.', offset: 3, fast: true, leadingTabs: 1 },
+ { name: 'inside a run with two leading tabs', text: 'Plain text.', offset: 3, fast: true, leadingTabs: 2 },
{ name: 'before an identical space', text: 'Plain text.', offset: 5, fast: true },
{ name: 'before identical text', text: ' *B* original', offset: 0, fast: true },
{ name: 'inside a hyperlink', text: 'Plain link text.', markdown: 'Plain [link text.](https://example.com)', offset: 8, fast: false },
]) test(`formatted typing at ${sample.name} preserves surrounding runs and one-step undo`, async ({ page }) => {
- await open(page, sample.markdown ?? sample.text.replace(/\*/g, '\\*'));
+ await open(page, sample.leadingTabs ? 'Source' : sample.markdown ?? sample.text.replace(/\*/g, '\\*'));
+ if (sample.leadingTabs) {
+ await page.evaluate(({tabs, text}) => window.editorTest.controllers[0].run(s => {
+ const id = window.editorTest.anchor;
+ const w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
+ const xml = new DOMParser().parseFromString(s.raw.getXml(id), 'application/xml');
+ const node = xml.getElementsByTagNameNS(w, 't')[0];
+ node.textContent = text;
+ for (let index = 0; index < tabs; index++) node.before(xml.createElementNS(w, 'w:tab'));
+ return s.raw.replaceXml(id, new XMLSerializer().serializeToString(xml));
+ }), { tabs: sample.leadingTabs, text: sample.text });
+ await expect(paragraphs(page).first()).toContainText(sample.text);
+ await settled(page);
+ }
await paragraphs(page).first().click();
- await page.keyboard.press('Home');
- for (let i = 0; i < sample.offset; i++) await page.keyboard.press('ArrowRight');
+ // Native text offsets omit the tab's rendered padding. Approach these
+ // interior positions from the text's end rather than counting that padding.
+ await page.keyboard.press(sample.leadingTabs ? 'End' : 'Home');
+ const movements = sample.leadingTabs ? sample.text.length - sample.offset : sample.offset;
+ for (let i = 0; i < movements; i++) await page.keyboard.press(sample.leadingTabs ? 'ArrowLeft' : 'ArrowRight');
await page.keyboard.press('Control+b');
const before = await page.evaluate(() => {
const controller = window.editorTest.controllers[0], s = controller.getSnapshot().session!;
@@ -173,6 +202,19 @@ for (const sample of [
expect(after.version).toBe(before.version + 1);
expect(after.runs.filter(run => run.effective.bold).map(run => run.text).join('')).toBe(typed);
expect(after.runs.filter(run => !run.effective.bold).map(run => run.text).join('')).toBe(sample.text);
+ if (sample.leadingTabs) {
+ const markers = await page.evaluate(() => {
+ const xml = new DOMParser().parseFromString(window.editorTest.controllers[0].read(s => s.raw.getXml(window.editorTest.anchor)), 'application/xml');
+ let offset = 0;
+ const positions = [];
+ for (const node of xml.getElementsByTagNameNS('http://schemas.openxmlformats.org/wordprocessingml/2006/main', '*')) {
+ if (node.localName === 't') offset += node.textContent!.length;
+ else if (node.localName === 'tab') positions.push(offset);
+ }
+ return positions;
+ });
+ expect(markers).toEqual(Array(sample.leadingTabs).fill(0));
+ }
expect(await page.evaluate(() => Reflect.get(window, 'typingCalls'))).toEqual(sample.fast
? { formatted: 1, transaction: 0, hash: 0 } : { formatted: 1, transaction: 1, hash: 1 });
if (sample.markdown) expect(await page.evaluate(() => window.editorTest.controllers[0].read(s => s.listHyperlinks()))).toMatchObject([{ target: 'https://example.com/' }]);
@@ -238,6 +280,44 @@ test('IME composition remains on the page until confirmed', async ({ page }) =>
expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]);
});
+test('incoming layout waits for composition and discards superseded preparation', async ({ page }) => {
+ const pageErrors: string[] = [];
+ page.on('pageerror', error => pageErrors.push(error.message));
+ await open(page, 'Say: ');
+ await settled(page);
+ const firstPage = page.locator('#pagination-container .page-box').first();
+ await firstPage.evaluate(element => { (element as HTMLElement).dataset.layoutRetained = 'true'; });
+ await paragraphs(page).first().click();
+ await page.keyboard.press('End');
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send('Input.imeSetComposition', { text: '日本', selectionStart: 2, selectionEnd: 2 });
+ // A host layout setting may change while the active canvas has an IME draft.
+ // Both requests must wait before creating another document tree; the second
+ // cancels the first without disturbing the active page or its composition.
+ for (const gap of ['32px', '40px']) {
+ await page.locator('.rdv-viewer').evaluate((element, gap) => {
+ (element as HTMLElement).style.setProperty('--rdv-page-gap', gap);
+ window.dispatchEvent(new Event('resize'));
+ }, gap);
+ await expect(page.locator('.rdv-paginated-document[aria-busy="true"]')).toHaveCount(1);
+ await page.waitForTimeout(100);
+ await expect(page.locator('.rdv-document-html')).toHaveCount(1);
+ await expect(firstPage).toHaveAttribute('data-layout-retained', 'true');
+ }
+ expect(await nativeText(page)).toEqual(['Say: ']);
+ await cdp.send('Input.imeSetComposition', { text: '日本語', selectionStart: 3, selectionEnd: 3 });
+ await cdp.send('Input.insertText', { text: '日本語' });
+ await expect.poll(() => nativeText(page)).toEqual(['Say: 日本語']);
+ await settled(page);
+ await expect(page.locator('.rdv-document-html')).toHaveCount(1);
+ await expect(firstPage).not.toHaveAttribute('data-layout-retained');
+ await page.keyboard.type('!');
+ await page.keyboard.press('Control+s');
+ expect(await nativeText(page)).toEqual(['Say: 日本語!']);
+ expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]);
+ expect(pageErrors).toEqual([]);
+});
+
test('conflicting external text edits preserve typing for recovery', async ({ page }) => {
await open(page, 'Original.');
await paragraphs(page).first().click();
diff --git a/tests/browser/session-caches.spec.ts b/tests/browser/session-caches.spec.ts
index aebfd8e..d29ef37 100644
--- a/tests/browser/session-caches.spec.ts
+++ b/tests/browser/session-caches.spec.ts
@@ -1,5 +1,37 @@
import { expect, test } from '@playwright/test';
+test('formatting aliases share canonical reads and invalidate together after local edits', async ({ page }) => {
+ await page.goto('/api-test.html');
+ await page.waitForFunction(() => !!window.rdv);
+ const result = await page.evaluate(async () => {
+ const c = new window.rdv.DocxSessionController();
+ const s = await c.open('blank', { emitMarkdownPatch: false }, '/wasm/');
+ const original = Object.keys(c.getAnchorIndex())[0];
+ s.replaceText(original, 'Heading text.');
+ const changed = s.setParagraphStyle(original, 'Heading1');
+ if (!changed.success) throw new Error('Heading setup failed');
+ const heading = changed.modified[0].id;
+ const inserted = s.insertParagraph(heading, 'after', 'Other paragraph.');
+ if (!inserted.success) throw new Error('Paragraph setup failed');
+ const other = inserted.created[0].id;
+ const alias = c.getFormatting(original), untouched = c.getFormatting(other);
+ const shared = alias?.anchorId === heading && alias === c.getFormatting(heading);
+ if (!s.applyFormat(heading, { start: 0, length: 7 }, { bold: true }).success) throw new Error('Formatting failed');
+ const refreshed = c.getFormatting(original);
+ const invalidated = refreshed !== alias && refreshed === c.getFormatting(heading) &&
+ JSON.stringify(refreshed) === JSON.stringify(s.getFormatting(original));
+ const unrelatedRetained = c.getFormatting(other) === untouched;
+ if (!s.applyFormat(other, { start: 0, length: 5 }, { italic: true }).success) throw new Error('Unrelated formatting failed');
+ const aliasRetained = c.getFormatting(original) === refreshed;
+ s.undo();
+ const undoInvalidated = c.getFormatting(original) !== refreshed &&
+ JSON.stringify(c.getFormatting(original)) === JSON.stringify(s.getFormatting(original));
+ c.close();
+ return { shared, invalidated, unrelatedRetained, aliasRetained, undoInvalidated };
+ });
+ expect(Object.values(result)).toEqual(Array(5).fill(true));
+});
+
test('split metadata caches match native style and revision reads while ownership changes', async ({ page }) => {
await page.goto('/api-test.html');
await page.waitForFunction(() => !!window.rdv);