@beyondwork/docx-react-component 1.0.72 → 1.0.74
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/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +70 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/ai/policy.ts +31 -0
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/chrome-preset-model.ts +6 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -1
- package/src/api/v3/ui/viewport.ts +1 -1
- package/src/compare/diff-engine.ts +1 -2
- package/src/core/commands/index.ts +14 -15
- package/src/core/selection/anchor-conversion.ts +2 -2
- package/src/core/selection/mapping.ts +10 -8
- package/src/core/selection/review-anchors.ts +3 -3
- package/src/core/state/editor-state.ts +49 -6
- package/src/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- package/src/io/export/serialize-footnotes.ts +6 -0
- package/src/io/export/serialize-headers-footers.ts +6 -0
- package/src/io/export/serialize-main-document.ts +7 -0
- package/src/io/export/serialize-paragraph-formatting.ts +1 -1
- package/src/io/export/serialize-runtime-revisions.ts +10 -10
- package/src/io/export/split-review-boundaries.ts +4 -4
- package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
- package/src/io/normalize/normalize-text.ts +38 -2
- package/src/io/ooxml/parse-comments.ts +2 -2
- package/src/io/ooxml/parse-headers-footers.ts +31 -0
- package/src/io/ooxml/parse-main-document.ts +127 -2
- package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
- package/src/model/anchor.ts +9 -1
- package/src/model/canonical-document.ts +76 -3
- package/src/preservation/store.ts +24 -0
- package/src/review/store/comment-anchors.ts +1 -1
- package/src/review/store/comment-remapping.ts +1 -1
- package/src/review/store/revision-actions.ts +4 -4
- package/src/review/store/revision-types.ts +1 -1
- package/src/review/store/scope-tag-diff.ts +1 -1
- package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
- package/src/runtime/document-runtime.ts +205 -37
- package/src/runtime/formatting/formatting-context.ts +1 -1
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +30 -1
- package/src/runtime/layout/paginated-layout-engine.ts +47 -0
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/action-validation.ts +30 -4
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/replacement/apply.ts +1 -0
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/scopes/semantic-scope-types.ts +19 -0
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +94 -4
- package/src/session/import/loader-types.ts +18 -0
- package/src/session/import/loader.ts +2 -0
- package/src/session/import/review-import.ts +12 -12
- package/src/session/import/workflow-scope-import.ts +9 -8
- package/src/shell/session-bootstrap.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
- package/src/ui-tailwind/theme/editor-theme.css +15 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +34 -49
- package/src/validation/compatibility-engine.ts +1 -1
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
- package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* compiler-backed.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
CanonicalDocument,
|
|
17
|
+
InlineNode,
|
|
18
|
+
} from "../../../model/canonical-document.ts";
|
|
16
19
|
import { resolveEffectiveFormattingForScope } from "../_formatting-seam.ts";
|
|
17
20
|
import type { ParagraphLikeEnumeratedScope } from "../enumerate-scopes.ts";
|
|
18
|
-
import { computeBlockPositions } from "../position-map.ts";
|
|
21
|
+
import { buildScopePositionMap, computeBlockPositions } from "../position-map.ts";
|
|
19
22
|
import { deriveReplaceability } from "../replaceability.ts";
|
|
20
23
|
import {
|
|
21
24
|
isStructuredReplacementContent,
|
|
@@ -32,6 +35,99 @@ import {
|
|
|
32
35
|
extractParagraphText,
|
|
33
36
|
} from "./_paragraph-text.ts";
|
|
34
37
|
|
|
38
|
+
function inlineLengthLocal(node: InlineNode): number {
|
|
39
|
+
switch (node.type) {
|
|
40
|
+
case "text":
|
|
41
|
+
return Array.from(node.text).length;
|
|
42
|
+
case "hyperlink":
|
|
43
|
+
case "field":
|
|
44
|
+
return (node.children as readonly InlineNode[]).reduce(
|
|
45
|
+
(total, child) => total + inlineLengthLocal(child),
|
|
46
|
+
0,
|
|
47
|
+
);
|
|
48
|
+
case "bookmark_start":
|
|
49
|
+
case "bookmark_end":
|
|
50
|
+
case "scope_marker_start":
|
|
51
|
+
case "scope_marker_end":
|
|
52
|
+
return 0;
|
|
53
|
+
default:
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Opt-in preservation helper: walk a paragraph's inline children,
|
|
60
|
+
* return the longest contiguous text-only run's [from, to] sub-range
|
|
61
|
+
* within the caller's `target` range. Opaque inlines / images / other
|
|
62
|
+
* non-text inlines break the run. Returns `null` when no text-only run
|
|
63
|
+
* exists inside the target range (paragraph is entirely opaques or the
|
|
64
|
+
* range falls on a non-text inline).
|
|
65
|
+
*
|
|
66
|
+
* Used by coord-08 §17 — `ReplacementPreservePolicy.opaqueFragments =
|
|
67
|
+
* true` narrows the replace range to this text-only sub-range so the
|
|
68
|
+
* opaque inlines survive the apply.
|
|
69
|
+
*/
|
|
70
|
+
function longestTextOnlyRangeInParagraph(
|
|
71
|
+
paragraph: ParagraphLikeEnumeratedScope["paragraph"],
|
|
72
|
+
paragraphFrom: number,
|
|
73
|
+
target: { readonly from: number; readonly to: number },
|
|
74
|
+
): { readonly from: number; readonly to: number } | null {
|
|
75
|
+
let best: { readonly from: number; readonly to: number } | null = null;
|
|
76
|
+
let runStart: number | null = null;
|
|
77
|
+
let cursor = paragraphFrom;
|
|
78
|
+
|
|
79
|
+
const consider = (from: number, to: number) => {
|
|
80
|
+
// Clip to the caller's target range.
|
|
81
|
+
const clippedFrom = Math.max(from, target.from);
|
|
82
|
+
const clippedTo = Math.min(to, target.to);
|
|
83
|
+
if (clippedTo <= clippedFrom) return;
|
|
84
|
+
if (!best || clippedTo - clippedFrom > best.to - best.from) {
|
|
85
|
+
best = { from: clippedFrom, to: clippedTo };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const isTextRun = (child: InlineNode): boolean => {
|
|
90
|
+
if (child.type === "text" || child.type === "tab" || child.type === "hard_break") {
|
|
91
|
+
// tab + hard_break carry length 1 and are safe to text-replace
|
|
92
|
+
// (the runtime's text.insert handles them structurally). Treat
|
|
93
|
+
// as part of the text run.
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (child.type === "hyperlink" || child.type === "field") {
|
|
97
|
+
// Hyperlink / field wrappers contribute their text-only children
|
|
98
|
+
// to the run; they're not themselves opaque.
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
child.type === "bookmark_start" ||
|
|
103
|
+
child.type === "bookmark_end" ||
|
|
104
|
+
child.type === "scope_marker_start" ||
|
|
105
|
+
child.type === "scope_marker_end"
|
|
106
|
+
) {
|
|
107
|
+
// Zero-width boundary markers — preserve via length 0. Don't
|
|
108
|
+
// break the text run.
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const child of paragraph.children) {
|
|
115
|
+
const len = inlineLengthLocal(child);
|
|
116
|
+
const childFrom = cursor;
|
|
117
|
+
const childTo = cursor + len;
|
|
118
|
+
if (isTextRun(child)) {
|
|
119
|
+
if (runStart === null) runStart = childFrom;
|
|
120
|
+
} else {
|
|
121
|
+
// Opaque / image / non-text inline breaks the run.
|
|
122
|
+
if (runStart !== null) consider(runStart, childFrom);
|
|
123
|
+
runStart = null;
|
|
124
|
+
}
|
|
125
|
+
cursor = childTo;
|
|
126
|
+
}
|
|
127
|
+
if (runStart !== null) consider(runStart, cursor);
|
|
128
|
+
return best;
|
|
129
|
+
}
|
|
130
|
+
|
|
35
131
|
export interface CompileParagraphOptions {
|
|
36
132
|
readonly document?: CanonicalDocument;
|
|
37
133
|
readonly paragraphIndex?: number;
|
|
@@ -125,10 +221,71 @@ export function compileParagraphReplacement(
|
|
|
125
221
|
const blockRange = blocks.find((b) => b.blockIndex === entry.blockIndex);
|
|
126
222
|
if (!blockRange) return null;
|
|
127
223
|
|
|
224
|
+
// Inline-range correction (2026-04-24 bug fix): when the scope is
|
|
225
|
+
// marker-backed AND the marker pair sits strictly INSIDE the
|
|
226
|
+
// paragraph (marker range is narrower than the paragraph's full
|
|
227
|
+
// block range), the replace range must follow the markers — not
|
|
228
|
+
// the paragraph. Otherwise surrounding text in the same paragraph
|
|
229
|
+
// gets destroyed on every applyReplacementScope call. The markers'
|
|
230
|
+
// positions are already tracked in `buildScopePositionMap().
|
|
231
|
+
// markerScopes`; we consult it here only when the scope is marker-
|
|
232
|
+
// backed so non-marker paragraphs pay zero cost.
|
|
233
|
+
let effectiveRange: { readonly from: number; readonly to: number } = {
|
|
234
|
+
from: blockRange.from,
|
|
235
|
+
to: blockRange.to,
|
|
236
|
+
};
|
|
237
|
+
let rangeKind: "block" | "inline-marker" | "opaque-preserving-text" = "block";
|
|
238
|
+
if (entry.handle.provenance === "marker-backed") {
|
|
239
|
+
const positionMap = buildScopePositionMap(options.document);
|
|
240
|
+
const markerRange = positionMap.markerScopes.get(entry.handle.scopeId);
|
|
241
|
+
if (
|
|
242
|
+
markerRange &&
|
|
243
|
+
markerRange.from >= blockRange.from &&
|
|
244
|
+
markerRange.to <= blockRange.to &&
|
|
245
|
+
// Strictly narrower than the paragraph — only then is the marker
|
|
246
|
+
// a sub-paragraph inline range. Whole-paragraph marker scopes
|
|
247
|
+
// (markers wrapping all of the paragraph's text) continue to use
|
|
248
|
+
// the block range, preserving the existing replace-whole-paragraph
|
|
249
|
+
// semantics.
|
|
250
|
+
(markerRange.from > blockRange.from || markerRange.to < blockRange.to)
|
|
251
|
+
) {
|
|
252
|
+
effectiveRange = markerRange;
|
|
253
|
+
rangeKind = "inline-marker";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Coord-08 §17 (2026-04-24) — opt-in opaque preservation. When the
|
|
258
|
+
// caller sets `preserve.opaqueFragments === true`, narrow the
|
|
259
|
+
// replace range to the longest contiguous text-only sub-range so
|
|
260
|
+
// opaque inlines (images, charts, preserve-only fragments) survive
|
|
261
|
+
// the apply at their original positions.
|
|
262
|
+
if (proposed.preserve?.opaqueFragments === true) {
|
|
263
|
+
const textOnly = longestTextOnlyRangeInParagraph(
|
|
264
|
+
entry.paragraph,
|
|
265
|
+
blockRange.from,
|
|
266
|
+
effectiveRange,
|
|
267
|
+
);
|
|
268
|
+
if (!textOnly) {
|
|
269
|
+
// Paragraph is entirely opaques within the target range — no
|
|
270
|
+
// text-only sub-range to replace. Refuse cleanly; the composer's
|
|
271
|
+
// `preserve:opaque-fragment:*` warning already signalled the
|
|
272
|
+
// opt-in intent.
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
effectiveRange = textOnly;
|
|
276
|
+
rangeKind = "opaque-preserving-text";
|
|
277
|
+
}
|
|
278
|
+
|
|
128
279
|
if (proposed.proposedContent.kind === "text") {
|
|
129
280
|
const text = proposed.proposedContent.text ?? "";
|
|
130
281
|
const stepKind =
|
|
131
282
|
options.posture === "suggest-mode" ? "text-insert-tracked" : "text-replace";
|
|
283
|
+
const summaryScope =
|
|
284
|
+
rangeKind === "inline-marker"
|
|
285
|
+
? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
286
|
+
: rangeKind === "opaque-preserving-text"
|
|
287
|
+
? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
288
|
+
: `paragraph #${entry.blockIndex}`;
|
|
132
289
|
return {
|
|
133
290
|
scopeId: entry.handle.scopeId,
|
|
134
291
|
targetKind: "paragraph",
|
|
@@ -138,9 +295,9 @@ export function compileParagraphReplacement(
|
|
|
138
295
|
kind: stepKind,
|
|
139
296
|
summary:
|
|
140
297
|
stepKind === "text-replace"
|
|
141
|
-
? `replace
|
|
142
|
-
: `suggest-mode
|
|
143
|
-
range: { from:
|
|
298
|
+
? `replace ${summaryScope} text (len ${text.length})`
|
|
299
|
+
: `suggest-mode ${summaryScope} text replace (len ${text.length})`,
|
|
300
|
+
range: { from: effectiveRange.from, to: effectiveRange.to },
|
|
144
301
|
text,
|
|
145
302
|
},
|
|
146
303
|
]),
|
|
@@ -161,6 +318,12 @@ export function compileParagraphReplacement(
|
|
|
161
318
|
const fragment = proposed.proposedContent.structured;
|
|
162
319
|
if (!isStructuredReplacementContent(fragment)) return null;
|
|
163
320
|
const blockCount = fragment.blocks.length;
|
|
321
|
+
const summaryScope =
|
|
322
|
+
rangeKind === "inline-marker"
|
|
323
|
+
? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
324
|
+
: rangeKind === "opaque-preserving-text"
|
|
325
|
+
? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
326
|
+
: `paragraph #${entry.blockIndex}`;
|
|
164
327
|
return {
|
|
165
328
|
scopeId: entry.handle.scopeId,
|
|
166
329
|
targetKind: "paragraph",
|
|
@@ -168,8 +331,8 @@ export function compileParagraphReplacement(
|
|
|
168
331
|
steps: Object.freeze([
|
|
169
332
|
{
|
|
170
333
|
kind: "fragment-replace",
|
|
171
|
-
summary: `replace
|
|
172
|
-
range: { from:
|
|
334
|
+
summary: `replace ${summaryScope} with structured fragment (${blockCount} block(s))`,
|
|
335
|
+
range: { from: effectiveRange.from, to: effectiveRange.to },
|
|
173
336
|
fragment,
|
|
174
337
|
},
|
|
175
338
|
]),
|
|
@@ -39,7 +39,7 @@ import type {
|
|
|
39
39
|
function anchorToRange(anchor: CanonicalAnchor): ScopePositionRange | null {
|
|
40
40
|
switch (anchor.kind) {
|
|
41
41
|
case "range":
|
|
42
|
-
return { from: anchor.
|
|
42
|
+
return { from: anchor.from, to: anchor.to };
|
|
43
43
|
case "node":
|
|
44
44
|
return { from: anchor.at, to: anchor.at };
|
|
45
45
|
case "detached":
|
|
@@ -296,6 +296,25 @@ export interface ReplacementPreservePolicy {
|
|
|
296
296
|
readonly comments?: boolean;
|
|
297
297
|
readonly revisions?: boolean;
|
|
298
298
|
readonly bookmarks?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* Opt-in (2026-04-24): allow the replace to proceed when the target
|
|
301
|
+
* range contains opaque fragments (inline images, charts, or other
|
|
302
|
+
* `preserve-only` payload) that the runtime cannot safely
|
|
303
|
+
* re-serialize. When `true`:
|
|
304
|
+
*
|
|
305
|
+
* - `computePreservationVerdict` downgrades
|
|
306
|
+
* `preserve:opaque-fragment:*` reasons from blockers to warnings
|
|
307
|
+
* (still surfaced on `validation.warnings` + audit-bundle).
|
|
308
|
+
* - `compileParagraphReplacement` narrows the replace range to the
|
|
309
|
+
* longest contiguous text-only sub-range, leaving opaques at
|
|
310
|
+
* their original inline offsets. If no text sub-range exists
|
|
311
|
+
* (paragraph is entirely opaques), compile still refuses.
|
|
312
|
+
*
|
|
313
|
+
* Default (absent / `false`) preserves the pre-opt-in refusal
|
|
314
|
+
* semantics. Callers that don't set this flag are protected from
|
|
315
|
+
* accidental opaque-fragment loss.
|
|
316
|
+
*/
|
|
317
|
+
readonly opaqueFragments?: boolean;
|
|
299
318
|
}
|
|
300
319
|
|
|
301
320
|
export interface ReplacementScope {
|
|
@@ -64,8 +64,7 @@ export function validateSelectionAgainstDocument(
|
|
|
64
64
|
head: collapsed,
|
|
65
65
|
isCollapsed: true,
|
|
66
66
|
activeRange: {
|
|
67
|
-
kind: "range",
|
|
68
|
-
range: { from: collapsed, to: collapsed },
|
|
67
|
+
kind: "range", from: collapsed, to: collapsed,
|
|
69
68
|
assoc: {
|
|
70
69
|
start: selection.activeRange.assoc,
|
|
71
70
|
end: selection.activeRange.assoc,
|
|
@@ -84,7 +83,8 @@ export function validateSelectionAgainstDocument(
|
|
|
84
83
|
return selection;
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
const
|
|
86
|
+
const from = Math.min(anchor, head);
|
|
87
|
+
const to = Math.max(anchor, head);
|
|
88
88
|
const assoc =
|
|
89
89
|
selection.activeRange.kind === "range"
|
|
90
90
|
? selection.activeRange.assoc
|
|
@@ -94,7 +94,7 @@ export function validateSelectionAgainstDocument(
|
|
|
94
94
|
anchor,
|
|
95
95
|
head,
|
|
96
96
|
isCollapsed: anchor === head,
|
|
97
|
-
activeRange: { kind: "range",
|
|
97
|
+
activeRange: { kind: "range", from, to, assoc },
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -95,6 +95,20 @@ interface ParagraphAccumulator {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export interface SurfaceProjectionOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Block-index intervals to render as real (non-placeholder). Blocks whose
|
|
100
|
+
* index falls in ANY interval are built as real `SurfaceBlockSnapshot`s;
|
|
101
|
+
* all others become size-preserving `placeholder-culled` opaque blocks.
|
|
102
|
+
*
|
|
103
|
+
* The canonical shape is an array of non-overlapping intervals (typically
|
|
104
|
+
* one for the visible viewport + overscan, and — when the caret is scrolled
|
|
105
|
+
* off-screen — a second one covering the caret's page so the selection
|
|
106
|
+
* block stays real without realizing the document-scale gap between the
|
|
107
|
+
* two). Callers still supplying the legacy scalar `viewportBlockRange` get
|
|
108
|
+
* wrapped into a single-element array internally.
|
|
109
|
+
*/
|
|
110
|
+
viewportBlockRanges?: readonly { start: number; end: number }[] | null;
|
|
111
|
+
/** @deprecated use `viewportBlockRanges`. Kept for back-compat; wrapped into a 1-element array when supplied alone. */
|
|
98
112
|
viewportBlockRange?: { start: number; end: number } | null;
|
|
99
113
|
/**
|
|
100
114
|
* Active markup mode. When set together with the document's
|
|
@@ -146,7 +160,13 @@ export function createEditorSurfaceSnapshot(
|
|
|
146
160
|
activeStory: EditorStoryTarget = { kind: "main" },
|
|
147
161
|
options: SurfaceProjectionOptions = {},
|
|
148
162
|
): EditorSurfaceSnapshot {
|
|
149
|
-
const
|
|
163
|
+
const viewportBlockRanges: readonly { start: number; end: number }[] | null = (() => {
|
|
164
|
+
if (options.viewportBlockRanges != null) {
|
|
165
|
+
return options.viewportBlockRanges.length === 0 ? null : options.viewportBlockRanges;
|
|
166
|
+
}
|
|
167
|
+
if (options.viewportBlockRange != null) return [options.viewportBlockRange];
|
|
168
|
+
return null;
|
|
169
|
+
})();
|
|
150
170
|
const root = normalizeDocumentRoot({
|
|
151
171
|
type: "doc",
|
|
152
172
|
children: [...getStoryBlocks(document, activeStory)],
|
|
@@ -199,8 +219,8 @@ export function createEditorSurfaceSnapshot(
|
|
|
199
219
|
|
|
200
220
|
for (let index = 0; index < root.children.length; index += 1) {
|
|
201
221
|
const isInViewport =
|
|
202
|
-
|
|
203
|
-
(index
|
|
222
|
+
viewportBlockRanges === null ||
|
|
223
|
+
isIndexInAnyRange(index, viewportBlockRanges);
|
|
204
224
|
// L7 Phase 2.9 — viewport bail on the style-cascade work. When the
|
|
205
225
|
// block is outside the viewport, the surface block produced below is
|
|
206
226
|
// immediately discarded in favor of a `placeholder-culled` entry (we
|
|
@@ -293,10 +313,25 @@ export function createEditorSurfaceSnapshot(
|
|
|
293
313
|
blocks,
|
|
294
314
|
lockedFragmentIds,
|
|
295
315
|
secondaryStories,
|
|
296
|
-
|
|
316
|
+
viewportBlockRanges,
|
|
297
317
|
};
|
|
298
318
|
}
|
|
299
319
|
|
|
320
|
+
/**
|
|
321
|
+
* True when `index` falls in any of the given half-open intervals. Linear
|
|
322
|
+
* scan because the typical caller passes 1 or 2 ranges; switching to binary
|
|
323
|
+
* search has no measurable payoff below ~8 ranges.
|
|
324
|
+
*/
|
|
325
|
+
function isIndexInAnyRange(
|
|
326
|
+
index: number,
|
|
327
|
+
ranges: readonly { start: number; end: number }[],
|
|
328
|
+
): boolean {
|
|
329
|
+
for (const r of ranges) {
|
|
330
|
+
if (index >= r.start && index < r.end) return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
300
335
|
function createSurfaceBlock(
|
|
301
336
|
block: BlockNode,
|
|
302
337
|
document: CanonicalDocumentEnvelope,
|
|
@@ -796,6 +831,58 @@ function resolveSurfaceParagraphShading(
|
|
|
796
831
|
};
|
|
797
832
|
}
|
|
798
833
|
|
|
834
|
+
/**
|
|
835
|
+
* Strip the verbatim-OOXML `rawXml` round-trip field from
|
|
836
|
+
* `FrameProperties` before forwarding to the public surface. Consumers
|
|
837
|
+
* (L04 paginated-layout placement, L11 render) read the modeled
|
|
838
|
+
* positioning + size fields; `rawXml` exists only so the serializer can
|
|
839
|
+
* merge extension attributes (`w14:*`, `w15:*`, `mc:Ignorable`) back
|
|
840
|
+
* onto the element on export.
|
|
841
|
+
*/
|
|
842
|
+
function toSurfaceFrameProperties(
|
|
843
|
+
frame: NonNullable<CanonicalParagraphFormatting["frameProperties"]>,
|
|
844
|
+
): NonNullable<
|
|
845
|
+
Extract<
|
|
846
|
+
import("../api/public-types.ts").SurfaceBlockSnapshot,
|
|
847
|
+
{ kind: "paragraph" }
|
|
848
|
+
>["frameProperties"]
|
|
849
|
+
> {
|
|
850
|
+
const {
|
|
851
|
+
widthTwips,
|
|
852
|
+
heightTwips,
|
|
853
|
+
hRule,
|
|
854
|
+
xTwips,
|
|
855
|
+
yTwips,
|
|
856
|
+
xAlign,
|
|
857
|
+
yAlign,
|
|
858
|
+
hAnchor,
|
|
859
|
+
vAnchor,
|
|
860
|
+
wrap,
|
|
861
|
+
hSpaceTwips,
|
|
862
|
+
vSpaceTwips,
|
|
863
|
+
dropCap,
|
|
864
|
+
lines,
|
|
865
|
+
anchorLock,
|
|
866
|
+
} = frame;
|
|
867
|
+
return {
|
|
868
|
+
...(widthTwips !== undefined ? { widthTwips } : {}),
|
|
869
|
+
...(heightTwips !== undefined ? { heightTwips } : {}),
|
|
870
|
+
...(hRule !== undefined ? { hRule } : {}),
|
|
871
|
+
...(xTwips !== undefined ? { xTwips } : {}),
|
|
872
|
+
...(yTwips !== undefined ? { yTwips } : {}),
|
|
873
|
+
...(xAlign !== undefined ? { xAlign } : {}),
|
|
874
|
+
...(yAlign !== undefined ? { yAlign } : {}),
|
|
875
|
+
...(hAnchor !== undefined ? { hAnchor } : {}),
|
|
876
|
+
...(vAnchor !== undefined ? { vAnchor } : {}),
|
|
877
|
+
...(wrap !== undefined ? { wrap } : {}),
|
|
878
|
+
...(hSpaceTwips !== undefined ? { hSpaceTwips } : {}),
|
|
879
|
+
...(vSpaceTwips !== undefined ? { vSpaceTwips } : {}),
|
|
880
|
+
...(dropCap !== undefined ? { dropCap } : {}),
|
|
881
|
+
...(lines !== undefined ? { lines } : {}),
|
|
882
|
+
...(anchorLock !== undefined ? { anchorLock } : {}),
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
799
886
|
function resolveSurfaceParagraphFormatting(
|
|
800
887
|
formatting: CanonicalParagraphFormatting,
|
|
801
888
|
themeResolver: ThemeColorResolver | undefined,
|
|
@@ -1066,6 +1153,9 @@ function createParagraphBlock(
|
|
|
1066
1153
|
: {}),
|
|
1067
1154
|
...(paragraph.indentation ? { indentation: paragraph.indentation } : {}),
|
|
1068
1155
|
...(paragraph.borders ? { borders: paragraph.borders } : {}),
|
|
1156
|
+
...(paragraph.frameProperties
|
|
1157
|
+
? { frameProperties: toSurfaceFrameProperties(paragraph.frameProperties) }
|
|
1158
|
+
: {}),
|
|
1069
1159
|
...(paragraph.shading
|
|
1070
1160
|
? { shading: resolveSurfaceParagraphShading(paragraph.shading, themeResolver) }
|
|
1071
1161
|
: {}),
|
|
@@ -119,6 +119,24 @@ export interface LoadDocxEditorSessionOptions {
|
|
|
119
119
|
* FRESH offload on first-open.
|
|
120
120
|
*/
|
|
121
121
|
offloadEmbeddedDocuments?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Phase 1 cosmetic-marker strip. When `true` (the default), the
|
|
124
|
+
* OOXML parser drops `<w:lastRenderedPageBreak/>`, `<w:proofErr/>`,
|
|
125
|
+
* and `<w:noBreakHyphen/>` during the body walk instead of emitting
|
|
126
|
+
* them as `opaque_inline` nodes. Aggregate counts are surfaced as
|
|
127
|
+
* a single `diagnostic:parser.skipped-cosmetic-markers` warning on
|
|
128
|
+
* the returned canonical document.
|
|
129
|
+
*
|
|
130
|
+
* These markers carry no contract semantics (Word regenerates them
|
|
131
|
+
* on reopen) and are structural boundaries that today cause
|
|
132
|
+
* `replaceText` to refuse ranges that happen to cross them. See
|
|
133
|
+
* `docs/architecture/cosmetic-marker-strip.md`.
|
|
134
|
+
*
|
|
135
|
+
* Set to `false` to preserve pre-strip behavior exactly — useful for
|
|
136
|
+
* byte-preservation tests and diagnostic scripts that need to
|
|
137
|
+
* inspect every marker.
|
|
138
|
+
*/
|
|
139
|
+
stripCosmeticMarkers?: boolean;
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
/**
|
|
@@ -540,6 +540,7 @@ export async function loadDocxSessionAsync(
|
|
|
540
540
|
mediaParts,
|
|
541
541
|
mainDocumentPath,
|
|
542
542
|
chartPartLookup,
|
|
543
|
+
{ stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
|
|
543
544
|
);
|
|
544
545
|
} finally {
|
|
545
546
|
if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
|
|
@@ -1312,6 +1313,7 @@ export function loadDocxSessionSync(
|
|
|
1312
1313
|
mediaParts,
|
|
1313
1314
|
mainDocumentPath,
|
|
1314
1315
|
chartPartLookup,
|
|
1316
|
+
{ stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
|
|
1315
1317
|
);
|
|
1316
1318
|
} finally {
|
|
1317
1319
|
if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
|
|
@@ -82,7 +82,7 @@ export function normalizeImportedRevisionRecords(
|
|
|
82
82
|
|
|
83
83
|
const preserveOnlyReason =
|
|
84
84
|
getStructuralPreserveOnlyReason(revision, paragraphRanges) ??
|
|
85
|
-
(opaqueRanges.some((range) => rangesIntersect(range, anchor.
|
|
85
|
+
(opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }))
|
|
86
86
|
? "Imported revision overlaps preserve-only OOXML and remains preserve-only."
|
|
87
87
|
: undefined);
|
|
88
88
|
|
|
@@ -118,7 +118,7 @@ export function normalizeImportedCommentThreads(
|
|
|
118
118
|
) {
|
|
119
119
|
return [];
|
|
120
120
|
}
|
|
121
|
-
return [revision.anchor.
|
|
121
|
+
return [{ from: revision.anchor.from, to: revision.anchor.to }];
|
|
122
122
|
});
|
|
123
123
|
const preserveOnlyCommentIds = new Set(parsed.diagnostics.map((diagnostic) => diagnostic.commentId));
|
|
124
124
|
const additionalDiagnostics: CommentImportDiagnostic[] = [];
|
|
@@ -129,7 +129,7 @@ export function normalizeImportedCommentThreads(
|
|
|
129
129
|
return thread;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, anchor.
|
|
132
|
+
const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }));
|
|
133
133
|
if (opaqueOverlap) {
|
|
134
134
|
preserveOnlyCommentIds.add(thread.commentId);
|
|
135
135
|
additionalDiagnostics.push({
|
|
@@ -143,7 +143,7 @@ export function normalizeImportedCommentThreads(
|
|
|
143
143
|
});
|
|
144
144
|
return {
|
|
145
145
|
...thread,
|
|
146
|
-
anchor: createDetachedAnchor(anchor.
|
|
146
|
+
anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
|
|
147
147
|
status: "detached",
|
|
148
148
|
metadata: {
|
|
149
149
|
...thread.metadata,
|
|
@@ -155,7 +155,7 @@ export function normalizeImportedCommentThreads(
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
const preserveOnlyRevisionOverlap = preserveOnlyRevisionRanges.some((range) =>
|
|
158
|
-
rangesIntersect(range, anchor.
|
|
158
|
+
rangesIntersect(range, { from: anchor.from, to: anchor.to }),
|
|
159
159
|
);
|
|
160
160
|
if (preserveOnlyRevisionOverlap) {
|
|
161
161
|
preserveOnlyCommentIds.add(thread.commentId);
|
|
@@ -170,7 +170,7 @@ export function normalizeImportedCommentThreads(
|
|
|
170
170
|
});
|
|
171
171
|
return {
|
|
172
172
|
...thread,
|
|
173
|
-
anchor: createDetachedAnchor(anchor.
|
|
173
|
+
anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
|
|
174
174
|
status: "detached",
|
|
175
175
|
metadata: {
|
|
176
176
|
...thread.metadata,
|
|
@@ -257,7 +257,7 @@ export function getStructuralPreserveOnlyReason(
|
|
|
257
257
|
|
|
258
258
|
if (
|
|
259
259
|
(form === "run-insertion" || form === "run-deletion") &&
|
|
260
|
-
anchor.
|
|
260
|
+
anchor.from === anchor.to
|
|
261
261
|
) {
|
|
262
262
|
return "Imported zero-width run revision remains preserve-only.";
|
|
263
263
|
}
|
|
@@ -265,9 +265,9 @@ export function getStructuralPreserveOnlyReason(
|
|
|
265
265
|
if (form === "paragraph-insertion" || form === "paragraph-deletion") {
|
|
266
266
|
const paragraphBoundary = paragraphRanges.find(
|
|
267
267
|
(boundary) =>
|
|
268
|
-
boundary.end === anchor.
|
|
269
|
-
(anchor.
|
|
270
|
-
anchor.
|
|
268
|
+
boundary.end === anchor.from ||
|
|
269
|
+
(anchor.from >= boundary.start &&
|
|
270
|
+
anchor.from <= boundary.end),
|
|
271
271
|
);
|
|
272
272
|
return paragraphBoundary
|
|
273
273
|
? undefined
|
|
@@ -276,8 +276,8 @@ export function getStructuralPreserveOnlyReason(
|
|
|
276
276
|
|
|
277
277
|
const paragraphBoundary = paragraphRanges.find(
|
|
278
278
|
(boundary) =>
|
|
279
|
-
anchor.
|
|
280
|
-
anchor.
|
|
279
|
+
anchor.from >= boundary.start &&
|
|
280
|
+
anchor.to <= boundary.end,
|
|
281
281
|
);
|
|
282
282
|
return paragraphBoundary
|
|
283
283
|
? undefined
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* P6-clean: `CommentThread` reaches through `src/model/review/`; every
|
|
8
8
|
* workflow type comes from `src/api/public-types.ts` (an allowed surface
|
|
9
|
-
* for the session layer). Anchor-shape conversion
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* for the session layer). Anchor-shape conversion is no longer needed —
|
|
10
|
+
* L02's FLAT WINS collapse (2026-04-24, `5b2f6f56`) made `CanonicalAnchor`,
|
|
11
|
+
* `InternalEditorAnchorProjection`, and the public `EditorAnchorProjection`
|
|
12
|
+
* structurally identical, so `thread.anchor` flows directly into the
|
|
13
|
+
* `WorkflowScope.anchor` slot without a helper call.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import { toPublicAnchorProjection } from "../../api/anchor-conversion.ts";
|
|
15
16
|
import type {
|
|
16
17
|
WorkflowMetadataSnapshot,
|
|
17
18
|
WorkflowOverlay,
|
|
@@ -155,7 +156,7 @@ export function createClmWorkflowScope(
|
|
|
155
156
|
scopeId,
|
|
156
157
|
version,
|
|
157
158
|
mode: directive.mode,
|
|
158
|
-
anchor:
|
|
159
|
+
anchor: thread.anchor,
|
|
159
160
|
storyTarget: { kind: "main" },
|
|
160
161
|
workItemId,
|
|
161
162
|
label: directive.description,
|
|
@@ -166,7 +167,7 @@ export function createClmWorkflowScope(
|
|
|
166
167
|
scopeId,
|
|
167
168
|
version,
|
|
168
169
|
mode: directive.mode,
|
|
169
|
-
anchor:
|
|
170
|
+
anchor: thread.anchor,
|
|
170
171
|
storyTarget: { kind: "main" },
|
|
171
172
|
label: directive.description,
|
|
172
173
|
metadata: createClmScopeMetadata(directive),
|
|
@@ -240,8 +241,8 @@ export function getNextClmScopeVersion(
|
|
|
240
241
|
anchor: Extract<CommentThread["anchor"], { kind: "range" }>,
|
|
241
242
|
): number {
|
|
242
243
|
const anchorRange = {
|
|
243
|
-
from: anchor.
|
|
244
|
-
to: anchor.
|
|
244
|
+
from: anchor.from,
|
|
245
|
+
to: anchor.to,
|
|
245
246
|
};
|
|
246
247
|
const overlappingVersions = scopes.flatMap((scope) => {
|
|
247
248
|
if (scope.anchor.kind !== "range") {
|
|
@@ -1139,6 +1139,9 @@ function createLoadingRuntimeBridge(input: {
|
|
|
1139
1139
|
},
|
|
1140
1140
|
getScope: () => null,
|
|
1141
1141
|
compileScopeBundleById: () => null,
|
|
1142
|
+
compileScopeList: () => [],
|
|
1143
|
+
compileScopeCardById: () => null,
|
|
1144
|
+
compileScopeRailSnapshot: () => ({ segments: [] }),
|
|
1142
1145
|
getMarkerBackedScopeIds: () => new Set<string>(),
|
|
1143
1146
|
debug: createLoadingDebugFacet(),
|
|
1144
1147
|
removeScope: () => undefined,
|
|
@@ -1281,6 +1284,7 @@ function createLoadingRuntimeBridge(input: {
|
|
|
1281
1284
|
getPerfCountersSnapshot: () => ({}),
|
|
1282
1285
|
resetPerfCounters: () => undefined,
|
|
1283
1286
|
setVisibleBlockRange: () => undefined,
|
|
1287
|
+
setVisibleBlockRanges: () => undefined,
|
|
1284
1288
|
requestViewportRefresh: () => undefined,
|
|
1285
1289
|
addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
|
|
1286
1290
|
setScopeVisibility: () => undefined,
|
|
@@ -200,6 +200,15 @@ export const editorSchema = new Schema({
|
|
|
200
200
|
hiddenTextOnly: { default: null },
|
|
201
201
|
placeholderCulled: { default: null },
|
|
202
202
|
blockId: { default: null },
|
|
203
|
+
/**
|
|
204
|
+
* `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
|
|
205
|
+
* (ECMA-376 §17.3.1.11). When set (and `dropCap` is neither `"drop"`
|
|
206
|
+
* nor `"margin"`), `toDOM` emits `position: absolute` with the
|
|
207
|
+
* xTwips/yTwips/widthTwips/heightTwips fields; the inline flow
|
|
208
|
+
* already treats the paragraph as zero-height per L04
|
|
209
|
+
* `measureBlockHeight` short-circuit (a298391e / coord-04 §1.19.d).
|
|
210
|
+
*/
|
|
211
|
+
frameProperties: { default: null },
|
|
203
212
|
},
|
|
204
213
|
parseDOM: [{ tag: "p" }],
|
|
205
214
|
toDOM(node) {
|
|
@@ -278,6 +287,29 @@ export const editorSchema = new Schema({
|
|
|
278
287
|
}
|
|
279
288
|
const pageBreak = node.attrs.pageBreakBefore as boolean | null;
|
|
280
289
|
if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
|
|
290
|
+
// `<w:framePr>` out-of-flow frame — mirror the static-path branch in
|
|
291
|
+
// tw-page-block-view.helpers.ts so PM-rendered page 1 absolutely
|
|
292
|
+
// positions the frame identically to pages 2+.
|
|
293
|
+
const framePr = node.attrs.frameProperties as
|
|
294
|
+
| {
|
|
295
|
+
xTwips?: number;
|
|
296
|
+
yTwips?: number;
|
|
297
|
+
widthTwips?: number;
|
|
298
|
+
heightTwips?: number;
|
|
299
|
+
hRule?: "auto" | "atLeast" | "exact";
|
|
300
|
+
dropCap?: "none" | "drop" | "margin";
|
|
301
|
+
}
|
|
302
|
+
| null;
|
|
303
|
+
if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
|
|
304
|
+
styles.push("position: absolute");
|
|
305
|
+
if (typeof framePr.xTwips === "number") styles.push(`left: ${framePr.xTwips / 20}pt`);
|
|
306
|
+
if (typeof framePr.yTwips === "number") styles.push(`top: ${framePr.yTwips / 20}pt`);
|
|
307
|
+
if (typeof framePr.widthTwips === "number") styles.push(`width: ${framePr.widthTwips / 20}pt`);
|
|
308
|
+
if (typeof framePr.heightTwips === "number") {
|
|
309
|
+
if (framePr.hRule === "exact") styles.push(`height: ${framePr.heightTwips / 20}pt`);
|
|
310
|
+
else if (framePr.hRule === "atLeast") styles.push(`min-height: ${framePr.heightTwips / 20}pt`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
281
313
|
const hiddenTextOnly = node.attrs.hiddenTextOnly as boolean | null;
|
|
282
314
|
if (hiddenTextOnly) {
|
|
283
315
|
attrs["data-hidden-paragraph"] = "true";
|