@beyondwork/docx-react-component 1.0.35 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +84 -1
  5. package/src/core/commands/index.ts +19 -2
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +178 -16
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/session-capabilities.ts +7 -4
  44. package/src/runtime/surface-projection.ts +1 -0
  45. package/src/runtime/text-ack-range.ts +49 -0
  46. package/src/ui/WordReviewEditor.tsx +15 -0
  47. package/src/ui/editor-runtime-boundary.ts +10 -1
  48. package/src/ui/editor-surface-controller.tsx +3 -0
  49. package/src/ui/headless/chrome-registry.ts +235 -0
  50. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  51. package/src/ui/headless/selection-tool-context.ts +2 -0
  52. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  53. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  54. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  57. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  58. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  60. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  62. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  63. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  64. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  65. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Centralized formatting resolution for the layout engine.
3
+ *
4
+ * Takes a surface block snapshot and resolves the effective formatting
5
+ * properties needed for measurement and page composition. This includes:
6
+ *
7
+ * - Paragraph spacing (before, after, line height, line rule)
8
+ * - Indentation (left, right, firstLine, hanging)
9
+ * - Tab stops with numbering geometry override
10
+ * - Font size for line height calculation
11
+ * - Pagination properties (keepNext, keepLines, widowControl, pageBreakBefore)
12
+ * - Contextual spacing suppression
13
+ *
14
+ * The resolver produces a flat, inspectable structure so that the measurement
15
+ * engine never needs to reach back into multiple sources.
16
+ */
17
+
18
+ import type {
19
+ SurfaceBlockSnapshot,
20
+ } from "../../api/public-types";
21
+ import type {
22
+ ParagraphSpacing,
23
+ } from "../../model/canonical-document.ts";
24
+
25
+ /** Tab stop as it appears on the surface snapshot. */
26
+ interface SurfaceTabStop {
27
+ pos: number;
28
+ val?: string;
29
+ leader?: string;
30
+ }
31
+
32
+ /** Normalized tab stop for layout computation. */
33
+ export interface LayoutTabStop {
34
+ position: number;
35
+ align: string;
36
+ leader?: string;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Font metric tables — common fonts used in CCEP/legal contracts
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Average character width in twips per half-point of font size.
45
+ * These are empirical values from measuring common document fonts.
46
+ * Key insight: Word uses font metrics from the OS font table; we use
47
+ * bounded approximations that are close enough for page composition.
48
+ */
49
+ const FONT_AVG_CHAR_WIDTH: Record<string, number> = {
50
+ // Proportional serif
51
+ "times new roman": 9.2,
52
+ "georgia": 10.0,
53
+ "garamond": 8.8,
54
+ "book antiqua": 9.6,
55
+ "palatino linotype": 9.8,
56
+ "cambria": 9.6,
57
+ // Proportional sans-serif
58
+ "arial": 10.4,
59
+ "calibri": 9.0,
60
+ "helvetica": 10.4,
61
+ "verdana": 12.0,
62
+ "tahoma": 10.6,
63
+ "segoe ui": 9.8,
64
+ "trebuchet ms": 10.2,
65
+ // Monospace
66
+ "courier new": 12.0,
67
+ "consolas": 11.0,
68
+ "lucida console": 12.0,
69
+ };
70
+
71
+ const DEFAULT_FONT_AVG_CHAR_WIDTH = 10.0; // reasonable fallback
72
+ const DEFAULT_FONT_SIZE_HALF_POINTS = 24; // 12pt
73
+ const DEFAULT_LINE_HEIGHT_FACTOR = 1.15; // Word default for Calibri body
74
+ const TWIPS_PER_POINT = 20;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Resolved types
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export interface ResolvedParagraphFormatting {
81
+ /** Effective spacing before in twips. */
82
+ spacingBefore: number;
83
+ /** Effective spacing after in twips. */
84
+ spacingAfter: number;
85
+ /** Effective line height in twips. */
86
+ lineHeight: number;
87
+ /** Line rule governing how lineHeight is interpreted. */
88
+ lineRule: "auto" | "exact" | "atLeast";
89
+ /** Left indentation in twips. */
90
+ indentLeft: number;
91
+ /** Right indentation in twips. */
92
+ indentRight: number;
93
+ /** First-line indent in twips (positive = indent, negative = outdent). */
94
+ firstLineIndent: number;
95
+ /** Hanging indent in twips (mutually exclusive with firstLineIndent). */
96
+ hangingIndent: number;
97
+ /** Resolved font size in half-points for the dominant run. */
98
+ fontSizeHalfPoints: number;
99
+ /** Average character width in twips for the dominant font. */
100
+ averageCharWidthTwips: number;
101
+ /** Effective tab stops sorted by position. */
102
+ tabStops: LayoutTabStop[];
103
+ /** Keep with next paragraph. */
104
+ keepNext: boolean;
105
+ /** Keep all lines of this paragraph on the same page. */
106
+ keepLines: boolean;
107
+ /** Force page break before this paragraph. */
108
+ pageBreakBefore: boolean;
109
+ /** Widow/orphan control (default true in Word). */
110
+ widowControl: boolean;
111
+ /** Contextual spacing — suppress before/after when adjacent styles match. */
112
+ contextualSpacing: boolean;
113
+ }
114
+
115
+ export interface ResolvedTableRowFormatting {
116
+ /** Explicit row height in twips, 0 if auto. */
117
+ explicitHeight: number;
118
+ /** Height rule. */
119
+ heightRule: "auto" | "exact" | "atLeast";
120
+ /** Whether this row is a header row (repeats on page breaks). */
121
+ isHeader: boolean;
122
+ /** Whether this row can split across pages. */
123
+ cantSplit: boolean;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Paragraph formatting resolution
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export function resolveBlockFormatting(
131
+ block: SurfaceBlockSnapshot,
132
+ ): ResolvedParagraphFormatting | null {
133
+ if (block.kind !== "paragraph") {
134
+ return null;
135
+ }
136
+
137
+ const spacing = resolveSpacing(block);
138
+ const indent = resolveIndentation(block);
139
+ const fontInfo = resolveDominantFont(block);
140
+ const lineHeight = resolveLineHeight(spacing, fontInfo.fontSizeHalfPoints);
141
+
142
+ return {
143
+ spacingBefore: spacing.before ?? 0,
144
+ spacingAfter: spacing.after ?? 0,
145
+ lineHeight: lineHeight.height,
146
+ lineRule: lineHeight.rule,
147
+ indentLeft: indent.left,
148
+ indentRight: indent.right,
149
+ firstLineIndent: indent.firstLine,
150
+ hangingIndent: indent.hanging,
151
+ fontSizeHalfPoints: fontInfo.fontSizeHalfPoints,
152
+ averageCharWidthTwips: fontInfo.avgCharWidth,
153
+ tabStops: resolveTabStops(block),
154
+ keepNext: Boolean(block.keepNext),
155
+ keepLines: Boolean(block.keepLines),
156
+ pageBreakBefore: Boolean(block.pageBreakBefore),
157
+ widowControl: block.widowControl !== false, // default true in Word
158
+ contextualSpacing: Boolean(block.contextualSpacing),
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Spacing resolution
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function resolveSpacing(
167
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
168
+ ): ParagraphSpacing {
169
+ // Numbering geometry spacing overrides direct paragraph spacing
170
+ const numbering = block.resolvedNumbering?.geometry.spacing;
171
+ const direct = block.spacing;
172
+
173
+ const normalizeLineRule = (
174
+ rule: string | undefined,
175
+ ): ParagraphSpacing["lineRule"] => {
176
+ if (rule === "auto" || rule === "exact" || rule === "atLeast") return rule;
177
+ return undefined;
178
+ };
179
+
180
+ if (numbering) {
181
+ return {
182
+ before: numbering.before ?? direct?.before,
183
+ after: numbering.after ?? direct?.after,
184
+ line: numbering.line ?? direct?.line,
185
+ lineRule: normalizeLineRule(numbering.lineRule) ?? normalizeLineRule(direct?.lineRule),
186
+ };
187
+ }
188
+
189
+ if (direct) {
190
+ return {
191
+ before: direct.before,
192
+ after: direct.after,
193
+ line: direct.line,
194
+ lineRule: normalizeLineRule(direct.lineRule),
195
+ };
196
+ }
197
+
198
+ return {};
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Indentation resolution
203
+ // ---------------------------------------------------------------------------
204
+
205
+ function resolveIndentation(
206
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
207
+ ): { left: number; right: number; firstLine: number; hanging: number } {
208
+ // Numbering geometry provides precise text column placement for legal numbering
209
+ const numGeometry = block.resolvedNumbering?.geometry;
210
+
211
+ if (numGeometry?.textColumn) {
212
+ const tc = numGeometry.textColumn;
213
+ return {
214
+ left: tc.start,
215
+ right: tc.right ?? 0,
216
+ firstLine: tc.firstLine ?? 0,
217
+ hanging: tc.hanging ?? 0,
218
+ };
219
+ }
220
+
221
+ if (numGeometry?.indentation) {
222
+ const ni = numGeometry.indentation;
223
+ return {
224
+ left: ni.left ?? 0,
225
+ right: ni.right ?? 0,
226
+ firstLine: ni.firstLine ?? 0,
227
+ hanging: ni.hanging ?? 0,
228
+ };
229
+ }
230
+
231
+ const ind = block.indentation;
232
+ if (!ind) {
233
+ return { left: 0, right: 0, firstLine: 0, hanging: 0 };
234
+ }
235
+
236
+ return {
237
+ left: ind.left ?? 0,
238
+ right: ind.right ?? 0,
239
+ firstLine: ind.firstLine ?? 0,
240
+ hanging: ind.hanging ?? (typeof ind.firstLine === "number" && ind.firstLine < 0 ? Math.abs(ind.firstLine) : 0),
241
+ };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Font / character width resolution
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function resolveDominantFont(
249
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
250
+ ): { fontSizeHalfPoints: number; avgCharWidth: number; fontFamily: string | undefined } {
251
+ // Find the dominant font from the segments (most common or first text run)
252
+ let fontFamily: string | undefined;
253
+ let fontSizeHalfPoints: number | undefined;
254
+ let maxTextLength = 0;
255
+
256
+ for (const segment of block.segments) {
257
+ if (segment.kind !== "text") continue;
258
+ const segFontFamily = segment.markAttrs?.fontFamily;
259
+ const segFontSize = segment.markAttrs?.fontSize;
260
+ const textLength = segment.text.length;
261
+
262
+ if (textLength > maxTextLength) {
263
+ maxTextLength = textLength;
264
+ if (typeof segFontFamily === "string") {
265
+ fontFamily = segFontFamily;
266
+ }
267
+ if (typeof segFontSize === "number") {
268
+ fontSizeHalfPoints = segFontSize;
269
+ }
270
+ }
271
+ }
272
+
273
+ const effectiveSize = fontSizeHalfPoints ?? DEFAULT_FONT_SIZE_HALF_POINTS;
274
+ const normalizedFamily = fontFamily?.toLowerCase();
275
+ const charWidthFactor = normalizedFamily !== undefined
276
+ ? (FONT_AVG_CHAR_WIDTH[normalizedFamily] ?? DEFAULT_FONT_AVG_CHAR_WIDTH)
277
+ : DEFAULT_FONT_AVG_CHAR_WIDTH;
278
+
279
+ // Average char width in twips = factor * (fontSize in half-points)
280
+ const avgCharWidth = Math.max(96, Math.round(charWidthFactor * effectiveSize));
281
+
282
+ return { fontSizeHalfPoints: effectiveSize, avgCharWidth, fontFamily };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Line height resolution
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function resolveLineHeight(
290
+ spacing: ParagraphSpacing,
291
+ fontSizeHalfPoints: number,
292
+ ): { height: number; rule: "auto" | "exact" | "atLeast" } {
293
+ const lineRule = spacing.lineRule ?? "auto";
294
+
295
+ if (lineRule === "exact" && typeof spacing.line === "number" && spacing.line > 0) {
296
+ return { height: spacing.line, rule: "exact" };
297
+ }
298
+
299
+ if (lineRule === "atLeast" && typeof spacing.line === "number" && spacing.line > 0) {
300
+ // atLeast: use the larger of the specified height or the auto-calculated height
301
+ const autoHeight = calculateAutoLineHeight(fontSizeHalfPoints);
302
+ return { height: Math.max(spacing.line, autoHeight), rule: "atLeast" };
303
+ }
304
+
305
+ // Auto: line is in 240ths of a line (240 = single space, 480 = double)
306
+ if (typeof spacing.line === "number" && spacing.line > 0) {
307
+ // Convert from 240ths: line/240 * fontSize * 20 (twips per pt)
308
+ const fontSizePoints = fontSizeHalfPoints / 2;
309
+ const lineSpacingFactor = spacing.line / 240;
310
+ const height = Math.round(fontSizePoints * TWIPS_PER_POINT * lineSpacingFactor * DEFAULT_LINE_HEIGHT_FACTOR);
311
+ return { height: Math.max(height, 200), rule: "auto" };
312
+ }
313
+
314
+ // Default: single space at current font size
315
+ return { height: calculateAutoLineHeight(fontSizeHalfPoints), rule: "auto" };
316
+ }
317
+
318
+ function calculateAutoLineHeight(fontSizeHalfPoints: number): number {
319
+ const fontSizePoints = fontSizeHalfPoints / 2;
320
+ return Math.max(200, Math.round(fontSizePoints * TWIPS_PER_POINT * DEFAULT_LINE_HEIGHT_FACTOR));
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Tab stop resolution
325
+ // ---------------------------------------------------------------------------
326
+
327
+ function resolveTabStops(
328
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
329
+ ): LayoutTabStop[] {
330
+ // Surface tab stops use { pos, val, leader } format
331
+ const surfaceTabs: SurfaceTabStop[] | undefined = block.tabStops;
332
+ // Numbering geometry tab stops are on the geometry, not the snapshot root
333
+ const numSurfaceTabs: SurfaceTabStop[] | undefined = block.resolvedNumbering?.geometry.tabStops;
334
+
335
+ const normalize = (tab: SurfaceTabStop): LayoutTabStop => ({
336
+ position: tab.pos,
337
+ align: tab.val ?? "left",
338
+ leader: tab.leader,
339
+ });
340
+
341
+ if (numSurfaceTabs && numSurfaceTabs.length > 0 && surfaceTabs && surfaceTabs.length > 0) {
342
+ const numPositions = new Set(numSurfaceTabs.map((t) => t.pos));
343
+ const merged = [
344
+ ...numSurfaceTabs.map(normalize),
345
+ ...surfaceTabs.filter((t) => !numPositions.has(t.pos)).map(normalize),
346
+ ];
347
+ return merged.sort((a, b) => a.position - b.position);
348
+ }
349
+
350
+ if (numSurfaceTabs && numSurfaceTabs.length > 0) {
351
+ return numSurfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
352
+ }
353
+
354
+ if (surfaceTabs && surfaceTabs.length > 0) {
355
+ return surfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
356
+ }
357
+
358
+ return [];
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Precise measurement helpers
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /**
366
+ * Calculate the usable text width for a paragraph line, accounting for
367
+ * indentation and column width.
368
+ */
369
+ export function resolveTextWidth(
370
+ formatting: ResolvedParagraphFormatting,
371
+ columnWidth: number,
372
+ isFirstLine: boolean,
373
+ ): number {
374
+ const baseWidth = columnWidth - formatting.indentLeft - formatting.indentRight;
375
+
376
+ if (isFirstLine) {
377
+ if (formatting.hangingIndent > 0) {
378
+ // Hanging indent: first line starts further left (wider available)
379
+ return Math.max(1, baseWidth + formatting.hangingIndent);
380
+ }
381
+ if (formatting.firstLineIndent !== 0) {
382
+ return Math.max(1, baseWidth - formatting.firstLineIndent);
383
+ }
384
+ }
385
+
386
+ return Math.max(1, baseWidth);
387
+ }
388
+
389
+ /**
390
+ * Calculate characters per line for a given text width and font.
391
+ */
392
+ export function resolveCharsPerLine(
393
+ textWidth: number,
394
+ avgCharWidth: number,
395
+ ): number {
396
+ return Math.max(1, Math.floor(textWidth / avgCharWidth));
397
+ }
398
+
399
+ /**
400
+ * Calculate the total height of a paragraph from its resolved formatting.
401
+ */
402
+ export function calculateParagraphHeight(
403
+ formatting: ResolvedParagraphFormatting,
404
+ lineCount: number,
405
+ ): number {
406
+ const contentHeight = formatting.lineHeight * lineCount;
407
+ return Math.max(240, contentHeight + formatting.spacingBefore + formatting.spacingAfter);
408
+ }
409
+
410
+ /**
411
+ * Estimate the number of prefix characters added by numbering.
412
+ */
413
+ export function resolveNumberingPrefixLength(
414
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
415
+ ): number {
416
+ if (block.resolvedNumbering?.geometry.textColumn) {
417
+ // When text column is resolved, the numbering marker is placed in its
418
+ // own lane — it doesn't consume characters from the text line.
419
+ return 0;
420
+ }
421
+
422
+ const prefix = block.numberingPrefix ?? "";
423
+ const suffix =
424
+ block.numberingSuffix === "space"
425
+ ? 1
426
+ : block.numberingSuffix === "tab"
427
+ ? 4
428
+ : 0;
429
+ return prefix.length + suffix;
430
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Scope tag registry — describes how each annotation family responds to
3
+ * ordinary text edits. The predicted-text lane uses this registry to:
4
+ * - classify an `adjusted` ack (comment anchor extended, revision trimmed)
5
+ * - decide whether to bail on prediction when an unknown tag is in range
6
+ * - drive decoration redraws after a reconciled commit
7
+ *
8
+ * Known annotation families ship as `DEFAULT_REGISTRY_ENTRIES`. Host
9
+ * integrations can register additional families; unknown families default
10
+ * to "bailIfCrossed" so the lane falls back to the canonical round-trip.
11
+ */
12
+
13
+ export interface ScopeTagBehavior {
14
+ /** Inserting text at the left edge extends the tag leftward. */
15
+ extendOnInsertLeft: boolean;
16
+ /** Inserting text at the right edge extends the tag rightward. */
17
+ extendOnInsertRight: boolean;
18
+ /** Deleting a tag edge trims the tag boundary. */
19
+ trimOnDelete: boolean;
20
+ /** Any edit that crosses this tag must abort prediction and rebuild canonically. */
21
+ bailIfCrossed: boolean;
22
+ }
23
+
24
+ export const DEFAULT_UNKNOWN_BEHAVIOR: ScopeTagBehavior = {
25
+ extendOnInsertLeft: false,
26
+ extendOnInsertRight: false,
27
+ trimOnDelete: false,
28
+ bailIfCrossed: true,
29
+ };
30
+
31
+ export const DEFAULT_REGISTRY_ENTRIES: Readonly<Record<string, ScopeTagBehavior>> = {
32
+ comment: {
33
+ extendOnInsertLeft: true,
34
+ extendOnInsertRight: true,
35
+ trimOnDelete: true,
36
+ bailIfCrossed: false,
37
+ },
38
+ revision: {
39
+ extendOnInsertLeft: true,
40
+ extendOnInsertRight: true,
41
+ trimOnDelete: true,
42
+ bailIfCrossed: false,
43
+ },
44
+ field: {
45
+ extendOnInsertLeft: false,
46
+ extendOnInsertRight: false,
47
+ trimOnDelete: false,
48
+ bailIfCrossed: true,
49
+ },
50
+ bookmark: {
51
+ extendOnInsertLeft: true,
52
+ extendOnInsertRight: true,
53
+ trimOnDelete: true,
54
+ bailIfCrossed: false,
55
+ },
56
+ sdt: {
57
+ extendOnInsertLeft: false,
58
+ extendOnInsertRight: false,
59
+ trimOnDelete: false,
60
+ bailIfCrossed: true,
61
+ },
62
+ opaque: {
63
+ extendOnInsertLeft: false,
64
+ extendOnInsertRight: false,
65
+ trimOnDelete: false,
66
+ bailIfCrossed: true,
67
+ },
68
+ };
69
+
70
+ export interface ScopeTagRegistry {
71
+ get(tagType: string): ScopeTagBehavior;
72
+ register(tagType: string, behavior: ScopeTagBehavior): void;
73
+ has(tagType: string): boolean;
74
+ list(): ReadonlyArray<readonly [string, ScopeTagBehavior]>;
75
+ }
76
+
77
+ export function createScopeTagRegistry(): ScopeTagRegistry {
78
+ const entries = new Map<string, ScopeTagBehavior>(
79
+ Object.entries(DEFAULT_REGISTRY_ENTRIES),
80
+ );
81
+ return {
82
+ get(tagType) {
83
+ return entries.get(tagType) ?? DEFAULT_UNKNOWN_BEHAVIOR;
84
+ },
85
+ register(tagType, behavior) {
86
+ entries.set(tagType, behavior);
87
+ },
88
+ has(tagType) {
89
+ return entries.has(tagType);
90
+ },
91
+ list() {
92
+ return Array.from(entries.entries());
93
+ },
94
+ };
95
+ }
@@ -23,7 +23,7 @@ export interface SessionCapabilities {
23
23
 
24
24
  // ── Document mode ──
25
25
  /** Runtime document mode — editing authority, distinct from view/workspace mode. */
26
- documentMode: "editing" | "suggesting" | "viewing";
26
+ documentMode: "editing" | "suggesting" | "viewing" | "commenting";
27
27
 
28
28
  // ── Command capabilities ──
29
29
  canUndo: boolean;
@@ -101,12 +101,15 @@ export function deriveCapabilities(
101
101
  ? "read-only-diagnostics"
102
102
  : reviewMode;
103
103
 
104
- // Command capabilities — document mode "viewing" disables editing
105
- const canEdit = isReady && !isReadOnly && !hasFatalError && documentMode !== "viewing";
104
+ // Command capabilities — "viewing" and "commenting" modes both disable editing;
105
+ // "commenting" additionally keeps comment creation enabled.
106
+ const canEdit = isReady && !isReadOnly && !hasFatalError
107
+ && documentMode !== "viewing" && documentMode !== "commenting";
106
108
  const canUndo = snapshot.commandState.canUndo && canEdit;
107
109
  const canRedo = snapshot.commandState.canRedo && canEdit;
110
+ const canComment = isReady && !hasFatalError && documentMode !== "viewing";
108
111
  const canAddComment =
109
- canEdit &&
112
+ canComment &&
110
113
  activeStory.kind === "main" &&
111
114
  !snapshot.selection.isCollapsed &&
112
115
  Boolean(snapshot.surface) &&
@@ -553,6 +553,7 @@ function createParagraphBlock(
553
553
  ...(paragraph.keepNext ? { keepNext: true } : {}),
554
554
  ...(paragraph.keepLines ? { keepLines: true } : {}),
555
555
  ...(paragraph.pageBreakBefore ? { pageBreakBefore: true } : {}),
556
+ ...(paragraph.widowControl !== undefined ? { widowControl: paragraph.widowControl } : {}),
556
557
  ...(paragraph.outlineLevel !== undefined ? { outlineLevel: paragraph.outlineLevel } : {}),
557
558
  ...(paragraph.bidi ? { bidi: true } : {}),
558
559
  ...(paragraph.suppressLineNumbers ? { suppressLineNumbers: true } : {}),
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Computes the post-edit range that the runtime's text-command ack reports
3
+ * as `adjustedRange`. The semantics: the union of all step post-edit ranges
4
+ * (where each step covers `[step.from, step.from + step.insertSize]` in the
5
+ * new document). When the transaction has no steps (e.g., a non-main-story
6
+ * `document.replace` that uses `createEmptyMapping()`), fall back to the
7
+ * normalized prior selection range so consumers still receive a meaningful
8
+ * pointer.
9
+ *
10
+ * Note: only `step.from` and `step.insertSize` are considered; `step.to`
11
+ * (the pre-edit end of the replaced range) is intentionally ignored. A pure
12
+ * delete (`insertSize === 0`) therefore yields a zero-width range at the
13
+ * post-delete cursor position, not the pre-edit affected span. If a consumer
14
+ * needs the source span of a delete, derive it from `step.to` directly.
15
+ */
16
+ export interface PriorSelectionRange {
17
+ from: number;
18
+ to: number;
19
+ }
20
+
21
+ export interface TextAckStepShape {
22
+ from: number;
23
+ insertSize: number;
24
+ }
25
+
26
+ export interface AdjustedRuntimeRange {
27
+ fromRuntime: number;
28
+ toRuntime: number;
29
+ }
30
+
31
+ export function computeAdjustedRange(
32
+ priorSelection: PriorSelectionRange,
33
+ steps: readonly TextAckStepShape[],
34
+ ): AdjustedRuntimeRange {
35
+ if (steps.length === 0) {
36
+ return {
37
+ fromRuntime: Math.min(priorSelection.from, priorSelection.to),
38
+ toRuntime: Math.max(priorSelection.from, priorSelection.to),
39
+ };
40
+ }
41
+ let from = Infinity;
42
+ let to = -Infinity;
43
+ for (const step of steps) {
44
+ if (step.from < from) from = step.from;
45
+ const stepEnd = step.from + step.insertSize;
46
+ if (stepEnd > to) to = stepEnd;
47
+ }
48
+ return { fromRuntime: from, toRuntime: to };
49
+ }
@@ -165,6 +165,7 @@ import {
165
165
  useRuntimeValue,
166
166
  } from "./runtime-snapshot-selectors.ts";
167
167
  import type { MarkupDisplay } from "./headless/comment-decoration-model";
168
+ import { resolveScopedChromePolicy } from "./headless/scoped-chrome-policy";
168
169
  import type {
169
170
  SelectionToolbarAnchor,
170
171
  SelectionToolbarModel,
@@ -577,6 +578,7 @@ export function __createWordReviewEditorRefBridge(
577
578
  getRuntimeContextAnalytics: (query) => {
578
579
  return clonePublicValue(runtime.getRuntimeContextAnalytics(query));
579
580
  },
581
+ layout: runtime.layout,
580
582
  goToNextReviewItem: () => {
581
583
  return clonePublicValue(navigateReviewQueue(runtime, "next"));
582
584
  },
@@ -1391,6 +1393,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1391
1393
  getRuntimeContextAnalytics: (query) => {
1392
1394
  return clonePublicValue(activeRuntime.getRuntimeContextAnalytics(query));
1393
1395
  },
1396
+ layout: activeRuntime.layout,
1394
1397
  goToNextReviewItem: () => {
1395
1398
  return clonePublicValue(navigateMountedReviewQueue("next"));
1396
1399
  },
@@ -1670,6 +1673,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1670
1673
  snapshot.comments.activeCommentId
1671
1674
  ? snapshot.comments.threads.find((thread) => thread.commentId === snapshot.comments.activeCommentId)
1672
1675
  : undefined;
1676
+ const scopedChromePolicy = resolveScopedChromePolicy({
1677
+ preset: effectiveChromePreset,
1678
+ compactMode: false,
1679
+ capabilities,
1680
+ interactionGuardSnapshot,
1681
+ workflowScopeSnapshot,
1682
+ activeListContext: viewState.activeListContext,
1683
+ });
1673
1684
  const selectionToolbar = buildSelectionToolbarModel({
1674
1685
  snapshot,
1675
1686
  viewState,
@@ -1736,6 +1747,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1736
1747
  preferListStructureContext: viewState.workspaceMode === "page",
1737
1748
  addCommentDisabledReason,
1738
1749
  suppressedSuggestionRevisionId,
1750
+ scopedChromePolicy,
1739
1751
  });
1740
1752
  const selectionToolbarSelectionKey = useMemo(
1741
1753
  () =>
@@ -2241,6 +2253,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2241
2253
  workflowMetadata={workflowMarkupSnapshot?.metadata}
2242
2254
  onSelectionToolbarAnchorChange={handleSelectionToolbarAnchorChange}
2243
2255
  {...editorCallbacks}
2256
+ dispatchRuntimeCommand={(command) =>
2257
+ activeRuntime.applyActiveStoryTextCommand(command as never)
2258
+ }
2244
2259
  onCommentActivated={(commentId) => {
2245
2260
  activeRuntime.openComment(commentId);
2246
2261
  setActiveRailTab("comments");