@beyondwork/docx-react-component 1.0.57 → 1.0.59

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 (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +1149 -8
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +120 -39
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +165 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +544 -35
  87. package/src/runtime/document-search.ts +176 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +183 -0
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/scope-resolver.ts +60 -0
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +293 -18
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +258 -44
  107. package/src/ui/editor-runtime-boundary.ts +13 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +23 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +158 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +105 -17
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +13 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +52 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -2,12 +2,21 @@ import { createEmptyMapping, type TransactionMapping } from "../../core/selectio
2
2
  import { parseTextStory } from "../../core/schema/text-schema.ts";
3
3
  import { createSelectionSnapshot, type CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
4
4
  import { applyTextTransaction } from "../../core/state/text-transaction.ts";
5
+ import {
6
+ removeCellFromRow,
7
+ removeTableRowPure,
8
+ } from "../../core/commands/table-structure-commands.ts";
9
+ import type { BlockNode, TableNode } from "../../model/canonical-document.ts";
5
10
  import {
6
11
  remapRevisionStore,
7
12
  setRevisionStatus,
8
13
  type RevisionStore,
9
14
  } from "./revision-store.ts";
10
- import { getRevisionActionability, type RevisionRecord } from "./revision-types.ts";
15
+ import {
16
+ getRevisionActionability,
17
+ type RevisionRecord,
18
+ type TableRevisionCoordinates,
19
+ } from "./revision-types.ts";
11
20
 
12
21
  export type RevisionActionIntent = "accept" | "reject";
13
22
 
@@ -18,7 +27,8 @@ export type RevisionActionSkipReason =
18
27
  | "preserve-only"
19
28
  | "structural-range"
20
29
  | "protected-range"
21
- | "invalid-range";
30
+ | "invalid-range"
31
+ | "requires-runtime-mutation";
22
32
 
23
33
  export interface ApplyRevisionActionOptions {
24
34
  document: CanonicalDocumentEnvelope;
@@ -81,6 +91,32 @@ export function applyRevisionAction(
81
91
  );
82
92
  }
83
93
 
94
+ if (
95
+ revision.metadata.originalRevisionType === "cellIns" ||
96
+ revision.metadata.originalRevisionType === "cellDel"
97
+ ) {
98
+ return applyStructuralTableAction(options, revision);
99
+ }
100
+
101
+ if (revision.metadata.originalRevisionType === "cellMerge") {
102
+ return applyCellMergeAction(options, revision);
103
+ }
104
+
105
+ if (
106
+ revision.metadata.originalRevisionType === "row-ins" ||
107
+ revision.metadata.originalRevisionType === "row-del"
108
+ ) {
109
+ return applyRowStructuralAction(options, revision);
110
+ }
111
+
112
+ if (
113
+ revision.kind === "move" &&
114
+ typeof revision.metadata.moveData?.linkedRevisionId === "string" &&
115
+ revision.metadata.moveData.linkedRevisionId.length > 0
116
+ ) {
117
+ return applyPairedMoveAction(options, revision);
118
+ }
119
+
84
120
  if (getRevisionActionability(revision) !== "actionable") {
85
121
  return skippedResult(
86
122
  options,
@@ -251,6 +287,450 @@ export function applyRevisionAction(
251
287
  };
252
288
  }
253
289
 
290
+ function applyPairedMoveAction(
291
+ options: ApplyRevisionActionOptions,
292
+ revision: RevisionRecord,
293
+ ): ApplyRevisionActionResult {
294
+ // Paired moves flip status atomically; the runtime loop already added
295
+ // the partner to the target list via `expandLinkedMovePartners`. Content
296
+ // removal on the exported document is handled by the serializer based on
297
+ // (direction, status) — see `serializeMoveRevisionMarkup`. This keeps the
298
+ // live canonical model unchanged (same pattern as property-change accept).
299
+ const resultingStatus = toResultingStatus(options.intent);
300
+ return {
301
+ document: options.document,
302
+ store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
303
+ mapping: createEmptyMapping(),
304
+ outcome: {
305
+ kind: "applied",
306
+ revisionId: revision.revisionId,
307
+ intent: options.intent,
308
+ resultingStatus,
309
+ contentChanged: false,
310
+ },
311
+ detachedRevisionIds: [],
312
+ };
313
+ }
314
+
315
+ function applyRowStructuralAction(
316
+ options: ApplyRevisionActionOptions,
317
+ revision: RevisionRecord,
318
+ ): ApplyRevisionActionResult {
319
+ const originalType = revision.metadata.originalRevisionType;
320
+ const coords = revision.metadata.tableRevisionCoordinates;
321
+ const shouldRemoveRow =
322
+ (originalType === "row-ins" && options.intent === "reject") ||
323
+ (originalType === "row-del" && options.intent === "accept");
324
+
325
+ if (shouldRemoveRow) {
326
+ if (!coords) {
327
+ return skippedResult(
328
+ options,
329
+ "structural-range",
330
+ "Row-structural revision is missing tableRevisionCoordinates; cannot resolve target row.",
331
+ );
332
+ }
333
+ const resolved = resolveCanonicalTableRow(options.document, coords);
334
+ if (!resolved) {
335
+ return skippedResult(
336
+ options,
337
+ "invalid-range",
338
+ `Row-structural revision coordinates (tableOrdinal=${coords.tableOrdinal}, rowIndex=${coords.rowIndex}) do not resolve to a table row in the current document.`,
339
+ );
340
+ }
341
+ const nextTable = removeTableRowPure(resolved.table, coords.rowIndex);
342
+ const nextDocument = resolved.buildNextDocument(nextTable);
343
+ const resultingStatus = toResultingStatus(options.intent);
344
+ return {
345
+ document: nextDocument,
346
+ store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
347
+ mapping: createEmptyMapping(),
348
+ outcome: {
349
+ kind: "applied",
350
+ revisionId: revision.revisionId,
351
+ intent: options.intent,
352
+ resultingStatus,
353
+ contentChanged: true,
354
+ },
355
+ detachedRevisionIds: [],
356
+ };
357
+ }
358
+
359
+ // Accept(row-ins) / Reject(row-del) = status flip only. Serializer drops
360
+ // the marker from trPr based on revision.status via
361
+ // `serializeStructuralRowMarkup`.
362
+ const resultingStatus = toResultingStatus(options.intent);
363
+ return {
364
+ document: options.document,
365
+ store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
366
+ mapping: createEmptyMapping(),
367
+ outcome: {
368
+ kind: "applied",
369
+ revisionId: revision.revisionId,
370
+ intent: options.intent,
371
+ resultingStatus,
372
+ contentChanged: false,
373
+ },
374
+ detachedRevisionIds: [],
375
+ };
376
+ }
377
+
378
+ function resolveCanonicalTableRow(
379
+ document: CanonicalDocumentEnvelope,
380
+ coords: TableRevisionCoordinates,
381
+ ): {
382
+ blockIndex: number;
383
+ table: TableNode;
384
+ buildNextDocument: (nextTable: TableNode) => CanonicalDocumentEnvelope;
385
+ } | undefined {
386
+ const children = document.content.children;
387
+
388
+ // Phase S: body-level lookup descends into sdt / custom_xml wrappers in
389
+ // DFS order so that body-level tableOrdinal matches the parse-time DFS
390
+ // that also descends into these wrappers.
391
+ const bodyLookup = findBodyTableByOrdinal(children, coords.tableOrdinal);
392
+
393
+ // Phase Q: nested-table path resolution.
394
+ // tableOrdinalPath = [bodyOrdinal, nestedOrdinal, ...] encodes the DFS
395
+ // position of the containing table inside the body-level table.
396
+ const path = coords.tableOrdinalPath;
397
+ if (path && path.length >= 2) {
398
+ const [bodyOrdinal, ...nestedOrdinals] = path;
399
+ const outer = findBodyTableByOrdinal(children, bodyOrdinal);
400
+ if (!outer) return undefined;
401
+
402
+ // Walk the nested-ordinal chain.
403
+ let currentTable: TableNode = outer.table;
404
+ const updateFns: Array<(newInner: TableNode) => TableNode> = [];
405
+
406
+ for (const nestedOrdinal of nestedOrdinals) {
407
+ const found = findNestedTableByOrdinal(currentTable, nestedOrdinal);
408
+ if (!found) return undefined;
409
+ updateFns.push(found.updateOuter);
410
+ currentTable = found.table;
411
+ }
412
+
413
+ if (coords.rowIndex < 0 || coords.rowIndex >= currentTable.rows.length) return undefined;
414
+
415
+ return {
416
+ blockIndex: outer.pathTopIndex,
417
+ table: currentTable,
418
+ buildNextDocument: (nextTable: TableNode) => {
419
+ // Rebuild from innermost outward.
420
+ let rebuilt: TableNode = nextTable;
421
+ for (let i = updateFns.length - 1; i >= 0; i -= 1) {
422
+ rebuilt = updateFns[i]!(rebuilt);
423
+ }
424
+ const nextChildren = outer.rebuildRoot(rebuilt);
425
+ return { ...document, content: { ...document.content, children: nextChildren } };
426
+ },
427
+ };
428
+ }
429
+
430
+ // Flat body-level resolution (Phase S-aware — descends through sdt/customXml).
431
+ if (!bodyLookup) return undefined;
432
+ if (coords.rowIndex < 0 || coords.rowIndex >= bodyLookup.table.rows.length) return undefined;
433
+ return {
434
+ blockIndex: bodyLookup.pathTopIndex,
435
+ table: bodyLookup.table,
436
+ buildNextDocument: (nextTable: TableNode) => {
437
+ const nextChildren = bodyLookup.rebuildRoot(nextTable);
438
+ return { ...document, content: { ...document.content, children: nextChildren } };
439
+ },
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Finds the Nth table in DFS order, descending into `sdt` and `custom_xml`
445
+ * wrapper children. Returns the table plus a `rebuildRoot` closure that
446
+ * reconstructs the top-level children array with a new table substituted in
447
+ * place. `pathTopIndex` is the index in the top-level children array that
448
+ * contains (or transitively contains) the table — useful for callers that
449
+ * need a legacy `blockIndex` value.
450
+ */
451
+ function findBodyTableByOrdinal(
452
+ rootChildren: readonly BlockNode[],
453
+ targetOrdinal: number,
454
+ ): {
455
+ table: TableNode;
456
+ pathTopIndex: number;
457
+ rebuildRoot: (newTable: TableNode) => BlockNode[];
458
+ } | undefined {
459
+ const path: number[] = [];
460
+ const counter = { value: -1 };
461
+ const table = walkForBodyTable(rootChildren, path, counter, targetOrdinal);
462
+ if (!table) return undefined;
463
+ const capturedPath = [...path];
464
+ const pathTopIndex = capturedPath[0] ?? -1;
465
+ return {
466
+ table,
467
+ pathTopIndex,
468
+ rebuildRoot: (newTable: TableNode) => substituteTableAtPath(rootChildren, capturedPath, newTable),
469
+ };
470
+ }
471
+
472
+ function walkForBodyTable(
473
+ children: readonly BlockNode[],
474
+ path: number[],
475
+ counter: { value: number },
476
+ targetOrdinal: number,
477
+ ): TableNode | undefined {
478
+ for (let i = 0; i < children.length; i += 1) {
479
+ const child = children[i];
480
+ path.push(i);
481
+ if (child?.type === "table") {
482
+ counter.value += 1;
483
+ if (counter.value === targetOrdinal) {
484
+ return child;
485
+ }
486
+ } else if (child?.type === "sdt" || child?.type === "custom_xml") {
487
+ const found = walkForBodyTable(child.children, path, counter, targetOrdinal);
488
+ if (found) return found;
489
+ }
490
+ path.pop();
491
+ }
492
+ return undefined;
493
+ }
494
+
495
+ function substituteTableAtPath(
496
+ children: readonly BlockNode[],
497
+ path: readonly number[],
498
+ newTable: TableNode,
499
+ ): BlockNode[] {
500
+ if (path.length === 0) return [...children];
501
+ const [head, ...rest] = path;
502
+ return children.map((child, i) => {
503
+ if (i !== head) return child;
504
+ if (rest.length === 0) return newTable;
505
+ if (child?.type === "sdt") {
506
+ return { ...child, children: substituteTableAtPath(child.children, rest, newTable) };
507
+ }
508
+ if (child?.type === "custom_xml") {
509
+ return { ...child, children: substituteTableAtPath(child.children, rest, newTable) };
510
+ }
511
+ return child;
512
+ });
513
+ }
514
+
515
+ function findNestedTableByOrdinal(
516
+ outerTable: TableNode,
517
+ nestedOrdinal: number,
518
+ ): {
519
+ table: TableNode;
520
+ updateOuter: (newInner: TableNode) => TableNode;
521
+ } | undefined {
522
+ let seen = -1;
523
+ for (let ri = 0; ri < outerTable.rows.length; ri += 1) {
524
+ const row = outerTable.rows[ri];
525
+ if (!row) continue;
526
+ for (let ci = 0; ci < row.cells.length; ci += 1) {
527
+ const cell = row.cells[ci];
528
+ if (!cell) continue;
529
+ for (let bi = 0; bi < cell.children.length; bi += 1) {
530
+ const block = cell.children[bi];
531
+ if (block?.type !== "table") continue;
532
+ seen += 1;
533
+ if (seen !== nestedOrdinal) continue;
534
+ const capturedRi = ri;
535
+ const capturedCi = ci;
536
+ const capturedBi = bi;
537
+ return {
538
+ table: block,
539
+ updateOuter: (newInner: TableNode): TableNode => {
540
+ const newCell = {
541
+ ...cell,
542
+ children: cell.children.map((ch, idx) =>
543
+ idx === capturedBi ? newInner : ch,
544
+ ),
545
+ };
546
+ const newRow = {
547
+ ...row,
548
+ cells: row.cells.map((c, idx) => (idx === capturedCi ? newCell : c)),
549
+ };
550
+ return {
551
+ ...outerTable,
552
+ rows: outerTable.rows.map((r, idx) => (idx === capturedRi ? newRow : r)),
553
+ };
554
+ },
555
+ };
556
+ }
557
+ }
558
+ }
559
+ return undefined;
560
+ }
561
+
562
+ /**
563
+ * Phase U — cellMerge accept/reject.
564
+ *
565
+ * LibreOffice does NOT treat `w:vMerge` as a redline topology change — it's
566
+ * a rendering/property annotation. The Word-generated `<w:cellMerge>`
567
+ * marker under `<w:tcPr>` records that a user merged cells with
568
+ * change-tracking on; accept/reject flips the canonical cell's
569
+ * `verticalMerge` property. No row structure changes, no content merges.
570
+ *
571
+ * - accept(cellMerge, direction="rest") → verticalMerge = "restart"
572
+ * - accept(cellMerge, direction="cont") → verticalMerge = "continue"
573
+ * - reject(cellMerge, *) → verticalMerge = undefined
574
+ *
575
+ * The serializer drops the `<w:cellMerge>` marker on both accept and reject
576
+ * via `serializeStructuralTableMarkup`.
577
+ */
578
+ function applyCellMergeAction(
579
+ options: ApplyRevisionActionOptions,
580
+ revision: RevisionRecord,
581
+ ): ApplyRevisionActionResult {
582
+ const coords = revision.metadata.tableRevisionCoordinates;
583
+ if (!coords || typeof coords.cellIndex !== "number") {
584
+ return skippedResult(
585
+ options,
586
+ "invalid-range",
587
+ `cellMerge ${options.intent} needs tableRevisionCoordinates.cellIndex.`,
588
+ );
589
+ }
590
+ const resolved = resolveCanonicalTableRow(options.document, coords);
591
+ if (!resolved) {
592
+ return skippedResult(
593
+ options,
594
+ "invalid-range",
595
+ `cellMerge ${options.intent} coordinates (tableOrdinal=${coords.tableOrdinal}, rowIndex=${coords.rowIndex}) do not resolve to a table row.`,
596
+ );
597
+ }
598
+ const row = resolved.table.rows[coords.rowIndex];
599
+ if (!row || coords.cellIndex >= row.cells.length) {
600
+ return skippedResult(
601
+ options,
602
+ "invalid-range",
603
+ `cellMerge ${options.intent} cellIndex=${coords.cellIndex} out of range for row with ${row?.cells.length ?? 0} cells.`,
604
+ );
605
+ }
606
+ const cell = row.cells[coords.cellIndex]!;
607
+
608
+ const direction = revision.metadata.cellMergeData?.direction ?? "rest";
609
+ const nextVerticalMerge: "restart" | "continue" | undefined =
610
+ options.intent === "accept"
611
+ ? direction === "cont"
612
+ ? "continue"
613
+ : "restart"
614
+ : undefined;
615
+
616
+ // Build the new cell with updated verticalMerge. We rebuild with a clean
617
+ // property set so "reject" genuinely clears the property (spread with
618
+ // `verticalMerge: undefined` doesn't drop the key under TS structural
619
+ // equality, so construct explicitly).
620
+ const { verticalMerge: _drop, ...cellRest } = cell;
621
+ const nextCell = nextVerticalMerge
622
+ ? { ...cellRest, verticalMerge: nextVerticalMerge }
623
+ : { ...cellRest };
624
+
625
+ const nextRow = {
626
+ ...row,
627
+ cells: row.cells.map((c, idx) => (idx === coords.cellIndex ? nextCell : c)),
628
+ };
629
+ const nextTable = {
630
+ ...resolved.table,
631
+ rows: resolved.table.rows.map((r, idx) => (idx === coords.rowIndex ? nextRow : r)),
632
+ };
633
+ const nextDocument = resolved.buildNextDocument(nextTable);
634
+
635
+ const resultingStatus = toResultingStatus(options.intent);
636
+ return {
637
+ document: nextDocument,
638
+ store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
639
+ mapping: createEmptyMapping(),
640
+ outcome: {
641
+ kind: "applied",
642
+ revisionId: revision.revisionId,
643
+ intent: options.intent,
644
+ resultingStatus,
645
+ contentChanged: true,
646
+ },
647
+ detachedRevisionIds: [],
648
+ };
649
+ }
650
+
651
+ function applyStructuralTableAction(
652
+ options: ApplyRevisionActionOptions,
653
+ revision: RevisionRecord,
654
+ ): ApplyRevisionActionResult {
655
+ const originalType = revision.metadata.originalRevisionType;
656
+
657
+ // Determine whether this operation removes the cell from the canonical doc:
658
+ // - cellDel accept: deletion approved — cell is removed.
659
+ // - cellIns reject: insertion undone — cell is removed.
660
+ // The mirror cases (cellIns accept, cellDel reject) are status-flips only;
661
+ // the serializer drops the marker and the canonical model is unchanged.
662
+ const shouldRemoveCell =
663
+ (originalType === "cellDel" && options.intent === "accept") ||
664
+ (originalType === "cellIns" && options.intent === "reject");
665
+
666
+ if (shouldRemoveCell) {
667
+ const coords = revision.metadata.tableRevisionCoordinates;
668
+ if (!coords || typeof coords.cellIndex !== "number") {
669
+ return skippedResult(
670
+ options,
671
+ "invalid-range",
672
+ `${originalType} ${options.intent} needs tableRevisionCoordinates.cellIndex; record was parsed before Phase K or is malformed.`,
673
+ );
674
+ }
675
+ const resolved = resolveCanonicalTableRow(options.document, coords);
676
+ if (!resolved) {
677
+ return skippedResult(
678
+ options,
679
+ "invalid-range",
680
+ `${originalType} ${options.intent} coordinates (tableOrdinal=${coords.tableOrdinal}, rowIndex=${coords.rowIndex}) do not resolve to a table row in the current document.`,
681
+ );
682
+ }
683
+ const row = resolved.table.rows[coords.rowIndex];
684
+ if (!row || coords.cellIndex >= row.cells.length) {
685
+ return skippedResult(
686
+ options,
687
+ "invalid-range",
688
+ `${originalType} ${options.intent} cellIndex=${coords.cellIndex} out of range for row with ${row?.cells.length ?? 0} cells.`,
689
+ );
690
+ }
691
+
692
+ // Single-cell row: removing the only cell means removing the row entirely.
693
+ const nextTable =
694
+ row.cells.length <= 1
695
+ ? removeTableRowPure(resolved.table, coords.rowIndex)
696
+ : removeCellFromRow(resolved.table, coords.rowIndex, coords.cellIndex);
697
+
698
+ const nextDocument = resolved.buildNextDocument(nextTable);
699
+ const resultingStatus = toResultingStatus(options.intent);
700
+ return {
701
+ document: nextDocument,
702
+ store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
703
+ mapping: createEmptyMapping(),
704
+ outcome: {
705
+ kind: "applied",
706
+ revisionId: revision.revisionId,
707
+ intent: options.intent,
708
+ resultingStatus,
709
+ contentChanged: true,
710
+ },
711
+ detachedRevisionIds: [],
712
+ };
713
+ }
714
+
715
+ // Status-flip only:
716
+ // cellIns accept = keep the inserted cell (serializer drops the marker).
717
+ // cellDel reject = keep the cell, undo the deletion tracking (serializer drops the marker).
718
+ const resultingStatus = toResultingStatus(options.intent);
719
+ return {
720
+ document: options.document,
721
+ store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
722
+ mapping: createEmptyMapping(),
723
+ outcome: {
724
+ kind: "applied",
725
+ revisionId: revision.revisionId,
726
+ intent: options.intent,
727
+ resultingStatus,
728
+ contentChanged: false,
729
+ },
730
+ detachedRevisionIds: [],
731
+ };
732
+ }
733
+
254
734
  function skippedResult(
255
735
  options: ApplyRevisionActionOptions,
256
736
  reason: RevisionActionSkipReason,
@@ -96,6 +96,8 @@ export function createRevisionRecord(
96
96
  ooxmlRevisionId: params.metadata?.ooxmlRevisionId,
97
97
  propertyChangeData: params.metadata?.propertyChangeData,
98
98
  moveData: params.metadata?.moveData,
99
+ cellMergeData: params.metadata?.cellMergeData,
100
+ tableRevisionCoordinates: params.metadata?.tableRevisionCoordinates,
99
101
  },
100
102
  });
101
103
  }
@@ -113,6 +115,19 @@ export function upsertRevisionRecord(
113
115
  };
114
116
  }
115
117
 
118
+ /**
119
+ * Set the status of a revision. Returns the store unchanged (no-op) when:
120
+ * - `revisionId` is not present in the store,
121
+ * - the revision is `detached`,
122
+ * - the caller asks to transition to `accepted` / `rejected` but the
123
+ * revision is not `actionable` (e.g. preserve-only imported revisions).
124
+ *
125
+ * This function is pure machinery; it has no emit capability. User-visible
126
+ * signaling for silent no-ops on `acceptChange` / `rejectChange` lives in
127
+ * `applyReviewCommand` in `src/core/commands/index.ts`, which emits a
128
+ * `review_target_not_found` transient warning via `effects.transientWarnings`
129
+ * when `applyRevisionRuntimeCommand` produces no applied outcome.
130
+ */
116
131
  export function setRevisionStatus(
117
132
  store: RevisionStore,
118
133
  revisionId: string,
@@ -32,6 +32,53 @@ export interface MoveData {
32
32
  linkedRevisionId?: string;
33
33
  }
34
34
 
35
+ /**
36
+ * Payload for `w:cellMerge` revisions. `direction` is the `w:vMerge`
37
+ * attribute on the marker:
38
+ * `"rest"` — this cell begins a vertical merge span (restart cell).
39
+ * `"cont"` — this cell continues a vertical merge span.
40
+ *
41
+ * Accept commits `TableCellNode.verticalMerge` to the matching state;
42
+ * reject clears `verticalMerge` to undefined. No row topology changes
43
+ * (mirrors LibreOffice's treatment — vMerge is a rendering property, not
44
+ * a structural mutation).
45
+ */
46
+ export interface CellMergeData {
47
+ direction: "rest" | "cont";
48
+ }
49
+
50
+ /**
51
+ * Ordinal coordinates of a structural table revision within the canonical
52
+ * document tree.
53
+ *
54
+ * Lane 7b row-insertion revisions (`<w:trPr><w:ins/></w:trPr>`) point at an
55
+ * entire row, not a text range — the runtime anchor position alone can't
56
+ * identify which row to remove on reject. This envelope carries the table's
57
+ * ordinal (the Nth `<w:tbl>` in the body) and the row's index within that
58
+ * table, both fixed at parse time. Accept/reject resolves these to a
59
+ * canonical-document `tableBlockIndex` by walking `doc.content.children[]`
60
+ * at action time.
61
+ */
62
+ export interface TableRevisionCoordinates {
63
+ tableOrdinal: number;
64
+ rowIndex: number;
65
+ /**
66
+ * Cell index within `row.cells[]`. Present on cell-level structural
67
+ * revisions (`cellIns` / `cellDel` / `cellMerge`); `undefined` on
68
+ * row-level records (`row-ins` / `row-del`).
69
+ */
70
+ cellIndex?: number;
71
+ /**
72
+ * For revisions inside nested tables (a `<w:tbl>` inside a cell), this
73
+ * carries the full ordinal path so `resolveCanonicalTableRow` can
74
+ * tree-walk the canonical document:
75
+ * `[bodyTableOrdinal, nestedTableIndex, ...]`
76
+ * Body-level revisions omit this field and use the flat `tableOrdinal`.
77
+ * Lane 7b Phase Q.
78
+ */
79
+ tableOrdinalPath?: readonly number[];
80
+ }
81
+
35
82
  export interface RevisionMetadataEnvelope {
36
83
  source: "runtime" | "import";
37
84
  storyTarget?: RevisionStoryTargetRecord;
@@ -56,6 +103,8 @@ export interface RevisionMetadataEnvelope {
56
103
  ooxmlRevisionId?: string;
57
104
  propertyChangeData?: PropertyChangeData;
58
105
  moveData?: MoveData;
106
+ cellMergeData?: CellMergeData;
107
+ tableRevisionCoordinates?: TableRevisionCoordinates;
59
108
  }
60
109
 
61
110
  export type PropertyChangeRevision = RevisionRecord & {
@@ -140,6 +189,33 @@ export function getRevisionActionability(
140
189
  | RevisionKind
141
190
  | Pick<RevisionRecord, "kind" | "metadata">,
142
191
  ): RevisionActionability {
192
+ // Lane 7b promotions run BEFORE the preserveOnlyReason check on purpose:
193
+ // `createRevisionRecord` injects a default "Imported preserve-only revision."
194
+ // reason for any kind that `getRevisionActionability(kind)` returns as
195
+ // preserve-only (see revision-store.ts), which covers both `kind: "move"`
196
+ // and `kind: "formatting"`. The overrides below flip the default back to
197
+ // actionable for the specific shapes that Lane 7b promoted: cellIns
198
+ // structural-table revisions, and linked-pair move revisions. Do not add a
199
+ // `&& !preserveOnlyReason` guard here — that would re-lock these shapes
200
+ // against the injected default and regress 7b.
201
+ if (
202
+ typeof revision !== "string" &&
203
+ (revision.metadata.originalRevisionType === "cellIns" ||
204
+ revision.metadata.originalRevisionType === "cellDel" ||
205
+ revision.metadata.originalRevisionType === "cellMerge")
206
+ ) {
207
+ return "actionable";
208
+ }
209
+
210
+ if (
211
+ typeof revision !== "string" &&
212
+ revision.kind === "move" &&
213
+ typeof revision.metadata.moveData?.linkedRevisionId === "string" &&
214
+ revision.metadata.moveData.linkedRevisionId.length > 0
215
+ ) {
216
+ return "actionable";
217
+ }
218
+
143
219
  if (
144
220
  typeof revision !== "string" &&
145
221
  typeof revision.metadata.preserveOnlyReason === "string" &&
@@ -157,6 +157,19 @@ export interface RemoteCursorTrackerHandle {
157
157
  * last published. Excludes the local client.
158
158
  */
159
159
  getRemoteCursors(): RemoteCursorState[];
160
+ /**
161
+ * Publishes the local peer's cursor position to awareness. If
162
+ * `suppressNextPublish()` was called since the last publish, this call
163
+ * is a no-op and the suppress flag is cleared (one-shot semantics).
164
+ */
165
+ publishLocalCursor(state: RemoteCursorState): void;
166
+ /**
167
+ * Suppresses the next `publishLocalCursor` call. The flag is
168
+ * consumed by the next publish attempt regardless of whether it
169
+ * actually skipped — calling this twice before a publish is still
170
+ * one skip.
171
+ */
172
+ suppressNextPublish(): void;
160
173
  destroy(): void;
161
174
  }
162
175
 
@@ -175,6 +188,7 @@ export function createRemoteCursorTracker(
175
188
  ): RemoteCursorTrackerHandle {
176
189
  const { awareness, localClientId, commandAppliedBridge } = options;
177
190
  const cache = new Map<number, RemoteCursorState>();
191
+ let suppressNext = false;
178
192
 
179
193
  function setFromAwareness(clientId: number): void {
180
194
  if (clientId === localClientId) {
@@ -251,6 +265,16 @@ export function createRemoteCursorTracker(
251
265
  getRemoteCursors() {
252
266
  return [...cache.values()];
253
267
  },
268
+ publishLocalCursor(state: RemoteCursorState) {
269
+ if (suppressNext) {
270
+ suppressNext = false;
271
+ return;
272
+ }
273
+ setLocalCursorState(awareness, state);
274
+ },
275
+ suppressNextPublish() {
276
+ suppressNext = true;
277
+ },
254
278
  destroy() {
255
279
  awareness.off("change", onAwarenessChange);
256
280
  unsubscribeCommandApplied?.();