@beyondwork/docx-react-component 1.0.47 → 1.0.48

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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +115 -1
  3. package/src/compare/diff-engine.ts +4 -0
  4. package/src/core/commands/add-scope.ts +257 -0
  5. package/src/core/commands/formatting-commands.ts +2 -0
  6. package/src/core/schema/text-schema.ts +95 -1
  7. package/src/core/state/text-transaction.ts +17 -5
  8. package/src/io/chart-preview-resolver.ts +27 -0
  9. package/src/io/docx-session.ts +226 -38
  10. package/src/io/export/serialize-main-document.ts +37 -0
  11. package/src/io/export/serialize-settings.ts +421 -0
  12. package/src/io/export/serialize-styles.ts +10 -0
  13. package/src/io/normalize/normalize-text.ts +1 -0
  14. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  15. package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
  16. package/src/io/ooxml/chart/parse-series.ts +570 -0
  17. package/src/io/ooxml/chart/resolve-color.ts +251 -0
  18. package/src/io/ooxml/chart/types.ts +420 -0
  19. package/src/io/ooxml/parse-block-structure.ts +99 -0
  20. package/src/io/ooxml/parse-complex-content.ts +87 -2
  21. package/src/io/ooxml/parse-main-document.ts +115 -1
  22. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  23. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  24. package/src/io/ooxml/parse-settings.ts +97 -1
  25. package/src/io/ooxml/parse-styles.ts +65 -0
  26. package/src/io/ooxml/parse-theme.ts +2 -127
  27. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  28. package/src/io/ooxml/xml-parser.ts +142 -0
  29. package/src/model/canonical-document.ts +94 -0
  30. package/src/model/scope-markers.ts +144 -0
  31. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  32. package/src/runtime/collab/checkpoint-election.ts +75 -0
  33. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  34. package/src/runtime/collab/checkpoint-store.ts +115 -0
  35. package/src/runtime/collab/event-types.ts +27 -0
  36. package/src/runtime/collab/index.ts +22 -0
  37. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  38. package/src/runtime/collab/runtime-collab-sync.ts +279 -0
  39. package/src/runtime/document-runtime.ts +214 -16
  40. package/src/runtime/editor-surface/capabilities.ts +63 -50
  41. package/src/runtime/layout/layout-engine-version.ts +8 -1
  42. package/src/runtime/prerender/cache-envelope.ts +19 -7
  43. package/src/runtime/prerender/cache-key.ts +25 -14
  44. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  45. package/src/runtime/prerender/customxml-cache.ts +211 -0
  46. package/src/runtime/prerender/customxml-probe.ts +78 -0
  47. package/src/runtime/prerender/prerender-document.ts +74 -7
  48. package/src/runtime/scope-resolver.ts +148 -0
  49. package/src/runtime/scope-tag-registry.ts +10 -0
  50. package/src/runtime/surface-projection.ts +8 -1
  51. package/src/ui/WordReviewEditor.tsx +30 -0
  52. package/src/ui/editor-runtime-boundary.ts +6 -1
  53. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.47",
4
+ "version": "1.0.48",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "packageManager": "pnpm@10.30.3",
7
7
  "type": "module",
@@ -1697,6 +1697,38 @@ export interface AddCommentReplyResult {
1697
1697
  entryId: string;
1698
1698
  }
1699
1699
 
1700
+ /**
1701
+ * S1 — Scope marker anchoring. Parameters for `WordReviewEditorRef.addScope`.
1702
+ * The runtime inserts a pair of zero-width `scope_marker_*` inline nodes at
1703
+ * the anchor's range boundaries so PM handles position bookkeeping through
1704
+ * text edits structurally (no remap-on-mutation required).
1705
+ */
1706
+ export interface AddScopeParams {
1707
+ /** Range anchor (typically `getRenderSnapshot().selection.activeRange`). */
1708
+ anchor: EditorAnchorProjection;
1709
+ /** Optional explicit scopeId; runtime mints one when omitted. */
1710
+ scopeId?: string;
1711
+ /** Workflow mode the scope enters (default `"comment"`). */
1712
+ mode?: WorkflowScopeMode;
1713
+ /** 3-mode persistence (A/B/C). Defaults to the overlay default. */
1714
+ persistence?: WorkflowMetadataPersistence;
1715
+ /**
1716
+ * Optional metadata payload. Persistence mode decides whether this is
1717
+ * held runtime-only, split across doc + workblock, or fully embedded.
1718
+ */
1719
+ metadata?: Partial<WorkflowMetadataEntry>;
1720
+ /** Non-main-body stories (footnote / header / endnote). */
1721
+ storyTarget?: EditorStoryTarget;
1722
+ /** Optional display label for the scope card / rail. */
1723
+ label?: string;
1724
+ }
1725
+
1726
+ export interface AddScopeResult {
1727
+ scopeId: string;
1728
+ /** Range anchor derived from the just-inserted marker positions. */
1729
+ anchor: EditorAnchorProjection;
1730
+ }
1731
+
1700
1732
  export interface ExportDocxOptions {
1701
1733
  fileName?: string;
1702
1734
  reason?: string;
@@ -2803,6 +2835,24 @@ export interface WordReviewEditorRef {
2803
2835
  addCommentReply(commentId: string, body: string): AddCommentReplyResult;
2804
2836
  editCommentBody(commentId: string, body: string): void;
2805
2837
  deleteComment(commentId: string): void;
2838
+ /**
2839
+ * S1 — Insert a workflow scope anchored by marker nodes. Returns
2840
+ * `{scopeId, anchor}` where the anchor is derived from the newly-inserted
2841
+ * markers' positions. Subsequent text edits inside / around / through the
2842
+ * scope update the anchor structurally (no remap bookkeeping).
2843
+ */
2844
+ addScope(params: AddScopeParams): AddScopeResult;
2845
+ /**
2846
+ * S1 — Resolve a scopeId to a live `WorkflowScope` with an anchor derived
2847
+ * from the marker positions currently in the document. Returns null when
2848
+ * the scope has been removed or was never added.
2849
+ */
2850
+ getScope(scopeId: string): WorkflowScope | null;
2851
+ /**
2852
+ * S1 — Remove a scope's markers from the document and drop the scope's
2853
+ * metadata record. No-op when the scopeId is unknown.
2854
+ */
2855
+ removeScope(scopeId: string): void;
2806
2856
  acceptChange(changeId: string): void;
2807
2857
  rejectChange(changeId: string): void;
2808
2858
  acceptAllChanges(): void;
@@ -3231,11 +3281,75 @@ export interface WordReviewEditorProps {
3231
3281
  * `shortcut.zoom-reset`.
3232
3282
  */
3233
3283
  onZoomRequested?: (direction: "in" | "out" | "reset") => void;
3284
+ /**
3285
+ * Optional: fires when the user invokes Replace (Ctrl+H) with the
3286
+ * editor focused. When wired, the editor calls this callback and
3287
+ * suppresses the browser's native Find-and-Replace fallback; when
3288
+ * omitted, the shortcut falls through to the browser. The callback
3289
+ * receives the current selection so the host can pre-populate its
3290
+ * Replace panel with the selected text.
3291
+ *
3292
+ * Capability id: `shortcut.replace`.
3293
+ */
3294
+ onReplaceRequested?: (context: ShortcutDelegationContext) => void;
3295
+ /**
3296
+ * Optional: fires when the user invokes Go To (Ctrl+G, Cmd+Option+G,
3297
+ * or F5) with the editor focused. When wired, the editor calls this
3298
+ * callback and suppresses the browser's native navigation; when
3299
+ * omitted, the shortcut falls through to the browser default. Use
3300
+ * the context's `selectionRange` to scope navigation (e.g.,
3301
+ * "go to the same clause in a sibling document").
3302
+ *
3303
+ * Capability id: `shortcut.go-to`.
3304
+ */
3305
+ onGoToRequested?: (context: ShortcutDelegationContext) => void;
3306
+ /**
3307
+ * Optional: fires when the user invokes Check Spelling (F7) with
3308
+ * the editor focused. The mounted editor does not ship a built-in
3309
+ * spell checker; when wired, this callback hands control off so
3310
+ * the host can open its own spell-check UI (or invoke a native
3311
+ * browser spellcheck). When omitted, F7 falls through to the
3312
+ * browser default.
3313
+ *
3314
+ * Capability id: `shortcut.spell`.
3315
+ */
3316
+ onSpellRequested?: (context: ShortcutDelegationContext) => void;
3317
+ /**
3318
+ * Optional: fires when the user invokes Thesaurus (Shift+F7). When
3319
+ * wired, the host opens its own thesaurus panel, typically seeded
3320
+ * with the currently-selected word from `context.selectionText`.
3321
+ * When omitted, Shift+F7 falls through to the browser default.
3322
+ *
3323
+ * Capability id: `shortcut.thesaurus`.
3324
+ */
3325
+ onThesaurusRequested?: (context: ShortcutDelegationContext) => void;
3326
+ /**
3327
+ * Optional: fires when the user invokes Extend-selection mode
3328
+ * (F8 in Word; repeated presses extend by word / sentence / paragraph).
3329
+ * The mounted editor does not today implement the F8 state machine;
3330
+ * when wired, the host owns the state-machine + UI feedback. When
3331
+ * omitted, F8 falls through to the browser default.
3332
+ *
3333
+ * Capability id: `shortcut.extend-selection`.
3334
+ */
3335
+ onExtendSelectionRequested?: (context: ShortcutDelegationContext) => void;
3336
+ /**
3337
+ * Optional: fires when the user invokes Return-to-last-edit
3338
+ * (Shift+F5). The mounted editor does not today track the
3339
+ * last-edit location; when wired, the host owns the history stack
3340
+ * and performs the scroll/selection move. When omitted, Shift+F5
3341
+ * falls through to the browser default.
3342
+ *
3343
+ * Capability id: `shortcut.last-edit`.
3344
+ */
3345
+ onLastEditRequested?: (context: ShortcutDelegationContext) => void;
3234
3346
  }
3235
3347
 
3236
3348
  /**
3237
3349
  * Selection context handed to host-delegated shortcut callbacks
3238
- * (`onFindRequested`, future `onReplaceRequested`, etc.) so the host
3350
+ * (`onFindRequested`, `onReplaceRequested`, `onGoToRequested`,
3351
+ * `onSpellRequested`, `onThesaurusRequested`,
3352
+ * `onExtendSelectionRequested`, `onLastEditRequested`) so the host
3239
3353
  * can pre-populate its own UI with the user's current selection.
3240
3354
  *
3241
3355
  * - `selectionText` is truncated to the first 500 characters —
@@ -515,6 +515,8 @@ function getInlineLength(node: InlineNode): number {
515
515
  case "footnote_ref":
516
516
  case "bookmark_start":
517
517
  case "bookmark_end":
518
+ case "scope_marker_start":
519
+ case "scope_marker_end":
518
520
  case "chart_preview":
519
521
  case "smartart_preview":
520
522
  case "shape":
@@ -566,6 +568,8 @@ function getInlineDisplayText(node: InlineNode): string {
566
568
  return "[Footnote]";
567
569
  case "bookmark_start":
568
570
  case "bookmark_end":
571
+ case "scope_marker_start":
572
+ case "scope_marker_end":
569
573
  return "";
570
574
  case "chart_preview":
571
575
  return "[Chart]";
@@ -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
+ }
@@ -1048,6 +1048,8 @@ function inlineNodeLength(node: InlineNode): number {
1048
1048
  );
1049
1049
  case "bookmark_start":
1050
1050
  case "bookmark_end":
1051
+ case "scope_marker_start":
1052
+ case "scope_marker_end":
1051
1053
  return 0;
1052
1054
  }
1053
1055
  }
@@ -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.length,
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
 
@@ -3,7 +3,9 @@ import type { TransactionMapping } from "../selection/mapping.ts";
3
3
  import {
4
4
  cloneParagraphProperties,
5
5
  cloneStoryUnit,
6
+ countLogicalPositions,
6
7
  createPlainText,
8
+ logicalPositionToUnitIndex,
7
9
  parseTextStory,
8
10
  serializeTextStory,
9
11
  type ParagraphProperties,
@@ -117,12 +119,18 @@ function applyLinearTextTransaction(
117
119
  const normalizedRange = resolveRange(selection, story.size, intent);
118
120
  const insertionUnits = createInsertionUnits(intent, story, normalizedRange.from);
119
121
 
120
- ensureEditableRange(story.units.slice(normalizedRange.from, normalizedRange.to));
122
+ // `normalizedRange.{from,to}` are logical positions (scope markers = 0 width,
123
+ // matching surface-projection). Translate to unit-array indices so scope
124
+ // marker units preserved at the boundary stay intact on either side.
125
+ const unitFrom = logicalPositionToUnitIndex(story.units, normalizedRange.from, "before");
126
+ const unitTo = logicalPositionToUnitIndex(story.units, normalizedRange.to, "after");
127
+
128
+ ensureEditableRange(story.units.slice(unitFrom, unitTo));
121
129
 
122
130
  const nextUnits = [
123
- ...story.units.slice(0, normalizedRange.from).map(cloneStoryUnit),
131
+ ...story.units.slice(0, unitFrom).map(cloneStoryUnit),
124
132
  ...insertionUnits.map(cloneStoryUnit),
125
- ...story.units.slice(normalizedRange.to).map(cloneStoryUnit),
133
+ ...story.units.slice(unitTo).map(cloneStoryUnit),
126
134
  ];
127
135
 
128
136
  const nextStory: TextStory = {
@@ -130,9 +138,13 @@ function applyLinearTextTransaction(
130
138
  units: normalizeStoryUnits(nextUnits),
131
139
  size: 0,
132
140
  };
133
- nextStory.size = nextStory.units.length;
141
+ nextStory.size = countLogicalPositions(nextStory.units);
134
142
 
135
- const caret = normalizedRange.from + insertionUnits.length;
143
+ // `normalizedRange.from` is the logical insertion point; count the logical
144
+ // positions added by `insertionUnits` (skipping any scope markers) to derive
145
+ // the post-insert caret.
146
+ const logicalInsertionSize = countLogicalPositions(insertionUnits);
147
+ const caret = normalizedRange.from + logicalInsertionSize;
136
148
 
137
149
  return {
138
150
  document: {
@@ -214,6 +214,33 @@ function extractPartTextFromPackage(pkg: OpcPackage, path: string): string | und
214
214
  }
215
215
  }
216
216
 
217
+ /**
218
+ * Build a chart-part lookup callback suitable for
219
+ * `parseMainDocumentXml(..., chartPartLookup)`.
220
+ *
221
+ * The callback is called synchronously during parsing with a chart
222
+ * relationship id (the `r:id` on a `<c:chart>` reference). It resolves
223
+ * the id to a chart-part target path via the document's relationship
224
+ * table, then decodes the matching package part's bytes as UTF-8. Unknown
225
+ * ids and missing parts return undefined, in which case the parser
226
+ * proceeds without a typed `ChartModel` (the drawing still produces a
227
+ * `ChartPreviewNode` with `rawXml`).
228
+ */
229
+ export function createChartPartLookup(
230
+ pkg: OpcPackage,
231
+ documentPartPath: string,
232
+ documentRelationships: readonly import("./ooxml/part-manifest.ts").OpcRelationship[],
233
+ ): (rId: string) => string | undefined {
234
+ const relById = new Map(documentRelationships.map((r) => [r.id, r]));
235
+ return (rId: string): string | undefined => {
236
+ const rel = relById.get(rId);
237
+ if (!rel) return undefined;
238
+ const target = resolveRelationshipTarget(documentPartPath, rel);
239
+ if (!target) return undefined;
240
+ return extractPartTextFromPackage(pkg, normalizePartPath(target));
241
+ };
242
+ }
243
+
217
244
  /**
218
245
  * Produce a new CanonicalDocument with the resolved chart_preview
219
246
  * nodes carrying previewMediaId + corresponding MediaCatalog entries.