@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.
- package/package.json +1 -1
- package/src/api/public-types.ts +115 -1
- 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/schema/text-schema.ts +95 -1
- package/src/core/state/text-transaction.ts +17 -5
- package/src/io/chart-preview-resolver.ts +27 -0
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +37 -0
- 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/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
- package/src/io/ooxml/chart/parse-series.ts +570 -0
- package/src/io/ooxml/chart/resolve-color.ts +251 -0
- package/src/io/ooxml/chart/types.ts +420 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +87 -2
- package/src/io/ooxml/parse-main-document.ts +115 -1
- 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/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +94 -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 +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +279 -0
- package/src/runtime/document-runtime.ts +214 -16
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- 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 +8 -1
- package/src/ui/WordReviewEditor.tsx +30 -0
- package/src/ui/editor-runtime-boundary.ts +6 -1
- 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.
|
|
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",
|
package/src/api/public-types.ts
CHANGED
|
@@ -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`,
|
|
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
|
+
}
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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,
|
|
131
|
+
...story.units.slice(0, unitFrom).map(cloneStoryUnit),
|
|
124
132
|
...insertionUnits.map(cloneStoryUnit),
|
|
125
|
-
...story.units.slice(
|
|
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
|
|
141
|
+
nextStory.size = countLogicalPositions(nextStory.units);
|
|
134
142
|
|
|
135
|
-
|
|
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.
|