@beyondwork/docx-react-component 1.0.74 → 1.0.76
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/package.json +1 -1
- package/src/api/public-types.ts +7 -0
- package/src/api/v3/runtime/workflow.ts +130 -1
- package/src/io/ooxml/parse-headers-footers.ts +7 -13
- package/src/io/ooxml/parse-main-document.ts +7 -31
- package/src/io/ooxml/table-opaque-preservation.ts +171 -0
- package/src/runtime/document-runtime.ts +28 -1
- package/src/runtime/surface-projection.ts +9 -0
- package/src/runtime/workflow/scope-writer.ts +212 -10
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +44 -5
- package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
- package/src/ui-tailwind/editor-surface/preserve-position.ts +211 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -5
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +49 -5
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
- package/src/ui-tailwind/tw-review-workspace.tsx +0 -6
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.76",
|
|
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
|
@@ -5625,6 +5625,13 @@ export interface ShortcutDelegationContext {
|
|
|
5625
5625
|
selectionRange: SelectionSnapshot;
|
|
5626
5626
|
}
|
|
5627
5627
|
|
|
5628
|
+
/**
|
|
5629
|
+
* Resolver contract (`resolveChromeVisibilityForPreset`): every field on
|
|
5630
|
+
* this interface is REQUIRED — no `| undefined`, no optional sugar. Each
|
|
5631
|
+
* preset's default-visibility block (`src/api/v3/ui/chrome-preset-model.ts`)
|
|
5632
|
+
* MUST set every field. If you add a new surface visibility flag, add it
|
|
5633
|
+
* here AND to all preset default blocks in the same commit.
|
|
5634
|
+
*/
|
|
5628
5635
|
export interface WordReviewEditorChromeVisibility {
|
|
5629
5636
|
toolbar: boolean;
|
|
5630
5637
|
alerts: boolean;
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
import type { RuntimeApiHandle } from "../_runtime-handle.ts";
|
|
12
12
|
import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
|
|
13
13
|
import type {
|
|
14
|
+
EditorStoryTarget,
|
|
14
15
|
OverlayKind,
|
|
15
16
|
OverlayVisibilityPolicy,
|
|
16
17
|
WorkflowMarkupModePolicy,
|
|
17
18
|
} from "../../public-types.ts";
|
|
18
19
|
import { emitUxResponse } from "../_ux-response.ts";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
createScopeFromAnchor,
|
|
22
|
+
createScopeFromBlockId,
|
|
23
|
+
} from "../../../runtime/workflow/scope-writer.ts";
|
|
20
24
|
import { attachScopeMetadata } from "../../../runtime/workflow/metadata-writer.ts";
|
|
21
25
|
import {
|
|
22
26
|
DEFAULT_REGISTRY_ENTRIES,
|
|
@@ -113,6 +117,59 @@ export interface CreateScopeResult {
|
|
|
113
117
|
readonly status: "created" | "block-not-found";
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Input for `createScopeFromAnchor` — sub-block marker-backed scope.
|
|
122
|
+
*
|
|
123
|
+
* Use this variant when the scope must bracket a range *within* a block
|
|
124
|
+
* (a phrase inside a paragraph) or across blocks (a selection that
|
|
125
|
+
* spans paragraph boundaries). For whole-block scopes, prefer
|
|
126
|
+
* `createScope({blockId})` — it stays block-aligned after edits at the
|
|
127
|
+
* block boundary.
|
|
128
|
+
*
|
|
129
|
+
* The `from`/`to` positions are consumed **once** at creation. After
|
|
130
|
+
* this call returns, use the returned `scopeId` as the reference. Do
|
|
131
|
+
* not cache, store, or reuse the positions — they are positional
|
|
132
|
+
* queries (KI-P9), valid only in the document state they were captured
|
|
133
|
+
* in. The scope itself is marker-backed and travels with its bracketed
|
|
134
|
+
* content through any number of subsequent edits.
|
|
135
|
+
*/
|
|
136
|
+
export interface CreateScopeFromAnchorInput {
|
|
137
|
+
readonly anchor: {
|
|
138
|
+
/** Zero-based surface offset, inclusive. Must be `>= 0`. */
|
|
139
|
+
readonly from: number;
|
|
140
|
+
/** Zero-based surface offset, exclusive of the marker slot. Must be `>= from` and `<= storyLength`. */
|
|
141
|
+
readonly to: number;
|
|
142
|
+
/** Non-main-body story (footnote / header / endnote). Defaults to `{kind:"main"}`. */
|
|
143
|
+
readonly storyTarget?: EditorStoryTarget;
|
|
144
|
+
};
|
|
145
|
+
readonly mode?: "edit" | "suggest" | "comment" | "view";
|
|
146
|
+
readonly label?: string;
|
|
147
|
+
readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
|
|
148
|
+
readonly stableRefHint?:
|
|
149
|
+
| "scope-id"
|
|
150
|
+
| "bookmark"
|
|
151
|
+
| "semantic-path"
|
|
152
|
+
| "runtime-handle";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type CreateScopeFromAnchorResult =
|
|
156
|
+
| { readonly scopeId: string; readonly status: "created" }
|
|
157
|
+
| {
|
|
158
|
+
readonly scopeId: "";
|
|
159
|
+
readonly status: "range-invalid";
|
|
160
|
+
readonly reason:
|
|
161
|
+
| "from-negative"
|
|
162
|
+
| "to-less-than-from"
|
|
163
|
+
| "range-exceeds-story-length";
|
|
164
|
+
readonly from: number;
|
|
165
|
+
readonly to: number;
|
|
166
|
+
readonly storyLength: number;
|
|
167
|
+
/** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
|
|
168
|
+
readonly message: string;
|
|
169
|
+
/** Machine-routable next-step hint (`"clamp-from-to-zero"` / `"swap-from-and-to"` / `"clamp-to-to-storyLength-or-pick-a-different-range"`). */
|
|
170
|
+
readonly nextStep: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
116
173
|
export const createScopeMetadata: ApiV3FnMetadata = {
|
|
117
174
|
name: "runtime.workflow.createScope",
|
|
118
175
|
status: "live-with-adapter",
|
|
@@ -129,6 +186,32 @@ export const createScopeMetadata: ApiV3FnMetadata = {
|
|
|
129
186
|
rwdReference: "§Runtime API § runtime.workflow.createScope",
|
|
130
187
|
};
|
|
131
188
|
|
|
189
|
+
export const createScopeFromAnchorMetadata: ApiV3FnMetadata = {
|
|
190
|
+
name: "runtime.workflow.createScopeFromAnchor",
|
|
191
|
+
status: "live-with-adapter",
|
|
192
|
+
sourceLayer: "workflow-review",
|
|
193
|
+
liveEvidence: {
|
|
194
|
+
runnerTest: "test/api/v3/workflow-create-scope-from-anchor-live.test.ts",
|
|
195
|
+
commit: "pending",
|
|
196
|
+
},
|
|
197
|
+
uxIntent: {
|
|
198
|
+
uiVisible: true,
|
|
199
|
+
expectsUxResponse: "scope-created",
|
|
200
|
+
expectedDelta:
|
|
201
|
+
"rail shows new scope chip for the sub-block range; editor surface refreshes with inline markers planted at from/to",
|
|
202
|
+
},
|
|
203
|
+
agentMetadata: {
|
|
204
|
+
readOrMutate: "mutate",
|
|
205
|
+
boundedScope: "selection",
|
|
206
|
+
auditCategory: "scope-creation",
|
|
207
|
+
},
|
|
208
|
+
stateClass: "A-canonical",
|
|
209
|
+
persistsTo: "customXml",
|
|
210
|
+
broadcastsVia: "crdt",
|
|
211
|
+
rwdReference:
|
|
212
|
+
"§Runtime API § runtime.workflow.createScopeFromAnchor. Companion to createScope({blockId}) for sub-block ranges. Consumes from/to once to plant inline scope_marker_start/end; the returned scopeId is the durable reference (KI-P9).",
|
|
213
|
+
};
|
|
214
|
+
|
|
132
215
|
export interface AttachMetadataInput {
|
|
133
216
|
readonly scopeId: string;
|
|
134
217
|
readonly metadataId: string;
|
|
@@ -411,6 +494,52 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
|
|
|
411
494
|
return { scopeId: adapterResult.scopeId, status: "created" };
|
|
412
495
|
},
|
|
413
496
|
|
|
497
|
+
createScopeFromAnchor(
|
|
498
|
+
input: CreateScopeFromAnchorInput,
|
|
499
|
+
): CreateScopeFromAnchorResult {
|
|
500
|
+
// @endStateApi — live-with-adapter. Delegates to the layer-06
|
|
501
|
+
// scope-writer with the anchor range passed through directly.
|
|
502
|
+
// Bounds are validated against the story length; out-of-bounds
|
|
503
|
+
// ranges return a typed `range-invalid` discriminator rather than
|
|
504
|
+
// inventing a scopeId.
|
|
505
|
+
const adapterResult = createScopeFromAnchor(runtime, {
|
|
506
|
+
anchor: input.anchor,
|
|
507
|
+
mode: input.mode,
|
|
508
|
+
label: input.label,
|
|
509
|
+
...(input.assoc ? { assoc: input.assoc } : {}),
|
|
510
|
+
...(input.stableRefHint
|
|
511
|
+
? { stableRefHint: input.stableRefHint }
|
|
512
|
+
: {}),
|
|
513
|
+
});
|
|
514
|
+
emitUxResponse(runtime, {
|
|
515
|
+
apiFn: createScopeFromAnchorMetadata.name,
|
|
516
|
+
intent: createScopeFromAnchorMetadata.uxIntent.expectedDelta ?? "",
|
|
517
|
+
mockOrLive: "live",
|
|
518
|
+
uiVisible: true,
|
|
519
|
+
expectedDelta: createScopeFromAnchorMetadata.uxIntent.expectedDelta,
|
|
520
|
+
actualDelta:
|
|
521
|
+
adapterResult.status === "created"
|
|
522
|
+
? {
|
|
523
|
+
kind: "inline-change",
|
|
524
|
+
payload: { scopeId: adapterResult.scopeId },
|
|
525
|
+
}
|
|
526
|
+
: undefined,
|
|
527
|
+
});
|
|
528
|
+
if (adapterResult.status !== "created") {
|
|
529
|
+
return {
|
|
530
|
+
scopeId: "",
|
|
531
|
+
status: "range-invalid",
|
|
532
|
+
reason: adapterResult.reason,
|
|
533
|
+
from: adapterResult.from,
|
|
534
|
+
to: adapterResult.to,
|
|
535
|
+
storyLength: adapterResult.storyLength,
|
|
536
|
+
message: adapterResult.message,
|
|
537
|
+
nextStep: adapterResult.nextStep,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return { scopeId: adapterResult.scopeId, status: "created" };
|
|
541
|
+
},
|
|
542
|
+
|
|
414
543
|
getVisibilityPolicy(kind: OverlayKind): OverlayVisibilityPolicy | null {
|
|
415
544
|
// @endStateApi — live. Class-A policy read; composition with
|
|
416
545
|
// class-C local preference lives in L10 (`ui.overlays.getVisibility`).
|
|
@@ -17,6 +17,7 @@ import { resolveHighlightColor } from "./highlight-colors.ts";
|
|
|
17
17
|
import type { ParseDrawingOpts } from "./parse-drawing.ts";
|
|
18
18
|
import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
|
|
19
19
|
import { classifyFieldInstruction } from "./parse-fields.ts";
|
|
20
|
+
import { isSafeTableFieldInstruction } from "./table-opaque-preservation.ts";
|
|
20
21
|
import { parseXmlWithOffsets as parseXml } from "./xml-parser.ts";
|
|
21
22
|
import { localName, readStringAttr } from "./xml-attr-helpers.ts";
|
|
22
23
|
import {
|
|
@@ -1018,8 +1019,12 @@ function containsRiskyElement(element: XmlElementNode): boolean {
|
|
|
1018
1019
|
const instruction =
|
|
1019
1020
|
readStringAttr(child, "w:instr") ??
|
|
1020
1021
|
extractTextContent(child);
|
|
1021
|
-
|
|
1022
|
-
|
|
1022
|
+
// Coord-01 §11 unification (2026-04-24): the field-safety check
|
|
1023
|
+
// is delegated to the shared `isSafeTableFieldInstruction` helper
|
|
1024
|
+
// so body-direct + secondary-story tables apply the same
|
|
1025
|
+
// allowlist. Legacy form fields (FORMTEXT/FORMCHECKBOX/
|
|
1026
|
+
// FORMDROPDOWN) now pass here just like in main-story tables.
|
|
1027
|
+
if (!isSafeTableFieldInstruction(instruction)) {
|
|
1023
1028
|
return true;
|
|
1024
1029
|
}
|
|
1025
1030
|
continue;
|
|
@@ -1041,17 +1046,6 @@ function containsRiskyElement(element: XmlElementNode): boolean {
|
|
|
1041
1046
|
return false;
|
|
1042
1047
|
}
|
|
1043
1048
|
|
|
1044
|
-
function isSafeSecondaryStoryFieldFamily(family: string): boolean {
|
|
1045
|
-
return (
|
|
1046
|
-
family === "REF" ||
|
|
1047
|
-
family === "PAGEREF" ||
|
|
1048
|
-
family === "NOTEREF" ||
|
|
1049
|
-
family === "TOC" ||
|
|
1050
|
-
family === "PAGE" ||
|
|
1051
|
-
family === "NUMPAGES"
|
|
1052
|
-
);
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
1049
|
function parseSimpleTableElement(
|
|
1056
1050
|
tblElement: XmlElementNode,
|
|
1057
1051
|
sourceXml: string,
|
|
@@ -41,6 +41,7 @@ import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
|
|
|
41
41
|
import { parseObject } from "./parse-object.ts";
|
|
42
42
|
import { parseDrawingFrame } from "./parse-drawing.ts";
|
|
43
43
|
import { readFrameProperties } from "./parse-paragraph-formatting.ts";
|
|
44
|
+
import { tableRequiresOpaquePreservation } from "./table-opaque-preservation.ts";
|
|
44
45
|
import { classifyFieldInstruction } from "./parse-fields.ts";
|
|
45
46
|
import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
|
|
46
47
|
import { resolveHighlightColor } from "./highlight-colors.ts";
|
|
@@ -2020,37 +2021,12 @@ function readCellCnfStyle(node: XmlElementNode): string | undefined {
|
|
|
2020
2021
|
* Tables matching this check stay opaque until the respective features
|
|
2021
2022
|
* are implemented in the table editing path.
|
|
2022
2023
|
*/
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
return true;
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
const simpleInstructions = [...rawXml.matchAll(/\bw:instr="([^"]*)"/g)].map((match) => match[1] ?? "");
|
|
2033
|
-
const complexInstructions = extractComplexFieldInstructions(rawXml);
|
|
2034
|
-
for (const instruction of [...simpleInstructions, ...complexInstructions]) {
|
|
2035
|
-
const classification = classifyFieldInstruction(instruction);
|
|
2036
|
-
if (!isSafeMainStoryTableFieldFamily(classification.family)) {
|
|
2037
|
-
return true;
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
return false;
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
function isSafeMainStoryTableFieldFamily(family: string): boolean {
|
|
2045
|
-
return (
|
|
2046
|
-
family === "REF" ||
|
|
2047
|
-
family === "PAGEREF" ||
|
|
2048
|
-
family === "NOTEREF" ||
|
|
2049
|
-
family === "TOC" ||
|
|
2050
|
-
family === "PAGE" ||
|
|
2051
|
-
family === "NUMPAGES"
|
|
2052
|
-
);
|
|
2053
|
-
}
|
|
2024
|
+
// `tableRequiresOpaquePreservation` now lives in
|
|
2025
|
+
// `src/io/ooxml/table-opaque-preservation.ts` as a shared helper used by
|
|
2026
|
+
// `parse-main-document.ts`, `parse-headers-footers.ts`, and
|
|
2027
|
+
// `parse-footnotes.ts`. Unified 2026-04-24 per coord-01 §11 to stop the
|
|
2028
|
+
// three parsers from drifting out of alignment. The top-of-file import
|
|
2029
|
+
// `tableRequiresOpaquePreservation` points at that module.
|
|
2054
2030
|
|
|
2055
2031
|
function extractComplexFieldInstructions(rawXml: string): string[] {
|
|
2056
2032
|
const tokenPattern =
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified table-opaque-preservation predicate — shared across
|
|
3
|
+
* `parse-main-document.ts`, `parse-headers-footers.ts`, and
|
|
4
|
+
* `parse-footnotes.ts` so the three parsers agree on which tables can
|
|
5
|
+
* be promoted to the typed `TableNode` shape and which must fall back
|
|
6
|
+
* to `opaque_block` for lossless rawXml round-trip.
|
|
7
|
+
*
|
|
8
|
+
* ## History
|
|
9
|
+
*
|
|
10
|
+
* Before this module existed, each of the three parsers maintained its
|
|
11
|
+
* own divergent allowlist. That divergence drove the coord-01 §11
|
|
12
|
+
* finding (2026-04-24): legacy form fields (`FORMTEXT`,
|
|
13
|
+
* `FORMCHECKBOX`, `FORMDROPDOWN`) classify as `UNKNOWN` under
|
|
14
|
+
* `FIELD_FAMILY_PATTERN` (which targets data-field families), so both
|
|
15
|
+
* the main-story and secondary-story table predicates rejected them
|
|
16
|
+
* even though the body-direct paragraph parser path (via
|
|
17
|
+
* `parseFFDataFromFldChar`) handles them correctly. The result was
|
|
18
|
+
* 58 cell paragraphs lost on APS Short Form because 2 body-direct
|
|
19
|
+
* tables flattened to opaque_block; 5 additional footer tables on
|
|
20
|
+
* the same doc also flatten (those carry `DOCPROPERTY` — a real data
|
|
21
|
+
* field, distinct from the legacy form-field case).
|
|
22
|
+
*
|
|
23
|
+
* ## Contract
|
|
24
|
+
*
|
|
25
|
+
* A `<w:tbl>` is "safe to parse as structured" iff NONE of the
|
|
26
|
+
* following are true:
|
|
27
|
+
*
|
|
28
|
+
* 1. **Revision markup.** `<w:ins>`, `<w:del>`, `<w:moveFrom>`,
|
|
29
|
+
* `<w:moveTo>`, `<w:rPrChange>`, `<w:pPrChange>`,
|
|
30
|
+
* `<w:tblPrChange>`, `<w:trPrChange>`, `<w:tcPrChange>`,
|
|
31
|
+
* `<w:sectPrChange>`, `<w:cellIns>`, `<w:cellDel>`,
|
|
32
|
+
* `<w:cellMerge>`, `<w:smartTag>`.
|
|
33
|
+
* Rationale: tracked-change-aware table editing isn't implemented;
|
|
34
|
+
* flattening preserves fidelity until it is.
|
|
35
|
+
*
|
|
36
|
+
* 2. **Field instruction outside the safe set.** Scan every
|
|
37
|
+
* `w:instr` simple-field attribute + every `<w:instrText>` inside
|
|
38
|
+
* a complex field (`<w:fldChar>begin</w:fldChar>...end`) and
|
|
39
|
+
* classify via `classifyFieldInstruction`. If the classified
|
|
40
|
+
* family isn't in `SAFE_TABLE_FIELD_FAMILIES` AND the instruction
|
|
41
|
+
* isn't a legacy form field (`isLegacyFormFieldInstruction`), the
|
|
42
|
+
* table flattens. The legacy-form-field short-circuit is the
|
|
43
|
+
* coord-01 §11 fix — those tokens are fully supported by the cell
|
|
44
|
+
* paragraph parser but classify as `UNKNOWN`.
|
|
45
|
+
*
|
|
46
|
+
* Extending this contract is a cross-layer architectural decision:
|
|
47
|
+
* widening `SAFE_TABLE_FIELD_FAMILIES` lets more tables parse as
|
|
48
|
+
* structured but commits the edit-command layer (L06/L08) to handle
|
|
49
|
+
* each field family's cell-level edit semantics. Keep this list
|
|
50
|
+
* aligned with the field families supported by the runtime
|
|
51
|
+
* formatting + scope-command pipeline.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { classifyFieldInstruction } from "./parse-fields.ts";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Field families safe enough to leave a `<w:tbl>` in structured
|
|
58
|
+
* canonical form. Widening this set commits L06 / L08 to cell-level
|
|
59
|
+
* edit semantics for that family — don't expand opportunistically.
|
|
60
|
+
*/
|
|
61
|
+
export const SAFE_TABLE_FIELD_FAMILIES: ReadonlySet<string> = new Set([
|
|
62
|
+
"REF",
|
|
63
|
+
"PAGEREF",
|
|
64
|
+
"NOTEREF",
|
|
65
|
+
"TOC",
|
|
66
|
+
"PAGE",
|
|
67
|
+
"NUMPAGES",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Revision + structural markup that forces table flattening.
|
|
72
|
+
*/
|
|
73
|
+
const RISKY_TABLE_MARKUP_RE =
|
|
74
|
+
/<w:(ins|del|moveFrom|moveTo|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|smartTag)\b/;
|
|
75
|
+
|
|
76
|
+
const SIMPLE_FIELD_INSTR_RE = /\bw:instr="([^"]*)"/g;
|
|
77
|
+
|
|
78
|
+
const COMPLEX_FIELD_TOKEN_RE =
|
|
79
|
+
/<(?:\w+:)?fldChar\b[^>]*?(?:\w+:)?fldCharType="(begin|separate|end)"[^>]*?(?:\/>|>[\s\S]*?<\/(?:\w+:)?fldChar>)|<(?:\w+:)?instrText\b[^>]*>([\s\S]*?)<\/(?:\w+:)?instrText>/gu;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decode the minimal set of XML entities that can appear inside an
|
|
83
|
+
* `instrText` payload. `classifyFieldInstruction` consumes the raw
|
|
84
|
+
* instruction text, so we normalize `&` / `"` / `<` /
|
|
85
|
+
* `>` / `'` before handing it off. Matches the pre-existing
|
|
86
|
+
* behavior in `parse-main-document.ts::extractComplexFieldInstructions`.
|
|
87
|
+
*/
|
|
88
|
+
function decodeXmlEntities(text: string): string {
|
|
89
|
+
return text
|
|
90
|
+
.replace(/</g, "<")
|
|
91
|
+
.replace(/>/g, ">")
|
|
92
|
+
.replace(/"/g, '"')
|
|
93
|
+
.replace(/'/g, "'")
|
|
94
|
+
.replace(/&/g, "&");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract every complex-field instruction (`<w:fldChar
|
|
99
|
+
* fldCharType="begin">...<w:instrText>...</w:instrText>...</w:fldChar
|
|
100
|
+
* fldCharType="end">`) as a single concatenated instruction string.
|
|
101
|
+
* Mirrors `parse-main-document.ts::extractComplexFieldInstructions`.
|
|
102
|
+
*/
|
|
103
|
+
export function extractComplexFieldInstructionsFromRaw(rawXml: string): string[] {
|
|
104
|
+
const instructions: string[] = [];
|
|
105
|
+
let active = "";
|
|
106
|
+
let capturing = false;
|
|
107
|
+
for (const match of rawXml.matchAll(COMPLEX_FIELD_TOKEN_RE)) {
|
|
108
|
+
const [, fldCharType, instrText] = match;
|
|
109
|
+
if (fldCharType === "begin") {
|
|
110
|
+
active = "";
|
|
111
|
+
capturing = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (fldCharType === "separate" || fldCharType === "end") {
|
|
115
|
+
if (capturing && active.trim().length > 0) instructions.push(active);
|
|
116
|
+
active = "";
|
|
117
|
+
capturing = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (capturing && instrText !== undefined) active += decodeXmlEntities(instrText);
|
|
121
|
+
}
|
|
122
|
+
return instructions;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Legacy form fields (ECMA-376 §17.16.21) — `FORMTEXT`, `FORMCHECKBOX`,
|
|
127
|
+
* `FORMDROPDOWN`. These are fully supported by the body-direct
|
|
128
|
+
* paragraph parser via `parseFFDataFromFldChar` but classify as
|
|
129
|
+
* `UNKNOWN` under `FIELD_FAMILY_PATTERN` (which targets data-field
|
|
130
|
+
* families like REF / TOC / MERGEFIELD). Short-circuiting them lets
|
|
131
|
+
* form-field cells stay in structured canonical tables instead of
|
|
132
|
+
* flattening the entire table to `opaque_block`. Coord-01 §11, 2026-04-24.
|
|
133
|
+
*/
|
|
134
|
+
export function isLegacyFormFieldInstruction(instruction: string): boolean {
|
|
135
|
+
return /^\s*(FORMTEXT|FORMCHECKBOX|FORMDROPDOWN)\b/i.test(instruction);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Decides whether a single field instruction (either `w:instr`
|
|
140
|
+
* attribute value or concatenated `instrText` run) is safe for
|
|
141
|
+
* structured-table parsing. Used by the shared predicate below;
|
|
142
|
+
* exposed for direct callers (the debug diagnostics script runs
|
|
143
|
+
* this to classify source instructions alongside the canonical).
|
|
144
|
+
*/
|
|
145
|
+
export function isSafeTableFieldInstruction(instruction: string): boolean {
|
|
146
|
+
if (isLegacyFormFieldInstruction(instruction)) return true;
|
|
147
|
+
const family = classifyFieldInstruction(instruction).family;
|
|
148
|
+
return SAFE_TABLE_FIELD_FAMILIES.has(family);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shared `<w:tbl>` opaque-preservation predicate. Returns `true`
|
|
153
|
+
* when the table must fall back to `opaque_block` for lossless
|
|
154
|
+
* round-trip, `false` when `parseTable` / `parseSimpleTableElement`
|
|
155
|
+
* can promote it to a structured `TableNode`.
|
|
156
|
+
*
|
|
157
|
+
* Callers (`parse-main-document.ts`, `parse-headers-footers.ts`,
|
|
158
|
+
* `parse-footnotes.ts`) pass the raw XML of the `<w:tbl>` element.
|
|
159
|
+
*/
|
|
160
|
+
export function tableRequiresOpaquePreservation(rawXml: string): boolean {
|
|
161
|
+
if (RISKY_TABLE_MARKUP_RE.test(rawXml)) return true;
|
|
162
|
+
|
|
163
|
+
const simpleInstructions = [...rawXml.matchAll(SIMPLE_FIELD_INSTR_RE)].map(
|
|
164
|
+
(match) => match[1] ?? "",
|
|
165
|
+
);
|
|
166
|
+
const complexInstructions = extractComplexFieldInstructionsFromRaw(rawXml);
|
|
167
|
+
for (const instruction of [...simpleInstructions, ...complexInstructions]) {
|
|
168
|
+
if (!isSafeTableFieldInstruction(instruction)) return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
@@ -1036,6 +1036,9 @@ function normalizeViewportRanges(
|
|
|
1036
1036
|
for (let i = 1; i < cleaned.length; i += 1) {
|
|
1037
1037
|
const next = cleaned[i]!;
|
|
1038
1038
|
const last = merged[merged.length - 1]!;
|
|
1039
|
+
// Mutation is safe: `last` is a fresh object from the `.map` step above,
|
|
1040
|
+
// not aliased with any caller-provided input. The `Object.freeze` at the
|
|
1041
|
+
// return site only runs after the merge loop finishes.
|
|
1039
1042
|
if (next.start <= last.end) {
|
|
1040
1043
|
if (next.end > last.end) last.end = next.end;
|
|
1041
1044
|
} else {
|
|
@@ -1372,6 +1375,17 @@ export function createDocumentRuntime(
|
|
|
1372
1375
|
| {
|
|
1373
1376
|
revisionToken: string;
|
|
1374
1377
|
activeStoryKey: string;
|
|
1378
|
+
/**
|
|
1379
|
+
* Serialized viewport ranges at build time. A surface snapshot is
|
|
1380
|
+
* only reusable for a subsequent `getCachedSurface` call if the
|
|
1381
|
+
* runtime's current `viewportRangesKey` matches — otherwise the
|
|
1382
|
+
* cached snapshot realizes a different set of blocks as real vs
|
|
1383
|
+
* placeholder-culled than the current runtime state demands.
|
|
1384
|
+
* Pre-refactor/11b this was a (revisionToken, activeStoryKey) key
|
|
1385
|
+
* only, which was silently stale-prone when callers applied new
|
|
1386
|
+
* viewport ranges without also calling `requestViewportRefresh()`.
|
|
1387
|
+
*/
|
|
1388
|
+
viewportRangesKey: string;
|
|
1375
1389
|
snapshot: RuntimeRenderSnapshot["surface"];
|
|
1376
1390
|
}
|
|
1377
1391
|
| undefined;
|
|
@@ -1533,7 +1547,8 @@ export function createDocumentRuntime(
|
|
|
1533
1547
|
if (
|
|
1534
1548
|
cachedSurface &&
|
|
1535
1549
|
cachedSurface.revisionToken === state.revisionToken &&
|
|
1536
|
-
cachedSurface.activeStoryKey === activeStoryKey
|
|
1550
|
+
cachedSurface.activeStoryKey === activeStoryKey &&
|
|
1551
|
+
cachedSurface.viewportRangesKey === viewportRangesKey
|
|
1537
1552
|
) {
|
|
1538
1553
|
return cachedSurface.snapshot;
|
|
1539
1554
|
}
|
|
@@ -1549,8 +1564,13 @@ export function createDocumentRuntime(
|
|
|
1549
1564
|
cachedSurface = {
|
|
1550
1565
|
revisionToken: state.revisionToken,
|
|
1551
1566
|
activeStoryKey,
|
|
1567
|
+
viewportRangesKey,
|
|
1552
1568
|
snapshot,
|
|
1553
1569
|
};
|
|
1570
|
+
// Keep the scroll-path fingerprint in lockstep so a subsequent
|
|
1571
|
+
// `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
|
|
1572
|
+
// and short-circuits instead of paying a redundant projection.
|
|
1573
|
+
cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
|
|
1554
1574
|
return snapshot;
|
|
1555
1575
|
}
|
|
1556
1576
|
|
|
@@ -2394,6 +2414,7 @@ export function createDocumentRuntime(
|
|
|
2394
2414
|
cachedSurface = {
|
|
2395
2415
|
revisionToken: state.revisionToken,
|
|
2396
2416
|
activeStoryKey: storyTargetKey(activeStory),
|
|
2417
|
+
viewportRangesKey,
|
|
2397
2418
|
snapshot: newSurface,
|
|
2398
2419
|
};
|
|
2399
2420
|
cachedRenderSnapshot = {
|
|
@@ -2427,6 +2448,12 @@ export function createDocumentRuntime(
|
|
|
2427
2448
|
|
|
2428
2449
|
function invalidateDerivedRuntimeCaches(): void {
|
|
2429
2450
|
cachedSurface = undefined;
|
|
2451
|
+
// Keep the scroll-path fingerprint in lockstep with `cachedSurface`.
|
|
2452
|
+
// Otherwise a post-commit `requestViewportRefresh` would compute a new
|
|
2453
|
+
// fingerprint, see it differs from the stale pre-commit value, and
|
|
2454
|
+
// rebuild + broadcast even when the freshly-hydrated `cachedSurface`
|
|
2455
|
+
// already matches — wasted projection + listener fan-out.
|
|
2456
|
+
cachedSurfaceFingerprint = null;
|
|
2430
2457
|
cachedCompatibility = undefined;
|
|
2431
2458
|
cachedComments = undefined;
|
|
2432
2459
|
cachedTrackedChanges = undefined;
|
|
@@ -106,6 +106,15 @@ export interface SurfaceProjectionOptions {
|
|
|
106
106
|
* block stays real without realizing the document-scale gap between the
|
|
107
107
|
* two). Callers still supplying the legacy scalar `viewportBlockRange` get
|
|
108
108
|
* wrapped into a single-element array internally.
|
|
109
|
+
*
|
|
110
|
+
* **Invariant (caller responsibility):** intervals must be sorted ascending
|
|
111
|
+
* by `start` and non-overlapping. The projection function itself only
|
|
112
|
+
* checks membership — it does not sort or merge — so an unsorted input
|
|
113
|
+
* produces a correct `isInViewport` answer but the snapshot's
|
|
114
|
+
* `viewportBlockRanges` field carries the unnormalized shape to downstream
|
|
115
|
+
* consumers (e.g. the surface-build-key serializer). Runtime callers
|
|
116
|
+
* route through `DocumentRuntime.applyViewportRanges` which normalizes;
|
|
117
|
+
* if you call `createEditorSurfaceSnapshot` directly, normalize first.
|
|
109
118
|
*/
|
|
110
119
|
viewportBlockRanges?: readonly { start: number; end: number }[] | null;
|
|
111
120
|
/** @deprecated use `viewportBlockRanges`. Kept for back-compat; wrapped into a 1-element array when supplied alone. */
|