@stll/folio-core 0.32.2 → 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 +649 -85
  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 +219 -15
  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 +39 -8
  100. package/dist/document-operations.js +169 -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 +3 -3
  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);
@@ -155,14 +261,16 @@ const nextRevisionSeed = (revisionIdCount) => {
155
261
  * operation is a safe cushion above that. A multi-paragraph
156
262
  * `insertAfterBlock` / `insertBeforeBlock` (`text` split on line breaks,
157
263
  * see `splitInsertParagraphTexts`) allocates one id per paragraph in
158
- * tracked-changes mode, so it needs more than four once split into more
159
- * than four paragraphs reserving less than that would let a later
160
- * `nextRevisionSeed` call reuse an id this operation already stamped on
161
- * the document.
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.
162
268
  */
163
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;
164
272
  const estimateRevisionIdReservation = (item) => {
165
- if (item.operation.type === "insertAfterBlock" || item.operation.type === "insertBeforeBlock") return Math.max(item.insertTexts?.length ?? 1, REVISION_IDS_PER_OPERATION);
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);
166
274
  return REVISION_IDS_PER_OPERATION;
167
275
  };
168
276
  /**
@@ -176,7 +284,8 @@ const estimateRevisionIdReservation = (item) => {
176
284
  const collectLiveBlocksByHash = (doc) => {
177
285
  const byHash = /* @__PURE__ */ new Map();
178
286
  doc.descendants((node, pos) => {
179
- if (!node.isTextblock) return;
287
+ if (isHiddenTableRow(node)) return false;
288
+ if (!node.isTextblock) return true;
180
289
  const cleanText = buildCleanBlockText(node, pos).text;
181
290
  const hash = hashFolioAIBlockText(normalizeFolioAIBlockText(cleanText));
182
291
  const bucket = byHash.get(hash) ?? [];
@@ -186,6 +295,7 @@ const collectLiveBlocksByHash = (doc) => {
186
295
  node
187
296
  });
188
297
  byHash.set(hash, bucket);
298
+ return false;
189
299
  });
190
300
  return byHash;
191
301
  };
@@ -200,14 +310,16 @@ const collectLiveBlocksByHash = (doc) => {
200
310
  const collectLiveBlocksByParaId = (doc) => {
201
311
  const byParaId = /* @__PURE__ */ new Map();
202
312
  doc.descendants((node, pos) => {
203
- if (!node.isTextblock) return;
313
+ if (isHiddenTableRow(node)) return false;
314
+ if (!node.isTextblock) return true;
204
315
  const paraId = node.attrs["paraId"];
205
- if (typeof paraId !== "string" || paraId.length === 0) return;
316
+ if (typeof paraId !== "string" || paraId.length === 0) return false;
206
317
  if (!byParaId.has(paraId)) byParaId.set(paraId, {
207
318
  from: pos,
208
319
  to: pos + node.nodeSize,
209
320
  node
210
321
  });
322
+ return false;
211
323
  });
212
324
  return byParaId;
213
325
  };
@@ -251,6 +363,21 @@ const ordinalAmongSameHash = (snapshot, blockId) => {
251
363
  */
252
364
  const SIGNATURE_LINE = "_".repeat(28);
253
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
+ /**
254
381
  * Build a borderless PM table mirroring docx-core's
255
382
  * `signatureTable` helper: one row, one cell per party, each cell
256
383
  * containing party name (bold), two spacer paragraphs, a signature
@@ -335,7 +462,96 @@ const buildEmphasisInlineContent = (schema, text, baseMarks) => {
335
462
  }
336
463
  return nodes.length > 0 ? nodes : [schema.text(text, [...baseMarks])];
337
464
  };
338
- 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 }) => {
339
555
  const applied = [];
340
556
  const skipped = [];
341
557
  const normalizations = [];
@@ -352,7 +568,8 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
352
568
  skipped: operations.map((operation) => ({
353
569
  id: operation.id,
354
570
  reason: "unsupportedBlock"
355
- }))
571
+ })),
572
+ nextRevisionId: revisionIdSeed ?? revisionStamp?.idSeed ?? revisionIdCursor
356
573
  };
357
574
  const liveBlocks = collectLiveBlocksByHash(view.state.doc);
358
575
  const liveBlocksByParaId = collectLiveBlocksByParaId(view.state.doc);
@@ -425,14 +642,40 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
425
642
  if (executableResolved.length === 0) return {
426
643
  applied,
427
644
  skipped,
428
- ...normalizations.length > 0 && { normalizations }
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;
429
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
+ }
430
673
  let tr = view.state.tr;
431
674
  const revisionIdReservation = executableResolved.reduce((total, item) => total + estimateRevisionIdReservation(item), 0);
432
- let revisionSeed = revisionIdSeed ?? nextRevisionSeed(revisionIdReservation);
433
- const date = (/* @__PURE__ */ new Date()).toISOString();
675
+ let revisionSeed = revisionIdSeed ?? revisionStamp?.idSeed ?? nextRevisionSeed(revisionIdReservation);
676
+ const date = revisionStamp?.date ?? (/* @__PURE__ */ new Date()).toISOString();
434
677
  const insertedColumnCounts = /* @__PURE__ */ new Map();
435
- for (const item of executableResolved.toSorted((left, right) => {
678
+ const executionOrder = executableResolved.toSorted((left, right) => {
436
679
  const leftCellShape = left.tableCellMerge ?? left.tableCellSplit;
437
680
  const rightCellShape = right.tableCellMerge ?? right.tableCellSplit;
438
681
  if (!leftCellShape && rightCellShape) return -1;
@@ -456,7 +699,55 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
456
699
  }
457
700
  if (left.from !== right.from) return right.from - left.from;
458
701
  return right.originalIndex - left.originalIndex;
459
- })) {
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
+ }
460
751
  const commentMark = item.commentId !== void 0 && commentType ? commentType.create({ commentId: item.commentId }) : null;
461
752
  const stepsBefore = tr.steps.length;
462
753
  let appliedRevisionIds;
@@ -511,7 +802,8 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
511
802
  revisionIdInsert,
512
803
  commentMark,
513
804
  suggestionId,
514
- initials
805
+ initials,
806
+ granularity: wordDiff?.granularity ?? "word"
515
807
  });
516
808
  if (producesTrackedChanges) appliedRevisionIds = [
517
809
  revisionIdDelete,
@@ -597,7 +889,8 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
597
889
  revisionIdInsert,
598
890
  commentMark,
599
891
  suggestionId,
600
- initials
892
+ initials,
893
+ granularity: wordDiff?.granularity ?? "word"
601
894
  });
602
895
  tr = applyReplaceBlockStyleId({
603
896
  item,
@@ -621,50 +914,21 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
621
914
  });
622
915
  continue;
623
916
  }
624
- const operation = item.operation;
625
- const baseAttrs = operation.inheritFormatting === false ? {} : stripBlockIdentityAttrs(item.blockNode.attrs);
626
- const insertedBlockRevisionIds = [];
627
- const nodes = [];
628
- for (const [paragraphIndex, text] of insertTexts.entries()) {
629
- const isFirstParagraph = paragraphIndex === 0;
630
- const marks = [];
631
- let paragraphRevisionId = null;
632
- if (producesTrackedChanges && insertionType) {
633
- paragraphRevisionId = revisionSeed++;
634
- marks.push(insertionType.create({
635
- revisionId: paragraphRevisionId,
636
- author,
637
- date,
638
- ...trackedRevisionExtras
639
- }));
640
- insertedBlockRevisionIds.push(paragraphRevisionId);
641
- }
642
- if (commentMark) marks.push(commentMark);
643
- const content = text.length > 0 ? buildEmphasisInlineContent(view.state.schema, text, marks) : null;
644
- const attrs = isFirstParagraph ? { ...baseAttrs } : {};
645
- if (isFirstParagraph && operation.pageBreakBefore === true) attrs["pageBreakBefore"] = true;
646
- if (isFirstParagraph && operation.styleId !== void 0) {
647
- attrs["styleId"] = operation.styleId;
648
- if (operation.inheritFormatting !== false) {
649
- attrs["listMarker"] = null;
650
- attrs["listMarkerHidden"] = null;
651
- attrs["listLevelNumFmts"] = null;
652
- attrs["listLevelStarts"] = null;
653
- attrs["listAbstractNumId"] = null;
654
- attrs["listStartOverride"] = null;
655
- }
656
- }
657
- if (isSuggested && suggestionId !== null && paragraphRevisionId !== null) attrs["_suggestedInsert"] = {
658
- suggestionId,
659
- revisionId: paragraphRevisionId,
660
- author,
661
- date,
662
- ...initials ? { initials } : {}
663
- };
664
- nodes.push(item.blockNode.type.create(attrs, content));
665
- }
666
- if (insertedBlockRevisionIds.length > 0) appliedRevisionIds = insertedBlockRevisionIds;
667
- tr = tr.insert(item.from, nodes);
917
+ const built = buildInsertedParagraphs({
918
+ item,
919
+ schema: view.state.schema,
920
+ mode,
921
+ author,
922
+ date,
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);
668
932
  break;
669
933
  }
670
934
  case "insertSignatureTable": {
@@ -912,16 +1176,213 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
912
1176
  }
913
1177
  if (deletionType) {
914
1178
  const revisionId = revisionSeed++;
915
- tr = tr.addMark(item.from, item.to, deletionType.create({
1179
+ const deletionMark = deletionType.create({
916
1180
  revisionId,
917
1181
  author,
918
1182
  date,
1183
+ ...isPairedMove(item.operation.moveId) && { moveKind: "moveFrom" },
919
1184
  ...trackedRevisionExtras
920
- }));
921
- 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
+ }
922
1205
  }
923
1206
  if (commentMark) tr = tr.addMark(item.from, item.to, commentMark);
924
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
+ }
925
1386
  case "commentOnBlock":
926
1387
  if (commentMark) tr = tr.addMark(item.from, item.to, commentMark);
927
1388
  break;
@@ -944,11 +1405,17 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
944
1405
  ...suggestionId !== null && { suggestionId }
945
1406
  });
946
1407
  }
947
- 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
+ }
948
1414
  return {
949
1415
  applied,
950
1416
  skipped,
951
- ...normalizations.length > 0 && { normalizations }
1417
+ ...normalizations.length > 0 && { normalizations },
1418
+ nextRevisionId: revisionSeed
952
1419
  };
953
1420
  };
954
1421
  const applyFolioAIEditOperations = (options) => applyFolioAIEditOperationsInternal(options);
@@ -970,10 +1437,11 @@ const previewFolioAIEditOperations = (options) => {
970
1437
  return {
971
1438
  applied: result.applied.map(({ id }) => ({ id })),
972
1439
  skipped: result.skipped,
973
- ...result.normalizations !== void 0 && { normalizations: result.normalizations }
1440
+ ...result.normalizations !== void 0 && { normalizations: result.normalizations },
1441
+ nextRevisionId: options.revisionStamp?.idSeed ?? revisionIdCursor
974
1442
  };
975
1443
  };
976
- 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 }) => {
977
1445
  let nextTr = tr;
978
1446
  const replacement = stripInlineEmphasisMarkers((() => {
979
1447
  if (item.operation.type === "replaceInBlock" || item.operation.type === "replaceRange") return item.operation.replace;
@@ -1027,7 +1495,7 @@ const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete,
1027
1495
  }
1028
1496
  }
1029
1497
  if (sourceText !== null && cleanBlock !== null) {
1030
- const segments = diffWordSegments(sourceText, replacement);
1498
+ const segments = diffWordSegments(sourceText, replacement, { granularity });
1031
1499
  const offsets = cleanBlock.offsets;
1032
1500
  const offsetAt = (cleanOffset) => offsets[cleanOffset] ?? null;
1033
1501
  const steps = [];
@@ -1178,11 +1646,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1178
1646
  };
1179
1647
  }
1180
1648
  if (operation.type === "insertAfterBlock" || operation.type === "insertBeforeBlock") {
1181
- if (operation.text.length === 0 && operation.pageBreakBefore !== true) return {
1182
- type: "skip",
1183
- reason: "emptyOperation"
1184
- };
1185
- const tableBoundary = findEnclosingTableBoundary(doc, blockFrom);
1649
+ const tableBoundary = findOutermostTableBoundary(doc, blockFrom);
1186
1650
  const isInsertAfter = operation.type === "insertAfterBlock";
1187
1651
  let insertFrom;
1188
1652
  if (tableBoundary) insertFrom = isInsertAfter ? tableBoundary.after : tableBoundary.before;
@@ -1207,7 +1671,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1207
1671
  reason: "emptyOperation"
1208
1672
  };
1209
1673
  const position = operation.position ?? "after";
1210
- const tableBoundary = findEnclosingTableBoundary(doc, blockFrom);
1674
+ const tableBoundary = findOutermostTableBoundary(doc, blockFrom);
1211
1675
  let insertFrom;
1212
1676
  if (tableBoundary) insertFrom = position === "after" ? tableBoundary.after : tableBoundary.before;
1213
1677
  else insertFrom = position === "after" ? blockTo : blockFrom;
@@ -1229,7 +1693,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1229
1693
  blockFrom,
1230
1694
  position: operation.position ?? "after"
1231
1695
  });
1232
- if (!insertion || (operation.cellTexts?.length ?? 0) > insertion.cells.length) return {
1696
+ if (!insertion || (operation.cellTexts?.length ?? 0) > insertion.columnCount) return {
1233
1697
  type: "skip",
1234
1698
  reason: "unsupportedBlock"
1235
1699
  };
@@ -1424,11 +1888,115 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1424
1888
  }
1425
1889
  };
1426
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
+ }
1427
1987
  if (operation.type === "deleteBlock" || operation.type === "replaceBlock") {
1428
1988
  const range = getTextRangeFromCleanBlock(cleanBlock);
1429
1989
  if (!range) {
1430
1990
  const insertionPoint = cleanBlock.offsets.at(0);
1431
- 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 {
1432
2000
  type: "resolved",
1433
2001
  operation: {
1434
2002
  operation,
@@ -1439,10 +2007,6 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1439
2007
  blockNode
1440
2008
  }
1441
2009
  };
1442
- return {
1443
- type: "skip",
1444
- reason: "unsupportedBlock"
1445
- };
1446
2010
  }
1447
2011
  if (operation.type === "replaceBlock" && operation.text === currentText) return {
1448
2012
  type: "skip",