@beyondwork/docx-react-component 1.0.49 → 1.0.51
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 +8 -2
- package/package.json +1 -1
- package/src/api/public-types.ts +20 -1
- package/src/core/commands/index.ts +21 -0
- package/src/io/export/serialize-comments.ts +50 -5
- package/src/io/paste/word-clipboard.ts +114 -0
- package/src/runtime/document-runtime.ts +24 -1
- package/src/runtime/layout/layout-engine-version.ts +52 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- package/src/runtime/render/index.ts +7 -0
- package/src/runtime/render/render-frame-diff.ts +298 -0
- package/src/runtime/render/render-frame-types.ts +22 -1
- package/src/runtime/render/render-kernel.ts +80 -12
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/structure-ops/fragment-insert.ts +134 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui/WordReviewEditor.tsx +4 -2
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +344 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +344 -0
- package/src/ui-tailwind/chart/render/number-format.ts +287 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R.1 Phase 6a — SelectionLayer cursor primitives.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over `(DocumentRootNode, CursorSelection, op) → CursorSelection`.
|
|
5
|
+
* `extend: true` keeps the anchor fixed and moves only the head — the
|
|
6
|
+
* LibreOffice `SwPaM` head/anchor split that matches Shift+arrow semantics.
|
|
7
|
+
*
|
|
8
|
+
* Positions are 0-based logical positions per the canonical story layer
|
|
9
|
+
* (`createPlainText(parseTextStory(doc))`). Scope markers are zero-width;
|
|
10
|
+
* paragraph breaks are 1 wide; text / tab / hard_break / image / opaque are 1
|
|
11
|
+
* wide. The plain-text string produced by `createPlainText` is a 1:1 mapping
|
|
12
|
+
* of those logical positions to characters, which makes position-math trivial
|
|
13
|
+
* and makes `Intl.Segmenter` (for word boundaries) a drop-in fit.
|
|
14
|
+
*
|
|
15
|
+
* What this module deliberately does NOT ship yet:
|
|
16
|
+
* - `moveUp` / `moveDown` — genuinely layout-dependent (need column tracking
|
|
17
|
+
* + line-wrap info). Follows Phase 6b once Lane 3a P9 exposes the per-run
|
|
18
|
+
* layout facet needed for column-preserving movement.
|
|
19
|
+
* - `moveLineStart` / `moveLineEnd` — same; these need soft-wrap info that
|
|
20
|
+
* the canonical story layer does not expose.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createPlainText, parseTextStory } from "../../core/schema/text-schema.ts";
|
|
24
|
+
import type { DocumentRootNode } from "../../model/canonical-document.ts";
|
|
25
|
+
|
|
26
|
+
export interface CursorSelection {
|
|
27
|
+
anchor: number;
|
|
28
|
+
head: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CursorMoveOptions {
|
|
32
|
+
/**
|
|
33
|
+
* When true, the anchor stays fixed and only the head moves — matches
|
|
34
|
+
* Shift+arrow "extend selection" semantics. When false (default), both
|
|
35
|
+
* anchor and head land at the new head, collapsing any range selection.
|
|
36
|
+
*/
|
|
37
|
+
extend?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function moveCharLeft(
|
|
41
|
+
doc: DocumentRootNode,
|
|
42
|
+
selection: CursorSelection,
|
|
43
|
+
options: CursorMoveOptions = {},
|
|
44
|
+
): CursorSelection {
|
|
45
|
+
const text = documentPlainText(doc);
|
|
46
|
+
// Word semantics (no-extend): a range selection collapses to the LEFT edge
|
|
47
|
+
// without moving; only a collapsed caret actually decrements by one.
|
|
48
|
+
if (!options.extend && selection.anchor !== selection.head) {
|
|
49
|
+
const leftEdge = Math.min(selection.anchor, selection.head);
|
|
50
|
+
return { anchor: leftEdge, head: leftEdge };
|
|
51
|
+
}
|
|
52
|
+
const currentHead = selection.head;
|
|
53
|
+
const nextHead = Math.max(0, currentHead - 1);
|
|
54
|
+
return finalize(selection, nextHead, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function moveCharRight(
|
|
58
|
+
doc: DocumentRootNode,
|
|
59
|
+
selection: CursorSelection,
|
|
60
|
+
options: CursorMoveOptions = {},
|
|
61
|
+
): CursorSelection {
|
|
62
|
+
const text = documentPlainText(doc);
|
|
63
|
+
// Word semantics (no-extend): a range selection collapses to the RIGHT edge
|
|
64
|
+
// without moving; only a collapsed caret actually increments by one.
|
|
65
|
+
if (!options.extend && selection.anchor !== selection.head) {
|
|
66
|
+
const rightEdge = Math.max(selection.anchor, selection.head);
|
|
67
|
+
return { anchor: rightEdge, head: rightEdge };
|
|
68
|
+
}
|
|
69
|
+
const currentHead = selection.head;
|
|
70
|
+
const nextHead = Math.min(text.length, currentHead + 1);
|
|
71
|
+
return finalize(selection, nextHead, options);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function moveParagraphStart(
|
|
75
|
+
doc: DocumentRootNode,
|
|
76
|
+
selection: CursorSelection,
|
|
77
|
+
options: CursorMoveOptions = {},
|
|
78
|
+
): CursorSelection {
|
|
79
|
+
const text = documentPlainText(doc);
|
|
80
|
+
const head = options.extend ? selection.head : selection.head;
|
|
81
|
+
// Walk backward until we find a paragraph break ('\n') or reach 0. The
|
|
82
|
+
// paragraph start is the character immediately after the break.
|
|
83
|
+
let cursor = Math.max(0, Math.min(text.length, head));
|
|
84
|
+
while (cursor > 0 && text[cursor - 1] !== "\n") {
|
|
85
|
+
cursor -= 1;
|
|
86
|
+
}
|
|
87
|
+
return finalize(selection, cursor, options);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function moveParagraphEnd(
|
|
91
|
+
doc: DocumentRootNode,
|
|
92
|
+
selection: CursorSelection,
|
|
93
|
+
options: CursorMoveOptions = {},
|
|
94
|
+
): CursorSelection {
|
|
95
|
+
const text = documentPlainText(doc);
|
|
96
|
+
const head = options.extend ? selection.head : selection.head;
|
|
97
|
+
let cursor = Math.max(0, Math.min(text.length, head));
|
|
98
|
+
while (cursor < text.length && text[cursor] !== "\n") {
|
|
99
|
+
cursor += 1;
|
|
100
|
+
}
|
|
101
|
+
return finalize(selection, cursor, options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `Intl.Segmenter` with `granularity: "word"` returns ICU word boundaries —
|
|
106
|
+
* the standards-backed equivalent of LibreOffice's `SwBreakIt`. We walk the
|
|
107
|
+
* boundary list (cached per-`doc` call) to find the next / previous
|
|
108
|
+
* word-start relative to the head, matching Word's Ctrl+arrow behavior.
|
|
109
|
+
*
|
|
110
|
+
* Word semantics:
|
|
111
|
+
* - Ctrl+Right: jump to the end of the current word, OR to the end of the
|
|
112
|
+
* next word if already at word-end. In practice we land on the next
|
|
113
|
+
* word-boundary strictly greater than the current head.
|
|
114
|
+
* - Ctrl+Left: jump to the start of the current word, OR to the start of
|
|
115
|
+
* the previous word if already at word-start. We land on the previous
|
|
116
|
+
* word-boundary strictly less than the current head.
|
|
117
|
+
*/
|
|
118
|
+
export function moveWordRight(
|
|
119
|
+
doc: DocumentRootNode,
|
|
120
|
+
selection: CursorSelection,
|
|
121
|
+
options: CursorMoveOptions = {},
|
|
122
|
+
): CursorSelection {
|
|
123
|
+
const text = documentPlainText(doc);
|
|
124
|
+
const currentHead = options.extend
|
|
125
|
+
? selection.head
|
|
126
|
+
: Math.max(selection.anchor, selection.head);
|
|
127
|
+
const boundaries = wordBoundaries(text);
|
|
128
|
+
const nextBoundary =
|
|
129
|
+
boundaries.find((pos) => pos > currentHead) ?? text.length;
|
|
130
|
+
return finalize(selection, nextBoundary, options);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function moveWordLeft(
|
|
134
|
+
doc: DocumentRootNode,
|
|
135
|
+
selection: CursorSelection,
|
|
136
|
+
options: CursorMoveOptions = {},
|
|
137
|
+
): CursorSelection {
|
|
138
|
+
const text = documentPlainText(doc);
|
|
139
|
+
const currentHead = options.extend
|
|
140
|
+
? selection.head
|
|
141
|
+
: Math.min(selection.anchor, selection.head);
|
|
142
|
+
const boundaries = wordBoundaries(text);
|
|
143
|
+
let prevBoundary = 0;
|
|
144
|
+
for (const pos of boundaries) {
|
|
145
|
+
if (pos >= currentHead) break;
|
|
146
|
+
prevBoundary = pos;
|
|
147
|
+
}
|
|
148
|
+
return finalize(selection, prevBoundary, options);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finalize(
|
|
152
|
+
selection: CursorSelection,
|
|
153
|
+
head: number,
|
|
154
|
+
options: CursorMoveOptions,
|
|
155
|
+
): CursorSelection {
|
|
156
|
+
if (options.extend) {
|
|
157
|
+
return { anchor: selection.anchor, head };
|
|
158
|
+
}
|
|
159
|
+
return { anchor: head, head };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function documentPlainText(doc: DocumentRootNode): string {
|
|
163
|
+
const story = parseTextStory(doc);
|
|
164
|
+
return createPlainText(story);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `Intl.Segmenter` is available in Node 16+ and all modern browsers. We lazy-
|
|
169
|
+
* init the default-locale instance; word segmentation is stateless, so one
|
|
170
|
+
* segmenter per process is fine.
|
|
171
|
+
*/
|
|
172
|
+
let cachedSegmenter: Intl.Segmenter | undefined;
|
|
173
|
+
function wordSegmenter(): Intl.Segmenter {
|
|
174
|
+
if (!cachedSegmenter) {
|
|
175
|
+
cachedSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
176
|
+
}
|
|
177
|
+
return cachedSegmenter;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Produce the sorted list of word-boundary positions in `text`. A boundary is
|
|
182
|
+
* the start of any segment whose `isWordLike` is true, plus the end-of-text.
|
|
183
|
+
* Whitespace and punctuation segments are skipped so caret jumps land on
|
|
184
|
+
* word endpoints rather than stopping at every space — matching Word's
|
|
185
|
+
* Ctrl+arrow behavior.
|
|
186
|
+
*/
|
|
187
|
+
function wordBoundaries(text: string): number[] {
|
|
188
|
+
const segments = wordSegmenter().segment(text);
|
|
189
|
+
const boundaries: number[] = [];
|
|
190
|
+
for (const segment of segments) {
|
|
191
|
+
if (segment.isWordLike) {
|
|
192
|
+
// Add both the start and the end of each word-like segment so
|
|
193
|
+
// Ctrl+Right from "hello|world" (between two words) advances to the end
|
|
194
|
+
// of "world" rather than stopping at the start.
|
|
195
|
+
boundaries.push(segment.index);
|
|
196
|
+
boundaries.push(segment.index + segment.segment.length);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Dedup + sort so callers can binary-search or linear-walk deterministically.
|
|
200
|
+
boundaries.push(text.length);
|
|
201
|
+
return Array.from(new Set(boundaries)).sort((a, b) => a - b);
|
|
202
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R.1 SelectionLayer — named module for cursor movement, validation, and
|
|
3
|
+
* anchor projection. See `docs/plans/lane-1-editing-foundation.md` §R.1.
|
|
4
|
+
*
|
|
5
|
+
* This file is the layer's single entry point. Callers consume
|
|
6
|
+
* `SelectionLayer.move(doc, sel, op)` / `SelectionLayer.validate(doc, sel)`
|
|
7
|
+
* rather than reaching into individual helpers. Phase 6a ships the six
|
|
8
|
+
* layout-independent primitives (char/word/paragraph × left/right). Phase 6b
|
|
9
|
+
* adds `moveUp` / `moveDown` / `moveLineStart` / `moveLineEnd` once Lane 3a
|
|
10
|
+
* P9's layout facet exposes per-run column info.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DocumentRootNode } from "../../model/canonical-document.ts";
|
|
14
|
+
import type { CanonicalDocumentEnvelope, SelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
15
|
+
import {
|
|
16
|
+
moveCharLeft,
|
|
17
|
+
moveCharRight,
|
|
18
|
+
moveParagraphEnd,
|
|
19
|
+
moveParagraphStart,
|
|
20
|
+
moveWordLeft,
|
|
21
|
+
moveWordRight,
|
|
22
|
+
type CursorMoveOptions,
|
|
23
|
+
type CursorSelection,
|
|
24
|
+
} from "./cursor-ops.ts";
|
|
25
|
+
import { validateSelectionAgainstDocument } from "./post-edit-validator.ts";
|
|
26
|
+
|
|
27
|
+
export type {
|
|
28
|
+
CursorMoveOptions,
|
|
29
|
+
CursorSelection,
|
|
30
|
+
} from "./cursor-ops.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cursor-move operations supported by the layer. Phase 6b will extend this
|
|
34
|
+
* with `"up" | "down" | "line-start" | "line-end"`.
|
|
35
|
+
*/
|
|
36
|
+
export type CursorMoveOp =
|
|
37
|
+
| "char-left"
|
|
38
|
+
| "char-right"
|
|
39
|
+
| "word-left"
|
|
40
|
+
| "word-right"
|
|
41
|
+
| "paragraph-start"
|
|
42
|
+
| "paragraph-end";
|
|
43
|
+
|
|
44
|
+
export interface SelectionLayer {
|
|
45
|
+
/**
|
|
46
|
+
* Apply a cursor-move operation and return the resulting selection. Pure;
|
|
47
|
+
* does not mutate any argument.
|
|
48
|
+
*/
|
|
49
|
+
move(
|
|
50
|
+
doc: DocumentRootNode,
|
|
51
|
+
selection: CursorSelection,
|
|
52
|
+
op: CursorMoveOp,
|
|
53
|
+
options?: CursorMoveOptions,
|
|
54
|
+
): CursorSelection;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run the I5 post-edit selection validator. Snaps orphaned offsets into the
|
|
58
|
+
* nearest valid position. Pure; returns the (possibly adjusted) selection.
|
|
59
|
+
*/
|
|
60
|
+
validate(
|
|
61
|
+
doc: CanonicalDocumentEnvelope,
|
|
62
|
+
selection: SelectionSnapshot,
|
|
63
|
+
maxOffset: number,
|
|
64
|
+
): SelectionSnapshot;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Default SelectionLayer instance. Stateless — the same instance is safe to
|
|
69
|
+
* share across runtimes.
|
|
70
|
+
*/
|
|
71
|
+
export const selectionLayer: SelectionLayer = {
|
|
72
|
+
move(doc, selection, op, options) {
|
|
73
|
+
switch (op) {
|
|
74
|
+
case "char-left":
|
|
75
|
+
return moveCharLeft(doc, selection, options);
|
|
76
|
+
case "char-right":
|
|
77
|
+
return moveCharRight(doc, selection, options);
|
|
78
|
+
case "word-left":
|
|
79
|
+
return moveWordLeft(doc, selection, options);
|
|
80
|
+
case "word-right":
|
|
81
|
+
return moveWordRight(doc, selection, options);
|
|
82
|
+
case "paragraph-start":
|
|
83
|
+
return moveParagraphStart(doc, selection, options);
|
|
84
|
+
case "paragraph-end":
|
|
85
|
+
return moveParagraphEnd(doc, selection, options);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
validate(doc, selection, maxOffset) {
|
|
89
|
+
return validateSelectionAgainstDocument(doc, selection, maxOffset);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 1 of the I2 Tier B rich-paste sub-plan (`docs/plans/lane-1-i2-tier-b-rich-paste.md`).
|
|
3
|
+
*
|
|
4
|
+
* `applyFragmentInsert` is the canonical splicer for `CanonicalDocumentFragment` —
|
|
5
|
+
* the block-level payload that the HTML and Word-clipboard paste parsers (Slices 2+3)
|
|
6
|
+
* will produce. Slice 1 ships the shape and a baseline "split-and-splice" semantic
|
|
7
|
+
* without any parser in front of it, so the public `insertFragment` method can be
|
|
8
|
+
* driven directly from tests + future hosts.
|
|
9
|
+
*
|
|
10
|
+
* Baseline semantics:
|
|
11
|
+
* 1. Empty fragment → no-op (no revisionToken bump).
|
|
12
|
+
* 2. Range selection → the range is deleted first via `applyTextTransaction`, then
|
|
13
|
+
* the caret is the range start.
|
|
14
|
+
* 3. Caret paragraph is split via `splitParagraph`; fragment blocks are spliced
|
|
15
|
+
* between the two halves. Empty halves at document boundaries are preserved —
|
|
16
|
+
* callers can trim them if desired.
|
|
17
|
+
*
|
|
18
|
+
* What Slice 1 deliberately does NOT do:
|
|
19
|
+
* - Merge-intent (pre-Slice-1 drafts had `firstParagraphMergeIntent` on the type).
|
|
20
|
+
* Merge semantics will land in a follow-up slice once we have a paste fixture
|
|
21
|
+
* that demonstrates the need.
|
|
22
|
+
* - Fragment insertion inside table cells beyond the trivial case. Table-cell
|
|
23
|
+
* splicing is currently best-effort: the target paragraph within the cell is
|
|
24
|
+
* split, but cross-cell fragments are rejected as a no-op.
|
|
25
|
+
* - Comment/revision remapping across the fragment boundary — the splicer returns
|
|
26
|
+
* an empty mapping; follow-up slices will produce richer mappings.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { CanonicalDocumentFragment } from "../../api/public-types.ts";
|
|
30
|
+
import {
|
|
31
|
+
type CanonicalDocumentEnvelope,
|
|
32
|
+
type SelectionSnapshot,
|
|
33
|
+
createSelectionSnapshot,
|
|
34
|
+
} from "../../core/state/editor-state.ts";
|
|
35
|
+
import { applyTextTransaction } from "../../core/state/text-transaction.ts";
|
|
36
|
+
import { splitParagraph, type TextCommandContext } from "../../core/commands/text-commands.ts";
|
|
37
|
+
import { resolveParagraphScope, type StructuralMutationResult } from "../../core/commands/structural-helpers.ts";
|
|
38
|
+
import type { BlockNode, DocumentRootNode, ParagraphNode } from "../../model/canonical-document.ts";
|
|
39
|
+
import { createEmptyMapping } from "../../core/selection/mapping.ts";
|
|
40
|
+
|
|
41
|
+
export function applyFragmentInsert(
|
|
42
|
+
document: CanonicalDocumentEnvelope,
|
|
43
|
+
selection: SelectionSnapshot,
|
|
44
|
+
fragment: CanonicalDocumentFragment,
|
|
45
|
+
context: TextCommandContext,
|
|
46
|
+
): StructuralMutationResult {
|
|
47
|
+
if (fragment.blocks.length === 0) {
|
|
48
|
+
return {
|
|
49
|
+
changed: false,
|
|
50
|
+
document,
|
|
51
|
+
selection,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collapse any range selection by first deleting the selected content. The
|
|
56
|
+
// resulting caret is at `min(anchor, head)`.
|
|
57
|
+
let workingDocument = document;
|
|
58
|
+
let workingSelection = selection;
|
|
59
|
+
if (selection.anchor !== selection.head) {
|
|
60
|
+
const collapseResult = applyTextTransaction(
|
|
61
|
+
workingDocument,
|
|
62
|
+
workingSelection,
|
|
63
|
+
{ type: "replace", insertion: [] },
|
|
64
|
+
context,
|
|
65
|
+
);
|
|
66
|
+
workingDocument = collapseResult.document;
|
|
67
|
+
workingSelection = collapseResult.selection;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Split the caret paragraph; the fragment blocks go between the two halves.
|
|
71
|
+
const splitResult = splitParagraph(workingDocument, workingSelection, context);
|
|
72
|
+
const splitRoot = splitResult.document.content;
|
|
73
|
+
if (!splitRoot || splitRoot.type !== "doc") {
|
|
74
|
+
return {
|
|
75
|
+
changed: false,
|
|
76
|
+
document,
|
|
77
|
+
selection,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Locate the split boundary by re-resolving the paragraph scope against the
|
|
82
|
+
// pre-split snapshot. The right-half index = scope.blockIndex + 1.
|
|
83
|
+
const scope = resolveParagraphScope(workingDocument, workingSelection);
|
|
84
|
+
if (!scope || scope.kind !== "top-level") {
|
|
85
|
+
// Table-cell fragment insert is out of scope for Slice 1.
|
|
86
|
+
return {
|
|
87
|
+
changed: false,
|
|
88
|
+
document,
|
|
89
|
+
selection,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rightHalfIndex = scope.blockIndex + 1;
|
|
94
|
+
const splicedChildren: BlockNode[] = [
|
|
95
|
+
...splitRoot.children.slice(0, rightHalfIndex),
|
|
96
|
+
...fragment.blocks.map((block) => cloneBlock(block)),
|
|
97
|
+
...splitRoot.children.slice(rightHalfIndex),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const nextRoot: DocumentRootNode = {
|
|
101
|
+
...splitRoot,
|
|
102
|
+
children: splicedChildren,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const nextDocument: CanonicalDocumentEnvelope = {
|
|
106
|
+
...splitResult.document,
|
|
107
|
+
updatedAt: context.timestamp,
|
|
108
|
+
content: nextRoot,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Caret lands at the end of the last fragment block. For Slice 1 we approximate
|
|
112
|
+
// this with a collapsed selection at position 0 of the new document — richer
|
|
113
|
+
// caret placement follows once parsers drive this.
|
|
114
|
+
const nextSelection = createSelectionSnapshot(0, 0);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
changed: true,
|
|
118
|
+
document: nextDocument,
|
|
119
|
+
selection: nextSelection,
|
|
120
|
+
mapping: createEmptyMapping(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cloneBlock(block: BlockNode): BlockNode {
|
|
125
|
+
// Slice 1 uses a structural clone; no need to deep-clone formatting attrs since
|
|
126
|
+
// fragment blocks are presumed freshly minted by the caller.
|
|
127
|
+
if (block.type === "paragraph") {
|
|
128
|
+
return {
|
|
129
|
+
...block,
|
|
130
|
+
children: block.children.map((child) => ({ ...child })),
|
|
131
|
+
} as ParagraphNode;
|
|
132
|
+
}
|
|
133
|
+
return JSON.parse(JSON.stringify(block)) as BlockNode;
|
|
134
|
+
}
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
resolveNumberingMarkerRunFormatting,
|
|
53
53
|
} from "./paragraph-style-resolver.ts";
|
|
54
54
|
import { resolveHyperlinkRunFormatting } from "./hyperlink-color-resolver.ts";
|
|
55
|
+
import { concretizeThemeColors } from "./theme-color-resolver.ts";
|
|
55
56
|
import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
|
|
56
57
|
|
|
57
58
|
interface ParagraphAccumulator {
|
|
@@ -874,6 +875,11 @@ function appendInlineSegments(
|
|
|
874
875
|
characterStyleId: undefined,
|
|
875
876
|
direct: directRunFormatting,
|
|
876
877
|
};
|
|
878
|
+
// L2.c — non-hyperlink body text also gets theme-color concretization
|
|
879
|
+
// so paragraph styles declaring `<w:color w:themeColor="accent1"/>`
|
|
880
|
+
// render with their theme slot's hex instead of falling back to
|
|
881
|
+
// default black. Hyperlink branch already resolves theme via the
|
|
882
|
+
// Hyperlink-style cascade; only the non-hyperlink path was missing.
|
|
877
883
|
const resolvedRunFormatting = cullBuild
|
|
878
884
|
? {}
|
|
879
885
|
: hyperlinkHref
|
|
@@ -882,7 +888,10 @@ function appendInlineSegments(
|
|
|
882
888
|
document.styles,
|
|
883
889
|
document.subParts?.resolvedTheme,
|
|
884
890
|
)
|
|
885
|
-
:
|
|
891
|
+
: concretizeThemeColors(
|
|
892
|
+
resolveEffectiveRunFormatting(runResolveInput, document.styles),
|
|
893
|
+
document.subParts?.resolvedTheme,
|
|
894
|
+
);
|
|
886
895
|
paragraph.segments.push({
|
|
887
896
|
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
888
897
|
kind: "text",
|
|
@@ -57,6 +57,52 @@ export function resolveThemeColorHex(
|
|
|
57
57
|
return applyThemeTintShade(baseHex, rPr.colorThemeTint, rPr.colorThemeShade);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Post-process a cascade of run formatting to concretize any
|
|
62
|
+
* theme-color-only reference into a paintable `colorHex`.
|
|
63
|
+
*
|
|
64
|
+
* L2.c body-text call site: `surface-projection.ts` runs the standard
|
|
65
|
+
* `resolveEffectiveRunFormatting` cascade, then pipes the result through
|
|
66
|
+
* this function with the document's resolved theme. Runs that declared
|
|
67
|
+
* `<w:color w:themeColor="accent1"/>` without a concrete `w:val` end up
|
|
68
|
+
* with `colorHex` set to the theme-resolved hex so downstream
|
|
69
|
+
* `pm-state-from-snapshot` can paint them.
|
|
70
|
+
*
|
|
71
|
+
* The theme-slot fields (`colorThemeSlot` / `Tint` / `Shade`) stay on
|
|
72
|
+
* the cascade so that the export path can re-emit the original theme
|
|
73
|
+
* reference — this function never overwrites them, it only adds
|
|
74
|
+
* `colorHex` when it was absent or `"auto"`.
|
|
75
|
+
*
|
|
76
|
+
* No-ops when:
|
|
77
|
+
* - no theme-slot is declared (nothing to resolve),
|
|
78
|
+
* - `colorHex` is already a concrete non-`"auto"` hex (direct wins),
|
|
79
|
+
* - theme is missing or the slot is unknown (graceful fallback),
|
|
80
|
+
* - resolver returns `undefined` (e.g. malformed tint/shade).
|
|
81
|
+
*/
|
|
82
|
+
export function concretizeThemeColors(
|
|
83
|
+
cascade: CanonicalRunFormatting,
|
|
84
|
+
theme: ResolvedTheme | undefined,
|
|
85
|
+
): CanonicalRunFormatting {
|
|
86
|
+
if (!cascade.colorThemeSlot) return cascade;
|
|
87
|
+
if (cascade.colorHex && cascade.colorHex !== "auto") return cascade;
|
|
88
|
+
// When colorHex is "auto" we intentionally bypass it during theme
|
|
89
|
+
// resolution: Word's cascade treats `<w:color w:val="auto"
|
|
90
|
+
// w:themeColor="accent1"/>` as "paint with the theme slot; auto is
|
|
91
|
+
// the fallback if theme is missing." Pass a stripped input to the
|
|
92
|
+
// resolver so `colorHex === "auto"` doesn't short-circuit.
|
|
93
|
+
const resolved = resolveThemeColorHex(
|
|
94
|
+
{
|
|
95
|
+
colorThemeSlot: cascade.colorThemeSlot,
|
|
96
|
+
colorThemeTint: cascade.colorThemeTint,
|
|
97
|
+
colorThemeShade: cascade.colorThemeShade,
|
|
98
|
+
},
|
|
99
|
+
theme,
|
|
100
|
+
);
|
|
101
|
+
if (!resolved || resolved === "auto") return cascade;
|
|
102
|
+
if (resolved === cascade.colorHex) return cascade;
|
|
103
|
+
return { ...cascade, colorHex: resolved };
|
|
104
|
+
}
|
|
105
|
+
|
|
60
106
|
/**
|
|
61
107
|
* Apply `w:themeTint` (shift toward white) and/or `w:themeShade` (shift
|
|
62
108
|
* toward black) to a base hex colour. Pure function.
|
|
@@ -486,7 +486,8 @@ export function __createWordReviewEditorRefBridge(
|
|
|
486
486
|
blur: () => runtime.blur(),
|
|
487
487
|
undo: () => runtime.undo(),
|
|
488
488
|
redo: () => runtime.redo(),
|
|
489
|
-
replaceText: (text, target) => runtime.replaceText(text, target),
|
|
489
|
+
replaceText: (text, target, formatting) => runtime.replaceText(text, target, formatting),
|
|
490
|
+
insertFragment: (fragment, target) => runtime.insertFragment(fragment, target),
|
|
490
491
|
addComment: (params) => runtime.addComment(params),
|
|
491
492
|
openComment: (commentId) => runtime.openComment(commentId),
|
|
492
493
|
resolveComment: (commentId) => runtime.resolveComment(commentId),
|
|
@@ -1476,7 +1477,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1476
1477
|
blur: () => activeRuntime.blur(),
|
|
1477
1478
|
undo: () => activeRuntime.undo(),
|
|
1478
1479
|
redo: () => activeRuntime.redo(),
|
|
1479
|
-
replaceText: (text, target) => activeRuntime.replaceText(text, target),
|
|
1480
|
+
replaceText: (text, target, formatting) => activeRuntime.replaceText(text, target, formatting),
|
|
1481
|
+
insertFragment: (fragment, target) => activeRuntime.insertFragment(fragment, target),
|
|
1480
1482
|
addComment: (params) =>
|
|
1481
1483
|
activeRuntime.addComment({
|
|
1482
1484
|
...params,
|
|
@@ -912,6 +912,7 @@ function createLoadingRuntimeBridge(input: {
|
|
|
912
912
|
getCanonicalDocument: () => input.sessionState.canonicalDocument,
|
|
913
913
|
getSourcePackage: () => input.sessionState.sourcePackage,
|
|
914
914
|
replaceText: () => undefined,
|
|
915
|
+
insertFragment: () => undefined,
|
|
915
916
|
applyActiveStoryTextCommand: () => ({
|
|
916
917
|
kind: "rejected",
|
|
917
918
|
newRevisionToken: "",
|