@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
@@ -372,6 +372,17 @@ export interface ParagraphNode {
372
372
  bidi?: boolean;
373
373
  suppressLineNumbers?: boolean;
374
374
  cnfStyle?: string;
375
+ /**
376
+ * Preserved w14 extension identifiers for this paragraph.
377
+ * Round-trip (§2 A.7) requires these to survive import → export so the
378
+ * `w14:paraId` / `w14:textId` attributes that Word places on paragraph
379
+ * and run boundaries are re-emitted with the same values. Both ids are
380
+ * 8-hex uppercase strings per ECMA-376 Part 1 Appendix A.
381
+ */
382
+ wordExtensionIds?: {
383
+ paraId?: string;
384
+ textId?: string;
385
+ };
375
386
  children: InlineNode[];
376
387
  }
377
388
 
@@ -0,0 +1,130 @@
1
+ import type { ScopeTagTouch } from "../../api/public-types.ts";
2
+ import type {
3
+ CanonicalAnchor,
4
+ CommentThread,
5
+ RevisionRecord,
6
+ } from "../../model/canonical-document.ts";
7
+
8
+ /**
9
+ * Diff prior vs next review state and emit one `ScopeTagTouch` per changed
10
+ * comment or revision anchor. The predicted-text lane consumes these touches
11
+ * to classify a text command's ack as `equivalent` (no touches), `adjusted`
12
+ * (anchors shifted), or to feed decoration redraws without a PM rebuild.
13
+ *
14
+ * Notes
15
+ * - Newly created annotations show up as "extended" (revisions) or "split"
16
+ * (comments) so the lane knows to redraw them.
17
+ * - Detached annotations show up as "detached".
18
+ * - Anchors whose range is byte-identical across the diff are omitted.
19
+ */
20
+ export function collectScopeTagTouches(
21
+ priorComments: Readonly<Record<string, CommentThread>>,
22
+ nextComments: Readonly<Record<string, CommentThread>>,
23
+ priorRevisions: Readonly<Record<string, RevisionRecord>>,
24
+ nextRevisions: Readonly<Record<string, RevisionRecord>>,
25
+ ): ScopeTagTouch[] {
26
+ const touches: ScopeTagTouch[] = [];
27
+
28
+ for (const [id, prior] of Object.entries(priorComments)) {
29
+ const next = nextComments[id];
30
+ if (!next) {
31
+ touches.push({
32
+ tagType: "comment",
33
+ tagId: id,
34
+ behavior: "detached",
35
+ range: anchorRange(prior.anchor),
36
+ });
37
+ continue;
38
+ }
39
+ const behavior = classifyAnchorChange(prior.anchor, next.anchor);
40
+ if (behavior !== "unchanged") {
41
+ touches.push({
42
+ tagType: "comment",
43
+ tagId: id,
44
+ behavior,
45
+ range: anchorRange(next.anchor),
46
+ });
47
+ }
48
+ }
49
+
50
+ for (const [id, next] of Object.entries(nextComments)) {
51
+ if (!priorComments[id]) {
52
+ touches.push({
53
+ tagType: "comment",
54
+ tagId: id,
55
+ behavior: "split",
56
+ range: anchorRange(next.anchor),
57
+ });
58
+ }
59
+ }
60
+
61
+ for (const [id, prior] of Object.entries(priorRevisions)) {
62
+ const next = nextRevisions[id];
63
+ if (!next) {
64
+ touches.push({
65
+ tagType: "revision",
66
+ tagId: id,
67
+ behavior: "detached",
68
+ range: anchorRange(prior.anchor),
69
+ });
70
+ continue;
71
+ }
72
+ const behavior = classifyAnchorChange(prior.anchor, next.anchor);
73
+ if (behavior !== "unchanged") {
74
+ touches.push({
75
+ tagType: "revision",
76
+ tagId: id,
77
+ behavior,
78
+ range: anchorRange(next.anchor),
79
+ });
80
+ }
81
+ }
82
+
83
+ for (const [id, next] of Object.entries(nextRevisions)) {
84
+ if (!priorRevisions[id]) {
85
+ touches.push({
86
+ tagType: "revision",
87
+ tagId: id,
88
+ behavior: "extended",
89
+ range: anchorRange(next.anchor),
90
+ });
91
+ }
92
+ }
93
+
94
+ return touches;
95
+ }
96
+
97
+ function anchorRange(anchor: CanonicalAnchor): { from: number; to: number } {
98
+ if (anchor.kind === "range") {
99
+ return { from: anchor.range.from, to: anchor.range.to };
100
+ }
101
+ if (anchor.kind === "node") {
102
+ return { from: anchor.at, to: anchor.at };
103
+ }
104
+ return { from: anchor.lastKnownRange.from, to: anchor.lastKnownRange.to };
105
+ }
106
+
107
+ function classifyAnchorChange(
108
+ prior: CanonicalAnchor,
109
+ next: CanonicalAnchor,
110
+ ): "unchanged" | "extended" | "trimmed" | "split" | "detached" {
111
+ if (prior.kind === "detached" && next.kind === "detached") {
112
+ const pr = prior.lastKnownRange;
113
+ const nr = next.lastKnownRange;
114
+ return pr.from === nr.from && pr.to === nr.to ? "unchanged" : "detached";
115
+ }
116
+ if (prior.kind !== "detached" && next.kind === "detached") {
117
+ return "detached";
118
+ }
119
+ if (prior.kind === "detached" && next.kind !== "detached") {
120
+ return "extended";
121
+ }
122
+ const priorR = anchorRange(prior);
123
+ const nextR = anchorRange(next);
124
+ if (priorR.from === nextR.from && priorR.to === nextR.to) return "unchanged";
125
+ const priorLen = priorR.to - priorR.from;
126
+ const nextLen = nextR.to - nextR.from;
127
+ if (nextLen > priorLen) return "extended";
128
+ if (nextLen < priorLen) return "trimmed";
129
+ return "split";
130
+ }
@@ -17,34 +17,20 @@ import type {
17
17
  EditorStoryTarget,
18
18
  EditorSurfaceSnapshot,
19
19
  SurfaceBlockSnapshot,
20
- SurfaceInlineSegment,
21
20
  } from "../api/public-types";
22
- import type {
23
- FootnoteCollection,
24
- } from "../model/canonical-document.ts";
25
21
  import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
26
22
  import { createSelectionSnapshot } from "../core/state/editor-state.ts";
27
23
  import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
28
24
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
29
25
  import {
30
- estimateBlockHeight,
31
- estimateParagraphHeight,
32
- getUsableColumnMetrics,
33
- getUsableColumnWidth,
34
- getUsablePageHeight,
35
- } from "./page-layout-estimation.ts";
36
- import {
37
- buildPageLayoutSnapshot,
38
26
  buildResolvedSections,
39
27
  findSectionForPosition as resolveSectionForPosition,
40
28
  resolveSectionForStoryTarget,
41
29
  type ResolvedDocumentSection,
42
30
  } from "./document-layout.ts";
31
+ import { buildPageStack } from "./layout/paginated-layout-engine.ts";
43
32
  import { findNoteReferencePosition } from "./view-state.ts";
44
33
 
45
- const MIN_BLOCK_HEIGHT_TWIPS = 240;
46
- const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
47
-
48
34
  interface NavigationBaseSnapshot {
49
35
  mainSurface: EditorSurfaceSnapshot;
50
36
  sections: ResolvedDocumentSection[];
@@ -152,186 +138,6 @@ export function findSectionForOffset(
152
138
  return sections.length > 0 ? resolveSectionForPosition(sections, offset).index : 0;
153
139
  }
154
140
 
155
- function buildPageStack(
156
- document: CanonicalDocumentEnvelope,
157
- sections: ResolvedDocumentSection[],
158
- mainSurface: EditorSurfaceSnapshot,
159
- ): DocumentPageSnapshot[] {
160
- const pages: DocumentPageSnapshot[] = [];
161
- let globalPageIndex = 0;
162
-
163
- for (const section of sections) {
164
- const layout = buildPageLayoutSnapshot(
165
- section.index,
166
- section.properties ?? document.subParts?.finalSectionProperties,
167
- document.subParts,
168
- );
169
- const sectionBlocks = collectSectionBlocks(mainSurface.blocks, section.start, section.end);
170
- const paginated = paginateSectionBlocks(
171
- section,
172
- sectionBlocks,
173
- layout,
174
- document.subParts?.footnoteCollection,
175
- );
176
-
177
- for (const page of paginated) {
178
- pages.push({
179
- ...page,
180
- pageIndex: globalPageIndex,
181
- });
182
- globalPageIndex += 1;
183
- }
184
- }
185
-
186
- // Guarantee at least one page
187
- if (pages.length === 0) {
188
- pages.push({
189
- pageIndex: 0,
190
- sectionIndex: 0,
191
- pageInSection: 0,
192
- startOffset: 0,
193
- endOffset: mainSurface.storySize,
194
- layout: buildPageLayoutSnapshot(
195
- 0,
196
- document.subParts?.finalSectionProperties,
197
- document.subParts,
198
- ),
199
- });
200
- }
201
-
202
- return pages;
203
- }
204
-
205
- function collectSectionBlocks(
206
- blocks: readonly SurfaceBlockSnapshot[],
207
- start: number,
208
- end: number,
209
- ): SurfaceBlockSnapshot[] {
210
- return blocks.filter((block) => block.to > start && block.from < end);
211
- }
212
-
213
- function paginateSectionBlocks(
214
- section: ResolvedDocumentSection,
215
- blocks: readonly SurfaceBlockSnapshot[],
216
- layout: DocumentPageSnapshot["layout"],
217
- footnotes: FootnoteCollection | undefined,
218
- ): Omit<DocumentPageSnapshot, "pageIndex">[] {
219
- if (blocks.length === 0) {
220
- return [
221
- {
222
- sectionIndex: section.index,
223
- pageInSection: 0,
224
- startOffset: section.start,
225
- endOffset: section.end,
226
- layout,
227
- },
228
- ];
229
- }
230
-
231
- const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
232
- const usableHeight = getUsablePageHeight(layout);
233
- const columnMetrics = getUsableColumnMetrics(layout);
234
- const maxColumns = Math.max(1, columnMetrics.length);
235
- let pageStart = section.start;
236
- let columnHeight = 0;
237
- let columnIndex = 0;
238
- let pageInSection = 0;
239
- let reservedNoteHeight = 0;
240
- const reservedNotes = new Set<string>();
241
-
242
- const pushPage = (endOffset: number): void => {
243
- const boundedEnd = Math.max(pageStart, Math.min(endOffset, section.end));
244
- if (boundedEnd === pageStart && pages.length > 0) {
245
- return;
246
- }
247
- pages.push({
248
- sectionIndex: section.index,
249
- pageInSection,
250
- startOffset: pageStart,
251
- endOffset: boundedEnd,
252
- layout,
253
- });
254
- pageInSection += 1;
255
- pageStart = boundedEnd;
256
- columnHeight = 0;
257
- columnIndex = 0;
258
- reservedNoteHeight = 0;
259
- reservedNotes.clear();
260
- };
261
-
262
- for (let index = 0; index < blocks.length; index += 1) {
263
- const block = blocks[index]!;
264
- const nextBoundary = blocks[index + 1]?.from ?? section.end;
265
- while (true) {
266
- const columnWidth =
267
- columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
268
- getUsableColumnWidth(layout);
269
- const baseHeight = estimateBlockHeight(block, columnWidth);
270
- const keepWithNextHeight =
271
- block.kind === "paragraph" && block.keepNext
272
- ? baseHeight + estimateBlockHeight(blocks[index + 1], columnWidth)
273
- : baseHeight;
274
- const noteHeight = estimateFootnoteReservation(block, footnotes, columnWidth, reservedNotes);
275
- const projectedHeight = columnHeight + keepWithNextHeight + reservedNoteHeight + noteHeight;
276
-
277
- if (block.kind === "paragraph" && block.pageBreakBefore && pageStart < block.from) {
278
- pushPage(block.from);
279
- continue;
280
- }
281
- if (projectedHeight > usableHeight && pageStart < block.from) {
282
- if (columnIndex < maxColumns - 1) {
283
- columnIndex += 1;
284
- columnHeight = 0;
285
- reservedNoteHeight = 0;
286
- reservedNotes.clear();
287
- continue;
288
- }
289
- pushPage(block.from);
290
- continue;
291
- }
292
-
293
- const effectiveNoteHeight = estimateFootnoteReservation(
294
- block,
295
- footnotes,
296
- columnWidth,
297
- reservedNotes,
298
- );
299
- columnHeight += baseHeight;
300
- reservedNoteHeight += effectiveNoteHeight;
301
- currentPageNoteIds(block).forEach((noteKey) => reservedNotes.add(noteKey));
302
-
303
- if (hasColumnBreak(block)) {
304
- if (columnIndex < maxColumns - 1) {
305
- columnIndex += 1;
306
- columnHeight = 0;
307
- reservedNoteHeight = 0;
308
- reservedNotes.clear();
309
- } else {
310
- pushPage(nextBoundary);
311
- }
312
- break;
313
- }
314
-
315
- if (index === blocks.length - 1) {
316
- pushPage(section.end);
317
- }
318
- break;
319
- }
320
- }
321
-
322
- return pages.length > 0
323
- ? pages
324
- : [
325
- {
326
- sectionIndex: section.index,
327
- pageInSection: 0,
328
- startOffset: section.start,
329
- endOffset: section.end,
330
- layout,
331
- },
332
- ];
333
- }
334
-
335
141
  function resolveActiveNavigationContext(
336
142
  document: CanonicalDocumentEnvelope,
337
143
  pages: readonly DocumentPageSnapshot[],
@@ -383,116 +189,6 @@ function findFirstPageIndexForSection(
383
189
  return match >= 0 ? match : 0;
384
190
  }
385
191
 
386
- function estimateFootnoteReservation(
387
- block: SurfaceBlockSnapshot,
388
- footnotes: FootnoteCollection | undefined,
389
- columnWidth: number,
390
- reservedNotes: ReadonlySet<string>,
391
- ): number {
392
- if (!footnotes || block.kind !== "paragraph") {
393
- return 0;
394
- }
395
-
396
- let reservation = 0;
397
- for (const noteKey of currentPageNoteIds(block)) {
398
- if (reservedNotes.has(noteKey)) {
399
- continue;
400
- }
401
-
402
- const [noteKind, noteId] = noteKey.split(":");
403
- const noteCollection =
404
- noteKind === "endnote" ? footnotes.endnotes : footnotes.footnotes;
405
- const note = noteCollection[noteId];
406
- reservation += FOOTNOTE_REFERENCE_RESERVATION_TWIPS;
407
- if (note) {
408
- reservation += note.blocks.reduce(
409
- (total, child) => total + estimateCanonicalNoteBlockHeight(child, columnWidth),
410
- 0,
411
- );
412
- }
413
- }
414
-
415
- return reservation;
416
- }
417
-
418
- function estimateCanonicalNoteBlockHeight(
419
- block: FootnoteCollection["footnotes"][string]["blocks"][number],
420
- columnWidth: number,
421
- ): number {
422
- switch (block.type) {
423
- case "paragraph":
424
- return estimateParagraphHeight(
425
- {
426
- blockId: "note",
427
- kind: "paragraph",
428
- from: 0,
429
- to: 0,
430
- ...(block.styleId ? { styleId: block.styleId } : {}),
431
- segments: createEstimatedNoteSegments(block.children),
432
- },
433
- columnWidth,
434
- );
435
- case "table":
436
- return MIN_BLOCK_HEIGHT_TWIPS * Math.max(1, block.rows.length);
437
- default:
438
- return MIN_BLOCK_HEIGHT_TWIPS;
439
- }
440
- }
441
-
442
- function createEstimatedNoteSegments(
443
- children: Extract<FootnoteCollection["footnotes"][string]["blocks"][number], { type: "paragraph" }>["children"],
444
- ): SurfaceInlineSegment[] {
445
- const segments: SurfaceInlineSegment[] = [];
446
-
447
- children.forEach((child, index) => {
448
- if (child.type === "text") {
449
- segments.push({
450
- segmentId: `note-${index}`,
451
- kind: "text",
452
- from: 0,
453
- to: Array.from(child.text).length,
454
- text: child.text,
455
- });
456
- return;
457
- }
458
-
459
- if (child.type === "hard_break" || child.type === "tab") {
460
- segments.push({
461
- segmentId: `note-${index}`,
462
- kind: child.type,
463
- from: 0,
464
- to: 1,
465
- });
466
- }
467
- });
468
-
469
- return segments;
470
- }
471
-
472
- function currentPageNoteIds(
473
- block: SurfaceBlockSnapshot,
474
- ): Set<string> {
475
- const notes = new Set<string>();
476
- if (block.kind !== "paragraph") {
477
- return notes;
478
- }
479
-
480
- for (const segment of block.segments) {
481
- if (segment.kind === "note_ref" && segment.noteId) {
482
- notes.add(`${segment.noteKind}:${segment.noteId}`);
483
- }
484
- }
485
- return notes;
486
- }
487
-
488
- function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
489
- return block.kind === "paragraph" && block.segments.some(
490
- (segment) =>
491
- segment.kind === "opaque_inline" &&
492
- segment.label === "Column break",
493
- );
494
- }
495
-
496
192
  // ---------------------------------------------------------------------------
497
193
  // Heading outline
498
194
  // ---------------------------------------------------------------------------