@stll/folio-core 0.32.1 → 0.33.0

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 (222) hide show
  1. package/dist/ai-edits/__fixtures__/paragraphs.d.ts +17 -0
  2. package/dist/ai-edits/__fixtures__/paragraphs.js +33 -0
  3. package/dist/ai-edits/apply.d.ts +49 -3
  4. package/dist/ai-edits/apply.js +692 -75
  5. package/dist/ai-edits/blockRange.d.ts +7 -3
  6. package/dist/ai-edits/blockRange.js +14 -6
  7. package/dist/ai-edits/headless.d.ts +39 -5
  8. package/dist/ai-edits/headless.js +76 -17
  9. package/dist/ai-edits/index.d.ts +6 -6
  10. package/dist/ai-edits/index.js +3 -3
  11. package/dist/ai-edits/read.d.ts +4 -0
  12. package/dist/ai-edits/read.js +30 -6
  13. package/dist/ai-edits/scoped-reading.js +4 -3
  14. package/dist/ai-edits/snapshot.d.ts +42 -2
  15. package/dist/ai-edits/snapshot.js +127 -62
  16. package/dist/ai-edits/table-row-column-mutations.d.ts +24 -1
  17. package/dist/ai-edits/table-row-column-mutations.js +98 -6
  18. package/dist/ai-edits/table-targets.d.ts +12 -3
  19. package/dist/ai-edits/table-targets.js +15 -5
  20. package/dist/ai-edits/types.d.ts +235 -7
  21. package/dist/ai-edits/word-diff.d.ts +54 -11
  22. package/dist/ai-edits/word-diff.js +221 -61
  23. package/dist/compare/__fixtures__/body-sequence.d.ts +33 -0
  24. package/dist/compare/__fixtures__/body-sequence.js +67 -0
  25. package/dist/compare/__fixtures__/nested-table.d.ts +16 -0
  26. package/dist/compare/__fixtures__/nested-table.js +50 -0
  27. package/dist/compare/__fixtures__/numbered-list.d.ts +34 -0
  28. package/dist/compare/__fixtures__/numbered-list.js +85 -0
  29. package/dist/compare/column-alignment.d.ts +27 -0
  30. package/dist/compare/column-alignment.js +162 -0
  31. package/dist/compare/compare.d.ts +107 -0
  32. package/dist/compare/compare.js +406 -0
  33. package/dist/compare/formatting.d.ts +29 -0
  34. package/dist/compare/formatting.js +84 -0
  35. package/dist/compare/plan.d.ts +22 -0
  36. package/dist/compare/plan.js +1059 -0
  37. package/dist/compare/reproducible-package.d.ts +17 -0
  38. package/dist/compare/reproducible-package.js +30 -0
  39. package/dist/compare/scenario.d.ts +77 -0
  40. package/dist/compare/scenario.js +263 -0
  41. package/dist/compare/types.d.ts +282 -0
  42. package/dist/compare/types.js +32 -0
  43. package/dist/compare/verification.d.ts +55 -0
  44. package/dist/compare/verification.js +146 -0
  45. package/dist/compat/eigenpal.d.ts +9 -6
  46. package/dist/compat/eigenpal.js +6 -3
  47. package/dist/controller/layoutPipeline.d.ts +13 -0
  48. package/dist/controller/layoutPipeline.js +15 -1
  49. package/dist/display-list/build/buildContext.d.ts +39 -0
  50. package/dist/display-list/build/buildContext.js +31 -0
  51. package/dist/display-list/build/buildDisplayList.d.ts +79 -0
  52. package/dist/display-list/build/buildDisplayList.js +283 -0
  53. package/dist/display-list/build/colors.d.ts +20 -0
  54. package/dist/display-list/build/colors.js +136 -0
  55. package/dist/display-list/build/floatingImages.d.ts +24 -0
  56. package/dist/display-list/build/floatingImages.js +41 -0
  57. package/dist/display-list/build/fontTable.d.ts +31 -0
  58. package/dist/display-list/build/fontTable.js +97 -0
  59. package/dist/display-list/build/furniture.d.ts +86 -0
  60. package/dist/display-list/build/furniture.js +98 -0
  61. package/dist/display-list/build/glyphs.d.ts +55 -0
  62. package/dist/display-list/build/glyphs.js +103 -0
  63. package/dist/display-list/build/headerFooterPrimitives.d.ts +17 -0
  64. package/dist/display-list/build/headerFooterPrimitives.js +40 -0
  65. package/dist/display-list/build/imagePrimitives.d.ts +35 -0
  66. package/dist/display-list/build/imagePrimitives.js +264 -0
  67. package/dist/display-list/build/pageBorderPrimitives.d.ts +23 -0
  68. package/dist/display-list/build/pageBorderPrimitives.js +111 -0
  69. package/dist/display-list/build/pageFurniture.d.ts +35 -0
  70. package/dist/display-list/build/pageFurniture.js +141 -0
  71. package/dist/display-list/build/paragraphPrimitives.d.ts +19 -0
  72. package/dist/display-list/build/paragraphPrimitives.js +819 -0
  73. package/dist/display-list/build/regions.d.ts +46 -0
  74. package/dist/display-list/build/regions.js +56 -0
  75. package/dist/display-list/build/storyPrimitives.d.ts +29 -0
  76. package/dist/display-list/build/storyPrimitives.js +279 -0
  77. package/dist/display-list/build/strokes.d.ts +19 -0
  78. package/dist/display-list/build/strokes.js +78 -0
  79. package/dist/display-list/build/tablePrimitives.d.ts +38 -0
  80. package/dist/display-list/build/tablePrimitives.js +409 -0
  81. package/dist/display-list/build/textBoxPrimitives.d.ts +14 -0
  82. package/dist/display-list/build/textBoxPrimitives.js +129 -0
  83. package/dist/display-list/build/textDecorations.d.ts +29 -0
  84. package/dist/display-list/build/textDecorations.js +29 -0
  85. package/dist/display-list/build/unsupported.d.ts +57 -0
  86. package/dist/display-list/build/unsupported.js +0 -0
  87. package/dist/display-list/build/watermarkPrimitives.d.ts +15 -0
  88. package/dist/display-list/build/watermarkPrimitives.js +128 -0
  89. package/dist/display-list/dom/renderDisplayListToDom.d.ts +26 -0
  90. package/dist/display-list/dom/renderDisplayListToDom.js +620 -0
  91. package/dist/display-list/editor/displayListPagePainter.d.ts +22 -0
  92. package/dist/display-list/editor/displayListPagePainter.js +63 -0
  93. package/dist/display-list/editor/pageRenderer.d.ts +24 -0
  94. package/dist/display-list/editor/pageRenderer.js +36 -0
  95. package/dist/display-list/primitives.d.ts +95 -0
  96. package/dist/display-list/primitives.js +127 -0
  97. package/dist/display-list/types.d.ts +472 -0
  98. package/dist/display-list/types.js +0 -0
  99. package/dist/document-operations.d.ts +42 -9
  100. package/dist/document-operations.js +171 -16
  101. package/dist/document-stories.d.ts +16 -0
  102. package/dist/document-stories.js +48 -4
  103. package/dist/docx/packageParts.d.ts +35 -0
  104. package/dist/docx/packageParts.js +120 -0
  105. package/dist/docx/paragraphParser.js +22 -22
  106. package/dist/docx/paragraphTraversal.js +1 -1
  107. package/dist/docx/renderedPageBreakNormalization.js +3 -1
  108. package/dist/docx/revisionIdNormalization.d.ts +13 -0
  109. package/dist/docx/revisionIdNormalization.js +103 -0
  110. package/dist/docx/rezip.js +26 -17
  111. package/dist/docx/selectiveSave.js +2 -2
  112. package/dist/docx/serializer/commentSerializer.js +6 -1
  113. package/dist/docx/serializer/paragraphSerializer.js +33 -4
  114. package/dist/docx/serializer/runSerializer.js +11 -10
  115. package/dist/docx/serializer/tableSerializer.js +2 -1
  116. package/dist/docx/server/applyDocxXmlPatchProposal.js +1 -1
  117. package/dist/docx/server/createBilingualDocx.js +3 -2
  118. package/dist/docx/streamingXmlParser.d.ts +13 -1
  119. package/dist/docx/streamingXmlParser.js +49 -11
  120. package/dist/docx/unzip.d.ts +1 -2
  121. package/dist/docx/unzip.js +25 -6
  122. package/dist/docx/xmlParser.d.ts +3 -1
  123. package/dist/docx/xmlParser.js +33 -27
  124. package/dist/export-pdf.d.ts +43 -0
  125. package/dist/export-pdf.js +98 -0
  126. package/dist/fonts/headlessMeasure.d.ts +76 -0
  127. package/dist/fonts/headlessMeasure.js +0 -0
  128. package/dist/fonts/sfnt/parse.d.ts +51 -0
  129. package/dist/fonts/sfnt/parse.js +525 -0
  130. package/dist/fonts/sfnt/subset.d.ts +20 -0
  131. package/dist/fonts/sfnt/subset.js +350 -0
  132. package/dist/fonts/sfnt/tables.d.ts +88 -0
  133. package/dist/fonts/sfnt/tables.js +131 -0
  134. package/dist/fonts/sfnt/woff.d.ts +14 -0
  135. package/dist/fonts/sfnt/woff.js +163 -0
  136. package/dist/generated/text_shaper.js +324 -0
  137. package/dist/generated/text_shaper_bg.wasm +0 -0
  138. package/dist/headless-layout.d.ts +75 -0
  139. package/dist/headless-layout.js +350 -0
  140. package/dist/index.d.ts +9 -6
  141. package/dist/index.js +6 -3
  142. package/dist/layout-bridge/convert/footnoteLayout.d.ts +1 -0
  143. package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
  144. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -0
  145. package/dist/layout-bridge/convert/headerFooterLayout.js +2 -0
  146. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +3 -1
  147. package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
  148. package/dist/layout-engine/measure/advanceComposition.d.ts +62 -0
  149. package/dist/layout-engine/measure/advanceComposition.js +148 -0
  150. package/dist/layout-engine/measure/cache.d.ts +2 -0
  151. package/dist/layout-engine/measure/measureContainer.js +13 -0
  152. package/dist/layout-engine/measure/measureParagraph.js +1 -2
  153. package/dist/layout-engine/measure/measureTypes.d.ts +12 -0
  154. package/dist/layout-engine/measure/tableInlinePlacement.d.ts +3 -2
  155. package/dist/layout-engine/measure/tableInlinePlacement.js +14 -1
  156. package/dist/layout-engine/tableIndentCompatibility.d.ts +5 -0
  157. package/dist/layout-engine/tableIndentCompatibility.js +15 -0
  158. package/dist/layout-engine/types.d.ts +8 -0
  159. package/dist/layout-painter/renderPage.d.ts +29 -1
  160. package/dist/layout-painter/renderPage.js +47 -19
  161. package/dist/layout-painter/renderParagraph.d.ts +21 -2
  162. package/dist/layout-painter/renderParagraph.js +1 -1
  163. package/dist/layout-painter/renderTable.js +37 -4
  164. package/dist/managers/editorShortcuts.d.ts +33 -1
  165. package/dist/managers/editorShortcuts.js +20 -1
  166. package/dist/markdown/renderRuns.js +7 -2
  167. package/dist/markdown/renderTable.js +5 -1
  168. package/dist/model.d.ts +3 -3
  169. package/dist/model.js +2 -2
  170. package/dist/pdf/contentStream.d.ts +73 -0
  171. package/dist/pdf/contentStream.js +143 -0
  172. package/dist/pdf/fonts.d.ts +96 -0
  173. package/dist/pdf/fonts.js +593 -0
  174. package/dist/pdf/images.d.ts +28 -0
  175. package/dist/pdf/images.js +439 -0
  176. package/dist/pdf/objects.d.ts +93 -0
  177. package/dist/pdf/objects.js +237 -0
  178. package/dist/pdf/pageSpace.d.ts +43 -0
  179. package/dist/pdf/pageSpace.js +57 -0
  180. package/dist/pdf/paint.d.ts +32 -0
  181. package/dist/pdf/paint.js +442 -0
  182. package/dist/pdf/writePdf.d.ts +56 -0
  183. package/dist/pdf/writePdf.js +345 -0
  184. package/dist/prosemirror/commands/comments.js +91 -6
  185. package/dist/prosemirror/conversion/fromProseDoc.js +41 -7
  186. package/dist/prosemirror/conversion/toProseDoc.js +41 -4
  187. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +15 -2
  188. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +35 -6
  189. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +2 -2
  190. package/dist/prosemirror/extensions/features/pasteCleanup.js +4 -3
  191. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +3 -1
  192. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +5 -3
  193. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.d.ts +7 -1
  194. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.js +9 -3
  195. package/dist/prosemirror/extensions/nodes/TableExtension.js +56 -39
  196. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +3 -1
  197. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +5 -3
  198. package/dist/prosemirror/plugins/suggestionMode.js +1 -0
  199. package/dist/prosemirror/revisionCarriers.js +19 -2
  200. package/dist/prosemirror/utils/extractTrackedChanges.d.ts +2 -2
  201. package/dist/prosemirror/utils/extractTrackedChanges.js +59 -23
  202. package/dist/prosemirror/validation.js +55 -27
  203. package/dist/prosemirror/zeroWidthAnchors.d.ts +10 -0
  204. package/dist/prosemirror/zeroWidthAnchors.js +23 -0
  205. package/dist/redline.js +12 -79
  206. package/dist/server.d.ts +4 -4
  207. package/dist/server.js +2 -2
  208. package/dist/shaping/placeRun.d.ts +51 -0
  209. package/dist/shaping/placeRun.js +84 -0
  210. package/dist/shaping/shaper.d.ts +92 -0
  211. package/dist/shaping/shaper.js +102 -0
  212. package/dist/types/block-id.d.ts +18 -2
  213. package/dist/types/block-id.js +49 -5
  214. package/dist/types/content.d.ts +2 -2
  215. package/dist/utils/clipboard.js +4 -3
  216. package/dist/utils/fontResolver.js +183 -0
  217. package/dist/utils/htmlComments.d.ts +17 -0
  218. package/dist/utils/htmlComments.js +22 -0
  219. package/dist/utils/scriptSegments.d.ts +12 -1
  220. package/dist/utils/scriptSegments.js +17 -1
  221. package/dist/version-comparison.d.ts +1 -1
  222. package/package.json +7 -3
@@ -1,16 +1,22 @@
1
- import { expectRunPropertyChangeMarkAttrs } from "../prosemirror/attrs/index.js";
1
+ import { expectParagraphAttrs, expectRunPropertyChangeMarkAttrs } from "../prosemirror/attrs/index.js";
2
+ import { PPR_CHANGE_SCOPED_ATTR_KEYS } from "../prosemirror/commands/propertyChangeScope.js";
2
3
  import { marksToTextFormatting } from "../prosemirror/conversion/fromProseDoc.js";
4
+ import { requestDeterministicParaIds } from "../prosemirror/extensions/features/ParaIdAllocatorExtension.js";
5
+ import { markStructuralChange } from "../prosemirror/extensions/features/ParagraphChangeTrackerExtension.js";
6
+ import { isZeroWidthAnchor } from "../prosemirror/zeroWidthAnchors.js";
3
7
  import { getFolioParaIdFromBlockId } from "../types/block-id.js";
4
8
  import { stripBlockIdentityAttrs } from "./block-identity.js";
5
9
  import { buildCleanBlockText } from "./clean-text.js";
6
10
  import { hasInlineEmphasis, parseInlineEmphasisRuns, stripInlineEmphasisMarkers } from "./inline-emphasis.js";
7
- import { hashFolioAIBlockText, normalizeFolioAIBlockText } from "./snapshot.js";
11
+ import { hashFolioAIBlockText, isHiddenTableRow, normalizeFolioAIBlockText } from "./snapshot.js";
8
12
  import { mergeTableRectangle, mergeTrackedVerticalTableCells, splitTableRectangle, splitTrackedVerticalTableCell } from "./table-cell-mutations.js";
9
13
  import { planTableMutations } from "./table-mutation-plan.js";
10
- import { applyTableColumnDeletion, applyTableColumnInsertion, applyTableRowDeletion, applyTableRowInsertion, findTableColumnInsertion, findTableRowInsertion, getTableColumnCoordinateKey } from "./table-row-column-mutations.js";
11
- import { findEnclosingTableBoundary, findEnclosingTableCell, findEnclosingTableRow, tableRectangleCutsMergedCell } from "./table-targets.js";
14
+ import { applyTableColumnDeletion, applyTableColumnInsertion, applyTableRowDeletion, applyTableRowInsertion, findTableColumnInsertion, findTableRowInsertion, getTableColumnCoordinateKey, splitCellParagraphTexts } from "./table-row-column-mutations.js";
15
+ import { findEnclosingTableCell, findEnclosingTableRow, findOutermostTableBoundary, tableRectangleCutsMergedCell } from "./table-targets.js";
12
16
  import { diffWordSegments } from "./word-diff.js";
17
+ import { panic } from "better-result";
13
18
  import { TableMap } from "prosemirror-tables";
19
+ import { canJoin, canSplit } from "prosemirror-transform";
14
20
  //#region src/ai-edits/apply.ts
15
21
  /**
16
22
  * Operation types applied in `"suggested"` mode. Every produced revision — an
@@ -33,6 +39,106 @@ const SUGGESTED_SUPPORTED_OPERATION_TYPES = /* @__PURE__ */ new Set([
33
39
  "insertTableColumn",
34
40
  "deleteTableColumn"
35
41
  ]);
42
+ /**
43
+ * The attrs that render a list label, cleared together whenever a paragraph
44
+ * stops being a list item. `w:numPr` alone is not enough: the marker attrs the
45
+ * editor caches would keep drawing a label on a paragraph that is no longer in
46
+ * the list. One frozen record rather than a list repeated at each call site,
47
+ * because a list repeated is a list that drifts.
48
+ */
49
+ const CLEARED_LIST_MARKER_ATTRS = Object.freeze({
50
+ listMarker: null,
51
+ listMarkerHidden: null,
52
+ listLevelNumFmts: null,
53
+ listLevelStarts: null,
54
+ listAbstractNumId: null,
55
+ listStartOverride: null
56
+ });
57
+ /**
58
+ * The attrs one `setBlockParagraphProperties` writes, or `null` when the
59
+ * block already holds them. `styleId: null` clears the style; `listLevel`
60
+ * moves `w:numPr/w:ilvl` and leaves `w:numId` alone, because a demoted item
61
+ * stays in the same list; `listLevel: null` drops `w:numPr` entirely, which
62
+ * is a paragraph that stopped being a list item.
63
+ */
64
+ const paragraphPropertiesPatch = (node, properties) => {
65
+ const patch = {};
66
+ if (properties.styleId !== void 0 && (node.attrs["styleId"] ?? null) !== properties.styleId) patch["styleId"] = properties.styleId;
67
+ if (properties.listLevel !== void 0) {
68
+ const numPr = node.attrs["numPr"];
69
+ const current = typeof numPr === "object" && numPr !== null && "ilvl" in numPr ? numPr.ilvl : void 0;
70
+ if (properties.listLevel === null) {
71
+ if (numPr !== null && numPr !== void 0) {
72
+ patch["numPr"] = null;
73
+ Object.assign(patch, CLEARED_LIST_MARKER_ATTRS);
74
+ }
75
+ } else if (current !== properties.listLevel) {
76
+ const numId = typeof numPr === "object" && numPr !== null && "numId" in numPr ? numPr.numId : void 0;
77
+ patch["numPr"] = {
78
+ ...typeof numId === "number" && { numId },
79
+ ilvl: properties.listLevel
80
+ };
81
+ }
82
+ }
83
+ return Object.keys(patch).length > 0 ? patch : null;
84
+ };
85
+ const REVISION_MARK_NAMES = /* @__PURE__ */ new Set(["insertion", "deletion"]);
86
+ /**
87
+ * Strip insertion and deletion marks from the zero-width anchors the batch
88
+ * touched.
89
+ *
90
+ * An operation marks a RANGE, and a zero-width anchor between two words is
91
+ * inside it. The operation did not create or delete that anchor, so the anchor
92
+ * must keep its existing revision ownership. Marking is by range everywhere;
93
+ * the guard lives here once rather than at every call site.
94
+ */
95
+ const withoutRevisionsOnZeroWidthAnchors = (tr, batchRevisionIds) => {
96
+ const anchors = [];
97
+ tr.doc.descendants((node, pos) => {
98
+ if (!isZeroWidthAnchor(node)) return true;
99
+ const marks = node.marks.filter(({ attrs, type }) => REVISION_MARK_NAMES.has(type.name) && batchRevisionIds.has(attrs["revisionId"]));
100
+ if (marks.length > 0) anchors.push({
101
+ from: pos,
102
+ to: pos + node.nodeSize,
103
+ marks
104
+ });
105
+ return false;
106
+ });
107
+ for (const { from, to, marks } of anchors) for (const mark of marks) tr = tr.removeMark(from, to, mark);
108
+ return tr;
109
+ };
110
+ /**
111
+ * The block's non-text inline children a block deletion still has to mark, as
112
+ * positions in `tr.doc`.
113
+ *
114
+ * Skips what is already deleted, so an earlier revision's run is not marked
115
+ * twice, and skips zero-width anchors because the operation did not create or
116
+ * delete them; their existing revision ownership must remain unchanged.
117
+ */
118
+ const undeletedContentAtomRanges = (tr, blockFrom) => {
119
+ const position = tr.mapping.map(blockFrom);
120
+ const block = tr.doc.nodeAt(position);
121
+ if (!block?.isTextblock) return [];
122
+ const ranges = [];
123
+ block.forEach((child, offset) => {
124
+ if (child.isText || isZeroWidthAnchor(child) || child.marks.some(({ type }) => type.name === "deletion")) return;
125
+ const from = position + 1 + offset;
126
+ ranges.push({
127
+ from,
128
+ to: from + child.nodeSize
129
+ });
130
+ });
131
+ return ranges;
132
+ };
133
+ /** The in-scope paragraph properties as they stand, for a `w:pPrChange` record. */
134
+ const paragraphPropertiesSnapshot = (node) => {
135
+ const snapshot = {};
136
+ for (const key of PPR_CHANGE_SCOPED_ATTR_KEYS) {
137
+ const value = node.attrs[key];
138
+ if (value !== null && value !== void 0) snapshot[key] = value;
139
+ }
140
+ return snapshot;
141
+ };
36
142
  const applyReplaceBlockStyleId = ({ item, tr }) => {
37
143
  if (item.operation.type !== "replaceBlock" || item.operation.styleId === void 0) return tr;
38
144
  const block = tr.doc.nodeAt(item.blockFrom);
@@ -143,12 +249,31 @@ const applyTrackedInlineFormatting = ({ tr, schema, doc, from, to, formatting, r
143
249
  * uniqueness across overlapping calls in the same JS realm.
144
250
  */
145
251
  let revisionIdCursor = Date.now() * 1e3;
146
- const nextRevisionSeed = (count) => {
252
+ const nextRevisionSeed = (revisionIdCount) => {
147
253
  const start = revisionIdCursor;
148
- revisionIdCursor += Math.max(count, 1) * 4;
254
+ revisionIdCursor += Math.max(revisionIdCount, 1);
149
255
  return start;
150
256
  };
151
257
  /**
258
+ * Ids one resolved operation may allocate from the shared revision-id range
259
+ * reserved by `nextRevisionSeed`. Most operation types allocate at most
260
+ * three (delete + insert + background-format clearing); reserving four per
261
+ * operation is a safe cushion above that. A multi-paragraph
262
+ * `insertAfterBlock` / `insertBeforeBlock` (`text` split on line breaks,
263
+ * see `splitInsertParagraphTexts`) allocates one id per paragraph in
264
+ * tracked-changes mode plus one for each paragraph MARK it brings, so it
265
+ * needs more than four once split into more than two paragraphs — reserving
266
+ * less than that would let a later `nextRevisionSeed` call reuse an id this
267
+ * operation already stamped on the document.
268
+ */
269
+ const REVISION_IDS_PER_OPERATION = 4;
270
+ /** Ids one inserted paragraph allocates: its runs, and its paragraph mark. */
271
+ const REVISION_IDS_PER_INSERTED_PARAGRAPH = 2;
272
+ const estimateRevisionIdReservation = (item) => {
273
+ if (item.operation.type === "insertAfterBlock" || item.operation.type === "insertBeforeBlock") return Math.max((item.insertTexts?.length ?? 1) * REVISION_IDS_PER_INSERTED_PARAGRAPH, REVISION_IDS_PER_OPERATION);
274
+ return REVISION_IDS_PER_OPERATION;
275
+ };
276
+ /**
152
277
  * Walk the live doc once and bucket every textblock by its
153
278
  * normalised text hash. Resolution then maps each snapshot anchor
154
279
  * to the live block at the same ordinal among same-hash siblings —
@@ -159,7 +284,8 @@ const nextRevisionSeed = (count) => {
159
284
  const collectLiveBlocksByHash = (doc) => {
160
285
  const byHash = /* @__PURE__ */ new Map();
161
286
  doc.descendants((node, pos) => {
162
- if (!node.isTextblock) return;
287
+ if (isHiddenTableRow(node)) return false;
288
+ if (!node.isTextblock) return true;
163
289
  const cleanText = buildCleanBlockText(node, pos).text;
164
290
  const hash = hashFolioAIBlockText(normalizeFolioAIBlockText(cleanText));
165
291
  const bucket = byHash.get(hash) ?? [];
@@ -169,6 +295,7 @@ const collectLiveBlocksByHash = (doc) => {
169
295
  node
170
296
  });
171
297
  byHash.set(hash, bucket);
298
+ return false;
172
299
  });
173
300
  return byHash;
174
301
  };
@@ -183,14 +310,16 @@ const collectLiveBlocksByHash = (doc) => {
183
310
  const collectLiveBlocksByParaId = (doc) => {
184
311
  const byParaId = /* @__PURE__ */ new Map();
185
312
  doc.descendants((node, pos) => {
186
- if (!node.isTextblock) return;
313
+ if (isHiddenTableRow(node)) return false;
314
+ if (!node.isTextblock) return true;
187
315
  const paraId = node.attrs["paraId"];
188
- if (typeof paraId !== "string" || paraId.length === 0) return;
316
+ if (typeof paraId !== "string" || paraId.length === 0) return false;
189
317
  if (!byParaId.has(paraId)) byParaId.set(paraId, {
190
318
  from: pos,
191
319
  to: pos + node.nodeSize,
192
320
  node
193
321
  });
322
+ return false;
194
323
  });
195
324
  return byParaId;
196
325
  };
@@ -234,6 +363,21 @@ const ordinalAmongSameHash = (snapshot, blockId) => {
234
363
  */
235
364
  const SIGNATURE_LINE = "_".repeat(28);
236
365
  /**
366
+ * A plain table from a grid of cell texts, `null` when the schema has no
367
+ * tables. In tracked mode every row carries `trIns`, which is how Word says
368
+ * "this table is new": there is no whole-table insertion element, only rows
369
+ * that were inserted.
370
+ */
371
+ const buildTableNode = ({ schema, rows, revision }) => {
372
+ const paragraphType = schema.nodes["paragraph"];
373
+ const cellType = schema.nodes["tableCell"];
374
+ const rowType = schema.nodes["tableRow"];
375
+ const tableType = schema.nodes["table"];
376
+ if (!paragraphType || !cellType || !rowType || !tableType || rows.length === 0) return null;
377
+ const rowNodes = rows.map((cells) => rowType.create(revision ? { trIns: revision } : null, cells.map((text) => cellType.create(null, splitCellParagraphTexts(text).map((line) => paragraphType.create(null, line.length > 0 ? schema.text(line) : null))))));
378
+ return tableType.create(null, rowNodes);
379
+ };
380
+ /**
237
381
  * Build a borderless PM table mirroring docx-core's
238
382
  * `signatureTable` helper: one row, one cell per party, each cell
239
383
  * containing party name (bold), two spacer paragraphs, a signature
@@ -279,6 +423,22 @@ const buildSignatureTableNode = ({ schema, parties }) => {
279
423
  const row = rowType.create({}, cells);
280
424
  return tableType.create({}, row);
281
425
  };
426
+ const LINE_BREAK_PATTERN = /\r\n|\r|\n/;
427
+ /**
428
+ * Split `insertAfterBlock` / `insertBeforeBlock` text on line breaks into one
429
+ * string per paragraph the operation should produce. A model routinely sends
430
+ * a whole clause — heading plus body — as one `text` with embedded newlines;
431
+ * Word has no such thing as a paragraph containing a line break, so each line
432
+ * becomes its own paragraph instead of literal newline characters inside one.
433
+ * Blank (whitespace-only) lines are dropped rather than becoming empty
434
+ * paragraphs, so a trailing/leading newline collapses away. Falls back to a
435
+ * single empty string when every line is blank, so callers never get zero
436
+ * paragraphs out of non-empty input.
437
+ */
438
+ const splitInsertParagraphTexts = (text) => {
439
+ const nonBlankLines = text.split(LINE_BREAK_PATTERN).filter((line) => line.trim().length > 0);
440
+ return nonBlankLines.length > 0 ? nonBlankLines : [""];
441
+ };
282
442
  /**
283
443
  * Build the inline content for an inserted or replaced block, promoting the
284
444
  * model's `**bold**` / `***bold italic***` markdown into real Word marks (the
@@ -302,9 +462,99 @@ const buildEmphasisInlineContent = (schema, text, baseMarks) => {
302
462
  }
303
463
  return nodes.length > 0 ? nodes : [schema.text(text, [...baseMarks])];
304
464
  };
305
- const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode = "tracked-changes", author = "AI", initials, createCommentId, revisionIdSeed }) => {
465
+ const isBatchableParagraphInsertion = (item, mode) => {
466
+ if (item.operation.type !== "insertAfterBlock" && item.operation.type !== "insertBeforeBlock") return false;
467
+ const insertTexts = item.insertTexts ?? [""];
468
+ const isEmptyInsert = insertTexts.length === 1 && insertTexts[0]?.length === 0;
469
+ return !(mode === "tracked-changes" && item.operation.pageBreakBefore === true && isEmptyInsert);
470
+ };
471
+ const buildInsertedParagraphs = ({ item, schema, mode, author, date, initials, commentMark, suggestionId, revisionSeed, isPairedMove }) => {
472
+ const operation = item.operation;
473
+ if (operation.type !== "insertAfterBlock" && operation.type !== "insertBeforeBlock") panic("Only paragraph insertions can build inserted paragraphs", { type: operation.type });
474
+ const insertionType = schema.marks["insertion"];
475
+ const producesTrackedChanges = mode !== "direct";
476
+ const isSuggested = mode === "suggested";
477
+ const trackedRevisionExtras = {
478
+ ...initials ? { initials } : {},
479
+ ...suggestionId !== null ? {
480
+ provenance: "suggested",
481
+ suggestionId
482
+ } : {}
483
+ };
484
+ const baseAttrs = operation.inheritFormatting === false ? {} : stripBlockIdentityAttrs(item.blockNode.attrs);
485
+ const insertTexts = item.insertTexts ?? [""];
486
+ const revisionIds = [];
487
+ const nodes = [];
488
+ let nextRevisionId = revisionSeed;
489
+ for (const [paragraphIndex, text] of insertTexts.entries()) {
490
+ const isFirstParagraph = paragraphIndex === 0;
491
+ const marks = [];
492
+ let paragraphRevisionId = null;
493
+ if (producesTrackedChanges && insertionType) {
494
+ paragraphRevisionId = nextRevisionId++;
495
+ marks.push(insertionType.create({
496
+ revisionId: paragraphRevisionId,
497
+ author,
498
+ date,
499
+ ...isPairedMove(operation.moveId) && { moveKind: "moveTo" },
500
+ ...trackedRevisionExtras
501
+ }));
502
+ revisionIds.push(paragraphRevisionId);
503
+ }
504
+ if (commentMark) marks.push(commentMark);
505
+ const content = text.length > 0 ? buildEmphasisInlineContent(schema, text, marks) : null;
506
+ const attrs = isFirstParagraph ? { ...baseAttrs } : {};
507
+ if (isFirstParagraph && operation.pageBreakBefore === true) attrs["pageBreakBefore"] = true;
508
+ if (isFirstParagraph && operation.listLevel === null) {
509
+ attrs["numPr"] = null;
510
+ Object.assign(attrs, CLEARED_LIST_MARKER_ATTRS);
511
+ } else if (isFirstParagraph && operation.listLevel !== void 0) {
512
+ const anchorNumPr = baseAttrs["numPr"];
513
+ const numId = typeof anchorNumPr === "object" && anchorNumPr !== null && "numId" in anchorNumPr ? anchorNumPr.numId : void 0;
514
+ attrs["numPr"] = {
515
+ ...typeof numId === "number" && { numId },
516
+ ilvl: operation.listLevel
517
+ };
518
+ }
519
+ if (isFirstParagraph && operation.styleId !== void 0) {
520
+ attrs["styleId"] = operation.styleId;
521
+ if (operation.inheritFormatting !== false && operation.styleId !== null) Object.assign(attrs, CLEARED_LIST_MARKER_ATTRS);
522
+ }
523
+ if (isSuggested && suggestionId !== null && paragraphRevisionId !== null) attrs["_suggestedInsert"] = {
524
+ suggestionId,
525
+ revisionId: paragraphRevisionId,
526
+ author,
527
+ date,
528
+ ...initials ? { initials } : {}
529
+ };
530
+ nodes.push(item.blockNode.type.create(attrs, content));
531
+ }
532
+ if (producesTrackedChanges && !isSuggested) for (const [index, node] of nodes.entries()) {
533
+ const revisionId = nextRevisionId++;
534
+ nodes[index] = node.type.create({
535
+ ...node.attrs,
536
+ pPrMark: {
537
+ kind: isPairedMove(operation.moveId) ? "moveTo" : "ins",
538
+ info: {
539
+ id: revisionId,
540
+ author,
541
+ date,
542
+ ...trackedRevisionExtras
543
+ }
544
+ }
545
+ }, node.content);
546
+ revisionIds.push(revisionId);
547
+ }
548
+ return {
549
+ nodes,
550
+ revisionIds,
551
+ nextRevisionId
552
+ };
553
+ };
554
+ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode = "tracked-changes", author = "AI", initials, createCommentId, revisionStamp, revisionIdSeed, wordDiff }) => {
306
555
  const applied = [];
307
556
  const skipped = [];
557
+ const normalizations = [];
308
558
  const resolved = [];
309
559
  const insertionType = view.state.schema.marks["insertion"];
310
560
  const deletionType = view.state.schema.marks["deletion"];
@@ -318,7 +568,8 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
318
568
  skipped: operations.map((operation) => ({
319
569
  id: operation.id,
320
570
  reason: "unsupportedBlock"
321
- }))
571
+ })),
572
+ nextRevisionId: revisionIdSeed ?? revisionStamp?.idSeed ?? revisionIdCursor
322
573
  };
323
574
  const liveBlocks = collectLiveBlocksByHash(view.state.doc);
324
575
  const liveBlocksByParaId = collectLiveBlocksByParaId(view.state.doc);
@@ -369,6 +620,11 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
369
620
  }
370
621
  claimedTableColumns.add(columnKey);
371
622
  }
623
+ if ((operation.type === "insertAfterBlock" || operation.type === "insertBeforeBlock") && LINE_BREAK_PATTERN.test(operation.text)) normalizations.push({
624
+ id: operation.id,
625
+ code: "splitMultilineText",
626
+ paragraphCount: resolution.operation.insertTexts?.length ?? 1
627
+ });
372
628
  const commentId = commentText !== void 0 ? createCommentId?.(commentText) : void 0;
373
629
  resolved.push({
374
630
  ...resolution.operation,
@@ -385,13 +641,41 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
385
641
  const executableResolved = tablePlan.executable;
386
642
  if (executableResolved.length === 0) return {
387
643
  applied,
388
- skipped
644
+ skipped,
645
+ ...normalizations.length > 0 && { normalizations },
646
+ nextRevisionId: revisionIdSeed ?? revisionStamp?.idSeed ?? revisionIdCursor
647
+ };
648
+ const moveSideCounts = /* @__PURE__ */ new Map();
649
+ for (const { operation } of executableResolved) {
650
+ const moveId = operation.type === "deleteBlock" || operation.type === "insertAfterBlock" || operation.type === "insertBeforeBlock" ? operation.moveId : void 0;
651
+ if (moveId === void 0) continue;
652
+ const counts = moveSideCounts.get(moveId) ?? {
653
+ from: 0,
654
+ to: 0
655
+ };
656
+ if (operation.type === "deleteBlock") counts.from += 1;
657
+ else counts.to += 1;
658
+ moveSideCounts.set(moveId, counts);
659
+ }
660
+ const isPairedMove = (moveId) => {
661
+ if (moveId === void 0) return false;
662
+ const counts = moveSideCounts.get(moveId);
663
+ return counts?.from === 1 && counts.to === 1;
389
664
  };
665
+ for (const { operation } of executableResolved) {
666
+ const moveId = operation.type === "deleteBlock" || operation.type === "insertAfterBlock" || operation.type === "insertBeforeBlock" ? operation.moveId : void 0;
667
+ if (moveId !== void 0 && !isPairedMove(moveId)) normalizations.push({
668
+ id: operation.id,
669
+ code: "unpairedMove",
670
+ moveId
671
+ });
672
+ }
390
673
  let tr = view.state.tr;
391
- let revisionSeed = revisionIdSeed ?? nextRevisionSeed(executableResolved.length);
392
- const date = (/* @__PURE__ */ new Date()).toISOString();
674
+ const revisionIdReservation = executableResolved.reduce((total, item) => total + estimateRevisionIdReservation(item), 0);
675
+ let revisionSeed = revisionIdSeed ?? revisionStamp?.idSeed ?? nextRevisionSeed(revisionIdReservation);
676
+ const date = revisionStamp?.date ?? (/* @__PURE__ */ new Date()).toISOString();
393
677
  const insertedColumnCounts = /* @__PURE__ */ new Map();
394
- for (const item of executableResolved.toSorted((left, right) => {
678
+ const executionOrder = executableResolved.toSorted((left, right) => {
395
679
  const leftCellShape = left.tableCellMerge ?? left.tableCellSplit;
396
680
  const rightCellShape = right.tableCellMerge ?? right.tableCellSplit;
397
681
  if (!leftCellShape && rightCellShape) return -1;
@@ -415,7 +699,55 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
415
699
  }
416
700
  if (left.from !== right.from) return right.from - left.from;
417
701
  return right.originalIndex - left.originalIndex;
418
- })) {
702
+ });
703
+ for (let executionIndex = 0; executionIndex < executionOrder.length; executionIndex++) {
704
+ const item = executionOrder[executionIndex];
705
+ if (!item) panic("The operation execution index exceeded the resolved plan", { executionIndex });
706
+ if (isBatchableParagraphInsertion(item, mode)) {
707
+ const insertionRun = [item];
708
+ for (let lookahead = executionIndex + 1; lookahead < executionOrder.length; lookahead++) {
709
+ const candidate = executionOrder[lookahead];
710
+ if (!candidate || candidate.from !== item.from || !isBatchableParagraphInsertion(candidate, mode)) break;
711
+ insertionRun.push(candidate);
712
+ }
713
+ if (insertionRun.length > 1) {
714
+ const nodeGroups = [];
715
+ const runApplied = [];
716
+ for (const insertion of insertionRun) {
717
+ const insertionSuggestionId = isSuggested ? insertion.operation.suggestionId ?? insertion.operation.id : null;
718
+ const insertionCommentMark = insertion.commentId !== void 0 && commentType ? commentType.create({ commentId: insertion.commentId }) : null;
719
+ const built = buildInsertedParagraphs({
720
+ item: insertion,
721
+ schema: view.state.schema,
722
+ mode,
723
+ author,
724
+ date,
725
+ initials,
726
+ commentMark: insertionCommentMark,
727
+ suggestionId: insertionSuggestionId,
728
+ revisionSeed,
729
+ isPairedMove
730
+ });
731
+ revisionSeed = built.nextRevisionId;
732
+ nodeGroups.push(built.nodes);
733
+ runApplied.push({
734
+ id: insertion.operation.id,
735
+ ...insertion.commentId !== void 0 && { commentId: insertion.commentId },
736
+ ...built.revisionIds[0] !== void 0 && {
737
+ revisionId: built.revisionIds[0],
738
+ revisionIds: built.revisionIds
739
+ },
740
+ ...insertionSuggestionId !== null && { suggestionId: insertionSuggestionId }
741
+ });
742
+ }
743
+ const nodes = [];
744
+ for (const group of nodeGroups.toReversed()) for (const node of group) nodes.push(node);
745
+ tr = tr.insert(item.from, nodes);
746
+ applied.push(...runApplied);
747
+ executionIndex += insertionRun.length - 1;
748
+ continue;
749
+ }
750
+ }
419
751
  const commentMark = item.commentId !== void 0 && commentType ? commentType.create({ commentId: item.commentId }) : null;
420
752
  const stepsBefore = tr.steps.length;
421
753
  let appliedRevisionIds;
@@ -470,7 +802,8 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
470
802
  revisionIdInsert,
471
803
  commentMark,
472
804
  suggestionId,
473
- initials
805
+ initials,
806
+ granularity: wordDiff?.granularity ?? "word"
474
807
  });
475
808
  if (producesTrackedChanges) appliedRevisionIds = [
476
809
  revisionIdDelete,
@@ -556,7 +889,8 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
556
889
  revisionIdInsert,
557
890
  commentMark,
558
891
  suggestionId,
559
- initials
892
+ initials,
893
+ granularity: wordDiff?.granularity ?? "word"
560
894
  });
561
895
  tr = applyReplaceBlockStyleId({
562
896
  item,
@@ -571,50 +905,30 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
571
905
  }
572
906
  case "insertAfterBlock":
573
907
  case "insertBeforeBlock": {
574
- if (mode === "tracked-changes" && item.operation.pageBreakBefore === true && (!item.insertText || item.insertText.length === 0)) {
908
+ const insertTexts = item.insertTexts ?? [""];
909
+ const isEmptyInsert = insertTexts.length === 1 && insertTexts[0]?.length === 0;
910
+ if (mode === "tracked-changes" && item.operation.pageBreakBefore === true && isEmptyInsert) {
575
911
  skipped.push({
576
912
  id: item.operation.id,
577
913
  reason: "unsupportedMode"
578
914
  });
579
915
  continue;
580
916
  }
581
- const marks = [];
582
- let insertedBlockRevisionId = null;
583
- if (producesTrackedChanges && insertionType) {
584
- const revisionId = revisionSeed++;
585
- insertedBlockRevisionId = revisionId;
586
- marks.push(insertionType.create({
587
- revisionId,
588
- author,
589
- date,
590
- ...trackedRevisionExtras
591
- }));
592
- appliedRevisionIds = [revisionId];
593
- }
594
- if (commentMark) marks.push(commentMark);
595
- const content = item.insertText && item.insertText.length > 0 ? buildEmphasisInlineContent(view.state.schema, item.insertText, marks) : null;
596
- const attrs = { ...item.operation.inheritFormatting === false ? {} : stripBlockIdentityAttrs(item.blockNode.attrs) };
597
- if (item.operation.pageBreakBefore === true) attrs["pageBreakBefore"] = true;
598
- if (item.operation.styleId !== void 0) {
599
- attrs["styleId"] = item.operation.styleId;
600
- if (item.operation.inheritFormatting !== false) {
601
- attrs["listMarker"] = null;
602
- attrs["listMarkerHidden"] = null;
603
- attrs["listLevelNumFmts"] = null;
604
- attrs["listLevelStarts"] = null;
605
- attrs["listAbstractNumId"] = null;
606
- attrs["listStartOverride"] = null;
607
- }
608
- }
609
- if (isSuggested && suggestionId !== null && insertedBlockRevisionId !== null) attrs["_suggestedInsert"] = {
610
- suggestionId,
611
- revisionId: insertedBlockRevisionId,
917
+ const built = buildInsertedParagraphs({
918
+ item,
919
+ schema: view.state.schema,
920
+ mode,
612
921
  author,
613
922
  date,
614
- ...initials ? { initials } : {}
615
- };
616
- const node = item.blockNode.type.create(attrs, content);
617
- tr = tr.insert(item.from, node);
923
+ initials,
924
+ commentMark,
925
+ suggestionId,
926
+ revisionSeed,
927
+ isPairedMove
928
+ });
929
+ revisionSeed = built.nextRevisionId;
930
+ if (built.revisionIds.length > 0) appliedRevisionIds = built.revisionIds;
931
+ tr = tr.insert(item.from, built.nodes);
618
932
  break;
619
933
  }
620
934
  case "insertSignatureTable": {
@@ -862,16 +1176,213 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
862
1176
  }
863
1177
  if (deletionType) {
864
1178
  const revisionId = revisionSeed++;
865
- tr = tr.addMark(item.from, item.to, deletionType.create({
1179
+ const deletionMark = deletionType.create({
866
1180
  revisionId,
867
1181
  author,
868
1182
  date,
1183
+ ...isPairedMove(item.operation.moveId) && { moveKind: "moveFrom" },
869
1184
  ...trackedRevisionExtras
870
- }));
871
- appliedRevisionIds = [revisionId];
1185
+ });
1186
+ tr = tr.addMark(item.from, item.to, deletionMark);
1187
+ const atomRanges = undeletedContentAtomRanges(tr, item.blockFrom);
1188
+ for (const { from, to } of atomRanges) tr = tr.addMark(from, to, deletionMark);
1189
+ const inlineRevisionApplied = item.from < item.to || atomRanges.length > 0;
1190
+ appliedRevisionIds = inlineRevisionApplied ? [revisionId] : [];
1191
+ const markPosition = tr.mapping.map(item.blockFrom);
1192
+ if (tr.doc.resolve(markPosition).parent.childCount > 1 && tr.doc.nodeAt(markPosition)?.attrs["pPrMark"] == null) {
1193
+ const markRevisionId = revisionSeed++;
1194
+ tr = tr.setNodeAttribute(markPosition, "pPrMark", {
1195
+ kind: isPairedMove(item.operation.moveId) ? "moveFrom" : "del",
1196
+ info: {
1197
+ id: markRevisionId,
1198
+ author,
1199
+ date,
1200
+ ...trackedRevisionExtras
1201
+ }
1202
+ });
1203
+ appliedRevisionIds = inlineRevisionApplied ? [revisionId, markRevisionId] : [markRevisionId];
1204
+ }
872
1205
  }
873
1206
  if (commentMark) tr = tr.addMark(item.from, item.to, commentMark);
874
1207
  break;
1208
+ case "insertTable": {
1209
+ const table = buildTableNode({
1210
+ schema: view.state.schema,
1211
+ rows: item.operation.rows,
1212
+ ...producesTrackedChanges && { revision: {
1213
+ revisionId: revisionSeed,
1214
+ author,
1215
+ date,
1216
+ ...trackedRevisionExtras
1217
+ } }
1218
+ });
1219
+ if (!table) {
1220
+ skipped.push({
1221
+ id: item.operation.id,
1222
+ reason: "unsupportedBlock"
1223
+ });
1224
+ continue;
1225
+ }
1226
+ if (producesTrackedChanges) appliedRevisionIds = [revisionSeed++];
1227
+ tr = tr.insert(item.from, table);
1228
+ markStructuralChange(tr);
1229
+ break;
1230
+ }
1231
+ case "deleteTable": {
1232
+ const deleted = item.deletedTable;
1233
+ if (!deleted) {
1234
+ skipped.push({
1235
+ id: item.operation.id,
1236
+ reason: "unsupportedBlock"
1237
+ });
1238
+ continue;
1239
+ }
1240
+ if (mode === "direct") {
1241
+ const position = tr.mapping.map(deleted.position, 1);
1242
+ const live = tr.doc.nodeAt(position);
1243
+ if (!live || live.type.spec["tableRole"] !== "table") {
1244
+ skipped.push({
1245
+ id: item.operation.id,
1246
+ reason: "unsupportedBlock"
1247
+ });
1248
+ continue;
1249
+ }
1250
+ tr = tr.delete(position, position + live.nodeSize);
1251
+ markStructuralChange(tr);
1252
+ break;
1253
+ }
1254
+ const livePosition = tr.mapping.map(deleted.position, 1);
1255
+ const liveTable = tr.doc.nodeAt(livePosition);
1256
+ if (!liveTable || liveTable.type.spec["tableRole"] !== "table") {
1257
+ skipped.push({
1258
+ id: item.operation.id,
1259
+ reason: "unsupportedBlock"
1260
+ });
1261
+ continue;
1262
+ }
1263
+ const revision = {
1264
+ revisionId: revisionSeed++,
1265
+ author,
1266
+ date,
1267
+ ...trackedRevisionExtras
1268
+ };
1269
+ const rowPositions = [];
1270
+ let rowOffset = livePosition + 1;
1271
+ liveTable.forEach((row) => {
1272
+ if (row.type.spec["tableRole"] === "row" && row.attrs["trDel"] == null) rowPositions.push(rowOffset);
1273
+ rowOffset += row.nodeSize;
1274
+ });
1275
+ if (rowPositions.length === 0) {
1276
+ skipped.push({
1277
+ id: item.operation.id,
1278
+ reason: "noopOperation"
1279
+ });
1280
+ continue;
1281
+ }
1282
+ for (const rowPosition of rowPositions.toReversed()) tr = tr.setNodeAttribute(rowPosition, "trDel", revision);
1283
+ appliedRevisionIds = [revision.revisionId];
1284
+ markStructuralChange(tr);
1285
+ break;
1286
+ }
1287
+ case "setBlockParagraphProperties": {
1288
+ const patch = paragraphPropertiesPatch(item.blockNode, item.operation.properties);
1289
+ if (patch === null) {
1290
+ skipped.push({
1291
+ id: item.operation.id,
1292
+ reason: "noopOperation"
1293
+ });
1294
+ continue;
1295
+ }
1296
+ if (mode === "direct") {
1297
+ tr = tr.setNodeMarkup(item.blockFrom, void 0, {
1298
+ ...item.blockNode.attrs,
1299
+ ...patch
1300
+ });
1301
+ break;
1302
+ }
1303
+ const revisionId = revisionSeed++;
1304
+ const change = {
1305
+ type: "paragraphPropertyChange",
1306
+ info: {
1307
+ id: revisionId,
1308
+ author,
1309
+ date,
1310
+ ...initials ? { initials } : {}
1311
+ },
1312
+ previousFormatting: paragraphPropertiesSnapshot(item.blockNode)
1313
+ };
1314
+ const existing = expectParagraphAttrs(item.blockNode)._propertyChanges;
1315
+ tr = tr.setNodeMarkup(item.blockFrom, void 0, {
1316
+ ...item.blockNode.attrs,
1317
+ ...patch,
1318
+ _propertyChanges: [...Array.isArray(existing) ? existing : [], change]
1319
+ });
1320
+ appliedRevisionIds = [revisionId];
1321
+ break;
1322
+ }
1323
+ case "splitBlock": {
1324
+ if (mode === "direct") {
1325
+ if (item.to > item.from) tr = tr.delete(item.from, item.to);
1326
+ tr = tr.split(item.from);
1327
+ break;
1328
+ }
1329
+ const revisionIdMark = revisionSeed++;
1330
+ const info = {
1331
+ id: revisionIdMark,
1332
+ author,
1333
+ date,
1334
+ ...trackedRevisionExtras
1335
+ };
1336
+ appliedRevisionIds = [revisionIdMark];
1337
+ if (item.to > item.from && deletionType) {
1338
+ const revisionIdSeparator = revisionSeed++;
1339
+ tr = tr.addMark(item.from, item.to, deletionType.create({
1340
+ revisionId: revisionIdSeparator,
1341
+ author,
1342
+ date,
1343
+ ...trackedRevisionExtras
1344
+ }));
1345
+ appliedRevisionIds = [revisionIdMark, revisionIdSeparator];
1346
+ }
1347
+ tr = tr.split(item.from);
1348
+ tr = tr.setNodeAttribute(item.blockFrom, "pPrMark", {
1349
+ kind: "ins",
1350
+ info
1351
+ });
1352
+ break;
1353
+ }
1354
+ case "mergeBlockWithNext": {
1355
+ const separator = item.operation.separator ?? "";
1356
+ const insertAt = item.blockTo - 1;
1357
+ if (mode === "direct") {
1358
+ if (separator.length > 0) tr = tr.insertText(separator, insertAt);
1359
+ tr = tr.join(item.blockTo + separator.length);
1360
+ break;
1361
+ }
1362
+ const revisionIdMark = revisionSeed++;
1363
+ appliedRevisionIds = [revisionIdMark];
1364
+ if (separator.length > 0 && insertionType) {
1365
+ const revisionIdSeparator = revisionSeed++;
1366
+ tr = tr.insertText(separator, insertAt);
1367
+ tr = tr.addMark(insertAt, insertAt + separator.length, insertionType.create({
1368
+ revisionId: revisionIdSeparator,
1369
+ author,
1370
+ date,
1371
+ ...trackedRevisionExtras
1372
+ }));
1373
+ appliedRevisionIds = [revisionIdMark, revisionIdSeparator];
1374
+ }
1375
+ tr = tr.setNodeAttribute(item.blockFrom, "pPrMark", {
1376
+ kind: "del",
1377
+ info: {
1378
+ id: revisionIdMark,
1379
+ author,
1380
+ date,
1381
+ ...trackedRevisionExtras
1382
+ }
1383
+ });
1384
+ break;
1385
+ }
875
1386
  case "commentOnBlock":
876
1387
  if (commentMark) tr = tr.addMark(item.from, item.to, commentMark);
877
1388
  break;
@@ -894,10 +1405,17 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
894
1405
  ...suggestionId !== null && { suggestionId }
895
1406
  });
896
1407
  }
897
- if (tr.docChanged) view.dispatch(tr);
1408
+ if (tr.docChanged) {
1409
+ const batchRevisionIds = new Set(applied.flatMap(({ revisionIds }) => revisionIds ?? []));
1410
+ tr = withoutRevisionsOnZeroWidthAnchors(tr, batchRevisionIds);
1411
+ if (revisionStamp) requestDeterministicParaIds(tr, `${revisionStamp.date}:${String(revisionStamp.idSeed)}`);
1412
+ view.dispatch(tr);
1413
+ }
898
1414
  return {
899
1415
  applied,
900
- skipped
1416
+ skipped,
1417
+ ...normalizations.length > 0 && { normalizations },
1418
+ nextRevisionId: revisionSeed
901
1419
  };
902
1420
  };
903
1421
  const applyFolioAIEditOperations = (options) => applyFolioAIEditOperationsInternal(options);
@@ -918,10 +1436,12 @@ const previewFolioAIEditOperations = (options) => {
918
1436
  });
919
1437
  return {
920
1438
  applied: result.applied.map(({ id }) => ({ id })),
921
- skipped: result.skipped
1439
+ skipped: result.skipped,
1440
+ ...result.normalizations !== void 0 && { normalizations: result.normalizations },
1441
+ nextRevisionId: options.revisionStamp?.idSeed ?? revisionIdCursor
922
1442
  };
923
1443
  };
924
- const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete, revisionIdInsert, commentMark, suggestionId = null, initials }) => {
1444
+ const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete, revisionIdInsert, commentMark, suggestionId = null, initials, granularity }) => {
925
1445
  let nextTr = tr;
926
1446
  const replacement = stripInlineEmphasisMarkers((() => {
927
1447
  if (item.operation.type === "replaceInBlock" || item.operation.type === "replaceRange") return item.operation.replace;
@@ -975,7 +1495,7 @@ const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete,
975
1495
  }
976
1496
  }
977
1497
  if (sourceText !== null && cleanBlock !== null) {
978
- const segments = diffWordSegments(sourceText, replacement);
1498
+ const segments = diffWordSegments(sourceText, replacement, { granularity });
979
1499
  const offsets = cleanBlock.offsets;
980
1500
  const offsetAt = (cleanOffset) => offsets[cleanOffset] ?? null;
981
1501
  const steps = [];
@@ -1126,15 +1646,12 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1126
1646
  };
1127
1647
  }
1128
1648
  if (operation.type === "insertAfterBlock" || operation.type === "insertBeforeBlock") {
1129
- if (operation.text.length === 0 && operation.pageBreakBefore !== true) return {
1130
- type: "skip",
1131
- reason: "emptyOperation"
1132
- };
1133
- const tableBoundary = findEnclosingTableBoundary(doc, blockFrom);
1649
+ const tableBoundary = findOutermostTableBoundary(doc, blockFrom);
1134
1650
  const isInsertAfter = operation.type === "insertAfterBlock";
1135
1651
  let insertFrom;
1136
1652
  if (tableBoundary) insertFrom = isInsertAfter ? tableBoundary.after : tableBoundary.before;
1137
1653
  else insertFrom = isInsertAfter ? blockTo : blockFrom;
1654
+ const insertTexts = LINE_BREAK_PATTERN.test(operation.text) ? splitInsertParagraphTexts(operation.text) : [operation.text];
1138
1655
  return {
1139
1656
  type: "resolved",
1140
1657
  operation: {
@@ -1144,7 +1661,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1144
1661
  blockFrom,
1145
1662
  blockTo,
1146
1663
  blockNode,
1147
- insertText: operation.text
1664
+ insertTexts
1148
1665
  }
1149
1666
  };
1150
1667
  }
@@ -1154,7 +1671,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1154
1671
  reason: "emptyOperation"
1155
1672
  };
1156
1673
  const position = operation.position ?? "after";
1157
- const tableBoundary = findEnclosingTableBoundary(doc, blockFrom);
1674
+ const tableBoundary = findOutermostTableBoundary(doc, blockFrom);
1158
1675
  let insertFrom;
1159
1676
  if (tableBoundary) insertFrom = position === "after" ? tableBoundary.after : tableBoundary.before;
1160
1677
  else insertFrom = position === "after" ? blockTo : blockFrom;
@@ -1176,7 +1693,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1176
1693
  blockFrom,
1177
1694
  position: operation.position ?? "after"
1178
1695
  });
1179
- if (!insertion || (operation.cellTexts?.length ?? 0) > insertion.cells.length) return {
1696
+ if (!insertion || (operation.cellTexts?.length ?? 0) > insertion.columnCount) return {
1180
1697
  type: "skip",
1181
1698
  reason: "unsupportedBlock"
1182
1699
  };
@@ -1371,11 +1888,115 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1371
1888
  }
1372
1889
  };
1373
1890
  }
1891
+ if (operation.type === "insertTable") {
1892
+ const boundary = findOutermostTableBoundary(doc, blockFrom) ?? {
1893
+ before: blockFrom,
1894
+ after: blockTo
1895
+ };
1896
+ const insertFrom = (operation.position ?? "after") === "after" ? boundary.after : boundary.before;
1897
+ return {
1898
+ type: "resolved",
1899
+ operation: {
1900
+ operation,
1901
+ from: insertFrom,
1902
+ to: insertFrom,
1903
+ blockFrom,
1904
+ blockTo,
1905
+ blockNode
1906
+ }
1907
+ };
1908
+ }
1909
+ if (operation.type === "deleteTable") {
1910
+ const target = findEnclosingTableRow(doc, blockFrom);
1911
+ if (!target) return {
1912
+ type: "skip",
1913
+ reason: "unsupportedBlock"
1914
+ };
1915
+ return {
1916
+ type: "resolved",
1917
+ operation: {
1918
+ operation,
1919
+ from: target.tablePosition,
1920
+ to: target.tablePosition + target.table.nodeSize,
1921
+ blockFrom,
1922
+ blockTo,
1923
+ blockNode,
1924
+ deletedTable: {
1925
+ position: target.tablePosition,
1926
+ node: target.table
1927
+ }
1928
+ }
1929
+ };
1930
+ }
1931
+ if (operation.type === "setBlockParagraphProperties") return {
1932
+ type: "resolved",
1933
+ operation: {
1934
+ operation,
1935
+ from: blockFrom,
1936
+ to: blockTo,
1937
+ blockFrom,
1938
+ blockTo,
1939
+ blockNode
1940
+ }
1941
+ };
1942
+ if (operation.type === "splitBlock") {
1943
+ const separator = operation.separator ?? "";
1944
+ const at = cleanBlock.offsets[operation.offset];
1945
+ const after = cleanBlock.offsets[operation.offset + separator.length];
1946
+ if (at === void 0 || after === void 0 || operation.offset === 0 || operation.offset + separator.length >= currentText.length) return {
1947
+ type: "skip",
1948
+ reason: "staleRange"
1949
+ };
1950
+ if (currentText.slice(operation.offset, operation.offset + separator.length) !== separator) return {
1951
+ type: "skip",
1952
+ reason: "staleRange"
1953
+ };
1954
+ if (!canSplit(doc, at)) return {
1955
+ type: "skip",
1956
+ reason: "unsupportedBlock"
1957
+ };
1958
+ return {
1959
+ type: "resolved",
1960
+ operation: {
1961
+ operation,
1962
+ from: at,
1963
+ to: after,
1964
+ blockFrom,
1965
+ blockTo,
1966
+ blockNode
1967
+ }
1968
+ };
1969
+ }
1970
+ if (operation.type === "mergeBlockWithNext") {
1971
+ if (!canJoin(doc, blockTo)) return {
1972
+ type: "skip",
1973
+ reason: "unsupportedBlock"
1974
+ };
1975
+ return {
1976
+ type: "resolved",
1977
+ operation: {
1978
+ operation,
1979
+ from: blockTo,
1980
+ to: blockTo,
1981
+ blockFrom,
1982
+ blockTo,
1983
+ blockNode
1984
+ }
1985
+ };
1986
+ }
1374
1987
  if (operation.type === "deleteBlock" || operation.type === "replaceBlock") {
1375
1988
  const range = getTextRangeFromCleanBlock(cleanBlock);
1376
1989
  if (!range) {
1377
1990
  const insertionPoint = cleanBlock.offsets.at(0);
1378
- if (operation.type === "replaceBlock" && currentText.length === 0 && operation.text.length > 0 && insertionPoint !== void 0) return {
1991
+ if (insertionPoint === void 0) return {
1992
+ type: "skip",
1993
+ reason: "unsupportedBlock"
1994
+ };
1995
+ if (!(operation.type === "deleteBlock" ? true : currentText.length === 0 && operation.text.length > 0)) return {
1996
+ type: "skip",
1997
+ reason: "unsupportedBlock"
1998
+ };
1999
+ return {
1379
2000
  type: "resolved",
1380
2001
  operation: {
1381
2002
  operation,
@@ -1386,10 +2007,6 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1386
2007
  blockNode
1387
2008
  }
1388
2009
  };
1389
- return {
1390
- type: "skip",
1391
- reason: "unsupportedBlock"
1392
- };
1393
2010
  }
1394
2011
  if (operation.type === "replaceBlock" && operation.text === currentText) return {
1395
2012
  type: "skip",