@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.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +84 -1
- package/src/core/commands/index.ts +19 -2
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +337 -50
- package/src/io/export/serialize-numbering.ts +115 -24
- package/src/io/export/serialize-tables.ts +13 -11
- package/src/io/export/table-properties-xml.ts +35 -16
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +5 -0
- package/src/io/ooxml/parse-footnotes.ts +2 -1
- package/src/io/ooxml/parse-headers-footers.ts +2 -1
- package/src/io/ooxml/parse-main-document.ts +21 -1
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +11 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-navigation.ts +1 -305
- package/src/runtime/document-runtime.ts +178 -16
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +188 -0
- package/src/runtime/layout/inert-layout-facet.ts +45 -0
- package/src/runtime/layout/layout-engine-instance.ts +618 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +433 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +788 -0
- package/src/runtime/layout/public-facet.ts +705 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/session-capabilities.ts +7 -4
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/ui/WordReviewEditor.tsx +15 -0
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +235 -0
- package/src/ui/headless/scoped-chrome-policy.ts +164 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
- package/src/ui-tailwind/theme/editor-theme.css +40 -14
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
- 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 —
|
|
105
|
-
|
|
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
|
-
|
|
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");
|