@stll/folio-core 0.32.2 → 0.33.1

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 (227) 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 +652 -86
  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 +56 -0
  24. package/dist/compare/__fixtures__/body-sequence.js +136 -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 +412 -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 +1141 -0
  37. package/dist/compare/reproducible-package.d.ts +21 -0
  38. package/dist/compare/reproducible-package.js +47 -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 +297 -0
  42. package/dist/compare/types.js +42 -0
  43. package/dist/compare/verification.d.ts +83 -0
  44. package/dist/compare/verification.js +189 -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/hyperlinkParser.d.ts +9 -1
  104. package/dist/docx/hyperlinkParser.js +19 -13
  105. package/dist/docx/packageParts.d.ts +35 -0
  106. package/dist/docx/packageParts.js +120 -0
  107. package/dist/docx/paraIdRangeNormalization.d.ts +40 -0
  108. package/dist/docx/paraIdRangeNormalization.js +64 -0
  109. package/dist/docx/paragraphParser.js +101 -26
  110. package/dist/docx/paragraphTraversal.js +1 -1
  111. package/dist/docx/renderedPageBreakNormalization.js +3 -1
  112. package/dist/docx/revisionIdNormalization.d.ts +27 -0
  113. package/dist/docx/revisionIdNormalization.js +121 -0
  114. package/dist/docx/rezip.d.ts +6 -0
  115. package/dist/docx/rezip.js +51 -19
  116. package/dist/docx/selectiveSave.js +2 -2
  117. package/dist/docx/serializer/commentSerializer.js +6 -1
  118. package/dist/docx/serializer/paragraphSerializer.js +73 -20
  119. package/dist/docx/serializer/runSerializer.js +11 -10
  120. package/dist/docx/serializer/tableSerializer.js +26 -10
  121. package/dist/docx/server/applyDocxXmlPatchProposal.js +1 -1
  122. package/dist/docx/server/createBilingualDocx.js +3 -2
  123. package/dist/docx/streamingXmlParser.d.ts +13 -1
  124. package/dist/docx/streamingXmlParser.js +49 -11
  125. package/dist/docx/unzip.d.ts +1 -2
  126. package/dist/docx/unzip.js +25 -6
  127. package/dist/docx/xmlParser.d.ts +3 -1
  128. package/dist/docx/xmlParser.js +33 -27
  129. package/dist/export-pdf.d.ts +43 -0
  130. package/dist/export-pdf.js +98 -0
  131. package/dist/fonts/headlessMeasure.d.ts +76 -0
  132. package/dist/fonts/headlessMeasure.js +0 -0
  133. package/dist/fonts/sfnt/parse.d.ts +51 -0
  134. package/dist/fonts/sfnt/parse.js +525 -0
  135. package/dist/fonts/sfnt/subset.d.ts +20 -0
  136. package/dist/fonts/sfnt/subset.js +350 -0
  137. package/dist/fonts/sfnt/tables.d.ts +88 -0
  138. package/dist/fonts/sfnt/tables.js +131 -0
  139. package/dist/fonts/sfnt/woff.d.ts +14 -0
  140. package/dist/fonts/sfnt/woff.js +163 -0
  141. package/dist/generated/text_shaper.js +324 -0
  142. package/dist/generated/text_shaper_bg.wasm +0 -0
  143. package/dist/headless-layout.d.ts +75 -0
  144. package/dist/headless-layout.js +350 -0
  145. package/dist/index.d.ts +9 -6
  146. package/dist/index.js +6 -3
  147. package/dist/layout-bridge/convert/footnoteLayout.d.ts +1 -0
  148. package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
  149. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -0
  150. package/dist/layout-bridge/convert/headerFooterLayout.js +2 -0
  151. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +3 -1
  152. package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
  153. package/dist/layout-engine/measure/advanceComposition.d.ts +62 -0
  154. package/dist/layout-engine/measure/advanceComposition.js +148 -0
  155. package/dist/layout-engine/measure/cache.d.ts +2 -0
  156. package/dist/layout-engine/measure/measureContainer.js +13 -0
  157. package/dist/layout-engine/measure/measureParagraph.js +1 -2
  158. package/dist/layout-engine/measure/measureTypes.d.ts +12 -0
  159. package/dist/layout-engine/measure/tableInlinePlacement.d.ts +3 -2
  160. package/dist/layout-engine/measure/tableInlinePlacement.js +14 -1
  161. package/dist/layout-engine/tableIndentCompatibility.d.ts +5 -0
  162. package/dist/layout-engine/tableIndentCompatibility.js +15 -0
  163. package/dist/layout-engine/types.d.ts +8 -0
  164. package/dist/layout-painter/renderPage.d.ts +29 -1
  165. package/dist/layout-painter/renderPage.js +47 -19
  166. package/dist/layout-painter/renderParagraph.d.ts +21 -2
  167. package/dist/layout-painter/renderParagraph.js +1 -1
  168. package/dist/layout-painter/renderTable.js +37 -4
  169. package/dist/managers/editorShortcuts.d.ts +33 -1
  170. package/dist/managers/editorShortcuts.js +20 -1
  171. package/dist/markdown/renderRuns.js +7 -2
  172. package/dist/markdown/renderTable.js +5 -1
  173. package/dist/model.d.ts +3 -3
  174. package/dist/model.js +2 -2
  175. package/dist/pdf/contentStream.d.ts +73 -0
  176. package/dist/pdf/contentStream.js +143 -0
  177. package/dist/pdf/fonts.d.ts +96 -0
  178. package/dist/pdf/fonts.js +593 -0
  179. package/dist/pdf/images.d.ts +28 -0
  180. package/dist/pdf/images.js +439 -0
  181. package/dist/pdf/objects.d.ts +93 -0
  182. package/dist/pdf/objects.js +237 -0
  183. package/dist/pdf/pageSpace.d.ts +43 -0
  184. package/dist/pdf/pageSpace.js +57 -0
  185. package/dist/pdf/paint.d.ts +32 -0
  186. package/dist/pdf/paint.js +442 -0
  187. package/dist/pdf/writePdf.d.ts +56 -0
  188. package/dist/pdf/writePdf.js +345 -0
  189. package/dist/prosemirror/commands/comments.js +96 -6
  190. package/dist/prosemirror/conversion/fromProseDoc.js +41 -7
  191. package/dist/prosemirror/conversion/toProseDoc.js +41 -4
  192. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +15 -2
  193. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +35 -6
  194. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +2 -2
  195. package/dist/prosemirror/extensions/features/pasteCleanup.js +4 -3
  196. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +3 -1
  197. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +5 -3
  198. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.d.ts +7 -1
  199. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.js +9 -3
  200. package/dist/prosemirror/extensions/nodes/TableExtension.js +56 -39
  201. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +3 -1
  202. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +5 -3
  203. package/dist/prosemirror/plugins/suggestionMode.js +1 -0
  204. package/dist/prosemirror/revisionCarriers.js +19 -2
  205. package/dist/prosemirror/utils/extractTrackedChanges.d.ts +2 -2
  206. package/dist/prosemirror/utils/extractTrackedChanges.js +59 -23
  207. package/dist/prosemirror/validation.js +55 -27
  208. package/dist/prosemirror/zeroWidthAnchors.d.ts +10 -0
  209. package/dist/prosemirror/zeroWidthAnchors.js +23 -0
  210. package/dist/redline.js +12 -79
  211. package/dist/server.d.ts +3 -3
  212. package/dist/server.js +2 -2
  213. package/dist/shaping/placeRun.d.ts +51 -0
  214. package/dist/shaping/placeRun.js +84 -0
  215. package/dist/shaping/shaper.d.ts +92 -0
  216. package/dist/shaping/shaper.js +102 -0
  217. package/dist/types/block-id.d.ts +18 -2
  218. package/dist/types/block-id.js +49 -5
  219. package/dist/types/content.d.ts +2 -2
  220. package/dist/utils/clipboard.js +4 -3
  221. package/dist/utils/fontResolver.js +183 -0
  222. package/dist/utils/htmlComments.d.ts +17 -0
  223. package/dist/utils/htmlComments.js +22 -0
  224. package/dist/utils/scriptSegments.d.ts +12 -1
  225. package/dist/utils/scriptSegments.js +17 -1
  226. package/dist/version-comparison.d.ts +1 -1
  227. 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": {
@@ -907,21 +1171,220 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
907
1171
  }
908
1172
  case "deleteBlock":
909
1173
  if (mode === "direct") {
910
- tr = tr.delete(item.blockFrom, item.blockTo);
1174
+ const at = tr.doc.resolve(item.blockFrom);
1175
+ tr = !(at.index() === at.parent.childCount - 1) || at.nodeBefore?.type.name === item.blockNode.type.name ? tr.delete(item.blockFrom, item.blockTo) : tr.delete(item.blockFrom + 1, item.blockTo - 1);
911
1176
  break;
912
1177
  }
913
1178
  if (deletionType) {
914
1179
  const revisionId = revisionSeed++;
915
- tr = tr.addMark(item.from, item.to, deletionType.create({
1180
+ const deletionMark = deletionType.create({
916
1181
  revisionId,
917
1182
  author,
918
1183
  date,
1184
+ ...isPairedMove(item.operation.moveId) && { moveKind: "moveFrom" },
919
1185
  ...trackedRevisionExtras
920
- }));
921
- appliedRevisionIds = [revisionId];
1186
+ });
1187
+ tr = tr.addMark(item.from, item.to, deletionMark);
1188
+ const atomRanges = undeletedContentAtomRanges(tr, item.blockFrom);
1189
+ for (const { from, to } of atomRanges) tr = tr.addMark(from, to, deletionMark);
1190
+ const inlineRevisionApplied = item.from < item.to || atomRanges.length > 0;
1191
+ appliedRevisionIds = inlineRevisionApplied ? [revisionId] : [];
1192
+ const markPosition = tr.mapping.map(item.blockFrom);
1193
+ const markPlace = tr.doc.resolve(markPosition);
1194
+ if (!(markPlace.index() === markPlace.parent.childCount - 1) && tr.doc.nodeAt(markPosition)?.attrs["pPrMark"] == null) {
1195
+ const markRevisionId = revisionSeed++;
1196
+ tr = tr.setNodeAttribute(markPosition, "pPrMark", {
1197
+ kind: isPairedMove(item.operation.moveId) ? "moveFrom" : "del",
1198
+ info: {
1199
+ id: markRevisionId,
1200
+ author,
1201
+ date,
1202
+ ...trackedRevisionExtras
1203
+ }
1204
+ });
1205
+ appliedRevisionIds = inlineRevisionApplied ? [revisionId, markRevisionId] : [markRevisionId];
1206
+ }
922
1207
  }
923
1208
  if (commentMark) tr = tr.addMark(item.from, item.to, commentMark);
924
1209
  break;
1210
+ case "insertTable": {
1211
+ const table = buildTableNode({
1212
+ schema: view.state.schema,
1213
+ rows: item.operation.rows,
1214
+ ...producesTrackedChanges && { revision: {
1215
+ revisionId: revisionSeed,
1216
+ author,
1217
+ date,
1218
+ ...trackedRevisionExtras
1219
+ } }
1220
+ });
1221
+ if (!table) {
1222
+ skipped.push({
1223
+ id: item.operation.id,
1224
+ reason: "unsupportedBlock"
1225
+ });
1226
+ continue;
1227
+ }
1228
+ if (producesTrackedChanges) appliedRevisionIds = [revisionSeed++];
1229
+ tr = tr.insert(item.from, table);
1230
+ markStructuralChange(tr);
1231
+ break;
1232
+ }
1233
+ case "deleteTable": {
1234
+ const deleted = item.deletedTable;
1235
+ if (!deleted) {
1236
+ skipped.push({
1237
+ id: item.operation.id,
1238
+ reason: "unsupportedBlock"
1239
+ });
1240
+ continue;
1241
+ }
1242
+ if (mode === "direct") {
1243
+ const position = tr.mapping.map(deleted.position, 1);
1244
+ const live = tr.doc.nodeAt(position);
1245
+ if (!live || live.type.spec["tableRole"] !== "table") {
1246
+ skipped.push({
1247
+ id: item.operation.id,
1248
+ reason: "unsupportedBlock"
1249
+ });
1250
+ continue;
1251
+ }
1252
+ tr = tr.delete(position, position + live.nodeSize);
1253
+ markStructuralChange(tr);
1254
+ break;
1255
+ }
1256
+ const livePosition = tr.mapping.map(deleted.position, 1);
1257
+ const liveTable = tr.doc.nodeAt(livePosition);
1258
+ if (!liveTable || liveTable.type.spec["tableRole"] !== "table") {
1259
+ skipped.push({
1260
+ id: item.operation.id,
1261
+ reason: "unsupportedBlock"
1262
+ });
1263
+ continue;
1264
+ }
1265
+ const revision = {
1266
+ revisionId: revisionSeed++,
1267
+ author,
1268
+ date,
1269
+ ...trackedRevisionExtras
1270
+ };
1271
+ const rowPositions = [];
1272
+ let rowOffset = livePosition + 1;
1273
+ liveTable.forEach((row) => {
1274
+ if (row.type.spec["tableRole"] === "row" && row.attrs["trDel"] == null) rowPositions.push(rowOffset);
1275
+ rowOffset += row.nodeSize;
1276
+ });
1277
+ if (rowPositions.length === 0) {
1278
+ skipped.push({
1279
+ id: item.operation.id,
1280
+ reason: "noopOperation"
1281
+ });
1282
+ continue;
1283
+ }
1284
+ for (const rowPosition of rowPositions.toReversed()) tr = tr.setNodeAttribute(rowPosition, "trDel", revision);
1285
+ appliedRevisionIds = [revision.revisionId];
1286
+ markStructuralChange(tr);
1287
+ break;
1288
+ }
1289
+ case "setBlockParagraphProperties": {
1290
+ const patch = paragraphPropertiesPatch(item.blockNode, item.operation.properties);
1291
+ if (patch === null) {
1292
+ skipped.push({
1293
+ id: item.operation.id,
1294
+ reason: "noopOperation"
1295
+ });
1296
+ continue;
1297
+ }
1298
+ if (mode === "direct") {
1299
+ tr = tr.setNodeMarkup(item.blockFrom, void 0, {
1300
+ ...item.blockNode.attrs,
1301
+ ...patch
1302
+ });
1303
+ break;
1304
+ }
1305
+ const revisionId = revisionSeed++;
1306
+ const change = {
1307
+ type: "paragraphPropertyChange",
1308
+ info: {
1309
+ id: revisionId,
1310
+ author,
1311
+ date,
1312
+ ...initials ? { initials } : {}
1313
+ },
1314
+ previousFormatting: paragraphPropertiesSnapshot(item.blockNode)
1315
+ };
1316
+ const existing = expectParagraphAttrs(item.blockNode)._propertyChanges;
1317
+ tr = tr.setNodeMarkup(item.blockFrom, void 0, {
1318
+ ...item.blockNode.attrs,
1319
+ ...patch,
1320
+ _propertyChanges: [...Array.isArray(existing) ? existing : [], change]
1321
+ });
1322
+ appliedRevisionIds = [revisionId];
1323
+ break;
1324
+ }
1325
+ case "splitBlock": {
1326
+ if (mode === "direct") {
1327
+ if (item.to > item.from) tr = tr.delete(item.from, item.to);
1328
+ tr = tr.split(item.from);
1329
+ break;
1330
+ }
1331
+ const revisionIdMark = revisionSeed++;
1332
+ const info = {
1333
+ id: revisionIdMark,
1334
+ author,
1335
+ date,
1336
+ ...trackedRevisionExtras
1337
+ };
1338
+ appliedRevisionIds = [revisionIdMark];
1339
+ if (item.to > item.from && deletionType) {
1340
+ const revisionIdSeparator = revisionSeed++;
1341
+ tr = tr.addMark(item.from, item.to, deletionType.create({
1342
+ revisionId: revisionIdSeparator,
1343
+ author,
1344
+ date,
1345
+ ...trackedRevisionExtras
1346
+ }));
1347
+ appliedRevisionIds = [revisionIdMark, revisionIdSeparator];
1348
+ }
1349
+ tr = tr.split(item.from);
1350
+ tr = tr.setNodeAttribute(item.blockFrom, "pPrMark", {
1351
+ kind: "ins",
1352
+ info
1353
+ });
1354
+ break;
1355
+ }
1356
+ case "mergeBlockWithNext": {
1357
+ const separator = item.operation.separator ?? "";
1358
+ const insertAt = item.blockTo - 1;
1359
+ if (mode === "direct") {
1360
+ if (separator.length > 0) tr = tr.insertText(separator, insertAt);
1361
+ tr = tr.join(item.blockTo + separator.length);
1362
+ break;
1363
+ }
1364
+ const revisionIdMark = revisionSeed++;
1365
+ appliedRevisionIds = [revisionIdMark];
1366
+ if (separator.length > 0 && insertionType) {
1367
+ const revisionIdSeparator = revisionSeed++;
1368
+ tr = tr.insertText(separator, insertAt);
1369
+ tr = tr.addMark(insertAt, insertAt + separator.length, insertionType.create({
1370
+ revisionId: revisionIdSeparator,
1371
+ author,
1372
+ date,
1373
+ ...trackedRevisionExtras
1374
+ }));
1375
+ appliedRevisionIds = [revisionIdMark, revisionIdSeparator];
1376
+ }
1377
+ tr = tr.setNodeAttribute(item.blockFrom, "pPrMark", {
1378
+ kind: "del",
1379
+ info: {
1380
+ id: revisionIdMark,
1381
+ author,
1382
+ date,
1383
+ ...trackedRevisionExtras
1384
+ }
1385
+ });
1386
+ break;
1387
+ }
925
1388
  case "commentOnBlock":
926
1389
  if (commentMark) tr = tr.addMark(item.from, item.to, commentMark);
927
1390
  break;
@@ -944,11 +1407,17 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
944
1407
  ...suggestionId !== null && { suggestionId }
945
1408
  });
946
1409
  }
947
- if (tr.docChanged) view.dispatch(tr);
1410
+ if (tr.docChanged) {
1411
+ const batchRevisionIds = new Set(applied.flatMap(({ revisionIds }) => revisionIds ?? []));
1412
+ tr = withoutRevisionsOnZeroWidthAnchors(tr, batchRevisionIds);
1413
+ if (revisionStamp) requestDeterministicParaIds(tr, `${revisionStamp.date}:${String(revisionStamp.idSeed)}`);
1414
+ view.dispatch(tr);
1415
+ }
948
1416
  return {
949
1417
  applied,
950
1418
  skipped,
951
- ...normalizations.length > 0 && { normalizations }
1419
+ ...normalizations.length > 0 && { normalizations },
1420
+ nextRevisionId: revisionSeed
952
1421
  };
953
1422
  };
954
1423
  const applyFolioAIEditOperations = (options) => applyFolioAIEditOperationsInternal(options);
@@ -970,10 +1439,11 @@ const previewFolioAIEditOperations = (options) => {
970
1439
  return {
971
1440
  applied: result.applied.map(({ id }) => ({ id })),
972
1441
  skipped: result.skipped,
973
- ...result.normalizations !== void 0 && { normalizations: result.normalizations }
1442
+ ...result.normalizations !== void 0 && { normalizations: result.normalizations },
1443
+ nextRevisionId: options.revisionStamp?.idSeed ?? revisionIdCursor
974
1444
  };
975
1445
  };
976
- const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete, revisionIdInsert, commentMark, suggestionId = null, initials }) => {
1446
+ const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete, revisionIdInsert, commentMark, suggestionId = null, initials, granularity }) => {
977
1447
  let nextTr = tr;
978
1448
  const replacement = stripInlineEmphasisMarkers((() => {
979
1449
  if (item.operation.type === "replaceInBlock" || item.operation.type === "replaceRange") return item.operation.replace;
@@ -1027,7 +1497,7 @@ const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete,
1027
1497
  }
1028
1498
  }
1029
1499
  if (sourceText !== null && cleanBlock !== null) {
1030
- const segments = diffWordSegments(sourceText, replacement);
1500
+ const segments = diffWordSegments(sourceText, replacement, { granularity });
1031
1501
  const offsets = cleanBlock.offsets;
1032
1502
  const offsetAt = (cleanOffset) => offsets[cleanOffset] ?? null;
1033
1503
  const steps = [];
@@ -1178,11 +1648,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1178
1648
  };
1179
1649
  }
1180
1650
  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);
1651
+ const tableBoundary = findOutermostTableBoundary(doc, blockFrom);
1186
1652
  const isInsertAfter = operation.type === "insertAfterBlock";
1187
1653
  let insertFrom;
1188
1654
  if (tableBoundary) insertFrom = isInsertAfter ? tableBoundary.after : tableBoundary.before;
@@ -1207,7 +1673,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1207
1673
  reason: "emptyOperation"
1208
1674
  };
1209
1675
  const position = operation.position ?? "after";
1210
- const tableBoundary = findEnclosingTableBoundary(doc, blockFrom);
1676
+ const tableBoundary = findOutermostTableBoundary(doc, blockFrom);
1211
1677
  let insertFrom;
1212
1678
  if (tableBoundary) insertFrom = position === "after" ? tableBoundary.after : tableBoundary.before;
1213
1679
  else insertFrom = position === "after" ? blockTo : blockFrom;
@@ -1229,7 +1695,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1229
1695
  blockFrom,
1230
1696
  position: operation.position ?? "after"
1231
1697
  });
1232
- if (!insertion || (operation.cellTexts?.length ?? 0) > insertion.cells.length) return {
1698
+ if (!insertion || (operation.cellTexts?.length ?? 0) > insertion.columnCount) return {
1233
1699
  type: "skip",
1234
1700
  reason: "unsupportedBlock"
1235
1701
  };
@@ -1424,11 +1890,115 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1424
1890
  }
1425
1891
  };
1426
1892
  }
1893
+ if (operation.type === "insertTable") {
1894
+ const boundary = findOutermostTableBoundary(doc, blockFrom) ?? {
1895
+ before: blockFrom,
1896
+ after: blockTo
1897
+ };
1898
+ const insertFrom = (operation.position ?? "after") === "after" ? boundary.after : boundary.before;
1899
+ return {
1900
+ type: "resolved",
1901
+ operation: {
1902
+ operation,
1903
+ from: insertFrom,
1904
+ to: insertFrom,
1905
+ blockFrom,
1906
+ blockTo,
1907
+ blockNode
1908
+ }
1909
+ };
1910
+ }
1911
+ if (operation.type === "deleteTable") {
1912
+ const target = findEnclosingTableRow(doc, blockFrom);
1913
+ if (!target) return {
1914
+ type: "skip",
1915
+ reason: "unsupportedBlock"
1916
+ };
1917
+ return {
1918
+ type: "resolved",
1919
+ operation: {
1920
+ operation,
1921
+ from: target.tablePosition,
1922
+ to: target.tablePosition + target.table.nodeSize,
1923
+ blockFrom,
1924
+ blockTo,
1925
+ blockNode,
1926
+ deletedTable: {
1927
+ position: target.tablePosition,
1928
+ node: target.table
1929
+ }
1930
+ }
1931
+ };
1932
+ }
1933
+ if (operation.type === "setBlockParagraphProperties") return {
1934
+ type: "resolved",
1935
+ operation: {
1936
+ operation,
1937
+ from: blockFrom,
1938
+ to: blockTo,
1939
+ blockFrom,
1940
+ blockTo,
1941
+ blockNode
1942
+ }
1943
+ };
1944
+ if (operation.type === "splitBlock") {
1945
+ const separator = operation.separator ?? "";
1946
+ const at = cleanBlock.offsets[operation.offset];
1947
+ const after = cleanBlock.offsets[operation.offset + separator.length];
1948
+ if (at === void 0 || after === void 0 || operation.offset === 0 || operation.offset + separator.length >= currentText.length) return {
1949
+ type: "skip",
1950
+ reason: "staleRange"
1951
+ };
1952
+ if (currentText.slice(operation.offset, operation.offset + separator.length) !== separator) return {
1953
+ type: "skip",
1954
+ reason: "staleRange"
1955
+ };
1956
+ if (!canSplit(doc, at)) return {
1957
+ type: "skip",
1958
+ reason: "unsupportedBlock"
1959
+ };
1960
+ return {
1961
+ type: "resolved",
1962
+ operation: {
1963
+ operation,
1964
+ from: at,
1965
+ to: after,
1966
+ blockFrom,
1967
+ blockTo,
1968
+ blockNode
1969
+ }
1970
+ };
1971
+ }
1972
+ if (operation.type === "mergeBlockWithNext") {
1973
+ if (!canJoin(doc, blockTo)) return {
1974
+ type: "skip",
1975
+ reason: "unsupportedBlock"
1976
+ };
1977
+ return {
1978
+ type: "resolved",
1979
+ operation: {
1980
+ operation,
1981
+ from: blockTo,
1982
+ to: blockTo,
1983
+ blockFrom,
1984
+ blockTo,
1985
+ blockNode
1986
+ }
1987
+ };
1988
+ }
1427
1989
  if (operation.type === "deleteBlock" || operation.type === "replaceBlock") {
1428
1990
  const range = getTextRangeFromCleanBlock(cleanBlock);
1429
1991
  if (!range) {
1430
1992
  const insertionPoint = cleanBlock.offsets.at(0);
1431
- if (operation.type === "replaceBlock" && currentText.length === 0 && operation.text.length > 0 && insertionPoint !== void 0) return {
1993
+ if (insertionPoint === void 0) return {
1994
+ type: "skip",
1995
+ reason: "unsupportedBlock"
1996
+ };
1997
+ if (!(operation.type === "deleteBlock" ? true : currentText.length === 0 && operation.text.length > 0)) return {
1998
+ type: "skip",
1999
+ reason: "unsupportedBlock"
2000
+ };
2001
+ return {
1432
2002
  type: "resolved",
1433
2003
  operation: {
1434
2004
  operation,
@@ -1439,10 +2009,6 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
1439
2009
  blockNode
1440
2010
  }
1441
2011
  };
1442
- return {
1443
- type: "skip",
1444
- reason: "unsupportedBlock"
1445
- };
1446
2012
  }
1447
2013
  if (operation.type === "replaceBlock" && operation.text === currentText) return {
1448
2014
  type: "skip",