@beyondwork/docx-react-component 1.0.49 → 1.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/api/public-types.ts +20 -1
- package/src/core/commands/index.ts +21 -0
- package/src/io/export/serialize-comments.ts +50 -5
- package/src/io/paste/word-clipboard.ts +114 -0
- package/src/runtime/document-runtime.ts +24 -1
- package/src/runtime/layout/layout-engine-version.ts +52 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- package/src/runtime/render/index.ts +7 -0
- package/src/runtime/render/render-frame-diff.ts +298 -0
- package/src/runtime/render/render-frame-types.ts +22 -1
- package/src/runtime/render/render-kernel.ts +80 -12
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/structure-ops/fragment-insert.ts +134 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui/WordReviewEditor.tsx +4 -2
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +344 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +344 -0
- package/src/ui-tailwind/chart/render/number-format.ts +287 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
package/README.md
CHANGED
|
@@ -234,8 +234,14 @@ Engineering work is organized into 9 lanes (5 active now + 2 later polish + 2 fi
|
|
|
234
234
|
| 3b | [**OOXML Fidelity & Round-Trip**](docs/plans/lane-3b-ooxml-fidelity.md) | **55%** | V1 + O3 + O4 + O2 (all 4 slices) + V7 closed; 🚨 O8 + L2.c + V6 + V2a/b/c + R3–R5 backlog |
|
|
235
235
|
| 4 | [**Collab + CLM/Vallor**](docs/plans/lane-4-collab-clm-vallor.md) | **80%** | P1–P14 + all P11 sub-bullets + P12 + perf-parity + P13 A/B/C shipped; P15 / P16 / P17 left |
|
|
236
236
|
| 5 | [**Charts (independent)**](docs/plans/lane-5-charts.md) | **30%** | Stages 0–2 shipped (parsers + theme); Stages 3–7 (SVG renderers + pixel-diff) left |
|
|
237
|
-
|
|
|
238
|
-
|
|
|
237
|
+
| 6a | [**Tokens & Theme Foundation**](docs/plans/lane-6a-tokens-theme-foundation.md) | **~25%** | Active (no gate) — canonical token substrate: brand-accent flip, semantic families, scope-tint tokens, chart palette, hex eviction, density + reduced-motion plumbing |
|
|
238
|
+
| 6b | [**Shell & Workspace Chrome**](docs/plans/lane-6b-shell-workspace-chrome.md) | **~15%** | Gated on 6a.S1+S2 — shell header + toolbar + status + alert banner + unsaved modal + collab chrome restyle; mode-dock decommission; TwCommandPalette |
|
|
239
|
+
| 6c | [**Context & Review Surfaces**](docs/plans/lane-6c-context-review-surfaces.md) | **~15%** | Gated on 6a.S1+S2 — selection toolbar + suggestion card + rail + scope + context toolbars + health panel restyle; TwCommentPreview / TwEmptyState / TwShortcutHint |
|
|
240
|
+
| 6d | [**Visual Fidelity**](docs/plans/lane-6d-visual-fidelity.md) | **~20%** | Gated on 6a.S1+S2 + external-lane deps — L8 A/B/C shipped; L8 Phase D + P11 overlays + P7 + P12 + P14 next |
|
|
241
|
+
| 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **0%** | LATER (gated on 6a–6d) — V5 covers, V6 REF/PAGEREF, V7 cascade audit |
|
|
242
|
+
| 7b | [**Revision & Redline Completion**](docs/plans/lane-7b-revision-redline-completion.md) | **0%** | LATER (gated on 6a–6d) — X4.a/b structural table revisions, X5 ffData, move-pairing |
|
|
243
|
+
| 7c | [**Preservation & Format Coverage**](docs/plans/lane-7c-preservation-format-coverage.md) | **0%** | LATER (gated on 6a–6d) — O6 Strict, OLE preserve-only, picture-SDT, w14/kern/VML defer |
|
|
244
|
+
| 7d | [**Platform Hardening & Hygiene**](docs/plans/lane-7d-platform-hardening-hygiene.md) | **0%** | LATER (gated on 6a–6d) — harness-crash-hardening, fastload activation, worktree consolidation |
|
|
239
245
|
| 8 | [**API Ergonomics / Errors / BC**](docs/plans/lane-8-api-ergonomics.md) | **40%** | LATER — Tracks A+C shipped (error catalog + ergonomics fixes); Tracks B+D+E + public-api.md end-to-end refactor remain |
|
|
240
246
|
| 9 | [**Shipping (v2.0.0)**](docs/plans/lane-9-shipping.md) | **0%** | FINAL — API freeze, semver discipline, changelog, telemetry, customer migration guides, doc completeness audit |
|
|
241
247
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.51",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
package/src/api/public-types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PersistedEditorSnapshot as RuntimePersistedEditorSnapshot } from "../core/state/editor-state.ts";
|
|
2
2
|
import type { HarnessDebugPorts } from "../internal/harness-debug-ports.ts";
|
|
3
|
-
import type { CanonicalParagraphFormatting, CanonicalRunFormatting, TextMark } from "../model/canonical-document.ts";
|
|
3
|
+
import type { BlockNode, CanonicalParagraphFormatting, CanonicalRunFormatting, TextMark } from "../model/canonical-document.ts";
|
|
4
4
|
import type { WordReviewEditorLayoutFacet } from "../runtime/layout/public-facet.ts";
|
|
5
5
|
import type { RenderFrameRect } from "../runtime/render/index.ts";
|
|
6
6
|
import type { ScopeRailPosture } from "../runtime/workflow-rail-segments.ts";
|
|
@@ -756,6 +756,17 @@ export interface InsertImageOptions {
|
|
|
756
756
|
/** Re-export canonical `TextMark` so hosts can construct explicit-mode directives for I7 `replaceText`. */
|
|
757
757
|
export type { TextMark };
|
|
758
758
|
|
|
759
|
+
/**
|
|
760
|
+
* I2 Tier B Slice 1 — a block-level payload for `insertFragment` and the paste
|
|
761
|
+
* parsers that will drive it in later slices. Carries zero or more canonical
|
|
762
|
+
* block nodes (paragraphs, tables, SDTs, etc.); a host that constructs one
|
|
763
|
+
* directly is responsible for the blocks being well-formed per the canonical
|
|
764
|
+
* schema. The HTML / Word-clipboard parsers (Slices 2+3) will produce these.
|
|
765
|
+
*/
|
|
766
|
+
export interface CanonicalDocumentFragment {
|
|
767
|
+
blocks: BlockNode[];
|
|
768
|
+
}
|
|
769
|
+
|
|
759
770
|
/**
|
|
760
771
|
* I7 — `replaceText` / `text.insert` formatting directive.
|
|
761
772
|
*
|
|
@@ -2919,6 +2930,14 @@ export interface WordReviewEditorRef {
|
|
|
2919
2930
|
target?: EditorAnchorProjection,
|
|
2920
2931
|
formatting?: TextFormattingDirective,
|
|
2921
2932
|
): void;
|
|
2933
|
+
/**
|
|
2934
|
+
* I2 Tier B Slice 1 — splice a canonical block-level fragment at the target
|
|
2935
|
+
* anchor (or at the current selection when omitted). Empty fragment = no-op.
|
|
2936
|
+
* Baseline semantic: split the caret paragraph and insert fragment blocks
|
|
2937
|
+
* between the halves; range selections are deleted first. Future slices will
|
|
2938
|
+
* add merge-intent + richer caret placement as paste parsers come online.
|
|
2939
|
+
*/
|
|
2940
|
+
insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
|
|
2922
2941
|
toggleBulletedList(): void;
|
|
2923
2942
|
toggleNumberedList(): void;
|
|
2924
2943
|
toggleBold(): void;
|
|
@@ -49,6 +49,7 @@ import type {
|
|
|
49
49
|
RuntimeRenderSnapshot,
|
|
50
50
|
SectionBreakType,
|
|
51
51
|
SectionLayoutPatch,
|
|
52
|
+
CanonicalDocumentFragment,
|
|
52
53
|
SectionPageNumberingPatch,
|
|
53
54
|
TextFormattingDirective,
|
|
54
55
|
WorkflowMetadataDefinition,
|
|
@@ -88,6 +89,7 @@ import {
|
|
|
88
89
|
setHeaderFooterLinkAtSectionIndex,
|
|
89
90
|
} from "./section-layout-commands.ts";
|
|
90
91
|
import { insertPageBreak, insertTable } from "./text-commands.ts";
|
|
92
|
+
import { applyFragmentInsert } from "../../runtime/structure-ops/fragment-insert.ts";
|
|
91
93
|
import type { TableSelectionDescriptor } from "../../runtime/table-commands.ts";
|
|
92
94
|
|
|
93
95
|
export type ContentChildrenPatch =
|
|
@@ -170,6 +172,16 @@ export type EditorCommand =
|
|
|
170
172
|
type: "paragraph.split";
|
|
171
173
|
origin?: CommandOrigin;
|
|
172
174
|
}
|
|
175
|
+
| {
|
|
176
|
+
/**
|
|
177
|
+
* I2 Tier B Slice 1 — splice a `CanonicalDocumentFragment` at the current
|
|
178
|
+
* selection. Baseline semantic: split the caret paragraph and insert
|
|
179
|
+
* fragment blocks between the halves; range selections are deleted first.
|
|
180
|
+
*/
|
|
181
|
+
type: "fragment.insert";
|
|
182
|
+
fragment: CanonicalDocumentFragment;
|
|
183
|
+
origin?: CommandOrigin;
|
|
184
|
+
}
|
|
173
185
|
| {
|
|
174
186
|
type: "runtime.set-read-only";
|
|
175
187
|
readOnly: boolean;
|
|
@@ -631,6 +643,15 @@ export function executeEditorCommand(
|
|
|
631
643
|
return applyTextCommand(state, context.timestamp, (document, selection) =>
|
|
632
644
|
splitParagraph(document, selection, context),
|
|
633
645
|
);
|
|
646
|
+
case "fragment.insert": {
|
|
647
|
+
// I2 Tier B Slice 1 — route through the structure-ops splicer. No
|
|
648
|
+
// suggesting-mode branch yet; fragment insertion always lands as a direct
|
|
649
|
+
// edit. Future slices will gate behind track-changes when a fixture needs it.
|
|
650
|
+
const result = applyFragmentInsert(state.document, state.selection, command.fragment, {
|
|
651
|
+
timestamp: context.timestamp,
|
|
652
|
+
});
|
|
653
|
+
return buildDocumentReplaceTransaction(state, context, result);
|
|
654
|
+
}
|
|
634
655
|
case "runtime.set-read-only":
|
|
635
656
|
return createTransaction(
|
|
636
657
|
{
|
|
@@ -760,11 +760,56 @@ function walkInlineNodeForBoundaries(
|
|
|
760
760
|
return;
|
|
761
761
|
}
|
|
762
762
|
case "t": {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
763
|
+
// O8 fix: emit a boundary entry for every interior code-point position
|
|
764
|
+
// so comment anchors landing mid-run resolve to a real source byte
|
|
765
|
+
// offset instead of silently dropping into skippedCommentIds. The walk
|
|
766
|
+
// advances through the raw XML source between each text child's start
|
|
767
|
+
// and end, treating XML entities (& < > " ' &#N;
|
|
768
|
+
// &#xN;) as a single code point whose byte cursor skips the whole
|
|
769
|
+
// entity. Surrogate pairs collapse to one code point to match
|
|
770
|
+
// Array.from(text).iteration used by the existing cursor math.
|
|
771
|
+
let cursor = getCursor();
|
|
772
|
+
for (const child of node.children) {
|
|
773
|
+
if (child.type !== "text") {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
let byte = child.start;
|
|
777
|
+
const end = child.end;
|
|
778
|
+
while (byte < end) {
|
|
779
|
+
if (!boundaries.has(cursor)) {
|
|
780
|
+
// Preserve the outer <w:r>-entry at runStart: it maps cursor to
|
|
781
|
+
// node.start (between runs, a valid insertion point), whereas the
|
|
782
|
+
// interior byte here would point inside <w:t> where inserting
|
|
783
|
+
// commentRangeStart/End would corrupt the text node.
|
|
784
|
+
boundaries.set(cursor, byte);
|
|
785
|
+
}
|
|
786
|
+
const ch = sourceXml.charCodeAt(byte);
|
|
787
|
+
if (ch === 0x26 /* & */) {
|
|
788
|
+
const semi = sourceXml.indexOf(";", byte + 1);
|
|
789
|
+
if (semi !== -1 && semi < end) {
|
|
790
|
+
byte = semi + 1;
|
|
791
|
+
} else {
|
|
792
|
+
byte += 1;
|
|
793
|
+
}
|
|
794
|
+
} else if (ch >= 0xd800 && ch <= 0xdbff && byte + 1 < end) {
|
|
795
|
+
// High surrogate followed by low surrogate = 1 code point / 2 units.
|
|
796
|
+
byte += 2;
|
|
797
|
+
} else {
|
|
798
|
+
byte += 1;
|
|
799
|
+
}
|
|
800
|
+
cursor += 1;
|
|
801
|
+
}
|
|
802
|
+
// Trailing boundary: map the cursor immediately after the last
|
|
803
|
+
// character to the byte just before </w:t>, so an anchor at the right
|
|
804
|
+
// edge of this text node resolves inside the run. The outer <w:r>
|
|
805
|
+
// close will overwrite nothing here because it runs after the inner
|
|
806
|
+
// walk and uses boundaries.set (last-write-wins is fine: node.end
|
|
807
|
+
// points to the same logical insertion point between runs).
|
|
808
|
+
if (!boundaries.has(cursor)) {
|
|
809
|
+
boundaries.set(cursor, end);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
setCursor(cursor);
|
|
768
813
|
return;
|
|
769
814
|
}
|
|
770
815
|
case "tab":
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* I2 Tier B Slice 2 — Office-clipboard / WordprocessingML paste parser.
|
|
3
|
+
*
|
|
4
|
+
* Adapts the authoritative `parseMainDocumentXml` → `normalizeParsedTextDocument`
|
|
5
|
+
* pipeline to a clipboard-paste payload. Inputs from the browser clipboard under
|
|
6
|
+
* the Office MIME types (`application/x-docx-fragment`,
|
|
7
|
+
* `application/vnd.ms-word.wordprocessingml.paste`) arrive either as:
|
|
8
|
+
*
|
|
9
|
+
* - a full `<w:document><w:body>…</w:body></w:document>` wrapper, or
|
|
10
|
+
* - a bare `<w:body>…</w:body>` fragment.
|
|
11
|
+
*
|
|
12
|
+
* This adapter auto-wraps the bare form, runs the full parse + normalize, and
|
|
13
|
+
* returns the resulting canonical `BlockNode`s as a `CanonicalDocumentFragment`.
|
|
14
|
+
* Errors (XML parse failure, missing body) are returned as a structured result
|
|
15
|
+
* instead of thrown, so `pm-command-bridge.ts` `handlePaste` can gracefully fall
|
|
16
|
+
* through to HTML or plain-text Tier A.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CanonicalDocumentFragment } from "../../api/public-types.ts";
|
|
20
|
+
import type { DocumentRootNode } from "../../model/canonical-document.ts";
|
|
21
|
+
import { parseMainDocumentXml } from "../ooxml/parse-main-document.ts";
|
|
22
|
+
import { normalizeParsedTextDocument } from "../normalize/normalize-text.ts";
|
|
23
|
+
import { serializeMainDocument } from "../export/serialize-main-document.ts";
|
|
24
|
+
|
|
25
|
+
export type ParseCanonicalFragmentResult =
|
|
26
|
+
| { ok: true; fragment: CanonicalDocumentFragment }
|
|
27
|
+
| { ok: false; reason: string };
|
|
28
|
+
|
|
29
|
+
const WORD_NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`;
|
|
30
|
+
|
|
31
|
+
export function parseCanonicalFragmentFromWordML(xml: string): ParseCanonicalFragmentResult {
|
|
32
|
+
if (typeof xml !== "string" || xml.length === 0) {
|
|
33
|
+
return { ok: false, reason: "empty WordML payload" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const prepared = ensureDocumentShell(xml);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const parsed = parseMainDocumentXml(prepared);
|
|
40
|
+
const normalized = normalizeParsedTextDocument(parsed);
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
fragment: { blocks: normalized.content.children },
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: error instanceof Error ? error.message : "unknown WordML parse error",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalize the incoming clipboard payload into a full `<w:document><w:body>…`
|
|
55
|
+
* shell. Handles three shapes:
|
|
56
|
+
*
|
|
57
|
+
* 1. Already-wrapped `<w:document>…</w:document>` → pass-through with the XML
|
|
58
|
+
* declaration normalized.
|
|
59
|
+
* 2. Bare `<w:body>…</w:body>` — wrap in `<w:document>…</w:document>`.
|
|
60
|
+
* 3. Any other fragment — wrap the whole input in `<w:document><w:body>…`.
|
|
61
|
+
*
|
|
62
|
+
* Namespace hygiene: if the input lacks the `xmlns:w` declaration on whatever
|
|
63
|
+
* outer element survives, it's added to the outer `<w:document>` wrapper.
|
|
64
|
+
*/
|
|
65
|
+
function ensureDocumentShell(xml: string): string {
|
|
66
|
+
const trimmed = xml.trim();
|
|
67
|
+
const withoutDecl = trimmed.replace(/^<\?xml[^?]*\?>/, "").trim();
|
|
68
|
+
|
|
69
|
+
const hasDocumentWrapper = /^<w:document[\s>]/i.test(withoutDecl);
|
|
70
|
+
if (hasDocumentWrapper) {
|
|
71
|
+
return ensureXmlDecl(trimmed);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hasBodyWrapper = /^<w:body[\s>]/i.test(withoutDecl);
|
|
75
|
+
const inner = hasBodyWrapper ? withoutDecl : `<w:body>${withoutDecl}</w:body>`;
|
|
76
|
+
|
|
77
|
+
const wrapped = `<w:document ${WORD_NS}>${stripRedundantNs(inner)}</w:document>`;
|
|
78
|
+
return ensureXmlDecl(wrapped);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureXmlDecl(xml: string): string {
|
|
82
|
+
if (/^\s*<\?xml/.test(xml)) return xml;
|
|
83
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>${xml}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strip `xmlns:w="…"` from the first child element so the outer
|
|
88
|
+
* `<w:document>` declaration is the single authoritative binding. The XML
|
|
89
|
+
* parser accepts both, but removing the duplicate keeps output clean.
|
|
90
|
+
*/
|
|
91
|
+
function stripRedundantNs(xml: string): string {
|
|
92
|
+
return xml.replace(/\s+xmlns:w="[^"]*"/, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* I2 Tier B Slice 4a — inverse of `parseCanonicalFragmentFromWordML`. Produces
|
|
97
|
+
* a full `<w:document><w:body>…</w:body></w:document>` payload suitable for
|
|
98
|
+
* writing to the system clipboard under the Office MIME types, or for exchange
|
|
99
|
+
* with agents that expect WordML.
|
|
100
|
+
*
|
|
101
|
+
* Implementation reuses the authoritative `serializeMainDocument` pipeline by
|
|
102
|
+
* wrapping `fragment.blocks` in a synthetic `DocumentRootNode`. The output is
|
|
103
|
+
* the full document XML — parsers (browsers, Word, our own Slice 2 adapter)
|
|
104
|
+
* accept the full envelope, so there's no need to strip it down to a bare
|
|
105
|
+
* `<w:body>` fragment.
|
|
106
|
+
*/
|
|
107
|
+
export function serializeFragmentToWordML(fragment: CanonicalDocumentFragment): string {
|
|
108
|
+
const root: DocumentRootNode = {
|
|
109
|
+
type: "doc",
|
|
110
|
+
children: fragment.blocks,
|
|
111
|
+
};
|
|
112
|
+
const serialized = serializeMainDocument(root);
|
|
113
|
+
return serialized.documentXml;
|
|
114
|
+
}
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
DocumentTextToken,
|
|
31
31
|
EditorSessionState,
|
|
32
32
|
EditorAnchorProjection,
|
|
33
|
+
CanonicalDocumentFragment,
|
|
33
34
|
TextFormattingDirective,
|
|
34
35
|
EditorError,
|
|
35
36
|
EditorStoryTarget,
|
|
@@ -266,7 +267,8 @@ export type ActiveStoryTextCommand =
|
|
|
266
267
|
| Extract<EditorCommand, { type: "text.insert-tab" }>
|
|
267
268
|
| Extract<EditorCommand, { type: "text.outdent-tab" }>
|
|
268
269
|
| Extract<EditorCommand, { type: "text.insert-hard-break" }>
|
|
269
|
-
| Extract<EditorCommand, { type: "paragraph.split" }
|
|
270
|
+
| Extract<EditorCommand, { type: "paragraph.split" }>
|
|
271
|
+
| Extract<EditorCommand, { type: "fragment.insert" }>;
|
|
270
272
|
|
|
271
273
|
export interface DocumentRuntime {
|
|
272
274
|
subscribe(listener: () => void): Unsubscribe;
|
|
@@ -275,6 +277,7 @@ export interface DocumentRuntime {
|
|
|
275
277
|
getCanonicalDocument(): CanonicalDocumentEnvelope;
|
|
276
278
|
getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
|
|
277
279
|
replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
|
|
280
|
+
insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
|
|
278
281
|
applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
|
|
279
282
|
dispatch(command: EditorCommand): void;
|
|
280
283
|
/**
|
|
@@ -2314,6 +2317,26 @@ export function createDocumentRuntime(
|
|
|
2314
2317
|
emitError(toRuntimeError(error));
|
|
2315
2318
|
}
|
|
2316
2319
|
},
|
|
2320
|
+
insertFragment(fragment, target) {
|
|
2321
|
+
// I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
|
|
2322
|
+
// runtime command handler routes into `applyFragmentInsert` (structure-ops).
|
|
2323
|
+
try {
|
|
2324
|
+
const timestamp = clock();
|
|
2325
|
+
applyTextCommandInActiveStory(
|
|
2326
|
+
{
|
|
2327
|
+
type: "fragment.insert",
|
|
2328
|
+
fragment,
|
|
2329
|
+
origin: createOrigin("api", timestamp),
|
|
2330
|
+
},
|
|
2331
|
+
{
|
|
2332
|
+
selection: target ? createSelectionFromPublicAnchor(target) : state.selection,
|
|
2333
|
+
blockedCommandName: "insertFragment",
|
|
2334
|
+
},
|
|
2335
|
+
);
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
emitError(toRuntimeError(error));
|
|
2338
|
+
}
|
|
2339
|
+
},
|
|
2317
2340
|
applyActiveStoryTextCommand(command) {
|
|
2318
2341
|
try {
|
|
2319
2342
|
return applyTextCommandInActiveStory(command);
|
|
@@ -42,8 +42,59 @@
|
|
|
42
42
|
* file under `src/runtime/layout/**` changed. Safe to treat
|
|
43
43
|
* versions 3, 4, and 5 as cache-compatible if a migration ever
|
|
44
44
|
* needs to collapse them.
|
|
45
|
+
* 6 — Lane 3a P9 Phase A. `RenderAnchorIndex` gains three chrome-kind
|
|
46
|
+
* resolvers (`byScopeId`, `byCommentId`, `byRevisionId`) sourced
|
|
47
|
+
* from the resolved `DecorationIndex`, and `buildAnchorIndex`
|
|
48
|
+
* runs in two phases inside the render kernel so the final index
|
|
49
|
+
* carries decoration-aware lookups. No cached-geometry change;
|
|
50
|
+
* consumers (Lane 1 R.1 SelectionLayer, Lane 6 P11 chrome rails)
|
|
51
|
+
* can now query one unified API instead of reaching into
|
|
52
|
+
* `frame.decorationIndex`. Cache envelopes from version 5 are
|
|
53
|
+
* invalidated on load because the anchor-index public shape
|
|
54
|
+
* changed even though pixel geometry did not.
|
|
55
|
+
* 7 — Lane 3a P9 Phase A2. Predicted-delta-aware anchor shifts.
|
|
56
|
+
* `byRuntimeOffset` and `bySelection` now compensate incoming
|
|
57
|
+
* offsets by `sumDeltasBefore(pendingDeltas, offset)` before the
|
|
58
|
+
* map lookup so chrome rects stay aligned with the caret during
|
|
59
|
+
* the predicted-dispatch window. No cached-geometry change; pure
|
|
60
|
+
* resolver behavior change. Cache envelopes from v6 auto-
|
|
61
|
+
* invalidate because the anchor-index now honors the previously-
|
|
62
|
+
* stubbed `pendingDeltas` input.
|
|
63
|
+
* 8 — Lane 3a P10 Phase B. `frame_diff` kernel event now carries a
|
|
64
|
+
* full `RenderFrameDiff` (addedPages / removedPages /
|
|
65
|
+
* unchangedPages / changedPages) instead of the prior
|
|
66
|
+
* `pageRange` payload. New module `src/runtime/render/render-frame-
|
|
67
|
+
* diff.ts` exports `diffRenderFrames(prev, next)` which computes
|
|
68
|
+
* the structural diff (blockId + rounded bbox + decoration-ref
|
|
69
|
+
* intersection — NOT reference equality, because the layout
|
|
70
|
+
* engine rebuilds fragment objects on every pass). Page-stack
|
|
71
|
+
* consumers subscribe to `frame_diff` and memoize unchanged pages
|
|
72
|
+
* to skip React reconciliation. No cached-geometry change; cache
|
|
73
|
+
* envelopes from v7 invalidate because the event shape changed.
|
|
74
|
+
* 9 — Lane 3a P10 Phase C. `analyzeInvalidation` for
|
|
75
|
+
* `section-change` now returns `scope: "bounded"` with
|
|
76
|
+
* `dirtyPageRange.firstPageIndex` set to the affected section's
|
|
77
|
+
* first rendered page (was `scope: "full"`). Pages in earlier
|
|
78
|
+
* sections retain pagination; pages in and after the affected
|
|
79
|
+
* section rebuild. Fallback to full rebuild when the target
|
|
80
|
+
* section has no materialized pages. No cached-geometry change;
|
|
81
|
+
* cache envelopes from v8 invalidate because the invalidation
|
|
82
|
+
* classifier changed the scope contract.
|
|
83
|
+
* 10 — Lane 3a P10 Phase D1. `spliceGraph` detects structural
|
|
84
|
+
* convergence between the fresh tail and the prior tail and
|
|
85
|
+
* reuses the prior `RuntimePageNode` reference for every page
|
|
86
|
+
* that matches on (sectionIndex, pageInSection, startOffset,
|
|
87
|
+
* endOffset, isBlankFiller, body / header / footer / footnote
|
|
88
|
+
* fragmentId lists). Downstream caches — render-frame-diff
|
|
89
|
+
* (`unchangedPages`), prerender cache (stable pageId), React
|
|
90
|
+
* page keys — observe stable references for the common case
|
|
91
|
+
* where a localized edit doesn't shift content forward. Fresh
|
|
92
|
+
* nodes are substituted from the first mismatch onward. No
|
|
93
|
+
* pixel-geometry change; cache envelopes from v9 invalidate
|
|
94
|
+
* because page-node identity semantics on bounded splices
|
|
95
|
+
* changed.
|
|
45
96
|
*/
|
|
46
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
97
|
+
export const LAYOUT_ENGINE_VERSION = 10 as const;
|
|
47
98
|
|
|
48
99
|
/**
|
|
49
100
|
* Serialization schema version for the LayCache payload (the cache envelope
|
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
* boundary inside the dirty range, style change, numbering change, etc.) we
|
|
8
8
|
* fall back to a full rebuild. The `scope` field declares which path the
|
|
9
9
|
* engine should follow.
|
|
10
|
+
*
|
|
11
|
+
* Bounded scopes today:
|
|
12
|
+
* - `content-edit`: bounded to the first page whose range overlaps the
|
|
13
|
+
* edit offsets (§analyzeContentEdit).
|
|
14
|
+
* - `section-change`: bounded to the first page of the affected section
|
|
15
|
+
* onward — P10 Phase C (§analyzeSectionChange).
|
|
16
|
+
* - `numbering-change`: bounded to the whole document (dirtyPageRange
|
|
17
|
+
* spans pages [0..end]); tightening to the earliest page referencing
|
|
18
|
+
* the numbering instance requires per-fragment numbering metadata
|
|
19
|
+
* on `RuntimeBlockFragment` which is not available today.
|
|
20
|
+
*
|
|
21
|
+
* Remaining full-rebuild triggers:
|
|
22
|
+
* - `styles-change` / `theme-change`: without per-fragment style-chain
|
|
23
|
+
* metadata on the graph, we cannot safely tighten these. Flagged as
|
|
24
|
+
* follow-up for when `RuntimeBlockFragment.resolvedStyleChain` lands.
|
|
10
25
|
*/
|
|
11
26
|
|
|
12
27
|
import type { LayoutInvalidationReason } from "./paginated-layout-engine.ts";
|
|
@@ -252,15 +267,57 @@ function analyzeSectionChange(
|
|
|
252
267
|
reason: Extract<LayoutInvalidationReason, { kind: "section-change" }>,
|
|
253
268
|
graph: RuntimePageGraph,
|
|
254
269
|
): InvalidationResult {
|
|
255
|
-
//
|
|
256
|
-
//
|
|
270
|
+
// P10 Phase C — section property changes affect pages from the first
|
|
271
|
+
// page of the affected section onward. Earlier pages in earlier
|
|
272
|
+
// sections retain their pagination. When the target section has no
|
|
273
|
+
// materialized pages (e.g. section-change for a section that hasn't
|
|
274
|
+
// rendered yet), fall back to a full recompute.
|
|
275
|
+
const firstPageOfSection = findFirstPageIndexForSection(
|
|
276
|
+
graph,
|
|
277
|
+
reason.sectionIndex,
|
|
278
|
+
);
|
|
279
|
+
if (firstPageOfSection < 0) {
|
|
280
|
+
return {
|
|
281
|
+
scope: "full",
|
|
282
|
+
requiresFullRecompute: true,
|
|
283
|
+
dirtySectionRange: {
|
|
284
|
+
from: reason.sectionIndex,
|
|
285
|
+
to: Math.max(0, graph.sections.length - 1),
|
|
286
|
+
},
|
|
287
|
+
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Bounded: rebuild from the affected section's first page to the end
|
|
292
|
+
// of the document. Rebuilding to the end (not just the section's last
|
|
293
|
+
// page) is required because a section property change — page size,
|
|
294
|
+
// margins, column count — can propagate to downstream sections if a
|
|
295
|
+
// `nextPage` / `evenPage` / `oddPage` break shifts.
|
|
257
296
|
return {
|
|
258
|
-
scope: "
|
|
259
|
-
requiresFullRecompute:
|
|
297
|
+
scope: "bounded",
|
|
298
|
+
requiresFullRecompute: false,
|
|
299
|
+
dirtyPageRange: {
|
|
300
|
+
firstPageIndex: firstPageOfSection,
|
|
301
|
+
lastPageIndex: Math.max(0, graph.pages.length - 1),
|
|
302
|
+
},
|
|
260
303
|
dirtySectionRange: {
|
|
261
304
|
from: reason.sectionIndex,
|
|
262
|
-
to: graph.sections.length - 1,
|
|
305
|
+
to: Math.max(0, graph.sections.length - 1),
|
|
263
306
|
},
|
|
264
307
|
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
265
308
|
};
|
|
266
309
|
}
|
|
310
|
+
|
|
311
|
+
function findFirstPageIndexForSection(
|
|
312
|
+
graph: RuntimePageGraph,
|
|
313
|
+
sectionIndex: number,
|
|
314
|
+
): number {
|
|
315
|
+
for (let i = 0; i < graph.pages.length; i += 1) {
|
|
316
|
+
const page = graph.pages[i]!;
|
|
317
|
+
if (page.isBlankFiller) continue;
|
|
318
|
+
if (page.sectionIndex === sectionIndex) return i;
|
|
319
|
+
}
|
|
320
|
+
// No page found for this section — section may be empty or past the
|
|
321
|
+
// rendered tail. Caller treats this as a full-rebuild trigger.
|
|
322
|
+
return -1;
|
|
323
|
+
}
|
|
@@ -522,7 +522,34 @@ export function spliceGraph(
|
|
|
522
522
|
graphRevision += 1;
|
|
523
523
|
const clampedFirst = Math.max(0, Math.min(firstDirtyIndex, prior.pages.length));
|
|
524
524
|
const preserved = prior.pages.slice(0, clampedFirst);
|
|
525
|
-
|
|
525
|
+
|
|
526
|
+
// P10 Phase D1 — convergence-point tail reuse. When a fresh page at
|
|
527
|
+
// index `clampedFirst + i` structurally matches the prior graph's
|
|
528
|
+
// page at the same index (same startOffset, endOffset, sectionIndex,
|
|
529
|
+
// fragment count), substitute the prior `RuntimePageNode` so
|
|
530
|
+
// downstream caches (render-frame-diff, prerender cache, React page
|
|
531
|
+
// keys) observe a stable reference. Stops at the first mismatch —
|
|
532
|
+
// everything after that re-pagination produced fresh has to flow
|
|
533
|
+
// through the fresh nodes because pagination may have shifted it.
|
|
534
|
+
// When prior has fewer pages past clampedFirst than fresh (e.g., an
|
|
535
|
+
// edit added pages), `reusedCount` caps at the prior length so we
|
|
536
|
+
// don't index past the prior tail.
|
|
537
|
+
const priorTail = prior.pages.slice(clampedFirst);
|
|
538
|
+
const reuseLimit = Math.min(priorTail.length, freshPages.length);
|
|
539
|
+
let reusedCount = 0;
|
|
540
|
+
while (
|
|
541
|
+
reusedCount < reuseLimit &&
|
|
542
|
+
pageNodesStructurallyEqual(priorTail[reusedCount]!, freshPages[reusedCount]!)
|
|
543
|
+
) {
|
|
544
|
+
reusedCount += 1;
|
|
545
|
+
}
|
|
546
|
+
const stableTailPrefix = priorTail.slice(0, reusedCount);
|
|
547
|
+
const divergentTail = freshPages.slice(reusedCount);
|
|
548
|
+
const nextPages: RuntimePageNode[] = [
|
|
549
|
+
...preserved,
|
|
550
|
+
...stableTailPrefix,
|
|
551
|
+
...divergentTail,
|
|
552
|
+
];
|
|
526
553
|
|
|
527
554
|
const survivingPageIds = new Set(nextPages.map((page) => page.pageId));
|
|
528
555
|
const mergedFragments: RuntimeBlockFragment[] = [];
|
|
@@ -558,3 +585,69 @@ export function spliceGraph(
|
|
|
558
585
|
contentPageCount,
|
|
559
586
|
};
|
|
560
587
|
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Compare two `RuntimePageNode`s by the fields that matter for
|
|
591
|
+
* pagination identity — if these all match, the fresh node represents
|
|
592
|
+
* the same content the prior graph already paginated. Used by
|
|
593
|
+
* `spliceGraph` to detect convergence between the fresh tail and the
|
|
594
|
+
* prior tail (P10 Phase D1) so downstream caches see stable references.
|
|
595
|
+
*
|
|
596
|
+
* We deliberately compare fragmentIds rather than full fragment objects
|
|
597
|
+
* because fragments are re-minted on each build even when they project
|
|
598
|
+
* identical canonical content; their id is derived from a stable
|
|
599
|
+
* structural hash.
|
|
600
|
+
*/
|
|
601
|
+
function pageNodesStructurallyEqual(
|
|
602
|
+
a: RuntimePageNode,
|
|
603
|
+
b: RuntimePageNode,
|
|
604
|
+
): boolean {
|
|
605
|
+
if (a.sectionIndex !== b.sectionIndex) return false;
|
|
606
|
+
if (a.pageInSection !== b.pageInSection) return false;
|
|
607
|
+
if (a.startOffset !== b.startOffset) return false;
|
|
608
|
+
if (a.endOffset !== b.endOffset) return false;
|
|
609
|
+
if (a.isBlankFiller !== b.isBlankFiller) return false;
|
|
610
|
+
if (a.regions.body.fragmentIds.length !== b.regions.body.fragmentIds.length) {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
for (let i = 0; i < a.regions.body.fragmentIds.length; i += 1) {
|
|
614
|
+
if (a.regions.body.fragmentIds[i] !== b.regions.body.fragmentIds[i]) {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Header / footer fragment lists: compare if present on either side.
|
|
619
|
+
if (!optionalRegionFragmentsEqual(a.regions.header, b.regions.header)) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
if (!optionalRegionFragmentsEqual(a.regions.footer, b.regions.footer)) {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
// Footnote-area regions: compare count + per-region fragment lists.
|
|
626
|
+
const aFoot = a.regions.footnotes ?? [];
|
|
627
|
+
const bFoot = b.regions.footnotes ?? [];
|
|
628
|
+
if (aFoot.length !== bFoot.length) return false;
|
|
629
|
+
for (let i = 0; i < aFoot.length; i += 1) {
|
|
630
|
+
if (!regionFragmentsEqual(aFoot[i]!, bFoot[i]!)) return false;
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function optionalRegionFragmentsEqual(
|
|
636
|
+
a: RuntimePageRegion | undefined,
|
|
637
|
+
b: RuntimePageRegion | undefined,
|
|
638
|
+
): boolean {
|
|
639
|
+
if (!a && !b) return true;
|
|
640
|
+
if (!a || !b) return false;
|
|
641
|
+
return regionFragmentsEqual(a, b);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function regionFragmentsEqual(
|
|
645
|
+
a: RuntimePageRegion,
|
|
646
|
+
b: RuntimePageRegion,
|
|
647
|
+
): boolean {
|
|
648
|
+
if (a.fragmentIds.length !== b.fragmentIds.length) return false;
|
|
649
|
+
for (let i = 0; i < a.fragmentIds.length; i += 1) {
|
|
650
|
+
if (a.fragmentIds[i] !== b.fragmentIds[i]) return false;
|
|
651
|
+
}
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
@@ -1824,22 +1824,15 @@ function resolveAnchorRects(
|
|
|
1824
1824
|
return rect ? [rect] : [];
|
|
1825
1825
|
}
|
|
1826
1826
|
case "scope-id": {
|
|
1827
|
-
|
|
1828
|
-
return frame.decorationIndex.workflow
|
|
1829
|
-
.filter((decoration) => decoration.refId === id)
|
|
1830
|
-
.map((decoration) => decoration.frame);
|
|
1827
|
+
return frame.anchorIndex.byScopeId(String(query.value));
|
|
1831
1828
|
}
|
|
1832
1829
|
case "comment-id": {
|
|
1833
|
-
const
|
|
1834
|
-
return
|
|
1835
|
-
.filter((decoration) => decoration.refId === id)
|
|
1836
|
-
.map((decoration) => decoration.frame);
|
|
1830
|
+
const rect = frame.anchorIndex.byCommentId(String(query.value));
|
|
1831
|
+
return rect ? [rect] : [];
|
|
1837
1832
|
}
|
|
1838
1833
|
case "revision-id": {
|
|
1839
|
-
const
|
|
1840
|
-
return
|
|
1841
|
-
.filter((decoration) => decoration.refId === id)
|
|
1842
|
-
.map((decoration) => decoration.frame);
|
|
1834
|
+
const rect = frame.anchorIndex.byRevisionId(String(query.value));
|
|
1835
|
+
return rect ? [rect] : [];
|
|
1843
1836
|
}
|
|
1844
1837
|
default: {
|
|
1845
1838
|
const exhaustive: never = query.kind;
|
|
@@ -30,6 +30,13 @@ export {
|
|
|
30
30
|
type LockedRangeInput,
|
|
31
31
|
} from "./decoration-resolver.ts";
|
|
32
32
|
|
|
33
|
+
export {
|
|
34
|
+
diffRenderFrames,
|
|
35
|
+
isEmptyDiff,
|
|
36
|
+
type ChangedPageEntry,
|
|
37
|
+
type RenderFrameDiff,
|
|
38
|
+
} from "./render-frame-diff.ts";
|
|
39
|
+
|
|
33
40
|
export {
|
|
34
41
|
DEFAULT_PX_PER_TWIP,
|
|
35
42
|
EMPTY_DECORATION_INDEX,
|