@beyondwork/docx-react-component 1.0.28 → 1.0.30

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 (92) hide show
  1. package/package.json +26 -37
  2. package/src/api/public-types.ts +531 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/index.ts +201 -79
  5. package/src/core/commands/table-structure-commands.ts +138 -5
  6. package/src/core/state/text-transaction.ts +370 -3
  7. package/src/index.ts +41 -0
  8. package/src/io/docx-session.ts +318 -25
  9. package/src/io/export/serialize-footnotes.ts +41 -46
  10. package/src/io/export/serialize-headers-footers.ts +36 -40
  11. package/src/io/export/serialize-main-document.ts +55 -89
  12. package/src/io/export/serialize-numbering.ts +104 -4
  13. package/src/io/export/serialize-runtime-revisions.ts +196 -2
  14. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +252 -0
  15. package/src/io/export/table-properties-xml.ts +318 -0
  16. package/src/io/normalize/normalize-text.ts +34 -3
  17. package/src/io/ooxml/parse-comments.ts +6 -0
  18. package/src/io/ooxml/parse-footnotes.ts +69 -13
  19. package/src/io/ooxml/parse-headers-footers.ts +54 -11
  20. package/src/io/ooxml/parse-main-document.ts +112 -42
  21. package/src/io/ooxml/parse-numbering.ts +341 -26
  22. package/src/io/ooxml/parse-revisions.ts +118 -4
  23. package/src/io/ooxml/parse-styles.ts +176 -0
  24. package/src/io/ooxml/parse-tables.ts +34 -25
  25. package/src/io/ooxml/revision-boundaries.ts +127 -3
  26. package/src/io/ooxml/workflow-payload.ts +544 -0
  27. package/src/model/canonical-document.ts +91 -1
  28. package/src/model/snapshot.ts +112 -1
  29. package/src/preservation/store.ts +73 -3
  30. package/src/review/store/comment-store.ts +19 -1
  31. package/src/review/store/revision-actions.ts +29 -0
  32. package/src/review/store/revision-store.ts +12 -1
  33. package/src/review/store/revision-types.ts +11 -0
  34. package/src/runtime/context-analytics.ts +824 -0
  35. package/src/runtime/document-locations.ts +521 -0
  36. package/src/runtime/document-navigation.ts +14 -1
  37. package/src/runtime/document-outline.ts +440 -0
  38. package/src/runtime/document-runtime.ts +941 -45
  39. package/src/runtime/event-refresh-hints.ts +137 -0
  40. package/src/runtime/numbering-prefix.ts +67 -39
  41. package/src/runtime/page-layout-estimation.ts +100 -7
  42. package/src/runtime/resolved-numbering-geometry.ts +293 -0
  43. package/src/runtime/session-capabilities.ts +2 -2
  44. package/src/runtime/suggestions-snapshot.ts +137 -0
  45. package/src/runtime/surface-projection.ts +223 -27
  46. package/src/runtime/table-style-resolver.ts +409 -0
  47. package/src/runtime/view-state.ts +17 -1
  48. package/src/runtime/workflow-markup.ts +54 -14
  49. package/src/ui/WordReviewEditor.tsx +1269 -87
  50. package/src/ui/editor-command-bag.ts +7 -0
  51. package/src/ui/editor-runtime-boundary.ts +111 -10
  52. package/src/ui/editor-shell-view.tsx +17 -15
  53. package/src/ui/editor-surface-controller.tsx +5 -0
  54. package/src/ui/headless/selection-tool-context.ts +19 -0
  55. package/src/ui/headless/selection-tool-resolver.ts +752 -0
  56. package/src/ui/headless/selection-tool-types.ts +129 -0
  57. package/src/ui/headless/selection-toolbar-model.ts +10 -33
  58. package/src/ui/runtime-shortcut-dispatch.ts +365 -0
  59. package/src/ui-tailwind/chrome/chrome-preset-model.ts +107 -0
  60. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +15 -0
  61. package/src/ui-tailwind/chrome/review-queue-bar.tsx +97 -0
  62. package/src/ui-tailwind/chrome/tw-context-analytics-summary.tsx +122 -0
  63. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +1 -9
  64. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +1 -5
  65. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +8 -29
  66. package/src/ui-tailwind/chrome/tw-selection-tool-blocked.tsx +23 -0
  67. package/src/ui-tailwind/chrome/tw-selection-tool-comment.tsx +35 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +37 -0
  69. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +298 -0
  70. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +116 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-suggestion.tsx +29 -0
  72. package/src/ui-tailwind/chrome/tw-selection-tool-workflow.tsx +27 -0
  73. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +3 -3
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +3 -3
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +86 -14
  76. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +57 -52
  77. package/src/ui-tailwind/editor-surface/pm-decorations.ts +36 -52
  78. package/src/ui-tailwind/editor-surface/pm-schema.ts +56 -5
  79. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +87 -24
  80. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +4 -0
  81. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +135 -32
  82. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +74 -7
  83. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +17 -17
  84. package/src/ui-tailwind/review/tw-review-rail.tsx +19 -17
  85. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +10 -10
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +10 -6
  87. package/src/ui-tailwind/theme/editor-theme.css +58 -40
  88. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -4
  89. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +250 -181
  90. package/src/ui-tailwind/tw-review-workspace.tsx +323 -280
  91. package/src/validation/compatibility-engine.ts +246 -2
  92. package/src/validation/docx-comment-proof.ts +24 -11
@@ -33,6 +33,7 @@ export function editorSessionStateFromPersistedSnapshot(
33
33
  warningLog: snapshot.warningLog,
34
34
  protectionSnapshot: snapshot.protectionSnapshot,
35
35
  sourcePackage: snapshot.sourcePackage,
36
+ workflowMetadata: snapshot.workflowMetadata,
36
37
  });
37
38
  }
38
39
 
@@ -56,5 +57,6 @@ export function persistedSnapshotFromEditorSessionState(
56
57
  warningLog: sessionState.warningLog,
57
58
  protectionSnapshot: sessionState.protectionSnapshot,
58
59
  sourcePackage: sessionState.sourcePackage,
60
+ workflowMetadata: sessionState.workflowMetadata,
59
61
  });
60
62
  }
@@ -305,16 +305,8 @@ export function executeEditorCommand(
305
305
  }
306
306
  case "paragraph.split":
307
307
  if (context.documentMode === "suggesting") {
308
- return createTransaction(state, {
309
- historyBoundary: "skip",
310
- markDirty: false,
311
- effects: {
312
- commandBlocked: {
313
- code: "suggesting_unsupported",
314
- message: "Paragraph splits are not supported in suggesting mode.",
315
- },
316
- },
317
- });
308
+ const suggestingResult = applySuggestingParagraphSplit(state, context);
309
+ if (suggestingResult) return suggestingResult;
318
310
  }
319
311
  return applyTextCommand(state, context.timestamp, (document, selection) =>
320
312
  splitParagraph(document, selection, context),
@@ -722,10 +714,31 @@ function normalizeSelection(selection: SelectionSnapshot): SelectionSnapshot {
722
714
  );
723
715
 
724
716
  if (activeRange.kind === "range") {
717
+ const rangeFrom = activeRange.range.from;
718
+ const rangeTo = activeRange.range.to;
719
+ const collapsed = rangeFrom === rangeTo;
720
+ const anchorWithinRange = selection.anchor >= rangeFrom && selection.anchor <= rangeTo;
721
+ const headWithinRange = selection.head >= rangeFrom && selection.head <= rangeTo;
722
+ const preserveDirectionalEndpoints = anchorWithinRange && headWithinRange;
723
+ const fallbackForward = selection.anchor <= selection.head;
724
+ const anchor = collapsed
725
+ ? rangeFrom
726
+ : preserveDirectionalEndpoints
727
+ ? selection.anchor
728
+ : fallbackForward
729
+ ? rangeFrom
730
+ : rangeTo;
731
+ const head = collapsed
732
+ ? rangeTo
733
+ : preserveDirectionalEndpoints
734
+ ? selection.head
735
+ : fallbackForward
736
+ ? rangeTo
737
+ : rangeFrom;
725
738
  return {
726
- anchor: activeRange.range.from,
727
- head: activeRange.range.to,
728
- isCollapsed: activeRange.range.from === activeRange.range.to,
739
+ anchor,
740
+ head,
741
+ isCollapsed: collapsed,
729
742
  activeRange,
730
743
  };
731
744
  }
@@ -1056,6 +1069,10 @@ function createRevisionStoreFromState(
1056
1069
  source: revision.metadata?.source ?? "runtime",
1057
1070
  storyTarget: revision.metadata?.storyTarget,
1058
1071
  preserveOnlyReason: revision.metadata?.preserveOnlyReason,
1072
+ suggestionId: revision.metadata?.suggestionId,
1073
+ semanticKind: revision.metadata?.semanticKind,
1074
+ linkedRevisionIds: revision.metadata?.linkedRevisionIds,
1075
+ predecessorSuggestionId: revision.metadata?.predecessorSuggestionId,
1059
1076
  importedRevisionForm: revision.metadata?.importedRevisionForm,
1060
1077
  originalRevisionType: revision.metadata?.originalRevisionType,
1061
1078
  ooxmlRevisionId: revision.metadata?.ooxmlRevisionId,
@@ -1083,6 +1100,10 @@ function toEditorRevisionRecords(
1083
1100
  source: revision.metadata.source,
1084
1101
  storyTarget: revision.metadata.storyTarget,
1085
1102
  preserveOnlyReason: revision.metadata.preserveOnlyReason,
1103
+ suggestionId: revision.metadata.suggestionId,
1104
+ semanticKind: revision.metadata.semanticKind,
1105
+ linkedRevisionIds: revision.metadata.linkedRevisionIds,
1106
+ predecessorSuggestionId: revision.metadata.predecessorSuggestionId,
1086
1107
  importedRevisionForm: revision.metadata.importedRevisionForm,
1087
1108
  originalRevisionType: revision.metadata.originalRevisionType,
1088
1109
  ooxmlRevisionId: revision.metadata.ooxmlRevisionId,
@@ -1209,6 +1230,7 @@ function createAuthoredRevision(
1209
1230
  to: number,
1210
1231
  authorId: string,
1211
1232
  timestamp: string,
1233
+ metadata: Partial<NonNullable<CanonicalRevisionRecord["metadata"]>> = {},
1212
1234
  ): CanonicalRevisionRecord {
1213
1235
  const changeId = createSuggestingRevisionId(existing, timestamp);
1214
1236
  return {
@@ -1224,11 +1246,33 @@ function createAuthoredRevision(
1224
1246
  warningIds: [],
1225
1247
  metadata: {
1226
1248
  source: "runtime",
1249
+ ...metadata,
1227
1250
  },
1228
1251
  status: "open",
1229
1252
  };
1230
1253
  }
1231
1254
 
1255
+ function createSuggestionMetadata(args: {
1256
+ suggestionId?: string;
1257
+ semanticKind:
1258
+ | "insertion"
1259
+ | "deletion"
1260
+ | "replacement"
1261
+ | "formatting-change"
1262
+ | "paragraph-property-change"
1263
+ | "structural-change"
1264
+ | "object-change";
1265
+ linkedRevisionIds?: string[];
1266
+ predecessorSuggestionId?: string;
1267
+ }): Partial<NonNullable<CanonicalRevisionRecord["metadata"]>> {
1268
+ return {
1269
+ suggestionId: args.suggestionId,
1270
+ semanticKind: args.semanticKind,
1271
+ linkedRevisionIds: args.linkedRevisionIds,
1272
+ predecessorSuggestionId: args.predecessorSuggestionId,
1273
+ };
1274
+ }
1275
+
1232
1276
  function createSuggestingUnsupportedTransaction(
1233
1277
  state: EditorState,
1234
1278
  message: string,
@@ -1309,7 +1353,7 @@ function applySuggestingInsert(
1309
1353
  }
1310
1354
 
1311
1355
  if (isCollapsed) {
1312
- // Pure insertion at cursor: apply normally, then create or extend an insertion revision.
1356
+ // Pure insertion at cursor: apply normally, then create insertion revision
1313
1357
  const result = insertText(state.document, selection, text, { timestamp: context.timestamp });
1314
1358
  const insertedFrom = from;
1315
1359
  const insertedTo = from + Array.from(text).length;
@@ -1321,53 +1365,28 @@ function applySuggestingInsert(
1321
1365
  result.mapping,
1322
1366
  );
1323
1367
 
1324
- // If there is an authored insertion revision that ends exactly where this
1325
- // text was inserted (i.e. the cursor was at the end of a prior insertion),
1326
- // extend that revision to cover the new text rather than creating a new one.
1327
- // This groups consecutive keystrokes into a single tracked change.
1328
- const adjacentInsertion = findAdjacentAuthoredInsertion(
1368
+ // Create the revision with pre-mapping positions it refers to content
1369
+ // that was just inserted, so its anchors are already correct in the new document
1370
+ const revision = createAuthoredRevision(
1329
1371
  reviewState.document.review.revisions,
1372
+ "insertion",
1330
1373
  insertedFrom,
1374
+ insertedTo,
1375
+ authorId,
1376
+ context.timestamp,
1377
+ createSuggestionMetadata({
1378
+ semanticKind: "insertion",
1379
+ }),
1331
1380
  );
1332
1381
 
1333
- let finalRevisionId: string;
1334
- let finalRevisions: Record<string, CanonicalRevisionRecord>;
1335
-
1336
- if (adjacentInsertion && adjacentInsertion.anchor.kind === "range") {
1337
- const extended: CanonicalRevisionRecord = {
1338
- ...adjacentInsertion,
1339
- anchor: {
1340
- kind: "range",
1341
- range: { from: adjacentInsertion.anchor.range.from, to: insertedTo },
1342
- assoc: { start: 1, end: -1 },
1343
- },
1344
- };
1345
- finalRevisionId = extended.changeId;
1346
- finalRevisions = {
1347
- ...reviewState.document.review.revisions,
1348
- [extended.changeId]: extended,
1349
- };
1350
- } else {
1351
- const revision = createAuthoredRevision(
1352
- reviewState.document.review.revisions,
1353
- "insertion",
1354
- insertedFrom,
1355
- insertedTo,
1356
- authorId,
1357
- context.timestamp,
1358
- );
1359
- finalRevisionId = revision.changeId;
1360
- finalRevisions = {
1361
- ...reviewState.document.review.revisions,
1362
- [revision.changeId]: revision,
1363
- };
1364
- }
1365
-
1366
1382
  const finalDocument: CanonicalDocumentEnvelope = {
1367
1383
  ...reviewState.document,
1368
1384
  review: {
1369
1385
  ...reviewState.document.review,
1370
- revisions: finalRevisions,
1386
+ revisions: {
1387
+ ...reviewState.document.review.revisions,
1388
+ [revision.changeId]: revision,
1389
+ },
1371
1390
  },
1372
1391
  };
1373
1392
 
@@ -1388,7 +1407,7 @@ function applySuggestingInsert(
1388
1407
  mapping: result.mapping,
1389
1408
  effects: {
1390
1409
  ...reviewState.effects,
1391
- revisionAuthored: { changeId: finalRevisionId, kind: "insertion" },
1410
+ revisionAuthored: { changeId: revision.changeId, kind: "insertion" },
1392
1411
  },
1393
1412
  },
1394
1413
  );
@@ -1407,16 +1426,24 @@ function applySuggestingInsert(
1407
1426
  result.document,
1408
1427
  result.mapping,
1409
1428
  );
1429
+ const replacementSuggestionId = createSuggestingRevisionId(
1430
+ reviewState.document.review.revisions,
1431
+ context.timestamp,
1432
+ );
1410
1433
 
1411
1434
  // Step 3: Create deletion revision for the selected range (text stays in place).
1412
1435
  // Deletion range uses pre-mapping positions since content was not removed.
1413
- const deletionRevision = createAuthoredRevision(
1436
+ let deletionRevision = createAuthoredRevision(
1414
1437
  reviewState.document.review.revisions,
1415
1438
  "deletion",
1416
1439
  from,
1417
1440
  to,
1418
1441
  authorId,
1419
1442
  context.timestamp,
1443
+ createSuggestionMetadata({
1444
+ suggestionId: replacementSuggestionId,
1445
+ semanticKind: "replacement",
1446
+ }),
1420
1447
  );
1421
1448
 
1422
1449
  // Step 4: Create insertion revision for the new text (positions already correct)
@@ -1430,7 +1457,19 @@ function applySuggestingInsert(
1430
1457
  insertedTo,
1431
1458
  authorId,
1432
1459
  context.timestamp,
1460
+ createSuggestionMetadata({
1461
+ suggestionId: replacementSuggestionId,
1462
+ semanticKind: "replacement",
1463
+ linkedRevisionIds: [deletionRevision.changeId],
1464
+ }),
1433
1465
  );
1466
+ deletionRevision = {
1467
+ ...deletionRevision,
1468
+ metadata: {
1469
+ ...deletionRevision.metadata,
1470
+ linkedRevisionIds: [insertionRevision.changeId],
1471
+ },
1472
+ };
1434
1473
 
1435
1474
  const finalDocument: CanonicalDocumentEnvelope = {
1436
1475
  ...reviewState.document,
@@ -1579,6 +1618,9 @@ function applySuggestingDelete(
1579
1618
  deleteTo,
1580
1619
  authorId,
1581
1620
  context.timestamp,
1621
+ createSuggestionMetadata({
1622
+ semanticKind: "deletion",
1623
+ }),
1582
1624
  );
1583
1625
 
1584
1626
  const nextDocument: CanonicalDocumentEnvelope = {
@@ -1644,9 +1686,12 @@ function applySuggestingInsertUnit(
1644
1686
  result.document,
1645
1687
  result.mapping,
1646
1688
  );
1689
+ const replacementSuggestionId = from !== to
1690
+ ? createSuggestingRevisionId(reviewState.document.review.revisions, context.timestamp)
1691
+ : undefined;
1647
1692
 
1648
1693
  // If non-collapsed, mark selected range as deletion (positions are pre-mapping, content preserved)
1649
- const deletionRevision = from !== to
1694
+ let deletionRevision = from !== to
1650
1695
  ? createAuthoredRevision(
1651
1696
  reviewState.document.review.revisions,
1652
1697
  "deletion",
@@ -1654,6 +1699,10 @@ function applySuggestingInsertUnit(
1654
1699
  to,
1655
1700
  authorId,
1656
1701
  context.timestamp,
1702
+ createSuggestionMetadata({
1703
+ suggestionId: replacementSuggestionId,
1704
+ semanticKind: "replacement",
1705
+ }),
1657
1706
  )
1658
1707
  : undefined;
1659
1708
 
@@ -1668,7 +1717,27 @@ function applySuggestingInsertUnit(
1668
1717
  insertPos + 1,
1669
1718
  authorId,
1670
1719
  context.timestamp,
1720
+ createSuggestionMetadata(
1721
+ from !== to
1722
+ ? {
1723
+ suggestionId: replacementSuggestionId,
1724
+ semanticKind: "replacement",
1725
+ linkedRevisionIds: deletionRevision ? [deletionRevision.changeId] : undefined,
1726
+ }
1727
+ : {
1728
+ semanticKind: "insertion",
1729
+ },
1730
+ ),
1671
1731
  );
1732
+ if (deletionRevision) {
1733
+ deletionRevision = {
1734
+ ...deletionRevision,
1735
+ metadata: {
1736
+ ...deletionRevision.metadata,
1737
+ linkedRevisionIds: [insertionRevision.changeId],
1738
+ },
1739
+ };
1740
+ }
1672
1741
 
1673
1742
  const finalDocument: CanonicalDocumentEnvelope = {
1674
1743
  ...reviewState.document,
@@ -1705,6 +1774,82 @@ function applySuggestingInsertUnit(
1705
1774
  );
1706
1775
  }
1707
1776
 
1777
+ function applySuggestingParagraphSplit(
1778
+ state: EditorState,
1779
+ context: CommandExecutionContext,
1780
+ ): EditorTransaction | undefined {
1781
+ if (state.readOnly) {
1782
+ return createTransaction(state, { historyBoundary: "skip", markDirty: false });
1783
+ }
1784
+
1785
+ const selection = state.selection;
1786
+ const from = Math.min(selection.anchor, selection.head);
1787
+ const to = Math.max(selection.anchor, selection.head);
1788
+ if (from !== to) {
1789
+ return createSuggestingUnsupportedTransaction(
1790
+ state,
1791
+ "Suggesting mode paragraph split currently requires a collapsed selection.",
1792
+ );
1793
+ }
1794
+
1795
+ const authorId = context.defaultAuthorId ?? "unknown";
1796
+ const result = splitParagraph(state.document, selection, { timestamp: context.timestamp });
1797
+
1798
+ const reviewState = remapReviewStateAfterContentChange(
1799
+ state,
1800
+ result.document,
1801
+ result.mapping,
1802
+ );
1803
+ const revision = createAuthoredRevision(
1804
+ reviewState.document.review.revisions,
1805
+ "insertion",
1806
+ from,
1807
+ from,
1808
+ authorId,
1809
+ context.timestamp,
1810
+ {
1811
+ ...createSuggestionMetadata({
1812
+ semanticKind: "structural-change",
1813
+ }),
1814
+ importedRevisionForm: "paragraph-insertion",
1815
+ originalRevisionType: "paragraph-ins",
1816
+ },
1817
+ );
1818
+
1819
+ const finalDocument: CanonicalDocumentEnvelope = {
1820
+ ...reviewState.document,
1821
+ review: {
1822
+ ...reviewState.document.review,
1823
+ revisions: {
1824
+ ...reviewState.document.review.revisions,
1825
+ [revision.changeId]: revision,
1826
+ },
1827
+ },
1828
+ };
1829
+
1830
+ return createTransaction(
1831
+ {
1832
+ ...state,
1833
+ document: finalDocument,
1834
+ selection: result.selection,
1835
+ warnings: reviewState.warnings,
1836
+ runtime: {
1837
+ ...state.runtime,
1838
+ activeCommentId: reviewState.activeCommentId,
1839
+ },
1840
+ },
1841
+ {
1842
+ historyBoundary: "push",
1843
+ markDirty: true,
1844
+ mapping: result.mapping,
1845
+ effects: {
1846
+ ...reviewState.effects,
1847
+ revisionAuthored: { changeId: revision.changeId, kind: "insertion" },
1848
+ },
1849
+ },
1850
+ );
1851
+ }
1852
+
1708
1853
  function findOverlappingAuthoredDeletion(
1709
1854
  revisions: Record<string, CanonicalRevisionRecord>,
1710
1855
  from: number,
@@ -1732,26 +1877,3 @@ function findOverlappingAuthoredDeletion(
1732
1877
  }
1733
1878
  return undefined;
1734
1879
  }
1735
-
1736
- /**
1737
- * Find an open authored insertion revision whose end position equals `cursorAt`.
1738
- * Used to extend an existing insertion when the user keeps typing at the same
1739
- * position rather than creating a new revision for every character.
1740
- */
1741
- function findAdjacentAuthoredInsertion(
1742
- revisions: Record<string, CanonicalRevisionRecord>,
1743
- cursorAt: number,
1744
- ): CanonicalRevisionRecord | undefined {
1745
- for (const revision of Object.values(revisions)) {
1746
- if (
1747
- revision.kind === "insertion" &&
1748
- revision.status === "open" &&
1749
- revision.metadata?.source === "runtime" &&
1750
- revision.anchor.kind === "range" &&
1751
- revision.anchor.range.to === cursorAt
1752
- ) {
1753
- return revision;
1754
- }
1755
- }
1756
- return undefined;
1757
- }
@@ -1,4 +1,8 @@
1
- import type { RuntimeRenderSnapshot as PublicRuntimeRenderSnapshot } from "../../api/public-types";
1
+ import type {
2
+ RuntimeRenderSnapshot as PublicRuntimeRenderSnapshot,
3
+ TableOperationCapabilitySnapshot,
4
+ TableStructureContextSnapshot,
5
+ } from "../../api/public-types";
2
6
  import type {
3
7
  DocumentRootNode,
4
8
  ParagraphNode,
@@ -82,6 +86,110 @@ export function applyTableStructureOperation(
82
86
  }
83
87
  }
84
88
 
89
+ export function getTableStructureContext(
90
+ document: CanonicalDocumentEnvelope,
91
+ snapshot: PublicRuntimeRenderSnapshot,
92
+ selectionDescriptor: TableSelectionDescriptor | null,
93
+ ): TableStructureContextSnapshot | null {
94
+ const root = document.content;
95
+ if (!root || root.type !== "doc") {
96
+ return null;
97
+ }
98
+
99
+ const effectiveSelection = selectionDescriptor ?? resolveTableSelectionFromSnapshot(snapshot);
100
+ if (!effectiveSelection) {
101
+ return null;
102
+ }
103
+
104
+ const target = root.children[effectiveSelection.tableBlockIndex];
105
+ if (!target || target.type !== "table") {
106
+ return null;
107
+ }
108
+
109
+ const row = target.rows[effectiveSelection.anchorCell.rowIndex];
110
+ const cellRef = row
111
+ ? findCellAtColumn(row, effectiveSelection.anchorCell.columnIndex)
112
+ : null;
113
+ if (!row || !cellRef) {
114
+ return null;
115
+ }
116
+
117
+ const simpleTable = isSimpleTable(target);
118
+ const columnCount = getLogicalColumnCount(target);
119
+ const splitWidth = Math.max(1, cellRef.cell.gridSpan ?? 1);
120
+ const splitHeight = Math.max(
121
+ 1,
122
+ computeRowSpan(
123
+ target,
124
+ effectiveSelection.anchorCell.rowIndex,
125
+ effectiveSelection.anchorCell.columnIndex,
126
+ splitWidth,
127
+ ),
128
+ );
129
+ const selectedCellCount =
130
+ effectiveSelection.selectionKind === "cell"
131
+ ? Math.max(
132
+ 1,
133
+ (effectiveSelection.rect.bottom - effectiveSelection.rect.top) *
134
+ (effectiveSelection.rect.right - effectiveSelection.rect.left),
135
+ )
136
+ : 1;
137
+
138
+ return {
139
+ tableBlockIndex: effectiveSelection.tableBlockIndex,
140
+ currentStyleId: target.styleId ?? null,
141
+ selectionKind: effectiveSelection.selectionKind,
142
+ rowCount: target.rows.length,
143
+ columnCount,
144
+ selectedCellCount,
145
+ isSimpleTable: simpleTable,
146
+ currentCell: {
147
+ rowIndex: effectiveSelection.anchorCell.rowIndex,
148
+ columnIndex: effectiveSelection.anchorCell.columnIndex,
149
+ isHeader: row.isHeader === true,
150
+ },
151
+ operations: {
152
+ setTableStyle: enabledCapability(),
153
+ setCellBackground: enabledCapability(),
154
+ addRowBefore: simpleTable
155
+ ? enabledCapability()
156
+ : disabledCapability("Only simple rectangular tables support row insertion right now."),
157
+ addRowAfter: simpleTable
158
+ ? enabledCapability()
159
+ : disabledCapability("Only simple rectangular tables support row insertion right now."),
160
+ deleteRow:
161
+ !simpleTable
162
+ ? disabledCapability("Only simple rectangular tables support row deletion right now.")
163
+ : target.rows.length <= 1
164
+ ? disabledCapability("At least two rows are required before a row can be deleted.")
165
+ : enabledCapability(),
166
+ addColumnBefore: simpleTable
167
+ ? enabledCapability()
168
+ : disabledCapability("Only simple rectangular tables support column insertion right now."),
169
+ addColumnAfter: simpleTable
170
+ ? enabledCapability()
171
+ : disabledCapability("Only simple rectangular tables support column insertion right now."),
172
+ deleteColumn:
173
+ !simpleTable
174
+ ? disabledCapability("Only simple rectangular tables support column deletion right now.")
175
+ : columnCount <= 1
176
+ ? disabledCapability("At least two columns are required before a column can be deleted.")
177
+ : enabledCapability(),
178
+ mergeCells:
179
+ !simpleTable
180
+ ? disabledCapability("Only simple rectangular tables support merging right now.")
181
+ : effectiveSelection.selectionKind !== "cell" || selectedCellCount <= 1
182
+ ? disabledCapability("Select more than one cell to merge them.")
183
+ : enabledCapability(),
184
+ splitCell:
185
+ splitWidth === 1 && splitHeight === 1
186
+ ? disabledCapability("Select a merged or spanning cell to split it.")
187
+ : enabledCapability(),
188
+ deleteTable: enabledCapability(),
189
+ },
190
+ };
191
+ }
192
+
85
193
  function addRow(
86
194
  document: CanonicalDocumentEnvelope,
87
195
  root: DocumentRootNode,
@@ -405,13 +513,27 @@ function setCellBackground(
405
513
  return createNoopStructuralMutation(document, fallbackSelection);
406
514
  }
407
515
 
516
+ const activeRow = table.rows[selection.anchorCell.rowIndex];
517
+ const activeCellRef = activeRow
518
+ ? findCellAtColumn(activeRow, selection.anchorCell.columnIndex)
519
+ : null;
520
+ const activeCellWidth = Math.max(1, activeCellRef?.cell.gridSpan ?? 1);
521
+ const activeCellHeight = Math.max(
522
+ 1,
523
+ computeRowSpan(
524
+ table,
525
+ selection.anchorCell.rowIndex,
526
+ selection.anchorCell.columnIndex,
527
+ activeCellWidth,
528
+ ),
529
+ );
408
530
  const targetRect =
409
531
  selection.selectionKind === "text"
410
532
  ? {
411
- top: 0,
412
- left: 0,
413
- bottom: table.rows.length,
414
- right: getLogicalColumnCount(table),
533
+ top: selection.anchorCell.rowIndex,
534
+ left: selection.anchorCell.columnIndex,
535
+ bottom: selection.anchorCell.rowIndex + activeCellHeight,
536
+ right: selection.anchorCell.columnIndex + activeCellWidth,
415
537
  }
416
538
  : selection.rect;
417
539
  let changed = false;
@@ -611,6 +733,17 @@ function toInternalSelectionSnapshot(
611
733
  };
612
734
  }
613
735
 
736
+ function enabledCapability(): TableOperationCapabilitySnapshot {
737
+ return { enabled: true };
738
+ }
739
+
740
+ function disabledCapability(reason: string): TableOperationCapabilitySnapshot {
741
+ return {
742
+ enabled: false,
743
+ reason,
744
+ };
745
+ }
746
+
614
747
  function isSimpleTable(table: TableNode): boolean {
615
748
  const width = getLogicalColumnCount(table);
616
749
  return table.rows.every((row) => {