@beyondwork/docx-react-component 1.0.47 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +199 -13
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/commands/index.ts +9 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/selection/anchor-conversion.ts +112 -0
- package/src/core/selection/review-anchors.ts +108 -3
- package/src/core/state/text-transaction.ts +103 -7
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +59 -1
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +46 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/chart-style-table.ts +543 -0
- package/src/io/ooxml/chart/color-palette.ts +101 -0
- package/src/io/ooxml/chart/compose-series-color.ts +147 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
- package/src/io/ooxml/chart/parse-series.ts +635 -0
- package/src/io/ooxml/chart/resolve-color.ts +261 -0
- package/src/io/ooxml/chart/types.ts +439 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +90 -2
- package/src/io/ooxml/parse-main-document.ts +156 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +160 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +29 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +330 -0
- package/src/runtime/collab/workflow-shared.ts +247 -0
- package/src/runtime/document-locations.ts +1 -9
- package/src/runtime/document-outline.ts +1 -9
- package/src/runtime/document-runtime.ts +288 -65
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +102 -37
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +48 -2
- package/src/ui/editor-runtime-boundary.ts +42 -1
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
- package/src/ui/unsupported-previews-policy.ts +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DocumentRootNode,
|
|
3
|
+
InlineNode,
|
|
4
|
+
ParagraphNode,
|
|
5
|
+
ScopeMarkerStartNode,
|
|
6
|
+
ScopeMarkerEndNode,
|
|
7
|
+
} from "../../model/canonical-document.ts";
|
|
8
|
+
import type { CanonicalDocumentEnvelope } from "../state/editor-state.ts";
|
|
9
|
+
|
|
10
|
+
export interface InsertScopeMarkersResult {
|
|
11
|
+
document: CanonicalDocumentEnvelope;
|
|
12
|
+
scopeId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pure helper — returns a new CanonicalDocumentEnvelope with a pair of
|
|
17
|
+
* scope-marker inline nodes inserted at the given position range.
|
|
18
|
+
*
|
|
19
|
+
* Supports the common case: `from` and `to` land in the same top-level
|
|
20
|
+
* paragraph. Cross-paragraph ranges are currently a no-op and return the
|
|
21
|
+
* document unchanged — multi-block scopes ship in a follow-up slice.
|
|
22
|
+
*/
|
|
23
|
+
export function insertScopeMarkers(
|
|
24
|
+
document: CanonicalDocumentEnvelope,
|
|
25
|
+
params: {
|
|
26
|
+
scopeId: string;
|
|
27
|
+
from: number;
|
|
28
|
+
to: number;
|
|
29
|
+
},
|
|
30
|
+
): InsertScopeMarkersResult {
|
|
31
|
+
const { scopeId, from, to } = params;
|
|
32
|
+
const root = document.content as DocumentRootNode;
|
|
33
|
+
if (!root || root.type !== "doc") return { document, scopeId };
|
|
34
|
+
|
|
35
|
+
const normalizedFrom = Math.min(from, to);
|
|
36
|
+
const normalizedTo = Math.max(from, to);
|
|
37
|
+
|
|
38
|
+
let cursor = 0;
|
|
39
|
+
let inserted = false;
|
|
40
|
+
const children = root.children.map((block, blockIndex) => {
|
|
41
|
+
if (inserted) return block;
|
|
42
|
+
if (block.type !== "paragraph") {
|
|
43
|
+
cursor += 1;
|
|
44
|
+
if (blockIndex < root.children.length - 1) cursor += 1;
|
|
45
|
+
return block;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const paragraphFrom = cursor;
|
|
49
|
+
const paragraphLength = block.children.reduce(
|
|
50
|
+
(total, child) => total + inlineLength(child as InlineNode),
|
|
51
|
+
0,
|
|
52
|
+
);
|
|
53
|
+
const paragraphTo = paragraphFrom + paragraphLength;
|
|
54
|
+
cursor = paragraphTo + 1;
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
normalizedFrom < paragraphFrom ||
|
|
58
|
+
normalizedTo > paragraphTo ||
|
|
59
|
+
normalizedFrom > paragraphTo
|
|
60
|
+
) {
|
|
61
|
+
return block;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
inserted = true;
|
|
65
|
+
const startOffset = normalizedFrom - paragraphFrom;
|
|
66
|
+
const endOffset = normalizedTo - paragraphFrom;
|
|
67
|
+
const newChildren = injectMarkersIntoInlineList(
|
|
68
|
+
block.children as InlineNode[],
|
|
69
|
+
scopeId,
|
|
70
|
+
startOffset,
|
|
71
|
+
endOffset,
|
|
72
|
+
);
|
|
73
|
+
return { ...block, children: newChildren } as ParagraphNode;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!inserted) return { document, scopeId };
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
document: {
|
|
80
|
+
...document,
|
|
81
|
+
content: { ...root, children },
|
|
82
|
+
},
|
|
83
|
+
scopeId,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns a new document with every scope_marker_* node whose scopeId matches
|
|
89
|
+
* removed. The containing paragraphs' other children are preserved in order.
|
|
90
|
+
*/
|
|
91
|
+
export function removeScopeMarkers(
|
|
92
|
+
document: CanonicalDocumentEnvelope,
|
|
93
|
+
scopeId: string,
|
|
94
|
+
): CanonicalDocumentEnvelope {
|
|
95
|
+
const root = document.content as DocumentRootNode;
|
|
96
|
+
if (!root || root.type !== "doc") return document;
|
|
97
|
+
|
|
98
|
+
let mutated = false;
|
|
99
|
+
const children = root.children.map((block) => {
|
|
100
|
+
if (block.type !== "paragraph") return block;
|
|
101
|
+
const kept = block.children.filter((child) => {
|
|
102
|
+
if (
|
|
103
|
+
(child.type === "scope_marker_start" ||
|
|
104
|
+
child.type === "scope_marker_end") &&
|
|
105
|
+
child.scopeId === scopeId
|
|
106
|
+
) {
|
|
107
|
+
mutated = true;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
if (kept.length === block.children.length) return block;
|
|
113
|
+
return { ...block, children: kept } as ParagraphNode;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!mutated) return document;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
...document,
|
|
120
|
+
content: { ...root, children },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function inlineLength(node: InlineNode): number {
|
|
125
|
+
switch (node.type) {
|
|
126
|
+
case "text":
|
|
127
|
+
return Array.from(node.text).length;
|
|
128
|
+
case "hyperlink":
|
|
129
|
+
case "field":
|
|
130
|
+
return node.children.reduce(
|
|
131
|
+
(total, child) => total + inlineLength(child as InlineNode),
|
|
132
|
+
0,
|
|
133
|
+
);
|
|
134
|
+
case "bookmark_start":
|
|
135
|
+
case "bookmark_end":
|
|
136
|
+
case "scope_marker_start":
|
|
137
|
+
case "scope_marker_end":
|
|
138
|
+
return 0;
|
|
139
|
+
default:
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function injectMarkersIntoInlineList(
|
|
145
|
+
inlines: InlineNode[],
|
|
146
|
+
scopeId: string,
|
|
147
|
+
startOffset: number,
|
|
148
|
+
endOffset: number,
|
|
149
|
+
): InlineNode[] {
|
|
150
|
+
const start: ScopeMarkerStartNode = {
|
|
151
|
+
type: "scope_marker_start",
|
|
152
|
+
scopeId,
|
|
153
|
+
};
|
|
154
|
+
const end: ScopeMarkerEndNode = {
|
|
155
|
+
type: "scope_marker_end",
|
|
156
|
+
scopeId,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const output: InlineNode[] = [];
|
|
160
|
+
let cursor = 0;
|
|
161
|
+
let startEmitted = false;
|
|
162
|
+
let endEmitted = false;
|
|
163
|
+
|
|
164
|
+
for (const node of inlines) {
|
|
165
|
+
const length = inlineLength(node);
|
|
166
|
+
const nodeStart = cursor;
|
|
167
|
+
const nodeEnd = cursor + length;
|
|
168
|
+
|
|
169
|
+
const startInside = !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
|
|
170
|
+
const endInside = !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
|
|
171
|
+
|
|
172
|
+
if (!startInside && !endInside) {
|
|
173
|
+
output.push(node);
|
|
174
|
+
cursor = nodeEnd;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Currently only text nodes support splitting for an internal cut.
|
|
179
|
+
if (node.type !== "text") {
|
|
180
|
+
// For non-text nodes, markers land at the node boundary closest to the
|
|
181
|
+
// target offset — avoids mid-atom splits which would corrupt the node.
|
|
182
|
+
if (startInside && !startEmitted && startOffset <= nodeStart) {
|
|
183
|
+
output.push(start);
|
|
184
|
+
startEmitted = true;
|
|
185
|
+
}
|
|
186
|
+
if (endInside && !endEmitted && endOffset <= nodeStart) {
|
|
187
|
+
output.push(end);
|
|
188
|
+
endEmitted = true;
|
|
189
|
+
}
|
|
190
|
+
output.push(node);
|
|
191
|
+
if (startInside && !startEmitted && startOffset >= nodeEnd) {
|
|
192
|
+
output.push(start);
|
|
193
|
+
startEmitted = true;
|
|
194
|
+
}
|
|
195
|
+
if (endInside && !endEmitted && endOffset >= nodeEnd) {
|
|
196
|
+
output.push(end);
|
|
197
|
+
endEmitted = true;
|
|
198
|
+
}
|
|
199
|
+
cursor = nodeEnd;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const text = node.text;
|
|
204
|
+
const chars = Array.from(text);
|
|
205
|
+
const marks = node.marks;
|
|
206
|
+
const pieces: {
|
|
207
|
+
cut: number;
|
|
208
|
+
emit: "start" | "end";
|
|
209
|
+
}[] = [];
|
|
210
|
+
|
|
211
|
+
if (startInside) pieces.push({ cut: startOffset - nodeStart, emit: "start" });
|
|
212
|
+
if (endInside) pieces.push({ cut: endOffset - nodeStart, emit: "end" });
|
|
213
|
+
pieces.sort((a, b) => a.cut - b.cut || (a.emit === "start" ? -1 : 1));
|
|
214
|
+
|
|
215
|
+
let priorCut = 0;
|
|
216
|
+
for (const piece of pieces) {
|
|
217
|
+
const segment = chars.slice(priorCut, piece.cut).join("");
|
|
218
|
+
if (segment.length > 0) {
|
|
219
|
+
output.push(
|
|
220
|
+
marks !== undefined
|
|
221
|
+
? { type: "text", text: segment, marks }
|
|
222
|
+
: { type: "text", text: segment },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (piece.emit === "start") {
|
|
226
|
+
output.push(start);
|
|
227
|
+
startEmitted = true;
|
|
228
|
+
} else {
|
|
229
|
+
output.push(end);
|
|
230
|
+
endEmitted = true;
|
|
231
|
+
}
|
|
232
|
+
priorCut = piece.cut;
|
|
233
|
+
}
|
|
234
|
+
const tail = chars.slice(priorCut).join("");
|
|
235
|
+
if (tail.length > 0) {
|
|
236
|
+
output.push(
|
|
237
|
+
marks !== undefined
|
|
238
|
+
? { type: "text", text: tail, marks }
|
|
239
|
+
: { type: "text", text: tail },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
cursor = nodeEnd;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Append markers that were at the very end of the paragraph.
|
|
247
|
+
if (!startEmitted) {
|
|
248
|
+
output.push(start);
|
|
249
|
+
startEmitted = true;
|
|
250
|
+
}
|
|
251
|
+
if (!endEmitted) {
|
|
252
|
+
output.push(end);
|
|
253
|
+
endEmitted = true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return output;
|
|
257
|
+
}
|
|
@@ -50,6 +50,7 @@ import type {
|
|
|
50
50
|
SectionBreakType,
|
|
51
51
|
SectionLayoutPatch,
|
|
52
52
|
SectionPageNumberingPatch,
|
|
53
|
+
TextFormattingDirective,
|
|
53
54
|
WorkflowMetadataDefinition,
|
|
54
55
|
WorkflowMetadataEntry,
|
|
55
56
|
WorkflowOverlay,
|
|
@@ -136,6 +137,13 @@ export type EditorCommand =
|
|
|
136
137
|
| {
|
|
137
138
|
type: "text.insert";
|
|
138
139
|
text: string;
|
|
140
|
+
/**
|
|
141
|
+
* I7 — optional directive controlling which character-level marks the inserted
|
|
142
|
+
* text carries. Defaults to `{ mode: "paragraph-default" }` (today's behavior:
|
|
143
|
+
* no inherited run marks). See `src/api/public-types.ts` `TextFormattingDirective`
|
|
144
|
+
* for semantics.
|
|
145
|
+
*/
|
|
146
|
+
formatting?: TextFormattingDirective;
|
|
139
147
|
origin?: CommandOrigin;
|
|
140
148
|
}
|
|
141
149
|
| {
|
|
@@ -556,7 +564,7 @@ export function executeEditorCommand(
|
|
|
556
564
|
: undefined;
|
|
557
565
|
if (suggestingResult) return suggestingResult;
|
|
558
566
|
return applyTextCommand(state, context.timestamp, (document, selection) =>
|
|
559
|
-
insertText(document, selection, command.text, context),
|
|
567
|
+
insertText(document, selection, command.text, context, command.formatting),
|
|
560
568
|
);
|
|
561
569
|
}
|
|
562
570
|
case "text.delete-backward": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InsertTableOptions } from "../../api/public-types";
|
|
1
|
+
import type { InsertTableOptions, TextFormattingDirective } from "../../api/public-types";
|
|
2
2
|
import type {
|
|
3
3
|
DocumentRootNode,
|
|
4
4
|
ParagraphNode,
|
|
@@ -126,6 +126,7 @@ export function insertText(
|
|
|
126
126
|
selection: SelectionSnapshot,
|
|
127
127
|
text: string,
|
|
128
128
|
context: TextCommandContext,
|
|
129
|
+
formatting?: TextFormattingDirective,
|
|
129
130
|
): TextTransactionResult {
|
|
130
131
|
return applyTextTransaction(
|
|
131
132
|
document,
|
|
@@ -138,6 +139,7 @@ export function insertText(
|
|
|
138
139
|
text,
|
|
139
140
|
},
|
|
140
141
|
],
|
|
142
|
+
formatting,
|
|
141
143
|
},
|
|
142
144
|
context,
|
|
143
145
|
);
|
|
@@ -26,6 +26,7 @@ export type StoryUnit =
|
|
|
26
26
|
| ImageUnit
|
|
27
27
|
| OpaqueInlineUnit
|
|
28
28
|
| OpaqueBlockUnit
|
|
29
|
+
| ScopeMarkerUnit
|
|
29
30
|
| ParagraphBreakUnit;
|
|
30
31
|
|
|
31
32
|
export interface TextCharacterUnit {
|
|
@@ -69,6 +70,18 @@ export interface ParagraphBreakUnit {
|
|
|
69
70
|
nextParagraph: ParagraphProperties;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Zero-width inline unit that preserves S1 scope-marker nodes through text
|
|
75
|
+
* transactions. Without this unit, scope markers would be silently dropped
|
|
76
|
+
* during `parseTextStory` / `serializeTextStory`, and any `text.insert` /
|
|
77
|
+
* `text.delete-*` dispatch would vaporize the structural scope anchors.
|
|
78
|
+
*/
|
|
79
|
+
export interface ScopeMarkerUnit {
|
|
80
|
+
kind: "scope_marker";
|
|
81
|
+
boundary: "start" | "end";
|
|
82
|
+
scopeId: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
export function parseTextStory(content: unknown): TextStory {
|
|
73
86
|
const root = normalizeDocumentRoot(content);
|
|
74
87
|
const firstParagraphNode = root.children.find(isParagraphNode);
|
|
@@ -111,10 +124,60 @@ export function parseTextStory(content: unknown): TextStory {
|
|
|
111
124
|
return {
|
|
112
125
|
firstParagraph,
|
|
113
126
|
units,
|
|
114
|
-
size: units
|
|
127
|
+
size: countLogicalPositions(units),
|
|
115
128
|
};
|
|
116
129
|
}
|
|
117
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Story positions are logical — scope-marker units are preserved in the
|
|
133
|
+
* `units` array for round-trip fidelity but they do NOT consume a position.
|
|
134
|
+
* This matches the surface-projection treatment (markers = 0 width, same as
|
|
135
|
+
* bookmark_start / bookmark_end) so a position 3 set via `selection.set`
|
|
136
|
+
* resolves to the same character in both views.
|
|
137
|
+
*/
|
|
138
|
+
export function countLogicalPositions(units: StoryUnit[]): number {
|
|
139
|
+
let size = 0;
|
|
140
|
+
for (const unit of units) {
|
|
141
|
+
if (unit.kind !== "scope_marker") size += 1;
|
|
142
|
+
}
|
|
143
|
+
return size;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Translate a logical (scope-marker-skipping) position into a unit-array
|
|
148
|
+
* index. Walks units and increments the unit cursor once per non-marker unit;
|
|
149
|
+
* scope markers are passed over transparently. Returns `units.length` when
|
|
150
|
+
* the logical position is at or beyond end-of-story.
|
|
151
|
+
*
|
|
152
|
+
* When `startBias === "after"` (default), the returned unit index is the
|
|
153
|
+
* first position AFTER any scope markers that sit exactly at the logical
|
|
154
|
+
* boundary — useful when slicing units as "...before this cursor". When
|
|
155
|
+
* `startBias === "before"`, markers at the boundary are included in the
|
|
156
|
+
* "after" slice.
|
|
157
|
+
*/
|
|
158
|
+
export function logicalPositionToUnitIndex(
|
|
159
|
+
units: StoryUnit[],
|
|
160
|
+
logicalPos: number,
|
|
161
|
+
startBias: "before" | "after" = "after",
|
|
162
|
+
): number {
|
|
163
|
+
let logicalCursor = 0;
|
|
164
|
+
let unitIndex = 0;
|
|
165
|
+
while (unitIndex < units.length) {
|
|
166
|
+
if (logicalCursor === logicalPos && startBias === "before") {
|
|
167
|
+
return unitIndex;
|
|
168
|
+
}
|
|
169
|
+
const unit = units[unitIndex]!;
|
|
170
|
+
if (unit.kind !== "scope_marker") {
|
|
171
|
+
if (logicalCursor === logicalPos && startBias === "after") {
|
|
172
|
+
return unitIndex;
|
|
173
|
+
}
|
|
174
|
+
logicalCursor += 1;
|
|
175
|
+
}
|
|
176
|
+
unitIndex += 1;
|
|
177
|
+
}
|
|
178
|
+
return unitIndex;
|
|
179
|
+
}
|
|
180
|
+
|
|
118
181
|
export function serializeTextStory(story: TextStory): DocumentRootNode {
|
|
119
182
|
const blocks: Array<ParagraphNode | OpaqueBlockNode> = [];
|
|
120
183
|
let currentParagraph: ParagraphNode | undefined = createParagraph(story.firstParagraph);
|
|
@@ -272,6 +335,15 @@ export function serializeTextStory(story: TextStory): DocumentRootNode {
|
|
|
272
335
|
warningId: unit.warningId,
|
|
273
336
|
});
|
|
274
337
|
break;
|
|
338
|
+
case "scope_marker":
|
|
339
|
+
pushInlineNode({
|
|
340
|
+
type:
|
|
341
|
+
unit.boundary === "start"
|
|
342
|
+
? "scope_marker_start"
|
|
343
|
+
: "scope_marker_end",
|
|
344
|
+
scopeId: unit.scopeId,
|
|
345
|
+
});
|
|
346
|
+
break;
|
|
275
347
|
}
|
|
276
348
|
}
|
|
277
349
|
|
|
@@ -305,6 +377,8 @@ export function createPlainText(story: TextStory): string {
|
|
|
305
377
|
return "\uFFF9";
|
|
306
378
|
case "opaque_block":
|
|
307
379
|
return "\uFFFA";
|
|
380
|
+
case "scope_marker":
|
|
381
|
+
return "";
|
|
308
382
|
}
|
|
309
383
|
})
|
|
310
384
|
.join("");
|
|
@@ -355,6 +429,12 @@ export function cloneStoryUnit(unit: StoryUnit): StoryUnit {
|
|
|
355
429
|
kind: "paragraph_break",
|
|
356
430
|
nextParagraph: cloneParagraphProperties(unit.nextParagraph),
|
|
357
431
|
};
|
|
432
|
+
case "scope_marker":
|
|
433
|
+
return {
|
|
434
|
+
kind: "scope_marker",
|
|
435
|
+
boundary: unit.boundary,
|
|
436
|
+
scopeId: unit.scopeId,
|
|
437
|
+
};
|
|
358
438
|
}
|
|
359
439
|
}
|
|
360
440
|
|
|
@@ -442,6 +522,20 @@ function flattenInlineNodes(
|
|
|
442
522
|
warningId: node.warningId,
|
|
443
523
|
});
|
|
444
524
|
break;
|
|
525
|
+
case "scope_marker_start":
|
|
526
|
+
units.push({
|
|
527
|
+
kind: "scope_marker",
|
|
528
|
+
boundary: "start",
|
|
529
|
+
scopeId: node.scopeId,
|
|
530
|
+
});
|
|
531
|
+
break;
|
|
532
|
+
case "scope_marker_end":
|
|
533
|
+
units.push({
|
|
534
|
+
kind: "scope_marker",
|
|
535
|
+
boundary: "end",
|
|
536
|
+
scopeId: node.scopeId,
|
|
537
|
+
});
|
|
538
|
+
break;
|
|
445
539
|
}
|
|
446
540
|
}
|
|
447
541
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical boundary between the internal `EditorAnchorProjection` shape
|
|
3
|
+
* (`src/core/selection/mapping.ts` — `{ kind: "range", range: DocRange,
|
|
4
|
+
* assoc }`) and the public `EditorAnchorProjection` shape
|
|
5
|
+
* (`src/api/public-types.ts` — `{ kind: "range", from, to, assoc }`).
|
|
6
|
+
*
|
|
7
|
+
* Two shapes stay split intentionally: the internal shape uses `DocRange`
|
|
8
|
+
* so mapping helpers compose ranges independently of assoc semantics;
|
|
9
|
+
* the public shape is flat so host consumers can destructure without
|
|
10
|
+
* reaching through a nested `range` member.
|
|
11
|
+
*
|
|
12
|
+
* Every conversion MUST go through this module. Every creation of a
|
|
13
|
+
* public-shape anchor MUST go through this module. A regression test
|
|
14
|
+
* in `test/api/anchor-boundary-invariants.test.ts` enforces the "no
|
|
15
|
+
* ad-hoc helpers outside this file" rule.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { EditorAnchorProjection as PublicEditorAnchorProjection } from "../../api/public-types";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_BOUNDARY_ASSOC,
|
|
21
|
+
createDetachedAnchor,
|
|
22
|
+
createNodeAnchor,
|
|
23
|
+
createRangeAnchor,
|
|
24
|
+
normalizeRange,
|
|
25
|
+
type DocRange,
|
|
26
|
+
type EditorAnchorProjection as InternalEditorAnchorProjection,
|
|
27
|
+
} from "./mapping.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default boundary-associativity used by public-shape range anchors
|
|
31
|
+
* when the caller does not provide one. Matches the inline default the
|
|
32
|
+
* three ad-hoc `createPublicRangeAnchor` helpers all used before this
|
|
33
|
+
* module existed (`{ start: -1, end: 1 }` — the "outward-biased"
|
|
34
|
+
* selection).
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_PUBLIC_ASSOC: { start: -1 | 1; end: -1 | 1 } = {
|
|
37
|
+
start: -1,
|
|
38
|
+
end: 1,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type PublicRangeAssoc = { start: -1 | 1; end: -1 | 1 };
|
|
42
|
+
|
|
43
|
+
export function createPublicRangeAnchor(
|
|
44
|
+
from: number,
|
|
45
|
+
to: number,
|
|
46
|
+
assoc: PublicRangeAssoc = DEFAULT_PUBLIC_ASSOC,
|
|
47
|
+
): PublicEditorAnchorProjection {
|
|
48
|
+
const range = normalizeRange({ from, to });
|
|
49
|
+
return {
|
|
50
|
+
kind: "range",
|
|
51
|
+
from: range.from,
|
|
52
|
+
to: range.to,
|
|
53
|
+
assoc,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createPublicNodeAnchor(
|
|
58
|
+
at: number,
|
|
59
|
+
assoc: -1 | 1 = 1,
|
|
60
|
+
): PublicEditorAnchorProjection {
|
|
61
|
+
return { kind: "node", at, assoc };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createPublicDetachedAnchor(
|
|
65
|
+
lastKnownRange: DocRange,
|
|
66
|
+
reason: "deleted" | "invalidatedByStructureChange" | "importAmbiguity",
|
|
67
|
+
): PublicEditorAnchorProjection {
|
|
68
|
+
return {
|
|
69
|
+
kind: "detached",
|
|
70
|
+
lastKnownRange: normalizeRange(lastKnownRange),
|
|
71
|
+
reason,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function toPublicAnchorProjection(
|
|
76
|
+
anchor: InternalEditorAnchorProjection,
|
|
77
|
+
): PublicEditorAnchorProjection {
|
|
78
|
+
switch (anchor.kind) {
|
|
79
|
+
case "range":
|
|
80
|
+
return {
|
|
81
|
+
kind: "range",
|
|
82
|
+
from: anchor.range.from,
|
|
83
|
+
to: anchor.range.to,
|
|
84
|
+
assoc: anchor.assoc,
|
|
85
|
+
};
|
|
86
|
+
case "node":
|
|
87
|
+
return { kind: "node", at: anchor.at, assoc: anchor.assoc };
|
|
88
|
+
case "detached":
|
|
89
|
+
return {
|
|
90
|
+
kind: "detached",
|
|
91
|
+
lastKnownRange: anchor.lastKnownRange,
|
|
92
|
+
reason: anchor.reason,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toInternalAnchorProjection(
|
|
98
|
+
anchor: PublicEditorAnchorProjection,
|
|
99
|
+
): InternalEditorAnchorProjection {
|
|
100
|
+
switch (anchor.kind) {
|
|
101
|
+
case "range":
|
|
102
|
+
return createRangeAnchor(
|
|
103
|
+
anchor.from,
|
|
104
|
+
anchor.to,
|
|
105
|
+
anchor.assoc ?? DEFAULT_BOUNDARY_ASSOC,
|
|
106
|
+
);
|
|
107
|
+
case "node":
|
|
108
|
+
return createNodeAnchor(anchor.at, anchor.assoc);
|
|
109
|
+
case "detached":
|
|
110
|
+
return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -103,20 +103,125 @@ export function rangeStaysWithinSingleParagraph(
|
|
|
103
103
|
return true;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* I8 — "mid-run-near-table" guard. Comment anchors whose endpoints
|
|
108
|
+
* land strictly inside a paragraph that sits adjacent to a table
|
|
109
|
+
* block are rejected: the serializer's per-paragraph offset walker
|
|
110
|
+
* (Lane 3 §O8) produces invalid OOXML for these anchors. Removed
|
|
111
|
+
* once O8 ships.
|
|
112
|
+
*/
|
|
113
|
+
export const TABLE_ADJACENT_WINDOW = 1;
|
|
114
|
+
|
|
115
|
+
export type CommentAnchorRejectionReason =
|
|
116
|
+
| "invalid_comment_anchor"
|
|
117
|
+
| "comment_anchor_table_adjacent";
|
|
118
|
+
|
|
106
119
|
export function canCreateDocxCommentAnchor(
|
|
107
120
|
content: unknown,
|
|
108
121
|
anchor: ReviewAnchor,
|
|
109
122
|
): boolean {
|
|
123
|
+
return commentAnchorRejectionReason(content, anchor) === null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function commentAnchorRejectionReason(
|
|
127
|
+
content: unknown,
|
|
128
|
+
anchor: ReviewAnchor,
|
|
129
|
+
): CommentAnchorRejectionReason | null {
|
|
110
130
|
if (anchor.kind !== "range") {
|
|
111
|
-
return
|
|
131
|
+
return "invalid_comment_anchor";
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
const normalized = normalizeRange(anchor.range);
|
|
115
135
|
if (normalized.from === normalized.to) {
|
|
116
|
-
return
|
|
136
|
+
return "invalid_comment_anchor";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!rangeStaysWithinCommentableStory(content, normalized)) {
|
|
140
|
+
return "invalid_comment_anchor";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (rangeLandsMidRunNearTableBoundary(content, normalized)) {
|
|
144
|
+
return "comment_anchor_table_adjacent";
|
|
117
145
|
}
|
|
118
146
|
|
|
119
|
-
return
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* I8.3 — Snap a rejected mid-run-near-table anchor to paragraph
|
|
152
|
+
* boundaries so downstream serialization stays safe. Returns `null`
|
|
153
|
+
* if the anchor cannot be rescued (e.g. crosses an opaque block).
|
|
154
|
+
*/
|
|
155
|
+
export function snapCommentAnchorAwayFromTable(
|
|
156
|
+
content: unknown,
|
|
157
|
+
anchor: ReviewAnchor,
|
|
158
|
+
): ReviewAnchor | null {
|
|
159
|
+
if (anchor.kind !== "range") return null;
|
|
160
|
+
|
|
161
|
+
const normalized = normalizeRange(anchor.range);
|
|
162
|
+
if (normalized.from === normalized.to) return null;
|
|
163
|
+
|
|
164
|
+
const reason = commentAnchorRejectionReason(content, anchor);
|
|
165
|
+
if (reason === null) return anchor;
|
|
166
|
+
if (reason !== "comment_anchor_table_adjacent") return null;
|
|
167
|
+
|
|
168
|
+
const surfaceBlocks = readSurfaceBlocks(content);
|
|
169
|
+
if (!surfaceBlocks) return null;
|
|
170
|
+
|
|
171
|
+
const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.from, "start");
|
|
172
|
+
const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.to, "end");
|
|
173
|
+
if (!fromOwner || !toOwner) return null;
|
|
174
|
+
|
|
175
|
+
const snappedFrom = normalized.from > fromOwner.from && normalized.from < fromOwner.to
|
|
176
|
+
? fromOwner.from
|
|
177
|
+
: normalized.from;
|
|
178
|
+
const snappedTo = normalized.to > toOwner.from && normalized.to < toOwner.to
|
|
179
|
+
? toOwner.to
|
|
180
|
+
: normalized.to;
|
|
181
|
+
|
|
182
|
+
if (snappedFrom === snappedTo) return null;
|
|
183
|
+
|
|
184
|
+
const snapped = createRangeAnchor(snappedFrom, snappedTo);
|
|
185
|
+
return canCreateDocxCommentAnchor(content, snapped) ? snapped : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function rangeLandsMidRunNearTableBoundary(
|
|
189
|
+
content: unknown,
|
|
190
|
+
range: DocRange,
|
|
191
|
+
): boolean {
|
|
192
|
+
const surfaceBlocks = readSurfaceBlocks(content);
|
|
193
|
+
if (!surfaceBlocks) return false;
|
|
194
|
+
|
|
195
|
+
const tableBlocks = surfaceBlocks.filter((block) => block.kind === "table");
|
|
196
|
+
if (tableBlocks.length === 0) return false;
|
|
197
|
+
|
|
198
|
+
const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, range.from, "start");
|
|
199
|
+
const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, range.to, "end");
|
|
200
|
+
if (!fromOwner || !toOwner) return false;
|
|
201
|
+
|
|
202
|
+
const fromMidRun = range.from > fromOwner.from && range.from < fromOwner.to;
|
|
203
|
+
const toMidRun = range.to > toOwner.from && range.to < toOwner.to;
|
|
204
|
+
if (!fromMidRun && !toMidRun) return false;
|
|
205
|
+
|
|
206
|
+
const fromAdjacent = fromMidRun && paragraphIsTableAdjacent(fromOwner, tableBlocks);
|
|
207
|
+
const toAdjacent = toMidRun && paragraphIsTableAdjacent(toOwner, tableBlocks);
|
|
208
|
+
return fromAdjacent || toAdjacent;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function paragraphIsTableAdjacent(
|
|
212
|
+
paragraph: FlattenedSurfaceBlock,
|
|
213
|
+
tableBlocks: readonly FlattenedSurfaceBlock[],
|
|
214
|
+
): boolean {
|
|
215
|
+
return tableBlocks.some((table) => {
|
|
216
|
+
if (table.tableId !== null && table.tableId === paragraph.tableId) {
|
|
217
|
+
// Skip: a table block at the same cell scope would be a
|
|
218
|
+
// descendant of the paragraph's containing cell, not a sibling.
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const leftGap = Math.abs(paragraph.from - table.to);
|
|
222
|
+
const rightGap = Math.abs(paragraph.to - table.from);
|
|
223
|
+
return leftGap <= TABLE_ADJACENT_WINDOW || rightGap <= TABLE_ADJACENT_WINDOW;
|
|
224
|
+
});
|
|
120
225
|
}
|
|
121
226
|
|
|
122
227
|
export function rangeStaysWithinCommentableStory(
|