@stll/folio-core 0.35.0 → 0.36.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 (80) hide show
  1. package/dist/ai-edits/apply.js +370 -118
  2. package/dist/ai-edits/headless.js +16 -10
  3. package/dist/ai-edits/index.d.ts +2 -2
  4. package/dist/ai-edits/index.js +2 -2
  5. package/dist/ai-edits/snapshot.js +6 -1
  6. package/dist/ai-edits/types.d.ts +19 -6
  7. package/dist/ai-edits/word-diff.d.ts +18 -3
  8. package/dist/ai-edits/word-diff.js +556 -67
  9. package/dist/compare/compare.js +7 -16
  10. package/dist/compare/plan.js +8 -5
  11. package/dist/compare/verification.d.ts +8 -9
  12. package/dist/compare/verification.js +51 -46
  13. package/dist/compat/eigenpal.d.ts +2 -2
  14. package/dist/compat/eigenpal.js +2 -2
  15. package/dist/controller/headerFooterEditorManager.js +5 -5
  16. package/dist/controller/hiddenEditorApi.js +1 -1
  17. package/dist/controller/noteEditorManager.js +2 -2
  18. package/dist/document-operations.d.ts +5 -3
  19. package/dist/document-operations.js +32 -5
  20. package/dist/docx/commentParser.js +2 -4
  21. package/dist/docx/commentRangeIntegrity.js +2 -4
  22. package/dist/docx/documentClone.d.ts +2 -0
  23. package/dist/docx/documentClone.js +2 -0
  24. package/dist/docx/headerFooterParser.js +2 -4
  25. package/dist/docx/normalizeBaseDirection.js +5 -7
  26. package/dist/docx/paragraphParser.js +27 -29
  27. package/dist/docx/paragraphPropertySource.d.ts +49 -0
  28. package/dist/docx/paragraphPropertySource.js +113 -0
  29. package/dist/docx/runConsolidator.js +3 -4
  30. package/dist/docx/runParser.js +2 -15
  31. package/dist/docx/sectionParser.js +1 -15
  32. package/dist/docx/serializer/paragraphSerializer.js +287 -30
  33. package/dist/docx/serializer/runSerializer.js +2 -10
  34. package/dist/docx/serializer/sectionPropertiesSerializer.js +2 -10
  35. package/dist/docx/serializer/tableSerializer.js +4 -23
  36. package/dist/docx/serializer/trackedChangeAttributes.d.ts +9 -0
  37. package/dist/docx/serializer/trackedChangeAttributes.js +21 -0
  38. package/dist/docx/server/createBilingualDocument.js +8 -10
  39. package/dist/docx/server/evaluateDocxXmlPatchProposal.js +22 -13
  40. package/dist/docx/server/validateDocxConformance.js +17 -13
  41. package/dist/docx/tableParser.js +1 -37
  42. package/dist/docx/trackedChangeInfo.d.ts +11 -0
  43. package/dist/docx/trackedChangeInfo.js +38 -0
  44. package/dist/docx/verbatimCapture.d.ts +20 -9
  45. package/dist/docx/verbatimCapture.js +208 -8
  46. package/dist/docx/xmlParser.d.ts +3 -1
  47. package/dist/docx/xmlParser.js +6 -1
  48. package/dist/docx/xmlSafety.d.ts +1 -1
  49. package/dist/docx/xmlSafety.js +157 -0
  50. package/dist/index.d.ts +2 -2
  51. package/dist/index.js +2 -2
  52. package/dist/layout-bridge/convert/headerFooterLayout.js +4 -4
  53. package/dist/layout-bridge/convert/toFlowBlocks.js +12 -7
  54. package/dist/markdown/renderBlock.js +7 -10
  55. package/dist/prosemirror/attrs/index.js +15 -0
  56. package/dist/prosemirror/commands/comments.js +365 -32
  57. package/dist/prosemirror/commands/pageBreak.js +1 -1
  58. package/dist/prosemirror/commands/propertyChangeScope.d.ts +26 -4
  59. package/dist/prosemirror/commands/propertyChangeScope.js +65 -3
  60. package/dist/prosemirror/conversion/fromProseDoc.d.ts +2 -4
  61. package/dist/prosemirror/conversion/fromProseDoc.js +130 -18
  62. package/dist/prosemirror/conversion/toProseDoc.js +27 -12
  63. package/dist/prosemirror/extensions/core/ParagraphExtension.js +48 -4
  64. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -1
  65. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +20 -7
  66. package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +2 -0
  67. package/dist/prosemirror/paragraphAlignment.d.ts +12 -0
  68. package/dist/prosemirror/paragraphAlignment.js +13 -0
  69. package/dist/prosemirror/plugins/suggestionMode.js +1 -1
  70. package/dist/prosemirror/schema/marks.d.ts +2 -0
  71. package/dist/prosemirror/schema/nodes.d.ts +12 -1
  72. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  73. package/dist/redline.js +4 -1
  74. package/dist/server.d.ts +2 -2
  75. package/dist/server.js +2 -2
  76. package/dist/utils/mergeDocumentContent.js +3 -3
  77. package/dist/utils/replaceText.js +2 -1
  78. package/dist/version-comparison.js +6 -4
  79. package/dist/watermark/index.js +2 -4
  80. package/package.json +7 -2
@@ -1,9 +1,11 @@
1
1
  import { expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunPropertyChangeMarkAttrs } from "../prosemirror/attrs/index.js";
2
- import { PPR_CHANGE_SCOPED_ATTR_KEYS } from "../prosemirror/commands/propertyChangeScope.js";
2
+ import { paragraphPropertiesSnapshot } from "../prosemirror/commands/propertyChangeScope.js";
3
3
  import { addedBreakCarrierBefore, finalParagraphsOf, paragraphEndsItsContainer } from "../prosemirror/containerFinalParagraph.js";
4
4
  import { marksToTextFormatting } from "../prosemirror/conversion/fromProseDoc.js";
5
5
  import { requestDeterministicParaIds } from "../prosemirror/extensions/features/ParaIdAllocatorExtension.js";
6
6
  import { markStructuralChange } from "../prosemirror/extensions/features/ParagraphChangeTrackerExtension.js";
7
+ import { directParagraphAlignment } from "../prosemirror/paragraphAlignment.js";
8
+ import { getDocumentStyleResolver } from "../prosemirror/plugins/documentStyles.js";
7
9
  import { isZeroWidthAnchor } from "../prosemirror/zeroWidthAnchors.js";
8
10
  import { getFolioParaIdFromBlockId } from "../types/block-id.js";
9
11
  import { stripBlockIdentityAttrs } from "./block-identity.js";
@@ -15,11 +17,32 @@ import { planTableMutations } from "./table-mutation-plan.js";
15
17
  import { applyTableColumnDeletion, applyTableColumnInsertion, applyTableRowDeletion, applyTableRowInsertion, findTableColumnInsertion, findTableRowInsertion, getTableColumnCoordinateKey, markTableRowContent, splitCellParagraphTexts } from "./table-row-column-mutations.js";
16
18
  import { findEnclosingTableCell, findEnclosingTableRow, findOutermostTableBoundary, tableRectangleCutsMergedCell } from "./table-targets.js";
17
19
  import { tableFromTemplate } from "./table-template.js";
18
- import { diffWordSegments } from "./word-diff.js";
20
+ import { wordDiffSessionFromOptions } from "./word-diff.js";
19
21
  import { panic } from "better-result";
20
22
  import { TableMap } from "prosemirror-tables";
21
23
  import { canJoin, canSplit } from "prosemirror-transform";
22
24
  //#region src/ai-edits/apply.ts
25
+ const coarseWordDiff = (before, after) => {
26
+ if (before === after) return before.length === 0 ? [] : [{
27
+ type: "equal",
28
+ text: before
29
+ }];
30
+ if (before.length === 0) return [{
31
+ type: "ins",
32
+ text: after
33
+ }];
34
+ if (after.length === 0) return [{
35
+ type: "del",
36
+ text: before
37
+ }];
38
+ return [{
39
+ type: "del",
40
+ text: before
41
+ }, {
42
+ type: "ins",
43
+ text: after
44
+ }];
45
+ };
23
46
  /**
24
47
  * Operation types applied in `"suggested"` mode. Every produced revision — an
25
48
  * inline mark, a whole-node `_suggestedInsert` marker, or a suggested
@@ -31,6 +54,7 @@ const SUGGESTED_SUPPORTED_OPERATION_TYPES = /* @__PURE__ */ new Set([
31
54
  "replaceInBlock",
32
55
  "replaceRange",
33
56
  "formatRange",
57
+ "setBlockParagraphProperties",
34
58
  "replaceBlock",
35
59
  "deleteBlock",
36
60
  "insertAfterBlock",
@@ -56,16 +80,44 @@ const CLEARED_LIST_MARKER_ATTRS = Object.freeze({
56
80
  listAbstractNumId: null,
57
81
  listStartOverride: null
58
82
  });
59
- /**
60
- * The attrs one `setBlockParagraphProperties` writes, or `null` when the
61
- * block already holds them. `styleId: null` clears the style; `listLevel`
62
- * moves `w:numPr/w:ilvl` and leaves `w:numId` alone, because a demoted item
63
- * stays in the same list; `listLevel: null` drops `w:numPr` entirely, which
64
- * is a paragraph that stopped being a list item.
65
- */
66
- const paragraphPropertiesPatch = (node, properties) => {
83
+ const REPLACE_BLOCK_IMPACT = {
84
+ text: {
85
+ changesStyle: false,
86
+ changesText: true
87
+ },
88
+ style: {
89
+ changesStyle: true,
90
+ changesText: false
91
+ },
92
+ textAndStyle: {
93
+ changesStyle: true,
94
+ changesText: true
95
+ }
96
+ };
97
+ const isResolvedReplaceBlockOperation = (item) => item.operation.type === "replaceBlock";
98
+ const resolveReplaceBlockImpact = ({ changesText, changesStyle }) => {
99
+ if (changesText) return changesStyle ? "textAndStyle" : "text";
100
+ if (changesStyle) return "style";
101
+ return panic("Cannot resolve a replaceBlock that changes neither text nor style");
102
+ };
103
+ const paragraphPropertiesPatch = ({ node, properties, resolvedAlignmentFromStyle }) => {
104
+ const attrs = expectParagraphAttrs(node);
105
+ const currentDirectAlignment = directParagraphAlignment(attrs);
67
106
  const patch = {};
68
- if (properties.styleId !== void 0 && (node.attrs["styleId"] ?? null) !== properties.styleId) patch["styleId"] = properties.styleId;
107
+ let originalFormatting = attrs._originalFormatting === void 0 || attrs._originalFormatting === null ? void 0 : { ...attrs._originalFormatting };
108
+ let originalFormattingChanged = false;
109
+ const nextStyleId = properties.styleId;
110
+ const styleChanged = nextStyleId !== void 0 && (attrs.styleId ?? null) !== nextStyleId;
111
+ if (styleChanged) {
112
+ patch["styleId"] = nextStyleId;
113
+ patch["alignmentFromStyle"] = resolvedAlignmentFromStyle;
114
+ if (properties.alignment === void 0) patch["alignment"] = currentDirectAlignment ?? resolvedAlignmentFromStyle ?? null;
115
+ originalFormatting ??= {};
116
+ if (nextStyleId === null) Reflect.deleteProperty(originalFormatting, "styleId");
117
+ else originalFormatting.styleId = nextStyleId;
118
+ if (currentDirectAlignment !== void 0) originalFormatting.alignment = currentDirectAlignment;
119
+ originalFormattingChanged = true;
120
+ }
69
121
  if (properties.listLevel !== void 0) {
70
122
  const numPr = node.attrs["numPr"];
71
123
  const current = typeof numPr === "object" && numPr !== null && "ilvl" in numPr ? numPr.ilvl : void 0;
@@ -82,6 +134,15 @@ const paragraphPropertiesPatch = (node, properties) => {
82
134
  };
83
135
  }
84
136
  }
137
+ if (properties.alignment !== void 0 && (styleChanged || (currentDirectAlignment ?? null) !== properties.alignment)) {
138
+ originalFormatting ??= {};
139
+ if (properties.alignment === null) Reflect.deleteProperty(originalFormatting, "alignment");
140
+ else originalFormatting.alignment = properties.alignment;
141
+ const alignmentFromStyle = properties.styleId === void 0 ? attrs.alignmentFromStyle : resolvedAlignmentFromStyle;
142
+ patch["alignment"] = properties.alignment ?? alignmentFromStyle ?? null;
143
+ originalFormattingChanged = true;
144
+ }
145
+ if (originalFormattingChanged && originalFormatting !== void 0) patch["_originalFormatting"] = Object.keys(originalFormatting).length > 0 ? originalFormatting : null;
85
146
  return Object.keys(patch).length > 0 ? patch : null;
86
147
  };
87
148
  const REVISION_MARK_NAMES = /* @__PURE__ */ new Set(["insertion", "deletion"]);
@@ -132,23 +193,55 @@ const undeletedContentAtomRanges = (tr, blockFrom) => {
132
193
  });
133
194
  return ranges;
134
195
  };
135
- /** The in-scope paragraph properties as they stand, for a `w:pPrChange` record. */
136
- const paragraphPropertiesSnapshot = (node) => {
137
- const snapshot = {};
138
- for (const key of PPR_CHANGE_SCOPED_ATTR_KEYS) {
139
- const value = node.attrs[key];
140
- if (value !== null && value !== void 0) snapshot[key] = value;
141
- }
142
- return snapshot;
196
+ /** The carrier pPr before this batch changed it, for final-mark rotation. */
197
+ const paragraphPropertiesBeforeBatch = (node, batchRevisionIds) => {
198
+ const propertyChanges = expectParagraphAttrs(node)._propertyChanges;
199
+ if (!Array.isArray(propertyChanges)) return paragraphPropertiesSnapshot(node);
200
+ const earliestBatchChange = propertyChanges.find(({ info }) => batchRevisionIds.has(info.id));
201
+ if (!earliestBatchChange) return paragraphPropertiesSnapshot(node);
202
+ return earliestBatchChange.previousFormatting ?? {};
143
203
  };
144
- const applyReplaceBlockStyleId = ({ item, tr }) => {
145
- if (item.operation.type !== "replaceBlock" || item.operation.styleId === void 0) return tr;
146
- const block = tr.doc.nodeAt(item.blockFrom);
147
- if (!block) return tr;
148
- return tr.setNodeMarkup(item.blockFrom, void 0, {
149
- ...block.attrs,
150
- styleId: item.operation.styleId
204
+ const resolveAlignmentFromStyle = ({ attrs, styleId, styleResolver }) => styleResolver?.resolveParagraphStyle(styleId).paragraphFormatting?.alignment ?? ((styleId ?? void 0) === (attrs.styleId ?? void 0) ? attrs.alignmentFromStyle : void 0);
205
+ const applyReplaceBlockStyleId = ({ item, tr, styleResolver, revisionInfo }) => {
206
+ if (item.operation.type !== "replaceBlock" || item.operation.styleId === void 0) return {
207
+ tr,
208
+ revisionId: null
209
+ };
210
+ const blockPosition = tr.mapping.map(item.blockFrom, -1);
211
+ const block = tr.doc.nodeAt(blockPosition);
212
+ if (!block) return {
213
+ tr,
214
+ revisionId: null
215
+ };
216
+ const attrs = expectParagraphAttrs(block);
217
+ const resolvedAlignmentFromStyle = resolveAlignmentFromStyle({
218
+ attrs,
219
+ styleId: item.operation.styleId,
220
+ styleResolver
221
+ });
222
+ const patch = paragraphPropertiesPatch({
223
+ node: block,
224
+ properties: { styleId: item.operation.styleId },
225
+ resolvedAlignmentFromStyle
151
226
  });
227
+ if (patch === null) return {
228
+ tr,
229
+ revisionId: null
230
+ };
231
+ const existing = attrs._propertyChanges;
232
+ const change = revisionInfo ? {
233
+ type: "paragraphPropertyChange",
234
+ info: revisionInfo,
235
+ previousFormatting: paragraphPropertiesSnapshot(block)
236
+ } : null;
237
+ return {
238
+ tr: tr.setNodeMarkup(blockPosition, void 0, {
239
+ ...block.attrs,
240
+ ...patch,
241
+ ...change ? { _propertyChanges: [...Array.isArray(existing) ? existing : [], change] } : {}
242
+ }),
243
+ revisionId: change?.info.id ?? null
244
+ };
152
245
  };
153
246
  const REPLACEMENT_BACKGROUND_CLEAR_FORMATTING = {
154
247
  highlight: false,
@@ -378,15 +471,17 @@ const nextRevisionSeed = (revisionIdCount) => {
378
471
  * `insertAfterBlock` / `insertBeforeBlock` (`text` split on line breaks,
379
472
  * see `splitInsertParagraphTexts`) allocates one id per paragraph in
380
473
  * tracked-changes mode plus one for each paragraph MARK it brings, so it
381
- * needs more than four once split into more than two paragraphs reserving
382
- * less than that would let a later `nextRevisionSeed` call reuse an id this
383
- * operation already stamped on the document.
474
+ * needs more than four once split into more than two paragraphs. Rotating a
475
+ * terminal run can add one paragraph-property revision per inserted paragraph;
476
+ * reserving less than that would let a later `nextRevisionSeed` call reuse an
477
+ * id this operation already stamped on the document.
384
478
  */
385
479
  const REVISION_IDS_PER_OPERATION = 4;
386
- /** Ids one inserted paragraph allocates: its runs, and its paragraph mark. */
387
- const REVISION_IDS_PER_INSERTED_PARAGRAPH = 2;
388
480
  const estimateRevisionIdReservation = (item) => {
389
- 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);
481
+ if (item.operation.type === "insertAfterBlock" || item.operation.type === "insertBeforeBlock") {
482
+ const paragraphCount = item.insertTexts?.length ?? 1;
483
+ return Math.max(paragraphCount * 3, REVISION_IDS_PER_OPERATION);
484
+ }
390
485
  return REVISION_IDS_PER_OPERATION;
391
486
  };
392
487
  /**
@@ -595,6 +690,13 @@ const isBatchableParagraphInsertion = (item, mode) => {
595
690
  const isEmptyInsert = insertTexts.length === 1 && insertTexts[0]?.length === 0;
596
691
  return !(mode === "tracked-changes" && item.operation.pageBreakBefore === true && isEmptyInsert);
597
692
  };
693
+ const addedBreakRevisionId = (value) => {
694
+ if (typeof value !== "object" || value === null || !("kind" in value) || !("info" in value)) return null;
695
+ if (value.kind !== "ins" && value.kind !== "moveTo") return null;
696
+ const info = value.info;
697
+ if (typeof info !== "object" || info === null || !("id" in info)) return null;
698
+ return typeof info.id === "number" ? info.id : null;
699
+ };
598
700
  /**
599
701
  * Move an ADDED paragraph break off every paragraph its container ends with.
600
702
  *
@@ -604,11 +706,10 @@ const isBatchableParagraphInsertion = (item, mode) => {
604
706
  * revision neither accepting nor rejecting everything can clear.
605
707
  *
606
708
  * Appending at a container's end therefore rotates exactly as removing from it
607
- * does. The break that was added sits between the paragraph the run was
608
- * appended after and the first appended one, so THAT paragraph's mark is the
609
- * inserted one, each appended paragraph but the last keeps an inserted mark of
610
- * its own, and the paragraph the container now ends with takes the free mark
611
- * the run was appended after. Only which paragraph is left markless changes.
709
+ * does. The first inserted break moves onto the paragraph the run was appended
710
+ * after, every later break moves one paragraph left, and the paragraph the
711
+ * container now ends with takes the free mark. Only which paragraph is left
712
+ * markless changes; the order and ownership of the breaks do not.
612
713
  *
613
714
  * A paragraph's properties live on its mark, so the paragraph that inherits
614
715
  * the free one records the other's as `w:pPrChange` — what a rejection reads
@@ -621,50 +722,95 @@ const isBatchableParagraphInsertion = (item, mode) => {
621
722
  */
622
723
  const withRotatedAddedFinalBreaks = ({ tr, batchRevisionIds, revisionSeed, author, date, initials }) => {
623
724
  const paragraphTypeName = tr.doc.type.schema.nodes["paragraph"]?.name ?? "paragraph";
624
- const rotations = finalParagraphsOf(tr.doc, paragraphTypeName).filter(({ node }) => {
725
+ const rotations = finalParagraphsOf(tr.doc, paragraphTypeName).flatMap(({ node, position }) => {
625
726
  const mark = node.attrs["pPrMark"];
626
- if (typeof mark !== "object" || mark === null || !("kind" in mark)) return false;
627
- if (mark.kind !== "ins" && mark.kind !== "moveTo") return false;
628
- const info = "info" in mark ? mark.info : void 0;
629
- const revisionId = typeof info === "object" && info !== null && "id" in info ? info.id : void 0;
630
- return typeof revisionId === "number" && batchRevisionIds.has(revisionId);
727
+ const revisionId = addedBreakRevisionId(mark);
728
+ return revisionId !== null && batchRevisionIds.has(revisionId) ? [{
729
+ mark,
730
+ node,
731
+ ownerRevisionId: revisionId,
732
+ position
733
+ }] : [];
631
734
  });
632
735
  let next = tr;
633
736
  let nextRevisionId = revisionSeed;
634
- for (const { position: finalPosition, node: final } of rotations) {
635
- const carrier = addedBreakCarrierBefore(next.doc.resolve(finalPosition), final.type.name);
737
+ const synthesizedRevisions = [];
738
+ for (const { position: finalPosition, node: final, mark: finalMark } of rotations) {
739
+ const resolved = next.doc.resolve(finalPosition);
740
+ const carrier = addedBreakCarrierBefore(resolved, final.type.name);
636
741
  if (!carrier) {
637
742
  next = next.setNodeAttribute(finalPosition, "pPrMark", null);
638
743
  continue;
639
744
  }
640
- next = next.setNodeAttribute(carrier.position, "pPrMark", final.attrs["pPrMark"]);
641
- const previousFormatting = paragraphPropertiesSnapshot(carrier.node);
642
- if (JSON.stringify(previousFormatting) === JSON.stringify(paragraphPropertiesSnapshot(final))) {
745
+ const path = [{
746
+ node: final,
747
+ position: finalPosition
748
+ }];
749
+ let position = finalPosition;
750
+ for (let index = resolved.index() - 1; index >= 0; index--) {
751
+ const sibling = resolved.parent.child(index);
752
+ position -= sibling.nodeSize;
753
+ if (sibling.type.name !== final.type.name) continue;
754
+ path.push({
755
+ node: sibling,
756
+ position
757
+ });
758
+ if (position === carrier.position) break;
759
+ }
760
+ if (path.at(-1)?.position !== carrier.position) {
643
761
  next = next.setNodeAttribute(finalPosition, "pPrMark", null);
644
762
  continue;
645
763
  }
646
- const existing = expectParagraphAttrs(final)._propertyChanges;
647
- next = next.setNodeMarkup(finalPosition, void 0, {
648
- ...final.attrs,
649
- pPrMark: null,
650
- _propertyChanges: [...Array.isArray(existing) ? existing : [], {
651
- type: "paragraphPropertyChange",
652
- info: {
653
- id: nextRevisionId++,
654
- author,
655
- date,
656
- ...initials ? { initials } : {}
657
- },
658
- previousFormatting
659
- }]
660
- });
764
+ if (path.slice(0, -1).some(({ node }) => {
765
+ const revisionId = addedBreakRevisionId(node.attrs["pPrMark"]);
766
+ return revisionId === null || !batchRevisionIds.has(revisionId);
767
+ })) continue;
768
+ const previousFormatting = paragraphPropertiesBeforeBatch(carrier.node, batchRevisionIds);
769
+ next = next.setNodeAttribute(finalPosition, "pPrMark", null);
770
+ let carriedMark = finalMark;
771
+ for (let index = 1; index < path.length; index++) {
772
+ const current = path[index - 1];
773
+ const previous = path[index];
774
+ if (!current || !previous) panic("A final-mark rotation exceeded its paragraph path", { index });
775
+ const ownerRevisionId = addedBreakRevisionId(carriedMark);
776
+ if (ownerRevisionId === null || !batchRevisionIds.has(ownerRevisionId)) panic("A final-mark rotation crossed a break outside its operation batch", { ownerRevisionId });
777
+ const displacedMark = previous.node.attrs["pPrMark"];
778
+ next = next.setNodeAttribute(previous.position, "pPrMark", carriedMark);
779
+ const currentFormatting = paragraphPropertiesSnapshot(current.node);
780
+ if (JSON.stringify(previousFormatting) !== JSON.stringify(currentFormatting)) {
781
+ const liveCurrent = next.doc.nodeAt(current.position);
782
+ if (!liveCurrent || liveCurrent.type.name !== final.type.name) panic("A final-mark rotation lost its paragraph", { position: current.position });
783
+ const existing = expectParagraphAttrs(liveCurrent)._propertyChanges;
784
+ const revisionId = nextRevisionId++;
785
+ next = next.setNodeMarkup(current.position, void 0, {
786
+ ...liveCurrent.attrs,
787
+ _propertyChanges: [...Array.isArray(existing) ? existing : [], {
788
+ type: "paragraphPropertyChange",
789
+ info: {
790
+ id: revisionId,
791
+ author,
792
+ date,
793
+ ...initials ? { initials } : {}
794
+ },
795
+ previousFormatting
796
+ }]
797
+ });
798
+ synthesizedRevisions.push({
799
+ ownerRevisionId,
800
+ revisionId
801
+ });
802
+ }
803
+ if (displacedMark == null) break;
804
+ carriedMark = displacedMark;
805
+ }
661
806
  }
662
807
  return {
663
808
  transaction: next,
664
- nextRevisionId
809
+ nextRevisionId,
810
+ synthesizedRevisions
665
811
  };
666
812
  };
667
- const buildInsertedParagraphs = ({ item, schema, mode, author, date, initials, commentMark, suggestionId, revisionSeed, isPairedMove }) => {
813
+ const buildInsertedParagraphs = ({ item, schema, alignmentFromStyle, mode, author, date, initials, commentMark, suggestionId, revisionSeed, isPairedMove }) => {
668
814
  const operation = item.operation;
669
815
  if (operation.type !== "insertAfterBlock" && operation.type !== "insertBeforeBlock") panic("Only paragraph insertions can build inserted paragraphs", { type: operation.type });
670
816
  const insertionType = schema.marks["insertion"];
@@ -716,6 +862,19 @@ const buildInsertedParagraphs = ({ item, schema, mode, author, date, initials, c
716
862
  attrs["styleId"] = operation.styleId;
717
863
  if (operation.inheritFormatting !== false && operation.styleId !== null) Object.assign(attrs, CLEARED_LIST_MARKER_ATTRS);
718
864
  }
865
+ if (isFirstParagraph && (operation.styleId !== void 0 || operation.alignment !== void 0)) {
866
+ const inheritedDirectAlignment = operation.inheritFormatting === false ? void 0 : directParagraphAlignment(expectParagraphAttrs(item.blockNode));
867
+ const directAlignment = operation.alignment === void 0 ? inheritedDirectAlignment : operation.alignment ?? void 0;
868
+ attrs["alignmentFromStyle"] = alignmentFromStyle;
869
+ attrs["alignment"] = directAlignment ?? alignmentFromStyle ?? null;
870
+ const sourceFormatting = attrs["_originalFormatting"];
871
+ const originalFormatting = typeof sourceFormatting === "object" && sourceFormatting !== null ? { ...sourceFormatting } : {};
872
+ if (operation.styleId !== void 0) if (operation.styleId === null) Reflect.deleteProperty(originalFormatting, "styleId");
873
+ else originalFormatting.styleId = operation.styleId;
874
+ if (operation.alignment !== void 0 || inheritedDirectAlignment !== void 0) if (directAlignment === void 0) Reflect.deleteProperty(originalFormatting, "alignment");
875
+ else originalFormatting.alignment = directAlignment;
876
+ attrs["_originalFormatting"] = Object.keys(originalFormatting).length > 0 ? originalFormatting : null;
877
+ }
719
878
  if (isSuggested && suggestionId !== null && paragraphRevisionId !== null) attrs["_suggestedInsert"] = {
720
879
  suggestionId,
721
880
  revisionId: paragraphRevisionId,
@@ -747,7 +906,7 @@ const buildInsertedParagraphs = ({ item, schema, mode, author, date, initials, c
747
906
  nextRevisionId
748
907
  };
749
908
  };
750
- const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode = "tracked-changes", author = "AI", initials, createCommentId, revisionStamp, revisionIdSeed, wordDiff, tableTemplates }) => {
909
+ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode = "tracked-changes", author = "AI", initials, createCommentId, revisionStamp, revisionIdSeed, wordDiff, wordDiffMode = "bounded", tableTemplates }) => {
751
910
  const applied = [];
752
911
  const skipped = [];
753
912
  const normalizations = [];
@@ -755,8 +914,21 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
755
914
  const insertionType = view.state.schema.marks["insertion"];
756
915
  const deletionType = view.state.schema.marks["deletion"];
757
916
  const commentType = view.state.schema.marks["comment"];
917
+ const styleResolver = getDocumentStyleResolver(view.state);
918
+ const alignmentFromStyleForInsertion = (item) => {
919
+ if (item.operation.type !== "insertAfterBlock" && item.operation.type !== "insertBeforeBlock") return;
920
+ const anchorAttrs = expectParagraphAttrs(item.blockNode);
921
+ const inheritedStyleId = item.operation.inheritFormatting === false ? null : anchorAttrs.styleId;
922
+ const styleId = item.operation.styleId !== void 0 ? item.operation.styleId : inheritedStyleId;
923
+ return resolveAlignmentFromStyle({
924
+ attrs: anchorAttrs,
925
+ styleId,
926
+ styleResolver
927
+ });
928
+ };
758
929
  const claimedTableRows = /* @__PURE__ */ new Set();
759
930
  const claimedTableColumns = /* @__PURE__ */ new Set();
931
+ const diffText = wordDiffMode === "coarse" ? coarseWordDiff : wordDiffSessionFromOptions(wordDiff).diff;
760
932
  const producesTrackedChanges = mode !== "direct";
761
933
  const isSuggested = mode === "suggested";
762
934
  if (producesTrackedChanges && (!insertionType || !deletionType)) return {
@@ -915,6 +1087,7 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
915
1087
  const built = buildInsertedParagraphs({
916
1088
  item: insertion,
917
1089
  schema: view.state.schema,
1090
+ alignmentFromStyle: alignmentFromStyleForInsertion(insertion),
918
1091
  mode,
919
1092
  author,
920
1093
  date,
@@ -999,7 +1172,7 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
999
1172
  commentMark,
1000
1173
  suggestionId,
1001
1174
  initials,
1002
- granularity: wordDiff?.granularity ?? "word"
1175
+ diffText
1003
1176
  });
1004
1177
  if (producesTrackedChanges) appliedRevisionIds = [
1005
1178
  revisionIdDelete,
@@ -1039,63 +1212,83 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
1039
1212
  });
1040
1213
  break;
1041
1214
  case "replaceBlock": {
1042
- const revisionIdDelete = revisionSeed++;
1043
- const revisionIdInsert = revisionSeed++;
1044
- const revisionIdBackground = revisionSeed++;
1215
+ if (!isResolvedReplaceBlockOperation(item)) panic("Resolved replaceBlock operation lost its impact discriminator");
1216
+ const { changesStyle, changesText } = REPLACE_BLOCK_IMPACT[item.replaceBlockImpact];
1217
+ const revisionIdDelete = revisionSeed;
1218
+ const revisionIdInsert = changesText ? revisionSeed + 1 : revisionSeed;
1219
+ const revisionIdBackground = changesText ? revisionSeed + 2 : revisionSeed;
1220
+ if (changesText) revisionSeed += 3;
1221
+ const revisionIdParagraph = producesTrackedChanges && changesStyle ? revisionSeed++ : null;
1045
1222
  if (item.operation.preserveFormatting === false && mode === "direct") {
1046
1223
  const replacement = item.operation.text;
1047
1224
  const paragraphType = view.state.schema.nodes["paragraph"];
1048
1225
  if (paragraphType) {
1049
- const replaceAttrs = item.operation.styleId !== void 0 ? { styleId: item.operation.styleId } : null;
1050
- const node = paragraphType.create(replaceAttrs, replacement.length === 0 ? null : buildEmphasisInlineContent(view.state.schema, replacement, []));
1226
+ const node = paragraphType.create(null, replacement.length === 0 ? null : buildEmphasisInlineContent(view.state.schema, replacement, []));
1051
1227
  tr = tr.replaceWith(item.blockFrom, item.blockTo, node);
1228
+ tr = applyReplaceBlockStyleId({
1229
+ item,
1230
+ tr,
1231
+ styleResolver
1232
+ }).tr;
1052
1233
  break;
1053
1234
  }
1054
1235
  }
1055
1236
  if (mode === "direct" && hasInlineEmphasis(item.operation.text)) {
1056
- const attrs = item.operation.styleId !== void 0 ? {
1057
- ...item.blockNode.attrs,
1058
- styleId: item.operation.styleId
1059
- } : { ...item.blockNode.attrs };
1060
- const node = item.blockNode.type.create(attrs, buildEmphasisInlineContent(view.state.schema, item.operation.text, commentMark ? [commentMark] : []));
1237
+ const node = item.blockNode.type.create({ ...item.blockNode.attrs }, buildEmphasisInlineContent(view.state.schema, item.operation.text, commentMark ? [commentMark] : []));
1061
1238
  tr = tr.replaceWith(item.blockFrom, item.blockTo, node);
1239
+ tr = applyReplaceBlockStyleId({
1240
+ item,
1241
+ tr,
1242
+ styleResolver
1243
+ }).tr;
1062
1244
  break;
1063
1245
  }
1064
- const stepsBeforeBackgroundClear = tr.steps.length;
1065
- tr = clearReplacementBackground({
1066
- tr,
1067
- schema: view.state.schema,
1068
- from: item.from,
1069
- to: item.to,
1070
- mode,
1071
- revisionId: revisionIdBackground,
1072
- author,
1073
- date,
1074
- initials,
1075
- suggestionId
1076
- });
1077
- const clearedBackground = tr.steps.length > stepsBeforeBackgroundClear;
1078
- tr = applyTextReplacement({
1079
- tr,
1080
- item,
1081
- mode,
1082
- author,
1083
- date,
1084
- revisionIdDelete,
1085
- revisionIdInsert,
1086
- commentMark,
1087
- suggestionId,
1088
- initials,
1089
- granularity: wordDiff?.granularity ?? "word"
1090
- });
1091
- tr = applyReplaceBlockStyleId({
1246
+ let clearedBackground = false;
1247
+ if (changesText) {
1248
+ const stepsBeforeBackgroundClear = tr.steps.length;
1249
+ tr = clearReplacementBackground({
1250
+ tr,
1251
+ schema: view.state.schema,
1252
+ from: item.from,
1253
+ to: item.to,
1254
+ mode,
1255
+ revisionId: revisionIdBackground,
1256
+ author,
1257
+ date,
1258
+ initials,
1259
+ suggestionId
1260
+ });
1261
+ clearedBackground = tr.steps.length > stepsBeforeBackgroundClear;
1262
+ tr = applyTextReplacement({
1263
+ tr,
1264
+ item,
1265
+ mode,
1266
+ author,
1267
+ date,
1268
+ revisionIdDelete,
1269
+ revisionIdInsert,
1270
+ commentMark,
1271
+ suggestionId,
1272
+ initials,
1273
+ diffText
1274
+ });
1275
+ }
1276
+ const styleResult = applyReplaceBlockStyleId({
1092
1277
  item,
1093
- tr
1278
+ tr,
1279
+ styleResolver,
1280
+ ...revisionIdParagraph !== null ? { revisionInfo: {
1281
+ id: revisionIdParagraph,
1282
+ author,
1283
+ date,
1284
+ ...trackedRevisionExtras
1285
+ } } : {}
1094
1286
  });
1287
+ tr = styleResult.tr;
1095
1288
  if (producesTrackedChanges) appliedRevisionIds = [
1096
- revisionIdDelete,
1097
- revisionIdInsert,
1098
- ...clearedBackground ? [revisionIdBackground] : []
1289
+ ...changesText ? [revisionIdDelete, revisionIdInsert] : [],
1290
+ ...clearedBackground ? [revisionIdBackground] : [],
1291
+ ...styleResult.revisionId === null ? [] : [styleResult.revisionId]
1099
1292
  ];
1100
1293
  break;
1101
1294
  }
@@ -1113,6 +1306,7 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
1113
1306
  const built = buildInsertedParagraphs({
1114
1307
  item,
1115
1308
  schema: view.state.schema,
1309
+ alignmentFromStyle: alignmentFromStyleForInsertion(item),
1116
1310
  mode,
1117
1311
  author,
1118
1312
  date,
@@ -1500,7 +1694,17 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
1500
1694
  case "setBlockParagraphProperties": {
1501
1695
  const blockPosition = tr.mapping.map(item.blockFrom);
1502
1696
  const liveBlock = tr.doc.nodeAt(blockPosition) ?? item.blockNode;
1503
- const patch = paragraphPropertiesPatch(liveBlock, item.operation.properties);
1697
+ const liveAttrs = expectParagraphAttrs(liveBlock);
1698
+ const resolvedAlignmentFromStyle = item.operation.properties.styleId === void 0 ? void 0 : resolveAlignmentFromStyle({
1699
+ attrs: liveAttrs,
1700
+ styleId: item.operation.properties.styleId,
1701
+ styleResolver
1702
+ });
1703
+ const patch = paragraphPropertiesPatch({
1704
+ node: liveBlock,
1705
+ properties: item.operation.properties,
1706
+ resolvedAlignmentFromStyle
1707
+ });
1504
1708
  if (patch === null) {
1505
1709
  skipped.push({
1506
1710
  id: item.operation.id,
@@ -1522,7 +1726,7 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
1522
1726
  id: revisionId,
1523
1727
  author,
1524
1728
  date,
1525
- ...initials ? { initials } : {}
1729
+ ...trackedRevisionExtras
1526
1730
  },
1527
1731
  previousFormatting: paragraphPropertiesSnapshot(liveBlock)
1528
1732
  };
@@ -1633,6 +1837,20 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
1633
1837
  });
1634
1838
  tr = rotated.transaction;
1635
1839
  revisionSeed = rotated.nextRevisionId;
1840
+ const receiptIndexByRevisionId = /* @__PURE__ */ new Map();
1841
+ for (const [receiptIndex, receipt] of applied.entries()) for (const revisionId of receipt.revisionIds ?? []) receiptIndexByRevisionId.set(revisionId, receiptIndex);
1842
+ for (const { ownerRevisionId, revisionId } of rotated.synthesizedRevisions) {
1843
+ const receiptIndex = receiptIndexByRevisionId.get(ownerRevisionId);
1844
+ const receipt = receiptIndex === void 0 ? void 0 : applied.at(receiptIndex);
1845
+ if (receiptIndex === void 0 || !receipt?.revisionIds) panic("A synthesized final-mark revision lost its operation receipt", {
1846
+ ownerRevisionId,
1847
+ revisionId
1848
+ });
1849
+ applied[receiptIndex] = {
1850
+ ...receipt,
1851
+ revisionIds: [...receipt.revisionIds, revisionId]
1852
+ };
1853
+ }
1636
1854
  if (revisionStamp) requestDeterministicParaIds(tr, `${revisionStamp.date}:${String(revisionStamp.idSeed)}`);
1637
1855
  view.dispatch(tr);
1638
1856
  }
@@ -1657,7 +1875,8 @@ const previewFolioAIEditOperations = (options) => {
1657
1875
  ...applyOptions,
1658
1876
  view: previewView,
1659
1877
  ...createCommentId !== void 0 && { createCommentId: () => previewCommentId-- },
1660
- revisionIdSeed: -1e9
1878
+ revisionIdSeed: -1e9,
1879
+ wordDiffMode: "coarse"
1661
1880
  });
1662
1881
  return {
1663
1882
  applied: result.applied.map(({ id }) => ({ id })),
@@ -1666,7 +1885,7 @@ const previewFolioAIEditOperations = (options) => {
1666
1885
  nextRevisionId: options.revisionStamp?.idSeed ?? revisionIdCursor
1667
1886
  };
1668
1887
  };
1669
- const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete, revisionIdInsert, commentMark, suggestionId = null, initials, granularity }) => {
1888
+ const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete, revisionIdInsert, commentMark, suggestionId = null, initials, diffText }) => {
1670
1889
  let nextTr = tr;
1671
1890
  const replacement = stripInlineEmphasisMarkers((() => {
1672
1891
  if (item.operation.type === "replaceInBlock" || item.operation.type === "replaceRange") return item.operation.replace;
@@ -1720,7 +1939,7 @@ const applyTextReplacement = ({ tr, item, mode, author, date, revisionIdDelete,
1720
1939
  }
1721
1940
  }
1722
1941
  if (sourceText !== null && cleanBlock !== null) {
1723
- const segments = diffWordSegments(sourceText, replacement, { granularity });
1942
+ const segments = diffText(sourceText, replacement);
1724
1943
  const offsets = cleanBlock.offsets;
1725
1944
  const offsetAt = (cleanOffset) => offsets[cleanOffset] ?? null;
1726
1945
  const steps = [];
@@ -2210,6 +2429,7 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
2210
2429
  };
2211
2430
  }
2212
2431
  if (operation.type === "deleteBlock" || operation.type === "replaceBlock") {
2432
+ const replaceChangesStyle = operation.type === "replaceBlock" && operation.styleId !== void 0 && operation.styleId !== (expectParagraphAttrs(blockNode).styleId ?? null);
2213
2433
  const range = getTextRangeFromCleanBlock(cleanBlock);
2214
2434
  if (!range) {
2215
2435
  const insertionPoint = cleanBlock.offsets.at(0);
@@ -2217,10 +2437,25 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
2217
2437
  type: "skip",
2218
2438
  reason: "unsupportedBlock"
2219
2439
  };
2220
- if (!(operation.type === "deleteBlock" ? true : currentText.length === 0 && operation.text.length > 0)) return {
2440
+ if (!(operation.type === "deleteBlock" ? true : currentText.length === 0 && (operation.text.length > 0 || replaceChangesStyle))) return {
2221
2441
  type: "skip",
2222
2442
  reason: "unsupportedBlock"
2223
2443
  };
2444
+ if (operation.type === "replaceBlock") return {
2445
+ type: "resolved",
2446
+ operation: {
2447
+ operation,
2448
+ from: insertionPoint,
2449
+ to: insertionPoint,
2450
+ blockFrom,
2451
+ blockTo,
2452
+ blockNode,
2453
+ replaceBlockImpact: resolveReplaceBlockImpact({
2454
+ changesText: operation.text !== currentText,
2455
+ changesStyle: replaceChangesStyle
2456
+ })
2457
+ }
2458
+ };
2224
2459
  return {
2225
2460
  type: "resolved",
2226
2461
  operation: {
@@ -2233,9 +2468,26 @@ const resolveOperation = ({ snapshot, operation, liveBlocks, liveBlocksByParaId,
2233
2468
  }
2234
2469
  };
2235
2470
  }
2236
- if (operation.type === "replaceBlock" && operation.text === currentText) return {
2237
- type: "skip",
2238
- reason: "noopOperation"
2471
+ if (operation.type === "replaceBlock" && operation.text === currentText) {
2472
+ if (!replaceChangesStyle) return {
2473
+ type: "skip",
2474
+ reason: "noopOperation"
2475
+ };
2476
+ }
2477
+ if (operation.type === "replaceBlock") return {
2478
+ type: "resolved",
2479
+ operation: {
2480
+ operation,
2481
+ from: range.from,
2482
+ to: range.to,
2483
+ blockFrom,
2484
+ blockTo,
2485
+ blockNode,
2486
+ replaceBlockImpact: resolveReplaceBlockImpact({
2487
+ changesText: operation.text !== currentText,
2488
+ changesStyle: replaceChangesStyle
2489
+ })
2490
+ }
2239
2491
  };
2240
2492
  return {
2241
2493
  type: "resolved",