@beyondwork/docx-react-component 1.0.46 → 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/commands/index.ts +120 -1
- 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 +219 -2
- 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/workflow-payload.ts +27 -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 +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 +37 -5
- 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 +404 -1
- package/src/runtime/document-runtime.ts +221 -16
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/layout/layout-engine-version.ts +27 -2
- 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/runtime/text-ack-range.ts +3 -3
- 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
|
+
}
|
|
@@ -34,7 +34,10 @@ import {
|
|
|
34
34
|
outdentParagraphAtSelection,
|
|
35
35
|
splitParagraph,
|
|
36
36
|
} from "./text-commands.ts";
|
|
37
|
-
import type {
|
|
37
|
+
import type {
|
|
38
|
+
BlockNode,
|
|
39
|
+
RevisionRecord as CanonicalRevisionRecord,
|
|
40
|
+
} from "../../model/canonical-document.ts";
|
|
38
41
|
import { remapCommentThreads } from "../../review/store/comment-remapping.ts";
|
|
39
42
|
import { collectScopeTagTouches } from "../../review/store/scope-tag-diff.ts";
|
|
40
43
|
import { applyRevisionRuntimeCommand } from "../../runtime/revision-runtime.ts";
|
|
@@ -86,6 +89,15 @@ import {
|
|
|
86
89
|
import { insertPageBreak, insertTable } from "./text-commands.ts";
|
|
87
90
|
import type { TableSelectionDescriptor } from "../../runtime/table-commands.ts";
|
|
88
91
|
|
|
92
|
+
export type ContentChildrenPatch =
|
|
93
|
+
| { kind: "keep-all" }
|
|
94
|
+
| { kind: "replace-all"; children: BlockNode[] }
|
|
95
|
+
| {
|
|
96
|
+
kind: "index-map";
|
|
97
|
+
baseLength: number;
|
|
98
|
+
entries: ReadonlyArray<{ index: number; block: BlockNode }>;
|
|
99
|
+
};
|
|
100
|
+
|
|
89
101
|
export interface CommandOrigin {
|
|
90
102
|
source:
|
|
91
103
|
| "keyboard"
|
|
@@ -112,6 +124,15 @@ export type EditorCommand =
|
|
|
112
124
|
protectionSelection?: SelectionSnapshot;
|
|
113
125
|
origin?: CommandOrigin;
|
|
114
126
|
}
|
|
127
|
+
| {
|
|
128
|
+
type: "document.patch";
|
|
129
|
+
patch: Partial<CanonicalDocumentEnvelope>;
|
|
130
|
+
contentChildren?: ContentChildrenPatch;
|
|
131
|
+
mapping?: TransactionMapping;
|
|
132
|
+
selection?: SelectionSnapshot;
|
|
133
|
+
protectionSelection?: SelectionSnapshot;
|
|
134
|
+
origin?: CommandOrigin;
|
|
135
|
+
}
|
|
115
136
|
| {
|
|
116
137
|
type: "text.insert";
|
|
117
138
|
text: string;
|
|
@@ -482,6 +503,53 @@ export function executeEditorCommand(
|
|
|
482
503
|
},
|
|
483
504
|
);
|
|
484
505
|
}
|
|
506
|
+
case "document.patch": {
|
|
507
|
+
const mapping = command.mapping ?? createEmptyMapping();
|
|
508
|
+
const nextDocument = applyDocumentPatch(
|
|
509
|
+
state.document,
|
|
510
|
+
command.patch,
|
|
511
|
+
command.contentChildren,
|
|
512
|
+
);
|
|
513
|
+
const selection =
|
|
514
|
+
command.selection ?? remapSelection(state.selection, mapping);
|
|
515
|
+
const reviewState = remapReviewStateAfterContentChange(
|
|
516
|
+
state,
|
|
517
|
+
nextDocument,
|
|
518
|
+
mapping,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
return createTransaction(
|
|
522
|
+
{
|
|
523
|
+
...state,
|
|
524
|
+
document: reviewState.document,
|
|
525
|
+
selection,
|
|
526
|
+
warnings: reviewState.warnings,
|
|
527
|
+
runtime: {
|
|
528
|
+
...state.runtime,
|
|
529
|
+
activeCommentId: reviewState.activeCommentId,
|
|
530
|
+
},
|
|
531
|
+
compatibility: {
|
|
532
|
+
...state.compatibility,
|
|
533
|
+
generatedAt: context.timestamp,
|
|
534
|
+
warnings: reviewState.warnings,
|
|
535
|
+
featureEntries: state.compatibility.featureEntries.map((entry) =>
|
|
536
|
+
entry.affectedAnchor
|
|
537
|
+
? {
|
|
538
|
+
...entry,
|
|
539
|
+
affectedAnchor: mapAnchor(entry.affectedAnchor, mapping),
|
|
540
|
+
}
|
|
541
|
+
: entry,
|
|
542
|
+
),
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
historyBoundary: "push",
|
|
547
|
+
markDirty: true,
|
|
548
|
+
mapping,
|
|
549
|
+
effects: reviewState.effects,
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
}
|
|
485
553
|
case "text.insert": {
|
|
486
554
|
const suggestingResult = context.documentMode === "suggesting"
|
|
487
555
|
? applySuggestingInsert(state, command.text, context)
|
|
@@ -1141,6 +1209,57 @@ export function executeEditorCommand(
|
|
|
1141
1209
|
}
|
|
1142
1210
|
}
|
|
1143
1211
|
|
|
1212
|
+
export function applyDocumentPatch(
|
|
1213
|
+
base: CanonicalDocumentEnvelope,
|
|
1214
|
+
patch: Partial<CanonicalDocumentEnvelope>,
|
|
1215
|
+
contentChildren?: ContentChildrenPatch,
|
|
1216
|
+
): CanonicalDocumentEnvelope {
|
|
1217
|
+
const nextContent = resolvePatchedContent(base, patch, contentChildren);
|
|
1218
|
+
const merged: CanonicalDocumentEnvelope = { ...base, ...patch };
|
|
1219
|
+
if (nextContent) {
|
|
1220
|
+
merged.content = nextContent;
|
|
1221
|
+
}
|
|
1222
|
+
return merged;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function resolvePatchedContent(
|
|
1226
|
+
base: CanonicalDocumentEnvelope,
|
|
1227
|
+
patch: Partial<CanonicalDocumentEnvelope>,
|
|
1228
|
+
contentChildren: ContentChildrenPatch | undefined,
|
|
1229
|
+
): CanonicalDocumentEnvelope["content"] | null {
|
|
1230
|
+
if (!contentChildren) {
|
|
1231
|
+
return patch.content ?? null;
|
|
1232
|
+
}
|
|
1233
|
+
if (contentChildren.kind === "keep-all") {
|
|
1234
|
+
return { ...base.content, children: base.content.children };
|
|
1235
|
+
}
|
|
1236
|
+
if (contentChildren.kind === "replace-all") {
|
|
1237
|
+
return {
|
|
1238
|
+
...(patch.content ?? base.content),
|
|
1239
|
+
children: contentChildren.children,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
const baseChildren = base.content.children;
|
|
1243
|
+
if (contentChildren.baseLength !== baseChildren.length) {
|
|
1244
|
+
throw new Error(
|
|
1245
|
+
`applyDocumentPatch: index-map baseLength ${contentChildren.baseLength} does not match current children length ${baseChildren.length}`,
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
const nextChildren: BlockNode[] = baseChildren.slice();
|
|
1249
|
+
for (const entry of contentChildren.entries) {
|
|
1250
|
+
if (entry.index < 0 || entry.index >= nextChildren.length) {
|
|
1251
|
+
throw new Error(
|
|
1252
|
+
`applyDocumentPatch: index-map entry index ${entry.index} out of range [0, ${nextChildren.length})`,
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
nextChildren[entry.index] = entry.block;
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
...(patch.content ?? base.content),
|
|
1259
|
+
children: nextChildren,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1144
1263
|
function buildDocumentReplaceTransaction(
|
|
1145
1264
|
state: EditorState,
|
|
1146
1265
|
context: CommandExecutionContext,
|