@beyondwork/docx-react-component 1.0.60 → 1.0.61
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 +33 -44
- package/src/api/public-types.ts +41 -0
- package/src/io/docx-session.ts +167 -8
- package/src/io/export/serialize-footnotes.ts +36 -5
- package/src/io/export/serialize-headers-footers.ts +7 -0
- package/src/io/export/serialize-main-document.ts +25 -18
- package/src/io/export/serialize-paragraph-formatting.ts +6 -0
- package/src/io/export/serialize-settings.ts +130 -3
- package/src/io/normalize/normalize-text.ts +8 -4
- package/src/io/ooxml/parse-footnotes.ts +11 -0
- package/src/io/ooxml/parse-headers-footers.ts +117 -42
- package/src/io/ooxml/parse-main-document.ts +20 -8
- package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
- package/src/io/ooxml/parse-settings.ts +91 -1
- package/src/model/canonical-document.ts +36 -2
- package/src/runtime/document-runtime.ts +424 -0
- package/src/runtime/footnote-resolver.ts +32 -8
- package/src/runtime/layout/layout-engine-version.ts +7 -1
- package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
- package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
- package/src/runtime/layout/paginated-layout-engine.ts +41 -8
- package/src/runtime/layout/resolved-formatting-document.ts +11 -9
- package/src/runtime/layout/resolved-formatting-state.ts +4 -0
- package/src/runtime/numbering-prefix.ts +26 -2
- package/src/runtime/surface-projection.ts +75 -14
- package/src/runtime/table-schema.ts +26 -0
- package/src/ui/WordReviewEditor.tsx +25 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +8 -0
- package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
- package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
|
@@ -132,11 +132,35 @@ function readShading(node: XmlElementNode): ParagraphShading | undefined {
|
|
|
132
132
|
const val = node.attributes["w:val"] ?? node.attributes.val;
|
|
133
133
|
const fill = node.attributes["w:fill"] ?? node.attributes.fill;
|
|
134
134
|
const color = node.attributes["w:color"] ?? node.attributes.color;
|
|
135
|
-
|
|
135
|
+
const themeFill = node.attributes["w:themeFill"] ?? node.attributes.themeFill;
|
|
136
|
+
const themeFillTint = node.attributes["w:themeFillTint"] ?? node.attributes.themeFillTint;
|
|
137
|
+
const themeFillShade = node.attributes["w:themeFillShade"] ?? node.attributes.themeFillShade;
|
|
138
|
+
const themeColor = node.attributes["w:themeColor"] ?? node.attributes.themeColor;
|
|
139
|
+
const themeColorTint = node.attributes["w:themeColorTint"] ?? node.attributes.themeColorTint;
|
|
140
|
+
const themeColorShade = node.attributes["w:themeColorShade"] ?? node.attributes.themeColorShade;
|
|
141
|
+
if (
|
|
142
|
+
!val &&
|
|
143
|
+
!fill &&
|
|
144
|
+
!color &&
|
|
145
|
+
!themeFill &&
|
|
146
|
+
!themeFillTint &&
|
|
147
|
+
!themeFillShade &&
|
|
148
|
+
!themeColor &&
|
|
149
|
+
!themeColorTint &&
|
|
150
|
+
!themeColorShade
|
|
151
|
+
) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
136
154
|
return {
|
|
137
155
|
...(val ? { val } : {}),
|
|
138
156
|
...(fill ? { fill } : {}),
|
|
139
157
|
...(color ? { color } : {}),
|
|
158
|
+
...(themeFill ? { themeFill } : {}),
|
|
159
|
+
...(themeFillTint ? { themeFillTint } : {}),
|
|
160
|
+
...(themeFillShade ? { themeFillShade } : {}),
|
|
161
|
+
...(themeColor ? { themeColor } : {}),
|
|
162
|
+
...(themeColorTint ? { themeColorTint } : {}),
|
|
163
|
+
...(themeColorShade ? { themeColorShade } : {}),
|
|
140
164
|
};
|
|
141
165
|
}
|
|
142
166
|
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ClrSchemeMapping,
|
|
4
4
|
ClrSchemeMappingSlot,
|
|
5
5
|
DocumentSettings,
|
|
6
|
+
FootnoteProperties,
|
|
6
7
|
ThemeColorSlot,
|
|
7
8
|
} from "../../model/canonical-document.ts";
|
|
8
9
|
import { parseXml } from "./xml-parser.ts";
|
|
@@ -51,6 +52,13 @@ export function parseSettingsXml(xml: string): DocumentSettings {
|
|
|
51
52
|
const compatPartition = compat ? partitionCompat(compat) : undefined;
|
|
52
53
|
const rootCompatFlags = readRootCompatFlags(settingsElement);
|
|
53
54
|
const themeFontLangElement = findChildElementOptional(settingsElement, "themeFontLang");
|
|
55
|
+
const defaultTabStop = readDefaultTabStop(settingsElement);
|
|
56
|
+
const footnotePr = readFootnoteLikeProperties(
|
|
57
|
+
findChildElementOptional(settingsElement, "footnotePr"),
|
|
58
|
+
);
|
|
59
|
+
const endnotePr = readFootnoteLikeProperties(
|
|
60
|
+
findChildElementOptional(settingsElement, "endnotePr"),
|
|
61
|
+
);
|
|
54
62
|
const clrSchemeMapping = parseClrSchemeMapping(settingsElement);
|
|
55
63
|
const unmodelled = readUnmodelledSettingsChildren(settingsElement);
|
|
56
64
|
|
|
@@ -71,6 +79,9 @@ export function parseSettingsXml(xml: string): DocumentSettings {
|
|
|
71
79
|
...(themeFontLangElement
|
|
72
80
|
? { themeFontLang: { ...themeFontLangElement.attributes } }
|
|
73
81
|
: {}),
|
|
82
|
+
...(defaultTabStop !== undefined ? { defaultTabStop } : {}),
|
|
83
|
+
...(footnotePr ? { footnotePr } : {}),
|
|
84
|
+
...(endnotePr ? { endnotePr } : {}),
|
|
74
85
|
...(clrSchemeMapping !== undefined ? { clrSchemeMapping } : {}),
|
|
75
86
|
...(unmodelled.length > 0 ? { unmodelledSettingsChildren: unmodelled } : {}),
|
|
76
87
|
};
|
|
@@ -86,6 +97,9 @@ const MODELLED_SETTINGS_CHILD_NAMES = new Set<string>([
|
|
|
86
97
|
"zoom",
|
|
87
98
|
"compat",
|
|
88
99
|
"themeFontLang",
|
|
100
|
+
"defaultTabStop",
|
|
101
|
+
"footnotePr",
|
|
102
|
+
"endnotePr",
|
|
89
103
|
"clrSchemeMapping",
|
|
90
104
|
]);
|
|
91
105
|
|
|
@@ -130,6 +144,83 @@ function readRootCompatFlags(
|
|
|
130
144
|
return flags;
|
|
131
145
|
}
|
|
132
146
|
|
|
147
|
+
function readDefaultTabStop(
|
|
148
|
+
settingsElement: XmlElementNode,
|
|
149
|
+
): number | undefined {
|
|
150
|
+
const element = findChildElementOptional(settingsElement, "defaultTabStop");
|
|
151
|
+
if (!element) return undefined;
|
|
152
|
+
const rawValue = element.attributes["w:val"] ?? element.attributes.val;
|
|
153
|
+
if (!rawValue) return undefined;
|
|
154
|
+
const parsed = Number.parseInt(rawValue, 10);
|
|
155
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readFootnoteLikeProperties(
|
|
159
|
+
element: XmlElementNode | undefined,
|
|
160
|
+
): FootnoteProperties | undefined {
|
|
161
|
+
if (!element) return undefined;
|
|
162
|
+
|
|
163
|
+
const result: FootnoteProperties = {};
|
|
164
|
+
for (const child of element.children) {
|
|
165
|
+
if (child.type !== "element") continue;
|
|
166
|
+
const name = localName(child.name);
|
|
167
|
+
const val = child.attributes["w:val"] ?? child.attributes.val;
|
|
168
|
+
|
|
169
|
+
if (name === "pos") {
|
|
170
|
+
if (
|
|
171
|
+
val === "pageBottom" ||
|
|
172
|
+
val === "beneathText" ||
|
|
173
|
+
val === "sectEnd" ||
|
|
174
|
+
val === "docEnd"
|
|
175
|
+
) {
|
|
176
|
+
result.pos = val;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (name === "numFmt") {
|
|
182
|
+
if (
|
|
183
|
+
val === "decimal" ||
|
|
184
|
+
val === "upperRoman" ||
|
|
185
|
+
val === "lowerRoman" ||
|
|
186
|
+
val === "upperLetter" ||
|
|
187
|
+
val === "lowerLetter" ||
|
|
188
|
+
val === "ordinal" ||
|
|
189
|
+
val === "cardinalText" ||
|
|
190
|
+
val === "ordinalText" ||
|
|
191
|
+
val === "hex" ||
|
|
192
|
+
val === "chicago" ||
|
|
193
|
+
val === "bullet" ||
|
|
194
|
+
val === "ideographDigital" ||
|
|
195
|
+
val === "japaneseCounting" ||
|
|
196
|
+
val === "arabicAbjad" ||
|
|
197
|
+
val === "arabicAlpha" ||
|
|
198
|
+
val === "none"
|
|
199
|
+
) {
|
|
200
|
+
result.numFmt = val;
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (name === "numStart") {
|
|
206
|
+
const parsed = Number.parseInt(val ?? "", 10);
|
|
207
|
+
if (Number.isFinite(parsed)) {
|
|
208
|
+
result.numStart = Math.max(1, parsed);
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
name === "numRestart" &&
|
|
215
|
+
(val === "continuous" || val === "eachSect" || val === "eachPage")
|
|
216
|
+
) {
|
|
217
|
+
result.numRestart = val;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
133
224
|
interface CompatPartition {
|
|
134
225
|
compatSettings: CompatSetting[];
|
|
135
226
|
compatFlags: Record<string, boolean>;
|
|
@@ -218,4 +309,3 @@ function readZoomLevel(
|
|
|
218
309
|
|
|
219
310
|
return { zoomLevel: parsed };
|
|
220
311
|
}
|
|
221
|
-
|
|
@@ -346,14 +346,20 @@ export interface FootnoteDefinition {
|
|
|
346
346
|
* footnote/endnote entries with `w:type="separator"` and
|
|
347
347
|
* `w:type="continuationSeparator"`.
|
|
348
348
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
349
|
+
* The canonical model keeps both the legacy run-only payload and the full
|
|
350
|
+
* first-paragraph XML. The paragraph form closes round-trip fidelity for
|
|
351
|
+
* imported separator paragraph properties, while the run form remains for
|
|
352
|
+
* back-compat with the current note-separator runtime helpers.
|
|
351
353
|
*/
|
|
352
354
|
export interface FootnoteSeparators {
|
|
353
355
|
/** Raw XML of the <w:r> children inside the separator paragraph. */
|
|
354
356
|
separatorContent?: string;
|
|
357
|
+
/** Full XML of the first separator paragraph. */
|
|
358
|
+
separatorParagraphXml?: string;
|
|
355
359
|
/** Raw XML of the <w:r> children inside the continuationSeparator paragraph. */
|
|
356
360
|
continuationSeparatorContent?: string;
|
|
361
|
+
/** Full XML of the first continuation-separator paragraph. */
|
|
362
|
+
continuationSeparatorParagraphXml?: string;
|
|
357
363
|
}
|
|
358
364
|
|
|
359
365
|
export interface FootnoteCollection {
|
|
@@ -399,6 +405,22 @@ export interface CompatSetting {
|
|
|
399
405
|
export interface DocumentSettings {
|
|
400
406
|
evenAndOddHeaders?: boolean;
|
|
401
407
|
zoomLevel?: "pageWidth" | "onePage" | number;
|
|
408
|
+
/**
|
|
409
|
+
* Document-wide default tab stop interval from `<w:defaultTabStop w:val>`.
|
|
410
|
+
* Value is stored in twips and feeds layout measurement whenever a paragraph
|
|
411
|
+
* does not declare an explicit next tab stop.
|
|
412
|
+
*/
|
|
413
|
+
defaultTabStop?: number;
|
|
414
|
+
/**
|
|
415
|
+
* Settings-level default footnote configuration from `<w:footnotePr>`.
|
|
416
|
+
* Section-level `SectionProperties.footnotePr` overrides this when present.
|
|
417
|
+
*/
|
|
418
|
+
footnotePr?: FootnoteProperties;
|
|
419
|
+
/**
|
|
420
|
+
* Settings-level default endnote configuration from `<w:endnotePr>`.
|
|
421
|
+
* Section-level `SectionProperties.endnotePr` overrides this when present.
|
|
422
|
+
*/
|
|
423
|
+
endnotePr?: EndnoteProperties;
|
|
402
424
|
/**
|
|
403
425
|
* Ordered list of <w:compatSetting> entries inside <w:compat>. Insertion
|
|
404
426
|
* order is preserved for serializer diff stability.
|
|
@@ -606,6 +628,18 @@ export interface ParagraphShading {
|
|
|
606
628
|
fill?: string;
|
|
607
629
|
color?: string;
|
|
608
630
|
val?: string;
|
|
631
|
+
/**
|
|
632
|
+
* Theme shading references (§17.3.5). When `themeFill` is set and `fill`
|
|
633
|
+
* is absent or `"auto"`, render-time shading resolves through the theme
|
|
634
|
+
* color resolver. The raw theme attrs remain on the canonical model so
|
|
635
|
+
* export can round-trip the original `<w:shd>` byte-for-byte.
|
|
636
|
+
*/
|
|
637
|
+
themeFill?: string;
|
|
638
|
+
themeFillTint?: string;
|
|
639
|
+
themeFillShade?: string;
|
|
640
|
+
themeColor?: string;
|
|
641
|
+
themeColorTint?: string;
|
|
642
|
+
themeColorShade?: string;
|
|
609
643
|
}
|
|
610
644
|
|
|
611
645
|
/** Body of an OOXML `<w:rPr>` (run properties). All fields optional; absence = "not specified at this level". */
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
AddCommentResult,
|
|
24
24
|
AddScopeParams,
|
|
25
25
|
AddScopeResult,
|
|
26
|
+
ClearHighlightOptions,
|
|
26
27
|
CommentSidebarSnapshot,
|
|
27
28
|
CommentSidebarThreadSnapshot,
|
|
28
29
|
CompatibilityReport,
|
|
@@ -159,6 +160,7 @@ import {
|
|
|
159
160
|
insertScopeMarkers,
|
|
160
161
|
removeScopeMarkers,
|
|
161
162
|
} from "../core/commands/add-scope.ts";
|
|
163
|
+
import { applyFormattingOperationToDocument } from "../core/commands/formatting-commands.ts";
|
|
162
164
|
import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
|
|
163
165
|
import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
|
|
164
166
|
import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
|
|
@@ -481,6 +483,22 @@ export interface DocumentRuntime {
|
|
|
481
483
|
rejectChange(changeId: string): void;
|
|
482
484
|
acceptAllChanges(): void;
|
|
483
485
|
rejectAllChanges(): void;
|
|
486
|
+
/**
|
|
487
|
+
* Clears the highlight (background color) on a range in the currently
|
|
488
|
+
* active story. The caller's selection is NOT moved.
|
|
489
|
+
*
|
|
490
|
+
* When `options.range` is omitted, the current document selection is used.
|
|
491
|
+
*
|
|
492
|
+
* Tracked-changes / suggesting mode is honored: in suggesting mode the
|
|
493
|
+
* clear is recorded as an `rPrChange` property-change suggestion when the
|
|
494
|
+
* resolved range is a single bounded text segment, and is reported via
|
|
495
|
+
* `command_blocked` when the range spans multiple segments.
|
|
496
|
+
*
|
|
497
|
+
* When `options.expandToFullHighlight` is `true`, the resolved range is
|
|
498
|
+
* grown outward before clearing to cover the entire contiguous highlighted
|
|
499
|
+
* span it touches. Expansion stops at paragraph and table-cell boundaries.
|
|
500
|
+
*/
|
|
501
|
+
clearHighlight(options?: ClearHighlightOptions): void;
|
|
484
502
|
openStory(target: EditorStoryTarget): boolean;
|
|
485
503
|
closeStory(): void;
|
|
486
504
|
getActiveStory(): EditorStoryTarget;
|
|
@@ -3705,6 +3723,143 @@ export function createDocumentRuntime(
|
|
|
3705
3723
|
origin: createOrigin("api", clock()),
|
|
3706
3724
|
});
|
|
3707
3725
|
},
|
|
3726
|
+
clearHighlight(options) {
|
|
3727
|
+
const resolvedRange = resolveClearHighlightRange(
|
|
3728
|
+
options?.range,
|
|
3729
|
+
state.selection,
|
|
3730
|
+
);
|
|
3731
|
+
if (!resolvedRange) {
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
if (viewState.documentMode === "viewing") {
|
|
3735
|
+
this.emitBlockedCommand("clearHighlight", [{
|
|
3736
|
+
code: "document_viewing_mode",
|
|
3737
|
+
message: "Cannot clear highlight in viewing mode.",
|
|
3738
|
+
}]);
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
const surfaceBlocks = getActiveStorySurfaceBlocks();
|
|
3743
|
+
if (!surfaceBlocks) {
|
|
3744
|
+
return;
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
const inputFrom = Math.min(resolvedRange.from, resolvedRange.to);
|
|
3748
|
+
const inputTo = Math.max(resolvedRange.from, resolvedRange.to);
|
|
3749
|
+
let targetFrom = inputFrom;
|
|
3750
|
+
let targetTo = inputTo;
|
|
3751
|
+
if (options?.expandToFullHighlight === true) {
|
|
3752
|
+
const expanded = expandRangeToHighlightExtent(
|
|
3753
|
+
surfaceBlocks,
|
|
3754
|
+
inputFrom,
|
|
3755
|
+
inputTo,
|
|
3756
|
+
);
|
|
3757
|
+
targetFrom = expanded.from;
|
|
3758
|
+
targetTo = expanded.to;
|
|
3759
|
+
}
|
|
3760
|
+
if (targetFrom === targetTo) {
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
const activeStoryDocument =
|
|
3765
|
+
activeStory.kind === "main"
|
|
3766
|
+
? state.document
|
|
3767
|
+
: {
|
|
3768
|
+
...state.document,
|
|
3769
|
+
content: {
|
|
3770
|
+
type: "doc" as const,
|
|
3771
|
+
children: [...getStoryBlocks(state.document, activeStory)],
|
|
3772
|
+
},
|
|
3773
|
+
};
|
|
3774
|
+
const syntheticSnapshot: RuntimeRenderSnapshot = {
|
|
3775
|
+
...cachedRenderSnapshot,
|
|
3776
|
+
...(activeStory.kind === "main" ? {} : { activeStory: MAIN_STORY_TARGET }),
|
|
3777
|
+
selection: {
|
|
3778
|
+
anchor: targetFrom,
|
|
3779
|
+
head: targetTo,
|
|
3780
|
+
isCollapsed: false,
|
|
3781
|
+
activeRange: {
|
|
3782
|
+
kind: "range",
|
|
3783
|
+
from: targetFrom,
|
|
3784
|
+
to: targetTo,
|
|
3785
|
+
assoc: { start: -1, end: 1 },
|
|
3786
|
+
},
|
|
3787
|
+
},
|
|
3788
|
+
};
|
|
3789
|
+
|
|
3790
|
+
const suggesting =
|
|
3791
|
+
getEffectiveDocumentMode(state.selection) === "suggesting";
|
|
3792
|
+
if (suggesting) {
|
|
3793
|
+
if (activeStory.kind !== "main") {
|
|
3794
|
+
this.emitBlockedCommand("clearHighlight", [{
|
|
3795
|
+
code: "suggesting_unsupported",
|
|
3796
|
+
message: `"clearHighlight" is not supported in suggesting mode for this story.`,
|
|
3797
|
+
}]);
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
const segment = findSingleSelectedTextSegment(syntheticSnapshot);
|
|
3801
|
+
if (!segment) {
|
|
3802
|
+
this.emitBlockedCommand("clearHighlight", [{
|
|
3803
|
+
code: "suggesting_unsupported",
|
|
3804
|
+
message: `"clearHighlight" requires one bounded text segment in suggesting mode.`,
|
|
3805
|
+
}]);
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
const beforeXml = buildRunPropertyBeforeXml(segment);
|
|
3809
|
+
const mutation = applyFormattingOperationToDocument(
|
|
3810
|
+
activeStoryDocument,
|
|
3811
|
+
syntheticSnapshot,
|
|
3812
|
+
{ type: "set-highlight-color", color: null },
|
|
3813
|
+
);
|
|
3814
|
+
if (!mutation.changed) {
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
const timestamp = clock();
|
|
3818
|
+
const nextDocument = appendPropertyChangeSuggestion(
|
|
3819
|
+
mutation.document,
|
|
3820
|
+
{ from: segment.from, to: segment.to },
|
|
3821
|
+
{
|
|
3822
|
+
originalRevisionType: "rPrChange",
|
|
3823
|
+
xmlTag: "rPrChange",
|
|
3824
|
+
beforeXml,
|
|
3825
|
+
semanticKind: "formatting-change",
|
|
3826
|
+
storyTarget: activeStory,
|
|
3827
|
+
authorId: defaultAuthorId ?? undefined,
|
|
3828
|
+
},
|
|
3829
|
+
timestamp,
|
|
3830
|
+
);
|
|
3831
|
+
this.dispatch({
|
|
3832
|
+
type: "document.replace",
|
|
3833
|
+
document: nextDocument,
|
|
3834
|
+
mapping: createEmptyMapping(),
|
|
3835
|
+
origin: createOrigin("api", timestamp),
|
|
3836
|
+
});
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
const result = applyFormattingOperationToDocument(
|
|
3841
|
+
activeStoryDocument,
|
|
3842
|
+
syntheticSnapshot,
|
|
3843
|
+
{ type: "set-highlight-color", color: null },
|
|
3844
|
+
);
|
|
3845
|
+
if (!result.changed) {
|
|
3846
|
+
return;
|
|
3847
|
+
}
|
|
3848
|
+
const nextDocument =
|
|
3849
|
+
activeStory.kind === "main"
|
|
3850
|
+
? result.document
|
|
3851
|
+
: replaceStoryBlocks(
|
|
3852
|
+
state.document,
|
|
3853
|
+
activeStory,
|
|
3854
|
+
result.document.content.children,
|
|
3855
|
+
);
|
|
3856
|
+
this.dispatch({
|
|
3857
|
+
type: "document.replace",
|
|
3858
|
+
document: nextDocument,
|
|
3859
|
+
mapping: createEmptyMapping(),
|
|
3860
|
+
origin: createOrigin("api", clock()),
|
|
3861
|
+
});
|
|
3862
|
+
},
|
|
3708
3863
|
openStory(target) {
|
|
3709
3864
|
const normalizedTarget =
|
|
3710
3865
|
target.kind === "header" || target.kind === "footer"
|
|
@@ -3815,6 +3970,7 @@ export function createDocumentRuntime(
|
|
|
3815
3970
|
collection,
|
|
3816
3971
|
collectSectionPropertiesInOrder(state.document),
|
|
3817
3972
|
state.document,
|
|
3973
|
+
state.document.subParts?.settings,
|
|
3818
3974
|
);
|
|
3819
3975
|
},
|
|
3820
3976
|
layout: layoutFacet,
|
|
@@ -7468,3 +7624,271 @@ async function upgradeMeasurementProvider(
|
|
|
7468
7624
|
// fall through — the empirical backend remains in place
|
|
7469
7625
|
}
|
|
7470
7626
|
}
|
|
7627
|
+
|
|
7628
|
+
function rangesOverlap(
|
|
7629
|
+
leftFrom: number,
|
|
7630
|
+
leftTo: number,
|
|
7631
|
+
rightFrom: number,
|
|
7632
|
+
rightTo: number,
|
|
7633
|
+
): boolean {
|
|
7634
|
+
return leftFrom < rightTo && rightFrom < leftTo;
|
|
7635
|
+
}
|
|
7636
|
+
|
|
7637
|
+
function resolveClearHighlightRange(
|
|
7638
|
+
inputRange: EditorAnchorProjection | undefined,
|
|
7639
|
+
selection: import("../core/state/editor-state.ts").SelectionSnapshot,
|
|
7640
|
+
): { from: number; to: number } | null {
|
|
7641
|
+
if (inputRange !== undefined) {
|
|
7642
|
+
if (inputRange.kind !== "range") return null;
|
|
7643
|
+
return { from: inputRange.from, to: inputRange.to };
|
|
7644
|
+
}
|
|
7645
|
+
const active = selection.activeRange;
|
|
7646
|
+
if (active.kind !== "range") return null;
|
|
7647
|
+
return { from: active.range.from, to: active.range.to };
|
|
7648
|
+
}
|
|
7649
|
+
|
|
7650
|
+
function expandRangeToHighlightExtent(
|
|
7651
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
7652
|
+
inputFrom: number,
|
|
7653
|
+
inputTo: number,
|
|
7654
|
+
): { from: number; to: number } {
|
|
7655
|
+
let from = inputFrom;
|
|
7656
|
+
let to = inputTo;
|
|
7657
|
+
forEachParagraphBlock(blocks, (paragraph) => {
|
|
7658
|
+
if (!rangesOverlap(from, to, paragraph.from, paragraph.to)) {
|
|
7659
|
+
return;
|
|
7660
|
+
}
|
|
7661
|
+
const textSegments = paragraph.segments.filter(
|
|
7662
|
+
(segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
|
|
7663
|
+
segment.kind === "text",
|
|
7664
|
+
);
|
|
7665
|
+
const expanded = expandRangeWithinParagraph(textSegments, inputFrom, inputTo);
|
|
7666
|
+
if (expanded.from < from) from = expanded.from;
|
|
7667
|
+
if (expanded.to > to) to = expanded.to;
|
|
7668
|
+
});
|
|
7669
|
+
return { from, to };
|
|
7670
|
+
}
|
|
7671
|
+
|
|
7672
|
+
function expandRangeWithinParagraph(
|
|
7673
|
+
segments: Array<Extract<SurfaceInlineSegment, { kind: "text" }>>,
|
|
7674
|
+
inputFrom: number,
|
|
7675
|
+
inputTo: number,
|
|
7676
|
+
): { from: number; to: number } {
|
|
7677
|
+
let from = inputFrom;
|
|
7678
|
+
let to = inputTo;
|
|
7679
|
+
let touchedLeftIndex = -1;
|
|
7680
|
+
let touchedRightIndex = -1;
|
|
7681
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
7682
|
+
const segment = segments[i]!;
|
|
7683
|
+
if (!isHighlightedSegment(segment)) continue;
|
|
7684
|
+
if (rangesOverlap(inputFrom, inputTo, segment.from, segment.to)) {
|
|
7685
|
+
if (touchedLeftIndex === -1) touchedLeftIndex = i;
|
|
7686
|
+
touchedRightIndex = i;
|
|
7687
|
+
}
|
|
7688
|
+
}
|
|
7689
|
+
if (touchedLeftIndex === -1) {
|
|
7690
|
+
return { from, to };
|
|
7691
|
+
}
|
|
7692
|
+
for (let i = touchedLeftIndex; i >= 0; i -= 1) {
|
|
7693
|
+
const segment = segments[i]!;
|
|
7694
|
+
if (!isHighlightedSegment(segment)) break;
|
|
7695
|
+
if (segment.to < from) break;
|
|
7696
|
+
if (segment.from < from) from = segment.from;
|
|
7697
|
+
}
|
|
7698
|
+
for (let i = touchedRightIndex; i < segments.length; i += 1) {
|
|
7699
|
+
const segment = segments[i]!;
|
|
7700
|
+
if (!isHighlightedSegment(segment)) break;
|
|
7701
|
+
if (segment.from > to) break;
|
|
7702
|
+
if (segment.to > to) to = segment.to;
|
|
7703
|
+
}
|
|
7704
|
+
return { from, to };
|
|
7705
|
+
}
|
|
7706
|
+
|
|
7707
|
+
function isHighlightedSegment(
|
|
7708
|
+
segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
|
|
7709
|
+
): boolean {
|
|
7710
|
+
const bg = segment.markAttrs?.backgroundColor;
|
|
7711
|
+
return typeof bg === "string" && bg.length > 0;
|
|
7712
|
+
}
|
|
7713
|
+
|
|
7714
|
+
function forEachParagraphBlock(
|
|
7715
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
7716
|
+
visit: (paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>) => void,
|
|
7717
|
+
): void {
|
|
7718
|
+
for (const block of blocks) {
|
|
7719
|
+
if (block.kind === "paragraph") {
|
|
7720
|
+
visit(block);
|
|
7721
|
+
continue;
|
|
7722
|
+
}
|
|
7723
|
+
if (block.kind === "table") {
|
|
7724
|
+
for (const row of block.rows) {
|
|
7725
|
+
for (const cell of row.cells) {
|
|
7726
|
+
forEachParagraphBlock(cell.content, visit);
|
|
7727
|
+
}
|
|
7728
|
+
}
|
|
7729
|
+
continue;
|
|
7730
|
+
}
|
|
7731
|
+
if (block.kind === "sdt_block") {
|
|
7732
|
+
forEachParagraphBlock(block.children, visit);
|
|
7733
|
+
}
|
|
7734
|
+
}
|
|
7735
|
+
}
|
|
7736
|
+
|
|
7737
|
+
function findSingleSelectedTextSegment(
|
|
7738
|
+
snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
|
|
7739
|
+
): Extract<SurfaceInlineSegment, { kind: "text" }> | null {
|
|
7740
|
+
if (
|
|
7741
|
+
!snapshot.surface ||
|
|
7742
|
+
snapshot.selection.activeRange.kind !== "range" ||
|
|
7743
|
+
snapshot.selection.isCollapsed
|
|
7744
|
+
) {
|
|
7745
|
+
return null;
|
|
7746
|
+
}
|
|
7747
|
+
const selectionFrom = Math.min(snapshot.selection.anchor, snapshot.selection.head);
|
|
7748
|
+
const selectionTo = Math.max(snapshot.selection.anchor, snapshot.selection.head);
|
|
7749
|
+
const segments = collectSelectedTextSegments(
|
|
7750
|
+
snapshot.surface.blocks,
|
|
7751
|
+
selectionFrom,
|
|
7752
|
+
selectionTo,
|
|
7753
|
+
);
|
|
7754
|
+
if (segments.length !== 1) {
|
|
7755
|
+
return null;
|
|
7756
|
+
}
|
|
7757
|
+
const [segment] = segments;
|
|
7758
|
+
if (!segment || segment.from !== selectionFrom || segment.to !== selectionTo) {
|
|
7759
|
+
return null;
|
|
7760
|
+
}
|
|
7761
|
+
return segment;
|
|
7762
|
+
}
|
|
7763
|
+
|
|
7764
|
+
function collectSelectedTextSegments(
|
|
7765
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
7766
|
+
selectionFrom: number,
|
|
7767
|
+
selectionTo: number,
|
|
7768
|
+
output: Array<Extract<SurfaceInlineSegment, { kind: "text" }>> = [],
|
|
7769
|
+
): Array<Extract<SurfaceInlineSegment, { kind: "text" }>> {
|
|
7770
|
+
for (const block of blocks) {
|
|
7771
|
+
if (block.kind === "paragraph") {
|
|
7772
|
+
for (const segment of block.segments) {
|
|
7773
|
+
if (
|
|
7774
|
+
segment.kind === "text" &&
|
|
7775
|
+
rangesOverlap(selectionFrom, selectionTo, segment.from, segment.to)
|
|
7776
|
+
) {
|
|
7777
|
+
output.push(segment);
|
|
7778
|
+
}
|
|
7779
|
+
}
|
|
7780
|
+
continue;
|
|
7781
|
+
}
|
|
7782
|
+
if (block.kind === "table") {
|
|
7783
|
+
for (const row of block.rows) {
|
|
7784
|
+
for (const cell of row.cells) {
|
|
7785
|
+
collectSelectedTextSegments(cell.content, selectionFrom, selectionTo, output);
|
|
7786
|
+
}
|
|
7787
|
+
}
|
|
7788
|
+
continue;
|
|
7789
|
+
}
|
|
7790
|
+
if (block.kind === "sdt_block") {
|
|
7791
|
+
collectSelectedTextSegments(block.children, selectionFrom, selectionTo, output);
|
|
7792
|
+
}
|
|
7793
|
+
}
|
|
7794
|
+
return output;
|
|
7795
|
+
}
|
|
7796
|
+
|
|
7797
|
+
function buildRunPropertyBeforeXml(
|
|
7798
|
+
segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
|
|
7799
|
+
): string {
|
|
7800
|
+
const parts: string[] = [];
|
|
7801
|
+
const marks = new Set(segment.marks ?? []);
|
|
7802
|
+
if (marks.has("bold")) parts.push("<w:b/>");
|
|
7803
|
+
if (marks.has("italic")) parts.push("<w:i/>");
|
|
7804
|
+
if (marks.has("underline")) parts.push("<w:u w:val=\"single\"/>");
|
|
7805
|
+
if (marks.has("strikethrough")) parts.push("<w:strike/>");
|
|
7806
|
+
if (marks.has("superscript")) parts.push("<w:vertAlign w:val=\"superscript\"/>");
|
|
7807
|
+
if (marks.has("subscript")) parts.push("<w:vertAlign w:val=\"subscript\"/>");
|
|
7808
|
+
if (segment.markAttrs?.fontFamily) {
|
|
7809
|
+
parts.push(
|
|
7810
|
+
`<w:rFonts w:ascii="${escapeAttributeXml(segment.markAttrs.fontFamily)}" w:hAnsi="${escapeAttributeXml(segment.markAttrs.fontFamily)}"/>`,
|
|
7811
|
+
);
|
|
7812
|
+
}
|
|
7813
|
+
if (segment.markAttrs?.fontSize !== undefined) {
|
|
7814
|
+
parts.push(`<w:sz w:val="${segment.markAttrs.fontSize}"/>`);
|
|
7815
|
+
}
|
|
7816
|
+
if (segment.markAttrs?.textColor) {
|
|
7817
|
+
parts.push(`<w:color w:val="${escapeAttributeXml(segment.markAttrs.textColor)}"/>`);
|
|
7818
|
+
}
|
|
7819
|
+
if (segment.markAttrs?.backgroundColor) {
|
|
7820
|
+
parts.push(
|
|
7821
|
+
`<w:shd w:val="clear" w:color="auto" w:fill="${escapeAttributeXml(segment.markAttrs.backgroundColor)}"/>`,
|
|
7822
|
+
);
|
|
7823
|
+
}
|
|
7824
|
+
return `<w:rPr>${parts.join("")}</w:rPr>`;
|
|
7825
|
+
}
|
|
7826
|
+
|
|
7827
|
+
function escapeAttributeXml(value: string): string {
|
|
7828
|
+
return value
|
|
7829
|
+
.replace(/&/g, "&")
|
|
7830
|
+
.replace(/</g, "<")
|
|
7831
|
+
.replace(/>/g, ">")
|
|
7832
|
+
.replace(/"/g, """);
|
|
7833
|
+
}
|
|
7834
|
+
|
|
7835
|
+
function appendPropertyChangeSuggestion(
|
|
7836
|
+
document: CanonicalDocumentEnvelope,
|
|
7837
|
+
anchor: { from: number; to: number },
|
|
7838
|
+
input: {
|
|
7839
|
+
originalRevisionType: "rPrChange" | "pPrChange";
|
|
7840
|
+
xmlTag: "rPrChange" | "pPrChange";
|
|
7841
|
+
beforeXml: string;
|
|
7842
|
+
semanticKind: "formatting-change" | "paragraph-property-change";
|
|
7843
|
+
storyTarget: EditorStoryTarget;
|
|
7844
|
+
authorId?: string;
|
|
7845
|
+
},
|
|
7846
|
+
timestamp: string,
|
|
7847
|
+
): CanonicalDocumentEnvelope {
|
|
7848
|
+
const existing = document.review.revisions;
|
|
7849
|
+
const changeId = createRuntimeSuggestionChangeId(existing, timestamp);
|
|
7850
|
+
const resolvedAuthorId = input.authorId ?? "unknown";
|
|
7851
|
+
return {
|
|
7852
|
+
...document,
|
|
7853
|
+
review: {
|
|
7854
|
+
...document.review,
|
|
7855
|
+
revisions: {
|
|
7856
|
+
...existing,
|
|
7857
|
+
[changeId]: {
|
|
7858
|
+
changeId,
|
|
7859
|
+
kind: "property-change",
|
|
7860
|
+
anchor: createRangeAnchor(anchor.from, anchor.to, { start: 1, end: -1 }),
|
|
7861
|
+
authorId: resolvedAuthorId,
|
|
7862
|
+
createdAt: timestamp,
|
|
7863
|
+
warningIds: [],
|
|
7864
|
+
metadata: {
|
|
7865
|
+
source: "runtime",
|
|
7866
|
+
storyTarget: input.storyTarget,
|
|
7867
|
+
suggestionId: changeId,
|
|
7868
|
+
semanticKind: input.semanticKind,
|
|
7869
|
+
originalRevisionType: input.originalRevisionType,
|
|
7870
|
+
propertyChangeData: {
|
|
7871
|
+
xmlTag: input.xmlTag,
|
|
7872
|
+
beforeXml: input.beforeXml,
|
|
7873
|
+
},
|
|
7874
|
+
},
|
|
7875
|
+
status: "open",
|
|
7876
|
+
},
|
|
7877
|
+
},
|
|
7878
|
+
},
|
|
7879
|
+
};
|
|
7880
|
+
}
|
|
7881
|
+
|
|
7882
|
+
function createRuntimeSuggestionChangeId(
|
|
7883
|
+
existing: CanonicalDocumentEnvelope["review"]["revisions"],
|
|
7884
|
+
timestamp: string,
|
|
7885
|
+
): string {
|
|
7886
|
+
const base = `change-${timestamp.replace(/[^0-9]/gu, "")}`;
|
|
7887
|
+
let counter = Object.keys(existing).length + 1;
|
|
7888
|
+
let candidate = `${base}-p${counter}`;
|
|
7889
|
+
while (existing[candidate]) {
|
|
7890
|
+
counter += 1;
|
|
7891
|
+
candidate = `${base}-p${counter}`;
|
|
7892
|
+
}
|
|
7893
|
+
return candidate;
|
|
7894
|
+
}
|