@beyondwork/docx-react-component 1.0.49 → 1.0.50
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 +4 -1
- 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 +11 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- package/src/runtime/render/render-frame-types.ts +14 -0
- package/src/runtime/render/render-kernel.ts +40 -2
- package/src/runtime/structure-ops/fragment-insert.ts +134 -0
- package/src/ui/WordReviewEditor.tsx +4 -2
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
package/README.md
CHANGED
|
@@ -235,7 +235,10 @@ Engineering work is organized into 9 lanes (5 active now + 2 later polish + 2 fi
|
|
|
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
237
|
| 6 | [**Visual Chrome / Layout Polish**](docs/plans/lane-6-visual-chrome-layout-polish.md) | **0%** | LATER — activates after Lane 3b V2c + Lane 2 Phase 2.2 ship; discrete paper cards, native chrome, float-wrap, validation bar |
|
|
238
|
-
|
|
|
238
|
+
| 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **0%** | LATER (gated on 6a+6b) — V5 covers, V6 REF/PAGEREF, V7 cascade audit |
|
|
239
|
+
| 7b | [**Revision & Redline Completion**](docs/plans/lane-7b-revision-redline-completion.md) | **0%** | LATER (gated on 6a+6b) — X4.a/b structural table revisions, X5 ffData, move-pairing |
|
|
240
|
+
| 7c | [**Preservation & Format Coverage**](docs/plans/lane-7c-preservation-format-coverage.md) | **0%** | LATER (gated on 6a+6b) — O6 Strict, OLE preserve-only, picture-SDT, w14/kern/VML defer |
|
|
241
|
+
| 7d | [**Platform Hardening & Hygiene**](docs/plans/lane-7d-platform-hardening-hygiene.md) | **0%** | LATER (gated on 6a+6b) — harness-crash-hardening, fastload activation, worktree consolidation |
|
|
239
242
|
| 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
243
|
| 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
244
|
|
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.50",
|
|
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,18 @@
|
|
|
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.
|
|
45
55
|
*/
|
|
46
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
56
|
+
export const LAYOUT_ENGINE_VERSION = 6 as const;
|
|
47
57
|
|
|
48
58
|
/**
|
|
49
59
|
* Serialization schema version for the LayCache payload (the cache envelope
|
|
@@ -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;
|
|
@@ -230,6 +230,20 @@ export interface RenderAnchorIndex {
|
|
|
230
230
|
tableBlockId: string,
|
|
231
231
|
rowIndex: number,
|
|
232
232
|
): RenderFrameRect | null;
|
|
233
|
+
/**
|
|
234
|
+
* Chrome-kind resolvers (P9 Phase A). Read against the frame's
|
|
235
|
+
* `decorationIndex` so chrome surfaces (scope rails, comment balloons,
|
|
236
|
+
* revision margin bars, Lane 1 R.1 SelectionLayer) query one unified
|
|
237
|
+
* API instead of reaching into `frame.decorationIndex` directly.
|
|
238
|
+
*
|
|
239
|
+
* `byScopeId` returns every rect because a single workflow scope may
|
|
240
|
+
* cover multiple pages (one `RenderBlockDecoration` per page); chrome
|
|
241
|
+
* rails read the list. `byCommentId` and `byRevisionId` return a single
|
|
242
|
+
* rect — `resolveDecorationIndex` emits one entry per thread/revision.
|
|
243
|
+
*/
|
|
244
|
+
byScopeId(scopeId: string): readonly RenderFrameRect[];
|
|
245
|
+
byCommentId(commentId: string): RenderFrameRect | null;
|
|
246
|
+
byRevisionId(revisionId: string): RenderFrameRect | null;
|
|
233
247
|
}
|
|
234
248
|
|
|
235
249
|
// ---------------------------------------------------------------------------
|
|
@@ -192,7 +192,16 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
const pendingDeltas = input.getPendingOpDeltas?.() ?? [];
|
|
195
|
-
|
|
195
|
+
// P9 Phase A — two-phase anchor-index build. The decoration resolver
|
|
196
|
+
// reads the anchor index to map runtime ranges to frame rects; the
|
|
197
|
+
// final anchor index then exposes chrome-kind resolvers that read
|
|
198
|
+
// back from the resolved decoration index. Rebuilding the index with
|
|
199
|
+
// the resolved decoration data avoids a post-hoc mutation seam.
|
|
200
|
+
const baseAnchorIndex = buildAnchorIndex(
|
|
201
|
+
renderPages,
|
|
202
|
+
pendingDeltas,
|
|
203
|
+
zoom.pxPerTwip,
|
|
204
|
+
);
|
|
196
205
|
const includeDecorations = options?.includeDecorations ?? true;
|
|
197
206
|
const sources = input.getDecorationSources?.();
|
|
198
207
|
const hasSources =
|
|
@@ -205,8 +214,14 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
205
214
|
const decorationIndex: DecorationIndex = !includeDecorations
|
|
206
215
|
? EMPTY_DECORATION_INDEX
|
|
207
216
|
: hasSources
|
|
208
|
-
? resolveDecorationIndex({ anchorIndex, ...sources })
|
|
217
|
+
? resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources })
|
|
209
218
|
: buildDecorationIndex(renderPages);
|
|
219
|
+
const anchorIndex = buildAnchorIndex(
|
|
220
|
+
renderPages,
|
|
221
|
+
pendingDeltas,
|
|
222
|
+
zoom.pxPerTwip,
|
|
223
|
+
decorationIndex,
|
|
224
|
+
);
|
|
210
225
|
|
|
211
226
|
// Revision: keyed off the engine's current page graph so repeated reads
|
|
212
227
|
// at the same revision return the same cached frame. We derive it
|
|
@@ -634,6 +649,7 @@ function buildAnchorIndex(
|
|
|
634
649
|
pages: readonly RenderPage[],
|
|
635
650
|
pendingDeltas: readonly PendingOpDelta[] = [],
|
|
636
651
|
pxPerTwip = 1,
|
|
652
|
+
decorationIndex: DecorationIndex = EMPTY_DECORATION_INDEX,
|
|
637
653
|
): RenderAnchorIndex {
|
|
638
654
|
const byRuntimeOffset = new Map<number, RenderFrameRect>();
|
|
639
655
|
const byFragmentId = new Map<string, RenderFrameRect>();
|
|
@@ -739,6 +755,28 @@ function buildAnchorIndex(
|
|
|
739
755
|
byTableRowEdge(tableBlockId, rowIndex) {
|
|
740
756
|
return tableRowEdges.get(`${tableBlockId}:${rowIndex}`) ?? null;
|
|
741
757
|
},
|
|
758
|
+
// P9 Phase A — chrome-kind resolvers sourced from the resolved
|
|
759
|
+
// decoration index. Empty by default (the initial frame build passes
|
|
760
|
+
// `EMPTY_DECORATION_INDEX` until decoration resolution runs); the
|
|
761
|
+
// kernel re-invokes `buildAnchorIndex` with the resolved index so the
|
|
762
|
+
// final anchor index carries chrome-aware lookups.
|
|
763
|
+
byScopeId(scopeId) {
|
|
764
|
+
return decorationIndex.workflow
|
|
765
|
+
.filter((decoration) => decoration.refId === scopeId)
|
|
766
|
+
.map((decoration) => decoration.frame);
|
|
767
|
+
},
|
|
768
|
+
byCommentId(commentId) {
|
|
769
|
+
const match = decorationIndex.comments.find(
|
|
770
|
+
(decoration) => decoration.refId === commentId,
|
|
771
|
+
);
|
|
772
|
+
return match?.frame ?? null;
|
|
773
|
+
},
|
|
774
|
+
byRevisionId(revisionId) {
|
|
775
|
+
const match = decorationIndex.revisions.find(
|
|
776
|
+
(decoration) => decoration.refId === revisionId,
|
|
777
|
+
);
|
|
778
|
+
return match?.frame ?? null;
|
|
779
|
+
},
|
|
742
780
|
};
|
|
743
781
|
}
|
|
744
782
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 1 of the I2 Tier B rich-paste sub-plan (`docs/plans/lane-1-i2-tier-b-rich-paste.md`).
|
|
3
|
+
*
|
|
4
|
+
* `applyFragmentInsert` is the canonical splicer for `CanonicalDocumentFragment` —
|
|
5
|
+
* the block-level payload that the HTML and Word-clipboard paste parsers (Slices 2+3)
|
|
6
|
+
* will produce. Slice 1 ships the shape and a baseline "split-and-splice" semantic
|
|
7
|
+
* without any parser in front of it, so the public `insertFragment` method can be
|
|
8
|
+
* driven directly from tests + future hosts.
|
|
9
|
+
*
|
|
10
|
+
* Baseline semantics:
|
|
11
|
+
* 1. Empty fragment → no-op (no revisionToken bump).
|
|
12
|
+
* 2. Range selection → the range is deleted first via `applyTextTransaction`, then
|
|
13
|
+
* the caret is the range start.
|
|
14
|
+
* 3. Caret paragraph is split via `splitParagraph`; fragment blocks are spliced
|
|
15
|
+
* between the two halves. Empty halves at document boundaries are preserved —
|
|
16
|
+
* callers can trim them if desired.
|
|
17
|
+
*
|
|
18
|
+
* What Slice 1 deliberately does NOT do:
|
|
19
|
+
* - Merge-intent (pre-Slice-1 drafts had `firstParagraphMergeIntent` on the type).
|
|
20
|
+
* Merge semantics will land in a follow-up slice once we have a paste fixture
|
|
21
|
+
* that demonstrates the need.
|
|
22
|
+
* - Fragment insertion inside table cells beyond the trivial case. Table-cell
|
|
23
|
+
* splicing is currently best-effort: the target paragraph within the cell is
|
|
24
|
+
* split, but cross-cell fragments are rejected as a no-op.
|
|
25
|
+
* - Comment/revision remapping across the fragment boundary — the splicer returns
|
|
26
|
+
* an empty mapping; follow-up slices will produce richer mappings.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { CanonicalDocumentFragment } from "../../api/public-types.ts";
|
|
30
|
+
import {
|
|
31
|
+
type CanonicalDocumentEnvelope,
|
|
32
|
+
type SelectionSnapshot,
|
|
33
|
+
createSelectionSnapshot,
|
|
34
|
+
} from "../../core/state/editor-state.ts";
|
|
35
|
+
import { applyTextTransaction } from "../../core/state/text-transaction.ts";
|
|
36
|
+
import { splitParagraph, type TextCommandContext } from "../../core/commands/text-commands.ts";
|
|
37
|
+
import { resolveParagraphScope, type StructuralMutationResult } from "../../core/commands/structural-helpers.ts";
|
|
38
|
+
import type { BlockNode, DocumentRootNode, ParagraphNode } from "../../model/canonical-document.ts";
|
|
39
|
+
import { createEmptyMapping } from "../../core/selection/mapping.ts";
|
|
40
|
+
|
|
41
|
+
export function applyFragmentInsert(
|
|
42
|
+
document: CanonicalDocumentEnvelope,
|
|
43
|
+
selection: SelectionSnapshot,
|
|
44
|
+
fragment: CanonicalDocumentFragment,
|
|
45
|
+
context: TextCommandContext,
|
|
46
|
+
): StructuralMutationResult {
|
|
47
|
+
if (fragment.blocks.length === 0) {
|
|
48
|
+
return {
|
|
49
|
+
changed: false,
|
|
50
|
+
document,
|
|
51
|
+
selection,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collapse any range selection by first deleting the selected content. The
|
|
56
|
+
// resulting caret is at `min(anchor, head)`.
|
|
57
|
+
let workingDocument = document;
|
|
58
|
+
let workingSelection = selection;
|
|
59
|
+
if (selection.anchor !== selection.head) {
|
|
60
|
+
const collapseResult = applyTextTransaction(
|
|
61
|
+
workingDocument,
|
|
62
|
+
workingSelection,
|
|
63
|
+
{ type: "replace", insertion: [] },
|
|
64
|
+
context,
|
|
65
|
+
);
|
|
66
|
+
workingDocument = collapseResult.document;
|
|
67
|
+
workingSelection = collapseResult.selection;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Split the caret paragraph; the fragment blocks go between the two halves.
|
|
71
|
+
const splitResult = splitParagraph(workingDocument, workingSelection, context);
|
|
72
|
+
const splitRoot = splitResult.document.content;
|
|
73
|
+
if (!splitRoot || splitRoot.type !== "doc") {
|
|
74
|
+
return {
|
|
75
|
+
changed: false,
|
|
76
|
+
document,
|
|
77
|
+
selection,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Locate the split boundary by re-resolving the paragraph scope against the
|
|
82
|
+
// pre-split snapshot. The right-half index = scope.blockIndex + 1.
|
|
83
|
+
const scope = resolveParagraphScope(workingDocument, workingSelection);
|
|
84
|
+
if (!scope || scope.kind !== "top-level") {
|
|
85
|
+
// Table-cell fragment insert is out of scope for Slice 1.
|
|
86
|
+
return {
|
|
87
|
+
changed: false,
|
|
88
|
+
document,
|
|
89
|
+
selection,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rightHalfIndex = scope.blockIndex + 1;
|
|
94
|
+
const splicedChildren: BlockNode[] = [
|
|
95
|
+
...splitRoot.children.slice(0, rightHalfIndex),
|
|
96
|
+
...fragment.blocks.map((block) => cloneBlock(block)),
|
|
97
|
+
...splitRoot.children.slice(rightHalfIndex),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const nextRoot: DocumentRootNode = {
|
|
101
|
+
...splitRoot,
|
|
102
|
+
children: splicedChildren,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const nextDocument: CanonicalDocumentEnvelope = {
|
|
106
|
+
...splitResult.document,
|
|
107
|
+
updatedAt: context.timestamp,
|
|
108
|
+
content: nextRoot,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Caret lands at the end of the last fragment block. For Slice 1 we approximate
|
|
112
|
+
// this with a collapsed selection at position 0 of the new document — richer
|
|
113
|
+
// caret placement follows once parsers drive this.
|
|
114
|
+
const nextSelection = createSelectionSnapshot(0, 0);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
changed: true,
|
|
118
|
+
document: nextDocument,
|
|
119
|
+
selection: nextSelection,
|
|
120
|
+
mapping: createEmptyMapping(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cloneBlock(block: BlockNode): BlockNode {
|
|
125
|
+
// Slice 1 uses a structural clone; no need to deep-clone formatting attrs since
|
|
126
|
+
// fragment blocks are presumed freshly minted by the caller.
|
|
127
|
+
if (block.type === "paragraph") {
|
|
128
|
+
return {
|
|
129
|
+
...block,
|
|
130
|
+
children: block.children.map((child) => ({ ...child })),
|
|
131
|
+
} as ParagraphNode;
|
|
132
|
+
}
|
|
133
|
+
return JSON.parse(JSON.stringify(block)) as BlockNode;
|
|
134
|
+
}
|
|
@@ -486,7 +486,8 @@ export function __createWordReviewEditorRefBridge(
|
|
|
486
486
|
blur: () => runtime.blur(),
|
|
487
487
|
undo: () => runtime.undo(),
|
|
488
488
|
redo: () => runtime.redo(),
|
|
489
|
-
replaceText: (text, target) => runtime.replaceText(text, target),
|
|
489
|
+
replaceText: (text, target, formatting) => runtime.replaceText(text, target, formatting),
|
|
490
|
+
insertFragment: (fragment, target) => runtime.insertFragment(fragment, target),
|
|
490
491
|
addComment: (params) => runtime.addComment(params),
|
|
491
492
|
openComment: (commentId) => runtime.openComment(commentId),
|
|
492
493
|
resolveComment: (commentId) => runtime.resolveComment(commentId),
|
|
@@ -1476,7 +1477,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1476
1477
|
blur: () => activeRuntime.blur(),
|
|
1477
1478
|
undo: () => activeRuntime.undo(),
|
|
1478
1479
|
redo: () => activeRuntime.redo(),
|
|
1479
|
-
replaceText: (text, target) => activeRuntime.replaceText(text, target),
|
|
1480
|
+
replaceText: (text, target, formatting) => activeRuntime.replaceText(text, target, formatting),
|
|
1481
|
+
insertFragment: (fragment, target) => activeRuntime.insertFragment(fragment, target),
|
|
1480
1482
|
addComment: (params) =>
|
|
1481
1483
|
activeRuntime.addComment({
|
|
1482
1484
|
...params,
|
|
@@ -912,6 +912,7 @@ function createLoadingRuntimeBridge(input: {
|
|
|
912
912
|
getCanonicalDocument: () => input.sessionState.canonicalDocument,
|
|
913
913
|
getSourcePackage: () => input.sessionState.sourcePackage,
|
|
914
914
|
replaceText: () => undefined,
|
|
915
|
+
insertFragment: () => undefined,
|
|
915
916
|
applyActiveStoryTextCommand: () => ({
|
|
916
917
|
kind: "rejected",
|
|
917
918
|
newRevisionToken: "",
|
|
@@ -11,8 +11,29 @@ import {
|
|
|
11
11
|
extractPlainTextSegments,
|
|
12
12
|
type PastePlainSegment,
|
|
13
13
|
} from "./paste-plain-text";
|
|
14
|
+
import { parseCanonicalFragmentFromWordML } from "../../io/paste/word-clipboard";
|
|
14
15
|
import type { PositionMap } from "./pm-position-map";
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* I2 Tier B Slice 2 — MIME types Word + the browser use for WordprocessingML
|
|
19
|
+
* clipboard payloads. The first one is the legacy MS-Office HTML-embedded
|
|
20
|
+
* format; the second is the native Word clipboard type. Browsers expose both
|
|
21
|
+
* under `ClipboardEvent.clipboardData.getData(mime)`.
|
|
22
|
+
*/
|
|
23
|
+
const WORDML_MIMES = [
|
|
24
|
+
"application/x-docx-fragment",
|
|
25
|
+
"application/vnd.ms-word.wordprocessingml.paste",
|
|
26
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
function readWordMLPayload(clipboard: DataTransfer): string | null {
|
|
30
|
+
for (const mime of WORDML_MIMES) {
|
|
31
|
+
const value = clipboard.getData(mime);
|
|
32
|
+
if (value && value.trim().length > 0) return value;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
/**
|
|
17
38
|
* Callback subset used by paste / drop dispatch. Exported so tests can
|
|
18
39
|
* record dispatch order without constructing the full
|
|
@@ -93,6 +114,18 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
|
|
|
93
114
|
charCount: number;
|
|
94
115
|
source: "paste" | "drop";
|
|
95
116
|
}) => void;
|
|
117
|
+
/**
|
|
118
|
+
* I2 Tier B Slice 2 — optional. Fires when the paste handler detects an
|
|
119
|
+
* Office-clipboard WordprocessingML payload and parses it successfully into
|
|
120
|
+
* a canonical fragment. The host is responsible for dispatching
|
|
121
|
+
* `runtime.insertFragment(fragment)`; the bridge does not reach into the
|
|
122
|
+
* runtime directly so this plumbing stays consistent with the Tier A
|
|
123
|
+
* plain-text callback pattern.
|
|
124
|
+
*/
|
|
125
|
+
onPasteFragment?: (meta: {
|
|
126
|
+
fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
|
|
127
|
+
source: "wordml";
|
|
128
|
+
}) => void;
|
|
96
129
|
/**
|
|
97
130
|
* Optional. Fires on `compositionstart` (true) and `compositionend`
|
|
98
131
|
* (false). The surface forwards this to the predicted lane's session
|
|
@@ -195,11 +228,17 @@ export function createCommandBridgePlugins(
|
|
|
195
228
|
return true; // Block PM from processing
|
|
196
229
|
},
|
|
197
230
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
231
|
+
// I2 paste handler — Tier B (WordML) preferred, Tier A (plain) fallback.
|
|
232
|
+
//
|
|
233
|
+
// Preference order per `docs/plans/lane-1-i2-tier-b-rich-paste.md`:
|
|
234
|
+
// 1. Office-clipboard WordprocessingML payload if the host wired
|
|
235
|
+
// `onPasteFragment` AND the clipboard carries the MIME. Parsed via
|
|
236
|
+
// `parseCanonicalFragmentFromWordML`.
|
|
237
|
+
// 2. Plain text via `extractPlainTextSegments` (Tier A).
|
|
238
|
+
// 3. `onBlockedInput` for HTML-only / empty payloads.
|
|
239
|
+
//
|
|
240
|
+
// Rich-paste fallback on parse failure or missing host callback: fall
|
|
241
|
+
// through to Tier A so the user isn't left with a silent no-op.
|
|
203
242
|
handlePaste(_view, event) {
|
|
204
243
|
if (isComposing) return true;
|
|
205
244
|
const clipboard = event.clipboardData;
|
|
@@ -207,6 +246,22 @@ export function createCommandBridgePlugins(
|
|
|
207
246
|
callbacks.onBlockedInput?.("paste", "Clipboard data was not available.");
|
|
208
247
|
return true;
|
|
209
248
|
}
|
|
249
|
+
|
|
250
|
+
// Tier B: WordprocessingML
|
|
251
|
+
if (callbacks.onPasteFragment) {
|
|
252
|
+
const wordml = readWordMLPayload(clipboard);
|
|
253
|
+
if (wordml) {
|
|
254
|
+
const parsed = parseCanonicalFragmentFromWordML(wordml);
|
|
255
|
+
if (parsed.ok && parsed.fragment.blocks.length > 0) {
|
|
256
|
+
callbacks.onPasteFragment({ fragment: parsed.fragment, source: "wordml" });
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
// Parse failed or empty — fall through to plain-text so the paste
|
|
260
|
+
// still does something (defensive against malformed clipboard payloads).
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Tier A: plain text
|
|
210
265
|
const plain = clipboard.getData("text/plain");
|
|
211
266
|
if (!plain) {
|
|
212
267
|
callbacks.onBlockedInput?.(
|