@beyondwork/docx-react-component 1.0.73 → 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 +33 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -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/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- 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/ooxml/parse-comments.ts +2 -2
- 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 +9 -1
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +39 -4
- 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/surface-build-keys.ts +5 -2
- 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/tw-review-workspace.tsx +13 -35
- 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
|
@@ -1525,9 +1525,9 @@ export function remapSelection(
|
|
|
1525
1525
|
|
|
1526
1526
|
if (activeRange.kind === "range") {
|
|
1527
1527
|
return {
|
|
1528
|
-
anchor: activeRange.
|
|
1529
|
-
head: activeRange.
|
|
1530
|
-
isCollapsed: activeRange.
|
|
1528
|
+
anchor: activeRange.from,
|
|
1529
|
+
head: activeRange.to,
|
|
1530
|
+
isCollapsed: activeRange.from === activeRange.to,
|
|
1531
1531
|
activeRange,
|
|
1532
1532
|
};
|
|
1533
1533
|
}
|
|
@@ -1664,8 +1664,8 @@ function normalizeSelection(selection: SelectionSnapshot): SelectionSnapshot {
|
|
|
1664
1664
|
);
|
|
1665
1665
|
|
|
1666
1666
|
if (activeRange.kind === "range") {
|
|
1667
|
-
const rangeFrom = activeRange.
|
|
1668
|
-
const rangeTo = activeRange.
|
|
1667
|
+
const rangeFrom = activeRange.from;
|
|
1668
|
+
const rangeTo = activeRange.to;
|
|
1669
1669
|
const collapsed = rangeFrom === rangeTo;
|
|
1670
1670
|
const anchorWithinRange = selection.anchor >= rangeFrom && selection.anchor <= rangeTo;
|
|
1671
1671
|
const headWithinRange = selection.head >= rangeFrom && selection.head <= rangeTo;
|
|
@@ -2256,7 +2256,8 @@ function createAuthoredRevision(
|
|
|
2256
2256
|
kind,
|
|
2257
2257
|
anchor: {
|
|
2258
2258
|
kind: "range",
|
|
2259
|
-
|
|
2259
|
+
from,
|
|
2260
|
+
to,
|
|
2260
2261
|
assoc: { start: 1, end: -1 },
|
|
2261
2262
|
},
|
|
2262
2263
|
authorId,
|
|
@@ -2396,8 +2397,7 @@ function applySuggestingInsert(
|
|
|
2396
2397
|
const updatedRevision: CanonicalRevisionRecord = {
|
|
2397
2398
|
...existingInsertion,
|
|
2398
2399
|
anchor: {
|
|
2399
|
-
kind: "range",
|
|
2400
|
-
range: { from: existingInsertion.anchor.range.from, to: insertedTo },
|
|
2400
|
+
kind: "range", from: existingInsertion.anchor.from, to: insertedTo,
|
|
2401
2401
|
assoc: { start: 1, end: -1 },
|
|
2402
2402
|
},
|
|
2403
2403
|
};
|
|
@@ -2635,19 +2635,18 @@ function applySuggestingDelete(
|
|
|
2635
2635
|
if (existingDeletion) {
|
|
2636
2636
|
// Extend the existing deletion revision to cover the new range
|
|
2637
2637
|
const extendedFrom = Math.min(
|
|
2638
|
-
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.
|
|
2638
|
+
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.from : deleteFrom,
|
|
2639
2639
|
deleteFrom,
|
|
2640
2640
|
);
|
|
2641
2641
|
const extendedTo = Math.max(
|
|
2642
|
-
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.
|
|
2642
|
+
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.to : deleteTo,
|
|
2643
2643
|
deleteTo,
|
|
2644
2644
|
);
|
|
2645
2645
|
|
|
2646
2646
|
const updatedRevision: CanonicalRevisionRecord = {
|
|
2647
2647
|
...existingDeletion,
|
|
2648
2648
|
anchor: {
|
|
2649
|
-
kind: "range",
|
|
2650
|
-
range: { from: extendedFrom, to: extendedTo },
|
|
2649
|
+
kind: "range", from: extendedFrom, to: extendedTo,
|
|
2651
2650
|
assoc: { start: 1, end: -1 },
|
|
2652
2651
|
},
|
|
2653
2652
|
};
|
|
@@ -2933,7 +2932,7 @@ function findAdjacentInsertionRevision(
|
|
|
2933
2932
|
revision.status === "open" &&
|
|
2934
2933
|
revision.metadata?.source === "runtime" &&
|
|
2935
2934
|
revision.anchor.kind === "range" &&
|
|
2936
|
-
revision.anchor.
|
|
2935
|
+
revision.anchor.to === cursorPos &&
|
|
2937
2936
|
revision.authorId === authorId
|
|
2938
2937
|
) {
|
|
2939
2938
|
return revision;
|
|
@@ -2954,8 +2953,8 @@ function findOverlappingAuthoredDeletion(
|
|
|
2954
2953
|
revision.metadata?.source === "runtime" &&
|
|
2955
2954
|
revision.anchor.kind === "range"
|
|
2956
2955
|
) {
|
|
2957
|
-
const revFrom = revision.anchor.
|
|
2958
|
-
const revTo = revision.anchor.
|
|
2956
|
+
const revFrom = revision.anchor.from;
|
|
2957
|
+
const revTo = revision.anchor.to;
|
|
2959
2958
|
// Adjacent or overlapping: the deletion is directly next to or overlapping the range
|
|
2960
2959
|
if (
|
|
2961
2960
|
(from >= revFrom && from <= revTo) ||
|
|
@@ -66,9 +66,11 @@ export function createRangeAnchor(
|
|
|
66
66
|
to = from,
|
|
67
67
|
assoc: BoundaryAssoc = DEFAULT_BOUNDARY_ASSOC,
|
|
68
68
|
): RangeAnchor {
|
|
69
|
+
const normalized = normalizeRange({ from, to });
|
|
69
70
|
return {
|
|
70
71
|
kind: "range",
|
|
71
|
-
|
|
72
|
+
from: normalized.from,
|
|
73
|
+
to: normalized.to,
|
|
72
74
|
assoc,
|
|
73
75
|
};
|
|
74
76
|
}
|
|
@@ -89,7 +91,7 @@ export function createEmptyMapping(): TransactionMapping {
|
|
|
89
91
|
|
|
90
92
|
export function getEffectiveRange(anchor: EditorAnchorProjection): DocRange {
|
|
91
93
|
if (anchor.kind === "range") {
|
|
92
|
-
return anchor.
|
|
94
|
+
return { from: anchor.from, to: anchor.to };
|
|
93
95
|
}
|
|
94
96
|
if (anchor.kind === "node") {
|
|
95
97
|
return { from: anchor.at, to: anchor.at };
|
|
@@ -140,13 +142,13 @@ export function mapAnchor(
|
|
|
140
142
|
return createNodeAnchor(mapped.position, anchor.assoc);
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
const from = mapPosition(anchor.
|
|
144
|
-
const to = mapPosition(anchor.
|
|
145
|
+
const from = mapPosition(anchor.from, anchor.assoc.start, mapping);
|
|
146
|
+
const to = mapPosition(anchor.to, anchor.assoc.end, mapping);
|
|
145
147
|
const mappedRange = normalizeRange({ from: from.position, to: to.position });
|
|
146
148
|
|
|
147
149
|
if (from.deleted && to.deleted && mappedRange.from === mappedRange.to) {
|
|
148
150
|
return createDetachedAnchor(
|
|
149
|
-
anchor.
|
|
151
|
+
{ from: anchor.from, to: anchor.to },
|
|
150
152
|
mapping.metadata?.invalidatesStructures
|
|
151
153
|
? "invalidatedByStructureChange"
|
|
152
154
|
: "deleted",
|
|
@@ -187,8 +189,8 @@ export function areAnchorsEqual(
|
|
|
187
189
|
|
|
188
190
|
if (left.kind === "range" && right.kind === "range") {
|
|
189
191
|
return (
|
|
190
|
-
left.
|
|
191
|
-
left.
|
|
192
|
+
left.from === right.from &&
|
|
193
|
+
left.to === right.to &&
|
|
192
194
|
left.assoc.start === right.assoc.start &&
|
|
193
195
|
left.assoc.end === right.assoc.end
|
|
194
196
|
);
|
|
@@ -213,7 +215,7 @@ export function anchorUnaffectedByMapping(
|
|
|
213
215
|
): boolean {
|
|
214
216
|
if (anchor.kind === "detached") return true;
|
|
215
217
|
if (mapping.steps.length === 0) return true;
|
|
216
|
-
const end = anchor.kind === "range" ? anchor.
|
|
218
|
+
const end = anchor.kind === "range" ? anchor.to : anchor.at;
|
|
217
219
|
return mapping.steps.every((s) => s.from > end);
|
|
218
220
|
}
|
|
219
221
|
|
|
@@ -40,7 +40,7 @@ export function mapReviewAnchor(
|
|
|
40
40
|
|
|
41
41
|
export function getAnchorRange(anchor: ReviewAnchor): DocRange {
|
|
42
42
|
if (anchor.kind === "range") {
|
|
43
|
-
return normalizeRange(anchor.
|
|
43
|
+
return normalizeRange({ from: anchor.from, to: anchor.to });
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
if (anchor.kind === "node") {
|
|
@@ -136,7 +136,7 @@ export function commentAnchorRejectionReason(
|
|
|
136
136
|
return "invalid_comment_anchor";
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
const normalized = normalizeRange(anchor.
|
|
139
|
+
const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
|
|
140
140
|
if (normalized.from === normalized.to) {
|
|
141
141
|
return "invalid_comment_anchor";
|
|
142
142
|
}
|
|
@@ -164,7 +164,7 @@ export function snapCommentAnchorAwayFromTable(
|
|
|
164
164
|
): ReviewAnchor | null {
|
|
165
165
|
if (anchor.kind !== "range") return null;
|
|
166
166
|
|
|
167
|
-
const normalized = normalizeRange(anchor.
|
|
167
|
+
const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
|
|
168
168
|
if (normalized.from === normalized.to) return null;
|
|
169
169
|
|
|
170
170
|
// If the anchor is already flagged invalid for other reasons (crosses an
|
|
@@ -119,6 +119,7 @@ export class ExportSession {
|
|
|
119
119
|
this.packageRelationships,
|
|
120
120
|
this.ownedPaths,
|
|
121
121
|
);
|
|
122
|
+
ensurePicNamespaceOnStoryParts(this.workingParts);
|
|
122
123
|
const parts = pruneReservedManifestParts(this.workingParts);
|
|
123
124
|
const manifest = buildManifest(parts, this.packageRelationships, this.sourcePackage.manifest.contentTypes);
|
|
124
125
|
return {
|
|
@@ -140,6 +141,58 @@ export function createExportSession(
|
|
|
140
141
|
return new ExportSession(sourcePackage, ownedOutputPaths);
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/**
|
|
145
|
+
* LibreOffice strict-parser safety net. Word tolerates `pic:` prefix
|
|
146
|
+
* usage (from `<pic:pic>` / `<pic:spPr>` inside drawings) without
|
|
147
|
+
* declaring `xmlns:pic` at the part root, because it inherits scope
|
|
148
|
+
* from `<wp:inline xmlns:pic=…>` wrappers or from `mc:AlternateContent`
|
|
149
|
+
* fallbacks. LibreOffice refuses the file ("Namespace prefix pic on
|
|
150
|
+
* spPr is not defined"). Our own serializers already inject
|
|
151
|
+
* `xmlns:pic` on freshly serialized parts — but parts that pass
|
|
152
|
+
* through unmodified from the source package keep the original
|
|
153
|
+
* declaration (which may be missing it). This sweep runs at toPackage
|
|
154
|
+
* time over the main story + every header / footer part, and injects
|
|
155
|
+
* `xmlns:pic` on the root element when the part uses the `pic:` prefix
|
|
156
|
+
* but doesn't declare it. Byte-stable for parts already correct.
|
|
157
|
+
*
|
|
158
|
+
* See commit a3e05a8e (serializer-side fix) + this (pass-through fix).
|
|
159
|
+
*/
|
|
160
|
+
const PIC_NAMESPACE_URI = "http://schemas.openxmlformats.org/drawingml/2006/picture";
|
|
161
|
+
const PIC_NAMESPACE_DECL = ` xmlns:pic="${PIC_NAMESPACE_URI}"`;
|
|
162
|
+
|
|
163
|
+
function ensurePicNamespaceOnStoryParts(
|
|
164
|
+
parts: Map<string, OpcPackagePart>,
|
|
165
|
+
): void {
|
|
166
|
+
for (const [path, part] of parts.entries()) {
|
|
167
|
+
if (!isPicSensitiveStoryPath(path)) continue;
|
|
168
|
+
const patched = ensurePicNamespaceOnRoot(part.bytes);
|
|
169
|
+
if (patched !== part.bytes) {
|
|
170
|
+
parts.set(path, { ...part, bytes: patched, crc32: 0 });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isPicSensitiveStoryPath(path: string): boolean {
|
|
176
|
+
// Normalize for leading slash variation.
|
|
177
|
+
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
|
178
|
+
if (normalized === "word/document.xml") return true;
|
|
179
|
+
if (/^word\/(?:header|footer|footnotes|endnotes)[^/]*\.xml$/.test(normalized)) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ensurePicNamespaceOnRoot(bytes: Uint8Array): Uint8Array {
|
|
184
|
+
const xml = new TextDecoder().decode(bytes);
|
|
185
|
+
// Match the first element (skipping the XML prolog + whitespace).
|
|
186
|
+
const rootMatch = xml.match(/^([\s\S]*?)<([A-Za-z][\w.:-]*)([^>]*)>/);
|
|
187
|
+
if (!rootMatch) return bytes;
|
|
188
|
+
const [fullMatch, prefix, tagName, rootAttrs] = rootMatch;
|
|
189
|
+
if (rootAttrs.includes("xmlns:pic=")) return bytes; // already declared
|
|
190
|
+
if (!/\bpic:[A-Za-z]/.test(xml)) return bytes; // doesn't use pic: prefix
|
|
191
|
+
const patchedRoot = `<${tagName}${rootAttrs}${PIC_NAMESPACE_DECL}>`;
|
|
192
|
+
const patched = `${prefix}${patchedRoot}${xml.slice(fullMatch.length)}`;
|
|
193
|
+
return new TextEncoder().encode(patched);
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
function clonePartsForExport(
|
|
144
197
|
parts: Map<string, OpcPackagePart>,
|
|
145
198
|
ownedPaths: ReadonlySet<string>,
|
|
@@ -269,16 +269,16 @@ export function serializeCommentAnchorsIntoDocumentXml(
|
|
|
269
269
|
continue;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
const startParagraph = findParagraphForEndpoint(paragraphs, anchor.
|
|
273
|
-
const endParagraph = findParagraphForEndpoint(paragraphs, anchor.
|
|
272
|
+
const startParagraph = findParagraphForEndpoint(paragraphs, anchor.from, "start");
|
|
273
|
+
const endParagraph = findParagraphForEndpoint(paragraphs, anchor.to, "end");
|
|
274
274
|
|
|
275
275
|
if (!startParagraph || !endParagraph) {
|
|
276
276
|
skippedCommentIds.push(thread.commentId);
|
|
277
277
|
continue;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
const startIndex = startParagraph.boundaries.get(anchor.
|
|
281
|
-
const endIndex = endParagraph.boundaries.get(anchor.
|
|
280
|
+
const startIndex = startParagraph.boundaries.get(anchor.from);
|
|
281
|
+
const endIndex = endParagraph.boundaries.get(anchor.to);
|
|
282
282
|
|
|
283
283
|
if (startIndex === undefined || endIndex === undefined) {
|
|
284
284
|
skippedCommentIds.push(thread.commentId);
|
|
@@ -135,8 +135,8 @@ function createParagraphPropertyChangeReplacement(
|
|
|
135
135
|
}
|
|
136
136
|
const paragraphBoundary = findParagraphBoundaryForRange(
|
|
137
137
|
boundaries,
|
|
138
|
-
revision.anchor.
|
|
139
|
-
revision.anchor.
|
|
138
|
+
revision.anchor.from,
|
|
139
|
+
revision.anchor.to,
|
|
140
140
|
);
|
|
141
141
|
if (!paragraphBoundary) {
|
|
142
142
|
return undefined;
|
|
@@ -168,14 +168,14 @@ function createRunPropertyChangeReplacement(
|
|
|
168
168
|
}
|
|
169
169
|
const paragraphBoundary = findParagraphBoundaryForRange(
|
|
170
170
|
boundaries,
|
|
171
|
-
revision.anchor.
|
|
172
|
-
revision.anchor.
|
|
171
|
+
revision.anchor.from,
|
|
172
|
+
revision.anchor.to,
|
|
173
173
|
);
|
|
174
174
|
if (!paragraphBoundary) {
|
|
175
175
|
return undefined;
|
|
176
176
|
}
|
|
177
|
-
const startIndex = paragraphBoundary.boundaries.get(revision.anchor.
|
|
178
|
-
const endIndex = paragraphBoundary.boundaries.get(revision.anchor.
|
|
177
|
+
const startIndex = paragraphBoundary.boundaries.get(revision.anchor.from);
|
|
178
|
+
const endIndex = paragraphBoundary.boundaries.get(revision.anchor.to);
|
|
179
179
|
if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
|
|
180
180
|
return undefined;
|
|
181
181
|
}
|
|
@@ -245,13 +245,13 @@ function createRangeRevisionReplacement(
|
|
|
245
245
|
if (anchor.kind !== "range") {
|
|
246
246
|
return undefined;
|
|
247
247
|
}
|
|
248
|
-
const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.
|
|
248
|
+
const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.from, anchor.to);
|
|
249
249
|
if (!paragraphBoundary) {
|
|
250
250
|
return undefined;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
const startIndex = paragraphBoundary.boundaries.get(anchor.
|
|
254
|
-
const endIndex = paragraphBoundary.boundaries.get(anchor.
|
|
253
|
+
const startIndex = paragraphBoundary.boundaries.get(anchor.from);
|
|
254
|
+
const endIndex = paragraphBoundary.boundaries.get(anchor.to);
|
|
255
255
|
if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
|
|
256
256
|
return undefined;
|
|
257
257
|
}
|
|
@@ -340,7 +340,7 @@ function findParagraphBoundaryForAnchor(
|
|
|
340
340
|
boundaries: readonly RevisionParagraphBoundary[],
|
|
341
341
|
revision: RevisionRecord,
|
|
342
342
|
): RevisionParagraphBoundary | undefined {
|
|
343
|
-
const anchor = revision.anchor.kind === "range" ? revision.anchor.
|
|
343
|
+
const anchor = revision.anchor.kind === "range" ? revision.anchor.from : undefined;
|
|
344
344
|
if (anchor === undefined) {
|
|
345
345
|
return undefined;
|
|
346
346
|
}
|
|
@@ -35,8 +35,8 @@ function collectSplitPositions(
|
|
|
35
35
|
continue;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
positions.add(thread.anchor.
|
|
39
|
-
positions.add(thread.anchor.
|
|
38
|
+
positions.add(thread.anchor.from);
|
|
39
|
+
positions.add(thread.anchor.to);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
for (const revision of revisions) {
|
|
@@ -51,8 +51,8 @@ function collectSplitPositions(
|
|
|
51
51
|
continue;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
positions.add(revision.anchor.
|
|
55
|
-
positions.add(revision.anchor.
|
|
54
|
+
positions.add(revision.anchor.from);
|
|
55
|
+
positions.add(revision.anchor.to);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
return positions;
|
|
@@ -200,8 +200,8 @@ function collectSplitPoints(
|
|
|
200
200
|
): number[] {
|
|
201
201
|
const points = new Set<number>();
|
|
202
202
|
for (const revision of revisions) {
|
|
203
|
-
const from = revision.anchor.
|
|
204
|
-
const to = revision.anchor.
|
|
203
|
+
const from = revision.anchor.from;
|
|
204
|
+
const to = revision.anchor.to;
|
|
205
205
|
if (from > start && from < end) {
|
|
206
206
|
points.add(from);
|
|
207
207
|
}
|
|
@@ -652,9 +652,9 @@ function extractNodeText(node: XmlNode): string {
|
|
|
652
652
|
|
|
653
653
|
function compareThreadsByAnchor(left: CommentThread, right: CommentThread): number {
|
|
654
654
|
const leftStart =
|
|
655
|
-
left.anchor.kind === "range" ? left.anchor.
|
|
655
|
+
left.anchor.kind === "range" ? left.anchor.from : Number.MAX_SAFE_INTEGER;
|
|
656
656
|
const rightStart =
|
|
657
|
-
right.anchor.kind === "range" ? right.anchor.
|
|
657
|
+
right.anchor.kind === "range" ? right.anchor.from : Number.MAX_SAFE_INTEGER;
|
|
658
658
|
|
|
659
659
|
if (leftStart !== rightStart) {
|
|
660
660
|
return leftStart - rightStart;
|
package/src/model/anchor.ts
CHANGED
|
@@ -25,9 +25,17 @@ export interface BoundaryAssoc {
|
|
|
25
25
|
end: Assoc;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Range anchor — flat shape `{ kind: "range", from, to, assoc }` after the
|
|
30
|
+
* 2026-04-23 flat-wins collapse. Prior to the collapse this carried a nested
|
|
31
|
+
* `{ range: DocRange, assoc }` shape. See `canonical-document.ts` CanonicalAnchor
|
|
32
|
+
* for the matching canonical shape + `repairCanonicalDocumentEnvelope` for the
|
|
33
|
+
* persisted-snapshot legacy-to-flat migration.
|
|
34
|
+
*/
|
|
28
35
|
export interface RangeAnchor {
|
|
29
36
|
kind: "range";
|
|
30
|
-
|
|
37
|
+
from: Position;
|
|
38
|
+
to: Position;
|
|
31
39
|
assoc: BoundaryAssoc;
|
|
32
40
|
}
|
|
33
41
|
|
|
@@ -2013,7 +2013,8 @@ export interface BoundaryAssoc {
|
|
|
2013
2013
|
export type CanonicalAnchor =
|
|
2014
2014
|
| {
|
|
2015
2015
|
kind: "range";
|
|
2016
|
-
|
|
2016
|
+
from: number;
|
|
2017
|
+
to: number;
|
|
2017
2018
|
assoc: BoundaryAssoc;
|
|
2018
2019
|
}
|
|
2019
2020
|
| {
|
|
@@ -2329,7 +2330,7 @@ export function repairCanonicalDocumentEnvelope(
|
|
|
2329
2330
|
review:
|
|
2330
2331
|
record.review === undefined
|
|
2331
2332
|
? base.review
|
|
2332
|
-
: (record.review as CanonicalDocument["review"]),
|
|
2333
|
+
: (migrateLegacyReviewAnchors(record.review) as CanonicalDocument["review"]),
|
|
2333
2334
|
preservation:
|
|
2334
2335
|
record.preservation === undefined
|
|
2335
2336
|
? base.preservation
|
|
@@ -2355,6 +2356,67 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
2355
2356
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2356
2357
|
}
|
|
2357
2358
|
|
|
2359
|
+
/**
|
|
2360
|
+
* Schema-compat shim for the 2026-04-23 flat-wins anchor collapse. Legacy
|
|
2361
|
+
* persisted snapshots carry range anchors as `{kind: "range", range: {from, to},
|
|
2362
|
+
* assoc}`. Rewrite those to the flat shape `{kind: "range", from, to, assoc}`
|
|
2363
|
+
* on rehydration so old snapshots continue to load.
|
|
2364
|
+
*/
|
|
2365
|
+
export function migrateLegacyReviewAnchors(review: unknown): unknown {
|
|
2366
|
+
if (!isPlainRecord(review)) {
|
|
2367
|
+
return review;
|
|
2368
|
+
}
|
|
2369
|
+
const migrated: Record<string, unknown> = { ...review };
|
|
2370
|
+
if (isPlainRecord(review.comments)) {
|
|
2371
|
+
migrated.comments = Object.fromEntries(
|
|
2372
|
+
Object.entries(review.comments).map(([id, record]) => [
|
|
2373
|
+
id,
|
|
2374
|
+
migrateReviewRecordAnchor(record),
|
|
2375
|
+
]),
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
if (isPlainRecord(review.revisions)) {
|
|
2379
|
+
migrated.revisions = Object.fromEntries(
|
|
2380
|
+
Object.entries(review.revisions).map(([id, record]) => [
|
|
2381
|
+
id,
|
|
2382
|
+
migrateReviewRecordAnchor(record),
|
|
2383
|
+
]),
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
return migrated;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function migrateReviewRecordAnchor(record: unknown): unknown {
|
|
2390
|
+
if (!isPlainRecord(record)) {
|
|
2391
|
+
return record;
|
|
2392
|
+
}
|
|
2393
|
+
const anchor = record.anchor;
|
|
2394
|
+
if (!isPlainRecord(anchor) || anchor.kind !== "range") {
|
|
2395
|
+
return record;
|
|
2396
|
+
}
|
|
2397
|
+
if (typeof anchor.from === "number" && typeof anchor.to === "number") {
|
|
2398
|
+
return record;
|
|
2399
|
+
}
|
|
2400
|
+
const nested = anchor.range;
|
|
2401
|
+
if (
|
|
2402
|
+
isPlainRecord(nested) &&
|
|
2403
|
+
typeof nested.from === "number" &&
|
|
2404
|
+
typeof nested.to === "number"
|
|
2405
|
+
) {
|
|
2406
|
+
const { range: _range, ...rest } = anchor;
|
|
2407
|
+
void _range;
|
|
2408
|
+
return {
|
|
2409
|
+
...record,
|
|
2410
|
+
anchor: {
|
|
2411
|
+
...rest,
|
|
2412
|
+
from: nested.from,
|
|
2413
|
+
to: nested.to,
|
|
2414
|
+
},
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
return record;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2358
2420
|
export function serializeCanonicalDocument(document: CanonicalDocument): string {
|
|
2359
2421
|
assertCanonicalDocument(document);
|
|
2360
2422
|
return stableStringify(document);
|
|
@@ -3507,7 +3569,18 @@ function validateAnchor(
|
|
|
3507
3569
|
}
|
|
3508
3570
|
|
|
3509
3571
|
if (kind === "range") {
|
|
3510
|
-
|
|
3572
|
+
if (typeof record.from !== "number") {
|
|
3573
|
+
issues.push({
|
|
3574
|
+
path: `${path}.from`,
|
|
3575
|
+
message: "range anchor from must be a number.",
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
3578
|
+
if (typeof record.to !== "number") {
|
|
3579
|
+
issues.push({
|
|
3580
|
+
path: `${path}.to`,
|
|
3581
|
+
message: "range anchor to must be a number.",
|
|
3582
|
+
});
|
|
3583
|
+
}
|
|
3511
3584
|
validateBoundaryAssoc(record.assoc, `${path}.assoc`, issues);
|
|
3512
3585
|
} else if (kind === "node") {
|
|
3513
3586
|
if (typeof record.at !== "number") {
|
|
@@ -170,6 +170,15 @@ export function describeStructuredWrapperBlock(
|
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
172
|
if (block.properties.sdtType === "docPartObj") {
|
|
173
|
+
// coord-02 §11 P0 — a Template content control that wraps a
|
|
174
|
+
// drawing_frame (CCEP header logos, cover hero photos) must stay
|
|
175
|
+
// recursable so surface-projection emits the inner image segment.
|
|
176
|
+
// Collapsing to opaque_block here silently drops every image
|
|
177
|
+
// inside a docPartObj SDT. Only collapse when the content is
|
|
178
|
+
// genuinely wrapper-heavy with no user-visible media.
|
|
179
|
+
if (sdtContainsDrawingFrame(block)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
173
182
|
return {
|
|
174
183
|
featureKey: "content-controls",
|
|
175
184
|
label: "Template content control",
|
|
@@ -220,6 +229,21 @@ function createDetail(fragment: OpaqueFragmentRecord): string {
|
|
|
220
229
|
: "Preserved whole-unit to keep unsupported OOXML intact.";
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
function sdtContainsDrawingFrame(block: Extract<BlockNode, { type: "sdt" }>): boolean {
|
|
233
|
+
const visit = (node: unknown): boolean => {
|
|
234
|
+
if (!node || typeof node !== "object") return false;
|
|
235
|
+
const n = node as { type?: string; children?: readonly unknown[] };
|
|
236
|
+
if (n.type === "drawing_frame") return true;
|
|
237
|
+
if (Array.isArray(n.children)) {
|
|
238
|
+
for (const child of n.children) {
|
|
239
|
+
if (visit(child)) return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
};
|
|
244
|
+
return block.children.some(visit);
|
|
245
|
+
}
|
|
246
|
+
|
|
223
247
|
function isTocContentControl(block: Extract<BlockNode, { type: "sdt" }>): boolean {
|
|
224
248
|
const searchText = [
|
|
225
249
|
block.properties.alias,
|
|
@@ -98,7 +98,7 @@ function normalizeCommentAnchor(
|
|
|
98
98
|
|
|
99
99
|
if (
|
|
100
100
|
mappedAnchor.kind === "range" &&
|
|
101
|
-
!rangeStaysWithinCommentableStory(nextContent, mappedAnchor.
|
|
101
|
+
!rangeStaysWithinCommentableStory(nextContent, { from: mappedAnchor.from, to: mappedAnchor.to })
|
|
102
102
|
) {
|
|
103
103
|
return detachReviewAnchor(previousRange, "invalidatedByStructureChange");
|
|
104
104
|
}
|
|
@@ -153,8 +153,8 @@ export function applyRevisionAction(
|
|
|
153
153
|
|
|
154
154
|
const story = parseTextStory(options.document.content);
|
|
155
155
|
const range = normalizeRange(
|
|
156
|
-
revision.anchor.
|
|
157
|
-
revision.anchor.
|
|
156
|
+
revision.anchor.from,
|
|
157
|
+
revision.anchor.to,
|
|
158
158
|
);
|
|
159
159
|
|
|
160
160
|
if (range.to > story.size) {
|
|
@@ -811,8 +811,8 @@ function resolveParagraphMarkDeletionRange(
|
|
|
811
811
|
|
|
812
812
|
const paragraphs = mapParagraphRanges(story);
|
|
813
813
|
const anchorPosition = normalizeRange(
|
|
814
|
-
revision.anchor.
|
|
815
|
-
revision.anchor.
|
|
814
|
+
revision.anchor.from,
|
|
815
|
+
revision.anchor.to,
|
|
816
816
|
).from;
|
|
817
817
|
const paragraph = paragraphs.find(
|
|
818
818
|
(candidate) =>
|
|
@@ -96,7 +96,7 @@ export function collectScopeTagTouches(
|
|
|
96
96
|
|
|
97
97
|
function anchorRange(anchor: CanonicalAnchor): { from: number; to: number } {
|
|
98
98
|
if (anchor.kind === "range") {
|
|
99
|
-
return { from: anchor.
|
|
99
|
+
return { from: anchor.from, to: anchor.to };
|
|
100
100
|
}
|
|
101
101
|
if (anchor.kind === "node") {
|
|
102
102
|
return { from: anchor.at, to: anchor.at };
|
|
@@ -88,10 +88,10 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
88
88
|
|
|
89
89
|
if (active.kind === "range") {
|
|
90
90
|
const directionForward = selection.anchor <= selection.head;
|
|
91
|
-
const isCollapsed = active.
|
|
91
|
+
const isCollapsed = active.from === active.to;
|
|
92
92
|
|
|
93
93
|
if (isCollapsed) {
|
|
94
|
-
const collapsedAt = mapPosition(active.
|
|
94
|
+
const collapsedAt = mapPosition(active.from, 1, mapping).position;
|
|
95
95
|
return {
|
|
96
96
|
anchor: collapsedAt,
|
|
97
97
|
head: collapsedAt,
|
|
@@ -107,7 +107,7 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
107
107
|
const mapped = mapAnchor(active, mapping);
|
|
108
108
|
|
|
109
109
|
if (mapped.kind === "range") {
|
|
110
|
-
const { from, to } = mapped
|
|
110
|
+
const { from, to } = mapped;
|
|
111
111
|
return {
|
|
112
112
|
anchor: directionForward ? from : to,
|
|
113
113
|
head: directionForward ? to : from,
|
|
@@ -122,7 +122,7 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
122
122
|
// positions that land inside a deleted span to `step.from +
|
|
123
123
|
// insertSize`, which is the correct logical "where the selection
|
|
124
124
|
// used to start" position.
|
|
125
|
-
const collapsedAt = mapPosition(active.
|
|
125
|
+
const collapsedAt = mapPosition(active.from, 1, mapping).position;
|
|
126
126
|
return {
|
|
127
127
|
anchor: collapsedAt,
|
|
128
128
|
head: collapsedAt,
|
|
@@ -147,9 +147,9 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
147
147
|
}
|
|
148
148
|
if (mapped.kind === "range") {
|
|
149
149
|
return {
|
|
150
|
-
anchor: mapped.
|
|
151
|
-
head: mapped.
|
|
152
|
-
isCollapsed: mapped.
|
|
150
|
+
anchor: mapped.from,
|
|
151
|
+
head: mapped.to,
|
|
152
|
+
isCollapsed: mapped.from === mapped.to,
|
|
153
153
|
activeRange: mapped,
|
|
154
154
|
};
|
|
155
155
|
}
|