@stll/folio-core 0.32.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-edits/__fixtures__/paragraphs.d.ts +17 -0
- package/dist/ai-edits/__fixtures__/paragraphs.js +33 -0
- package/dist/ai-edits/apply.d.ts +49 -3
- package/dist/ai-edits/apply.js +692 -75
- package/dist/ai-edits/blockRange.d.ts +7 -3
- package/dist/ai-edits/blockRange.js +14 -6
- package/dist/ai-edits/headless.d.ts +39 -5
- package/dist/ai-edits/headless.js +76 -17
- package/dist/ai-edits/index.d.ts +6 -6
- package/dist/ai-edits/index.js +3 -3
- package/dist/ai-edits/read.d.ts +4 -0
- package/dist/ai-edits/read.js +30 -6
- package/dist/ai-edits/scoped-reading.js +4 -3
- package/dist/ai-edits/snapshot.d.ts +42 -2
- package/dist/ai-edits/snapshot.js +127 -62
- package/dist/ai-edits/table-row-column-mutations.d.ts +24 -1
- package/dist/ai-edits/table-row-column-mutations.js +98 -6
- package/dist/ai-edits/table-targets.d.ts +12 -3
- package/dist/ai-edits/table-targets.js +15 -5
- package/dist/ai-edits/types.d.ts +235 -7
- package/dist/ai-edits/word-diff.d.ts +54 -11
- package/dist/ai-edits/word-diff.js +221 -61
- package/dist/compare/__fixtures__/body-sequence.d.ts +33 -0
- package/dist/compare/__fixtures__/body-sequence.js +67 -0
- package/dist/compare/__fixtures__/nested-table.d.ts +16 -0
- package/dist/compare/__fixtures__/nested-table.js +50 -0
- package/dist/compare/__fixtures__/numbered-list.d.ts +34 -0
- package/dist/compare/__fixtures__/numbered-list.js +85 -0
- package/dist/compare/column-alignment.d.ts +27 -0
- package/dist/compare/column-alignment.js +162 -0
- package/dist/compare/compare.d.ts +107 -0
- package/dist/compare/compare.js +406 -0
- package/dist/compare/formatting.d.ts +29 -0
- package/dist/compare/formatting.js +84 -0
- package/dist/compare/plan.d.ts +22 -0
- package/dist/compare/plan.js +1059 -0
- package/dist/compare/reproducible-package.d.ts +17 -0
- package/dist/compare/reproducible-package.js +30 -0
- package/dist/compare/scenario.d.ts +77 -0
- package/dist/compare/scenario.js +263 -0
- package/dist/compare/types.d.ts +282 -0
- package/dist/compare/types.js +32 -0
- package/dist/compare/verification.d.ts +55 -0
- package/dist/compare/verification.js +146 -0
- package/dist/compat/eigenpal.d.ts +9 -6
- package/dist/compat/eigenpal.js +6 -3
- package/dist/controller/layoutPipeline.d.ts +13 -0
- package/dist/controller/layoutPipeline.js +15 -1
- package/dist/display-list/build/buildContext.d.ts +39 -0
- package/dist/display-list/build/buildContext.js +31 -0
- package/dist/display-list/build/buildDisplayList.d.ts +79 -0
- package/dist/display-list/build/buildDisplayList.js +283 -0
- package/dist/display-list/build/colors.d.ts +20 -0
- package/dist/display-list/build/colors.js +136 -0
- package/dist/display-list/build/floatingImages.d.ts +24 -0
- package/dist/display-list/build/floatingImages.js +41 -0
- package/dist/display-list/build/fontTable.d.ts +31 -0
- package/dist/display-list/build/fontTable.js +97 -0
- package/dist/display-list/build/furniture.d.ts +86 -0
- package/dist/display-list/build/furniture.js +98 -0
- package/dist/display-list/build/glyphs.d.ts +55 -0
- package/dist/display-list/build/glyphs.js +103 -0
- package/dist/display-list/build/headerFooterPrimitives.d.ts +17 -0
- package/dist/display-list/build/headerFooterPrimitives.js +40 -0
- package/dist/display-list/build/imagePrimitives.d.ts +35 -0
- package/dist/display-list/build/imagePrimitives.js +264 -0
- package/dist/display-list/build/pageBorderPrimitives.d.ts +23 -0
- package/dist/display-list/build/pageBorderPrimitives.js +111 -0
- package/dist/display-list/build/pageFurniture.d.ts +35 -0
- package/dist/display-list/build/pageFurniture.js +141 -0
- package/dist/display-list/build/paragraphPrimitives.d.ts +19 -0
- package/dist/display-list/build/paragraphPrimitives.js +819 -0
- package/dist/display-list/build/regions.d.ts +46 -0
- package/dist/display-list/build/regions.js +56 -0
- package/dist/display-list/build/storyPrimitives.d.ts +29 -0
- package/dist/display-list/build/storyPrimitives.js +279 -0
- package/dist/display-list/build/strokes.d.ts +19 -0
- package/dist/display-list/build/strokes.js +78 -0
- package/dist/display-list/build/tablePrimitives.d.ts +38 -0
- package/dist/display-list/build/tablePrimitives.js +409 -0
- package/dist/display-list/build/textBoxPrimitives.d.ts +14 -0
- package/dist/display-list/build/textBoxPrimitives.js +129 -0
- package/dist/display-list/build/textDecorations.d.ts +29 -0
- package/dist/display-list/build/textDecorations.js +29 -0
- package/dist/display-list/build/unsupported.d.ts +57 -0
- package/dist/display-list/build/unsupported.js +0 -0
- package/dist/display-list/build/watermarkPrimitives.d.ts +15 -0
- package/dist/display-list/build/watermarkPrimitives.js +128 -0
- package/dist/display-list/dom/renderDisplayListToDom.d.ts +26 -0
- package/dist/display-list/dom/renderDisplayListToDom.js +620 -0
- package/dist/display-list/editor/displayListPagePainter.d.ts +22 -0
- package/dist/display-list/editor/displayListPagePainter.js +63 -0
- package/dist/display-list/editor/pageRenderer.d.ts +24 -0
- package/dist/display-list/editor/pageRenderer.js +36 -0
- package/dist/display-list/primitives.d.ts +95 -0
- package/dist/display-list/primitives.js +127 -0
- package/dist/display-list/types.d.ts +472 -0
- package/dist/display-list/types.js +0 -0
- package/dist/document-operations.d.ts +42 -9
- package/dist/document-operations.js +171 -16
- package/dist/document-stories.d.ts +16 -0
- package/dist/document-stories.js +48 -4
- package/dist/docx/packageParts.d.ts +35 -0
- package/dist/docx/packageParts.js +120 -0
- package/dist/docx/paragraphParser.js +22 -22
- package/dist/docx/paragraphTraversal.js +1 -1
- package/dist/docx/renderedPageBreakNormalization.js +3 -1
- package/dist/docx/revisionIdNormalization.d.ts +13 -0
- package/dist/docx/revisionIdNormalization.js +103 -0
- package/dist/docx/rezip.js +26 -17
- package/dist/docx/selectiveSave.js +2 -2
- package/dist/docx/serializer/commentSerializer.js +6 -1
- package/dist/docx/serializer/paragraphSerializer.js +33 -4
- package/dist/docx/serializer/runSerializer.js +11 -10
- package/dist/docx/serializer/tableSerializer.js +2 -1
- package/dist/docx/server/applyDocxXmlPatchProposal.js +1 -1
- package/dist/docx/server/createBilingualDocx.js +3 -2
- package/dist/docx/streamingXmlParser.d.ts +13 -1
- package/dist/docx/streamingXmlParser.js +49 -11
- package/dist/docx/unzip.d.ts +1 -2
- package/dist/docx/unzip.js +25 -6
- package/dist/docx/xmlParser.d.ts +3 -1
- package/dist/docx/xmlParser.js +33 -27
- package/dist/export-pdf.d.ts +43 -0
- package/dist/export-pdf.js +98 -0
- package/dist/fonts/headlessMeasure.d.ts +76 -0
- package/dist/fonts/headlessMeasure.js +0 -0
- package/dist/fonts/sfnt/parse.d.ts +51 -0
- package/dist/fonts/sfnt/parse.js +525 -0
- package/dist/fonts/sfnt/subset.d.ts +20 -0
- package/dist/fonts/sfnt/subset.js +350 -0
- package/dist/fonts/sfnt/tables.d.ts +88 -0
- package/dist/fonts/sfnt/tables.js +131 -0
- package/dist/fonts/sfnt/woff.d.ts +14 -0
- package/dist/fonts/sfnt/woff.js +163 -0
- package/dist/generated/text_shaper.js +324 -0
- package/dist/generated/text_shaper_bg.wasm +0 -0
- package/dist/headless-layout.d.ts +75 -0
- package/dist/headless-layout.js +350 -0
- package/dist/index.d.ts +9 -6
- package/dist/index.js +6 -3
- package/dist/layout-bridge/convert/footnoteLayout.d.ts +1 -0
- package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.js +2 -0
- package/dist/layout-bridge/convert/toFlowBlocks.d.ts +3 -1
- package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
- package/dist/layout-engine/measure/advanceComposition.d.ts +62 -0
- package/dist/layout-engine/measure/advanceComposition.js +148 -0
- package/dist/layout-engine/measure/cache.d.ts +2 -0
- package/dist/layout-engine/measure/measureContainer.js +13 -0
- package/dist/layout-engine/measure/measureParagraph.js +1 -2
- package/dist/layout-engine/measure/measureTypes.d.ts +12 -0
- package/dist/layout-engine/measure/tableInlinePlacement.d.ts +3 -2
- package/dist/layout-engine/measure/tableInlinePlacement.js +14 -1
- package/dist/layout-engine/tableIndentCompatibility.d.ts +5 -0
- package/dist/layout-engine/tableIndentCompatibility.js +15 -0
- package/dist/layout-engine/types.d.ts +8 -0
- package/dist/layout-painter/renderPage.d.ts +29 -1
- package/dist/layout-painter/renderPage.js +47 -19
- package/dist/layout-painter/renderParagraph.d.ts +21 -2
- package/dist/layout-painter/renderParagraph.js +1 -1
- package/dist/layout-painter/renderTable.js +37 -4
- package/dist/managers/editorShortcuts.d.ts +33 -1
- package/dist/managers/editorShortcuts.js +20 -1
- package/dist/markdown/renderRuns.js +7 -2
- package/dist/markdown/renderTable.js +5 -1
- package/dist/model.d.ts +3 -3
- package/dist/model.js +2 -2
- package/dist/pdf/contentStream.d.ts +73 -0
- package/dist/pdf/contentStream.js +143 -0
- package/dist/pdf/fonts.d.ts +96 -0
- package/dist/pdf/fonts.js +593 -0
- package/dist/pdf/images.d.ts +28 -0
- package/dist/pdf/images.js +439 -0
- package/dist/pdf/objects.d.ts +93 -0
- package/dist/pdf/objects.js +237 -0
- package/dist/pdf/pageSpace.d.ts +43 -0
- package/dist/pdf/pageSpace.js +57 -0
- package/dist/pdf/paint.d.ts +32 -0
- package/dist/pdf/paint.js +442 -0
- package/dist/pdf/writePdf.d.ts +56 -0
- package/dist/pdf/writePdf.js +345 -0
- package/dist/prosemirror/commands/comments.js +91 -6
- package/dist/prosemirror/conversion/fromProseDoc.js +41 -7
- package/dist/prosemirror/conversion/toProseDoc.js +41 -4
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +15 -2
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +35 -6
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +2 -2
- package/dist/prosemirror/extensions/features/pasteCleanup.js +4 -3
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +3 -1
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +5 -3
- package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.d.ts +7 -1
- package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.js +9 -3
- package/dist/prosemirror/extensions/nodes/TableExtension.js +56 -39
- package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +3 -1
- package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +5 -3
- package/dist/prosemirror/plugins/suggestionMode.js +1 -0
- package/dist/prosemirror/revisionCarriers.js +19 -2
- package/dist/prosemirror/utils/extractTrackedChanges.d.ts +2 -2
- package/dist/prosemirror/utils/extractTrackedChanges.js +59 -23
- package/dist/prosemirror/validation.js +55 -27
- package/dist/prosemirror/zeroWidthAnchors.d.ts +10 -0
- package/dist/prosemirror/zeroWidthAnchors.js +23 -0
- package/dist/redline.js +12 -79
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/shaping/placeRun.d.ts +51 -0
- package/dist/shaping/placeRun.js +84 -0
- package/dist/shaping/shaper.d.ts +92 -0
- package/dist/shaping/shaper.js +102 -0
- package/dist/types/block-id.d.ts +18 -2
- package/dist/types/block-id.js +49 -5
- package/dist/types/content.d.ts +2 -2
- package/dist/utils/clipboard.js +4 -3
- package/dist/utils/fontResolver.js +183 -0
- package/dist/utils/htmlComments.d.ts +17 -0
- package/dist/utils/htmlComments.js +22 -0
- package/dist/utils/scriptSegments.d.ts +12 -1
- package/dist/utils/scriptSegments.js +17 -1
- package/dist/version-comparison.d.ts +1 -1
- package/package.json +7 -3
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
import { createFolioAITextRangeHandle, trailingBodyBlockId } from "../ai-edits/snapshot.js";
|
|
2
|
+
import { alignFolioBlocks } from "../version-comparison.js";
|
|
3
|
+
import { alignTableColumns } from "./column-alignment.js";
|
|
4
|
+
import { inlineFormattingSegments } from "./formatting.js";
|
|
5
|
+
import { panic } from "better-result";
|
|
6
|
+
//#region src/compare/plan.ts
|
|
7
|
+
/**
|
|
8
|
+
* Turn two block snapshots of one story into the change list a caller reads
|
|
9
|
+
* and the {@link FolioAIEditOperation}s that reproduce the target when every
|
|
10
|
+
* generated tracked change is accepted.
|
|
11
|
+
*
|
|
12
|
+
* Pure: no parsing, no serialization, no clock. Every alignment runs through
|
|
13
|
+
* {@link alignFolioBlocks}, shared with the version-diff and redline paths.
|
|
14
|
+
*
|
|
15
|
+
* ## Aligning by container, not by block
|
|
16
|
+
*
|
|
17
|
+
* A single alignment over every paragraph in the story cannot see structure.
|
|
18
|
+
* It pairs on text and document order, so it will put a paragraph inside a
|
|
19
|
+
* table cell opposite one outside it, or a cell of one row opposite a cell of
|
|
20
|
+
* the next — and rewriting either pair in place leaves the target's text in
|
|
21
|
+
* the wrong container. A whole row that was added or removed likewise arrives
|
|
22
|
+
* as a scatter of unmatched paragraphs, which no block operation can turn back
|
|
23
|
+
* into a row: a block insertion anchored in a table lands beside the table,
|
|
24
|
+
* never as a new row in it.
|
|
25
|
+
*
|
|
26
|
+
* So the story is aligned in three nested passes, each over things that can
|
|
27
|
+
* actually stand in for one another:
|
|
28
|
+
*
|
|
29
|
+
* 1. **Segments.** Maximal runs of body text and of one table each, so a table
|
|
30
|
+
* is only ever compared against a table.
|
|
31
|
+
* 2. **Rows**, within a paired table segment, so a whole-row change stays whole
|
|
32
|
+
* and becomes `insertTableRow` / `deleteTableRow`.
|
|
33
|
+
* 3. **Cells**, within a paired row, matched by physical cell index.
|
|
34
|
+
*
|
|
35
|
+
* ## Move detection
|
|
36
|
+
*
|
|
37
|
+
* A base-only and a target-only block with identical text and at least
|
|
38
|
+
* {@link MOVE_MINIMUM_WORD_COUNT} words are one relocation. The operations stay
|
|
39
|
+
* a delete plus an insert — the tracked-change grammar Word round-trips has no
|
|
40
|
+
* durable "moved from here" mark on this path — but the change list reports a
|
|
41
|
+
* single `move` so the relocation is not read as unrelated churn. The word
|
|
42
|
+
* floor keeps boilerplate one-liners from pairing as spurious moves.
|
|
43
|
+
*/
|
|
44
|
+
/** Words a relocated block needs before the move pass will pair it. */
|
|
45
|
+
const MOVE_MINIMUM_WORD_COUNT = 3;
|
|
46
|
+
/**
|
|
47
|
+
* Cap on same-text base-only blocks the move pass keeps per text. Without it a
|
|
48
|
+
* document repeating one paragraph thousands of times would make the pass
|
|
49
|
+
* quadratic on attacker-controlled input.
|
|
50
|
+
*/
|
|
51
|
+
const MAX_MOVE_CANDIDATES_PER_TEXT = 64;
|
|
52
|
+
/**
|
|
53
|
+
* How much of a relocated paragraph must survive the relocation for it still
|
|
54
|
+
* to read as one. Below it the two paragraphs are a deletion and an unrelated
|
|
55
|
+
* insertion, and calling them a move would tell the reader the wrong story
|
|
56
|
+
* about where the text came from.
|
|
57
|
+
*/
|
|
58
|
+
const MOVE_SIMILARITY_THRESHOLD = .8;
|
|
59
|
+
/**
|
|
60
|
+
* Total pair comparisons the similarity pass may make in one story. Exact text
|
|
61
|
+
* matches are found by lookup; only what is left pays this, and it is capped
|
|
62
|
+
* so two documents of unmatched paragraphs cannot make the pass quadratic.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_MOVE_SIMILARITY_COMPARISONS = 2e4;
|
|
65
|
+
/**
|
|
66
|
+
* Dice coefficient over word tokens: twice the shared tokens over the two
|
|
67
|
+
* token counts. Multiset rather than set, so a paragraph repeating a word does
|
|
68
|
+
* not match one that says it once.
|
|
69
|
+
*/
|
|
70
|
+
const tokenSimilarity = (left, right) => {
|
|
71
|
+
const leftTokens = left.split(/\s+/u).filter((token) => token.length > 0);
|
|
72
|
+
const rightTokens = right.split(/\s+/u).filter((token) => token.length > 0);
|
|
73
|
+
if (leftTokens.length === 0 || rightTokens.length === 0) return 0;
|
|
74
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
75
|
+
for (const token of leftTokens) remaining.set(token, (remaining.get(token) ?? 0) + 1);
|
|
76
|
+
let shared = 0;
|
|
77
|
+
for (const token of rightTokens) {
|
|
78
|
+
const count = remaining.get(token) ?? 0;
|
|
79
|
+
if (count > 0) {
|
|
80
|
+
remaining.set(token, count - 1);
|
|
81
|
+
shared += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return 2 * shared / (leftTokens.length + rightTokens.length);
|
|
85
|
+
};
|
|
86
|
+
const wordCountReaches = (text, minimum) => {
|
|
87
|
+
let count = 0;
|
|
88
|
+
for (const word of text.split(/\s+/u)) if (word.length > 0 && ++count >= minimum) return true;
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The document's segments, normalized to strictly alternate body, table, body,
|
|
93
|
+
* ... and to begin and end with a body segment — inserting empty body segments
|
|
94
|
+
* where the document has none.
|
|
95
|
+
*
|
|
96
|
+
* Without the padding the two sides can hold different segment shapes (a
|
|
97
|
+
* document that opens with a table against one that opens with a heading), and
|
|
98
|
+
* the alignment's positional fallback would then put a body segment opposite a
|
|
99
|
+
* table. That pair is unusable, so the whole table would be reissued as a
|
|
100
|
+
* deletion plus an insertion. Padding keeps like opposite like.
|
|
101
|
+
*/
|
|
102
|
+
const splitSegments = (blocks) => {
|
|
103
|
+
const segments = [];
|
|
104
|
+
let currentTableIndex = null;
|
|
105
|
+
for (const block of blocks) {
|
|
106
|
+
const tableIndex = block.table?.outerTableIndex ?? null;
|
|
107
|
+
const current = segments.at(-1);
|
|
108
|
+
if (current !== void 0 && currentTableIndex === tableIndex) {
|
|
109
|
+
current.blocks.push(block);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (block.table && segments.at(-1)?.kind !== "body") segments.push({
|
|
113
|
+
kind: "body",
|
|
114
|
+
blocks: []
|
|
115
|
+
});
|
|
116
|
+
segments.push({
|
|
117
|
+
kind: block.table ? "table" : "body",
|
|
118
|
+
blocks: [block]
|
|
119
|
+
});
|
|
120
|
+
currentTableIndex = tableIndex;
|
|
121
|
+
}
|
|
122
|
+
if (segments.length === 0 || segments.at(-1)?.kind === "table") segments.push({
|
|
123
|
+
kind: "body",
|
|
124
|
+
blocks: []
|
|
125
|
+
});
|
|
126
|
+
return segments;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Blocks of one table segment grouped into rows, in document order.
|
|
130
|
+
*
|
|
131
|
+
* Keyed by table AND row, not by row alone: a segment holds the whole
|
|
132
|
+
* outermost table, so a nested table's first row would otherwise merge with
|
|
133
|
+
* its parent's first row and the alignment would compare one against the
|
|
134
|
+
* other.
|
|
135
|
+
*/
|
|
136
|
+
const groupRows = (blocks) => {
|
|
137
|
+
const rows = /* @__PURE__ */ new Map();
|
|
138
|
+
for (const block of blocks) {
|
|
139
|
+
if (!block.table) continue;
|
|
140
|
+
const key = `${String(block.table.tableIndex)}:${String(block.table.rowIndex)}`;
|
|
141
|
+
const row = rows.get(key);
|
|
142
|
+
if (row) row.push(block);
|
|
143
|
+
else rows.set(key, [block]);
|
|
144
|
+
}
|
|
145
|
+
return [...rows.values()];
|
|
146
|
+
};
|
|
147
|
+
const rowText = (row) => row.map(({ text }) => text).join(" ");
|
|
148
|
+
const rowLocation = (row) => row.at(0)?.table ?? null;
|
|
149
|
+
/** Zip one paired row's cells by physical cell index, then by paragraph order. */
|
|
150
|
+
const alignRowCells = (baseRow, targetRow, baseColumnKeys, targetColumnKeys) => {
|
|
151
|
+
const byCell = (row, columnKeys) => {
|
|
152
|
+
const cells = /* @__PURE__ */ new Map();
|
|
153
|
+
for (const block of row) {
|
|
154
|
+
const table = block.table;
|
|
155
|
+
let cellIndex = table?.cellIndex ?? 0;
|
|
156
|
+
if (table && columnKeys) {
|
|
157
|
+
const alignedColumn = columnKeys.get(table.gridColumnIndex);
|
|
158
|
+
if (alignedColumn === void 0) return panic("A paired table cell has no aligned grid column", { gridColumnIndex: table.gridColumnIndex });
|
|
159
|
+
cellIndex = alignedColumn;
|
|
160
|
+
}
|
|
161
|
+
const blocks = cells.get(cellIndex);
|
|
162
|
+
if (blocks) blocks.push(block);
|
|
163
|
+
else cells.set(cellIndex, [block]);
|
|
164
|
+
}
|
|
165
|
+
return cells;
|
|
166
|
+
};
|
|
167
|
+
const baseCells = byCell(baseRow, baseColumnKeys);
|
|
168
|
+
const targetCells = byCell(targetRow, targetColumnKeys);
|
|
169
|
+
const cellIndexes = [.../* @__PURE__ */ new Set([...baseCells.keys(), ...targetCells.keys()])].toSorted((left, right) => left - right);
|
|
170
|
+
const steps = [];
|
|
171
|
+
for (const cellIndex of cellIndexes) {
|
|
172
|
+
const baseBlocks = baseCells.get(cellIndex) ?? [];
|
|
173
|
+
const targetBlocks = targetCells.get(cellIndex) ?? [];
|
|
174
|
+
const paired = Math.min(baseBlocks.length, targetBlocks.length);
|
|
175
|
+
for (let index = 0; index < paired; index++) {
|
|
176
|
+
const baseBlock = baseBlocks[index];
|
|
177
|
+
const targetBlock = targetBlocks[index];
|
|
178
|
+
if (baseBlock && targetBlock) steps.push({
|
|
179
|
+
type: "pair",
|
|
180
|
+
baseBlock,
|
|
181
|
+
targetBlock
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
for (const block of baseBlocks.slice(paired)) steps.push({
|
|
185
|
+
type: "baseOnly",
|
|
186
|
+
block
|
|
187
|
+
});
|
|
188
|
+
for (const block of targetBlocks.slice(paired)) steps.push({
|
|
189
|
+
type: "targetOnly",
|
|
190
|
+
block
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return steps;
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Align one table's rows, then its cells.
|
|
197
|
+
*
|
|
198
|
+
* Running the block alignment straight over a table's paragraphs cannot see
|
|
199
|
+
* rows: it happily pairs a cell of one row with a cell of another, and a row
|
|
200
|
+
* that was wholly added or removed shows up as a scatter of unmatched
|
|
201
|
+
* paragraphs. Aligning rows first — each row standing in as one proxy block of
|
|
202
|
+
* its joined cell text — keeps a whole-row change whole, and confines every
|
|
203
|
+
* other difference to a cell that really corresponds.
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* One segment's blocks split per table, in first-appearance order. A segment
|
|
207
|
+
* is a whole outermost table, so it holds the parent's blocks and every nested
|
|
208
|
+
* table's; a row alignment that mixed them would compare the parent's first
|
|
209
|
+
* row against a nested table's.
|
|
210
|
+
*/
|
|
211
|
+
const groupTables = (blocks) => {
|
|
212
|
+
const tables = /* @__PURE__ */ new Map();
|
|
213
|
+
for (const block of blocks) {
|
|
214
|
+
if (!block.table) continue;
|
|
215
|
+
const existing = tables.get(block.table.tableIndex);
|
|
216
|
+
if (existing) existing.push(block);
|
|
217
|
+
else tables.set(block.table.tableIndex, [block]);
|
|
218
|
+
}
|
|
219
|
+
return [...tables.values()];
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Align a paired table segment: each table in it against the table at the same
|
|
223
|
+
* place on the other side, then that table's rows, then its cells. Tables are
|
|
224
|
+
* paired by order within the segment because the segment IS one table plus
|
|
225
|
+
* whatever nests inside it — the nth nested table of one answers to the nth of
|
|
226
|
+
* the other.
|
|
227
|
+
*/
|
|
228
|
+
const buildTableSegmentSteps = (baseBlocks, targetBlocks) => {
|
|
229
|
+
const baseTables = groupTables(baseBlocks);
|
|
230
|
+
const targetTables = groupTables(targetBlocks);
|
|
231
|
+
const steps = [];
|
|
232
|
+
const paired = Math.min(baseTables.length, targetTables.length);
|
|
233
|
+
for (let index = 0; index < paired; index++) steps.push(...buildTableSteps(baseTables[index] ?? [], targetTables[index] ?? []));
|
|
234
|
+
for (const blocks of baseTables.slice(paired)) {
|
|
235
|
+
const location = blocks.at(0)?.table;
|
|
236
|
+
if (location) steps.push({
|
|
237
|
+
type: "baseTable",
|
|
238
|
+
blocks,
|
|
239
|
+
location
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
for (const blocks of targetTables.slice(paired)) {
|
|
243
|
+
const location = blocks.at(0)?.table;
|
|
244
|
+
if (location) steps.push({
|
|
245
|
+
type: "targetTable",
|
|
246
|
+
blocks,
|
|
247
|
+
location
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return steps;
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* How alike two rows must be for one to be read as the other, edited. Below
|
|
254
|
+
* it the pair is a deleted row and an inserted one.
|
|
255
|
+
*/
|
|
256
|
+
const ROW_PAIR_SIMILARITY = .5;
|
|
257
|
+
/**
|
|
258
|
+
* How alike two rows are: their text, halved when their shapes differ.
|
|
259
|
+
*
|
|
260
|
+
* A row's shape is its physical cell count. Two rows with the same words in a
|
|
261
|
+
* different number of cells are not the same row edited, and pairing them
|
|
262
|
+
* would report every cell as changed rather than the row as replaced.
|
|
263
|
+
*/
|
|
264
|
+
const rowSimilarity = (base, target) => {
|
|
265
|
+
if (!base || !target) return 0;
|
|
266
|
+
const text = tokenSimilarity(rowText(base), rowText(target));
|
|
267
|
+
return rowCellTexts(base).length === rowCellTexts(target).length ? text : text / 2;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Align one table's rows.
|
|
271
|
+
*
|
|
272
|
+
* Exact text first, then SIMILARITY rather than position. Positional fallback
|
|
273
|
+
* is what made a deleted row plus a few cell edits report as a change in every
|
|
274
|
+
* row of the table: each row was paired with the one below it, so every cell
|
|
275
|
+
* differed. Pairing on similarity, and stepping one row on the side whose next
|
|
276
|
+
* row matches better, keeps the deletion where it happened.
|
|
277
|
+
*/
|
|
278
|
+
const alignTableRows = (baseRows, targetRows, baseColumnKeys, targetColumnKeys) => {
|
|
279
|
+
const steps = [];
|
|
280
|
+
const pushRow = (row, side) => {
|
|
281
|
+
const location = rowLocation(row);
|
|
282
|
+
if (location) steps.push({
|
|
283
|
+
type: side === "base" ? "baseRow" : "targetRow",
|
|
284
|
+
blocks: row,
|
|
285
|
+
location
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
let baseCursor = 0;
|
|
289
|
+
let targetCursor = 0;
|
|
290
|
+
while (baseCursor < baseRows.length && targetCursor < targetRows.length) {
|
|
291
|
+
const baseRow = baseRows[baseCursor];
|
|
292
|
+
const targetRow = targetRows[targetCursor];
|
|
293
|
+
if (!baseRow || !targetRow) break;
|
|
294
|
+
const here = rowSimilarity(baseRow, targetRow);
|
|
295
|
+
if (here < 1) {
|
|
296
|
+
const baseAhead = rowSimilarity(baseRows[baseCursor + 1], targetRow);
|
|
297
|
+
const targetAhead = rowSimilarity(baseRow, targetRows[targetCursor + 1]);
|
|
298
|
+
if (baseAhead >= ROW_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
|
|
299
|
+
pushRow(baseRow, "base");
|
|
300
|
+
baseCursor += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (targetAhead >= ROW_PAIR_SIMILARITY && targetAhead > here) {
|
|
304
|
+
pushRow(targetRow, "target");
|
|
305
|
+
targetCursor += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
steps.push(...alignRowCells(baseRow, targetRow, baseColumnKeys, targetColumnKeys));
|
|
310
|
+
baseCursor += 1;
|
|
311
|
+
targetCursor += 1;
|
|
312
|
+
}
|
|
313
|
+
for (const row of baseRows.slice(baseCursor)) pushRow(row, "base");
|
|
314
|
+
for (const row of targetRows.slice(targetCursor)) pushRow(row, "target");
|
|
315
|
+
return steps;
|
|
316
|
+
};
|
|
317
|
+
const buildTableSteps = (baseBlocks, targetBlocks) => {
|
|
318
|
+
const columns = alignTableColumns(baseBlocks, targetBlocks);
|
|
319
|
+
return columns ? [...columns.steps, ...alignTableRows(groupRows(columns.baseBlocks), groupRows(columns.targetBlocks), columns.baseColumnKeys, columns.targetColumnKeys)] : alignTableRows(groupRows(baseBlocks), groupRows(targetBlocks));
|
|
320
|
+
};
|
|
321
|
+
const buildBodySteps = (baseBlocks, targetBlocks) => alignFolioBlocks(baseBlocks, targetBlocks).map((event) => {
|
|
322
|
+
switch (event.type) {
|
|
323
|
+
case "pair": return {
|
|
324
|
+
type: "pair",
|
|
325
|
+
baseBlock: event.baseBlock,
|
|
326
|
+
targetBlock: event.revisedBlock
|
|
327
|
+
};
|
|
328
|
+
case "baseOnly": return {
|
|
329
|
+
type: "baseOnly",
|
|
330
|
+
block: event.block
|
|
331
|
+
};
|
|
332
|
+
case "revisedOnly": return {
|
|
333
|
+
type: "targetOnly",
|
|
334
|
+
block: event.block
|
|
335
|
+
};
|
|
336
|
+
default: return panic("Unhandled block alignment event", { event });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
/**
|
|
340
|
+
* Every block of an unpaired segment, as one-sided steps. A whole table stays
|
|
341
|
+
* whole: reissuing it row by row would need a table to put the rows in, and
|
|
342
|
+
* the point of an unpaired table segment is that there is none.
|
|
343
|
+
*/
|
|
344
|
+
const unpairedSegmentSteps = (segment, side) => {
|
|
345
|
+
if (segment.kind !== "table") return segment.blocks.map((block) => side === "base" ? {
|
|
346
|
+
type: "baseOnly",
|
|
347
|
+
block
|
|
348
|
+
} : {
|
|
349
|
+
type: "targetOnly",
|
|
350
|
+
block
|
|
351
|
+
});
|
|
352
|
+
const location = segment.blocks.at(0)?.table;
|
|
353
|
+
if (!location) return [];
|
|
354
|
+
return [{
|
|
355
|
+
type: side === "base" ? "baseTable" : "targetTable",
|
|
356
|
+
blocks: segment.blocks,
|
|
357
|
+
location
|
|
358
|
+
}];
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* Align the two stories segment by segment, in order.
|
|
362
|
+
*
|
|
363
|
+
* Segments are paired by position rather than by text. Both sides have been
|
|
364
|
+
* normalized to the same alternating shape, so position already carries the
|
|
365
|
+
* meaning: the nth table of one document answers to the nth table of the
|
|
366
|
+
* other, and the body text between two tables answers to the body text between
|
|
367
|
+
* the same two tables. Matching segments on text instead lets a paragraph that
|
|
368
|
+
* moved across a table steal the table's own pairing, which reissues the whole
|
|
369
|
+
* table as a deletion and an insertion.
|
|
370
|
+
*
|
|
371
|
+
* When the two documents hold different numbers of tables the shapes diverge,
|
|
372
|
+
* and the surplus segments are reported one-sided. The operation builder turns
|
|
373
|
+
* those segments into `insertTable` or `deleteTable` operations.
|
|
374
|
+
*/
|
|
375
|
+
const segmentText = (segment) => segment.blocks.map(({ text }) => text).join(" ");
|
|
376
|
+
/**
|
|
377
|
+
* How alike two table segments must be before a lookahead match may steal the
|
|
378
|
+
* pairing from the table in front of it. Two tables drawn from one document's
|
|
379
|
+
* vocabulary score alike by chance, so a lookahead has to clear this bar as
|
|
380
|
+
* well as beat what it displaces.
|
|
381
|
+
*/
|
|
382
|
+
const TABLE_PAIR_SIMILARITY = .5;
|
|
383
|
+
/**
|
|
384
|
+
* Pair the two segment sequences, marking the segments only one side has.
|
|
385
|
+
*
|
|
386
|
+
* Pairing by index cannot see a table added or removed: the nth table of one
|
|
387
|
+
* document is put opposite the nth of the other, so deleting the first table
|
|
388
|
+
* shifts every later one and the comparison rewrites each table's contents
|
|
389
|
+
* into the next table along. Segments strictly alternate body, table, body,
|
|
390
|
+
* ..., so a one-segment lookahead on each side is enough to tell "this table
|
|
391
|
+
* changed a lot" from "this table is gone": the next table on the other side
|
|
392
|
+
* matching better is what says the current one is unpaired.
|
|
393
|
+
*/
|
|
394
|
+
const alignSegments = (baseSegments, targetSegments) => {
|
|
395
|
+
const paired = [];
|
|
396
|
+
const similarity = (left, right) => left === void 0 || right === void 0 || left.kind !== right.kind ? 0 : tokenSimilarity(segmentText(left), segmentText(right));
|
|
397
|
+
let baseCursor = 0;
|
|
398
|
+
let targetCursor = 0;
|
|
399
|
+
while (baseCursor < baseSegments.length && targetCursor < targetSegments.length) {
|
|
400
|
+
const baseSegment = baseSegments[baseCursor];
|
|
401
|
+
const targetSegment = targetSegments[targetCursor];
|
|
402
|
+
if (!baseSegment || !targetSegment) break;
|
|
403
|
+
if (baseSegment.kind !== targetSegment.kind) {
|
|
404
|
+
paired.push({
|
|
405
|
+
baseSegment,
|
|
406
|
+
targetSegment: null
|
|
407
|
+
});
|
|
408
|
+
baseCursor += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (baseSegment.kind === "body") {
|
|
412
|
+
paired.push({
|
|
413
|
+
baseSegment,
|
|
414
|
+
targetSegment
|
|
415
|
+
});
|
|
416
|
+
baseCursor += 1;
|
|
417
|
+
targetCursor += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const here = similarity(baseSegment, targetSegment);
|
|
421
|
+
const baseAhead = similarity(baseSegments[baseCursor + 2], targetSegment);
|
|
422
|
+
const targetAhead = similarity(baseSegment, targetSegments[targetCursor + 2]);
|
|
423
|
+
if (baseAhead >= TABLE_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
|
|
424
|
+
paired.push({
|
|
425
|
+
baseSegment,
|
|
426
|
+
targetSegment: null
|
|
427
|
+
});
|
|
428
|
+
baseCursor += 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (targetAhead >= TABLE_PAIR_SIMILARITY && targetAhead > here) {
|
|
432
|
+
paired.push({
|
|
433
|
+
baseSegment: null,
|
|
434
|
+
targetSegment
|
|
435
|
+
});
|
|
436
|
+
targetCursor += 1;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
paired.push({
|
|
440
|
+
baseSegment,
|
|
441
|
+
targetSegment
|
|
442
|
+
});
|
|
443
|
+
baseCursor += 1;
|
|
444
|
+
targetCursor += 1;
|
|
445
|
+
}
|
|
446
|
+
for (const segment of baseSegments.slice(baseCursor)) paired.push({
|
|
447
|
+
baseSegment: segment,
|
|
448
|
+
targetSegment: null
|
|
449
|
+
});
|
|
450
|
+
for (const segment of targetSegments.slice(targetCursor)) paired.push({
|
|
451
|
+
baseSegment: null,
|
|
452
|
+
targetSegment: segment
|
|
453
|
+
});
|
|
454
|
+
return paired;
|
|
455
|
+
};
|
|
456
|
+
const isEmptyParagraphNode = (block, snapshot) => {
|
|
457
|
+
const anchor = snapshot.anchors[block.id] ?? panic("A comparison snapshot block has no matching anchor", { blockId: block.id });
|
|
458
|
+
return block.text === "" && block.table === void 0 && anchor.to - anchor.from === 2;
|
|
459
|
+
};
|
|
460
|
+
const buildSteps = ({ story, baseSnapshot, targetSnapshot }) => {
|
|
461
|
+
const baseBlocks = baseSnapshot.blocks;
|
|
462
|
+
const targetBlocks = targetSnapshot.blocks;
|
|
463
|
+
const baseLast = baseBlocks.at(-1);
|
|
464
|
+
const targetLast = targetBlocks.at(-1);
|
|
465
|
+
const terminalCarrierPair = story.type === "main" && baseLast !== void 0 && isEmptyParagraphNode(baseLast, baseSnapshot) && targetLast !== void 0 && isEmptyParagraphNode(targetLast, targetSnapshot) ? {
|
|
466
|
+
type: "pair",
|
|
467
|
+
baseBlock: baseLast,
|
|
468
|
+
targetBlock: targetLast
|
|
469
|
+
} : null;
|
|
470
|
+
const alignedBaseBlocks = terminalCarrierPair ? baseBlocks.slice(0, -1) : baseBlocks;
|
|
471
|
+
const alignedTargetBlocks = terminalCarrierPair ? targetBlocks.slice(0, -1) : targetBlocks;
|
|
472
|
+
const steps = [];
|
|
473
|
+
for (const { baseSegment, targetSegment } of alignSegments(splitSegments(alignedBaseBlocks), splitSegments(alignedTargetBlocks))) {
|
|
474
|
+
if (baseSegment && targetSegment) {
|
|
475
|
+
steps.push(...baseSegment.kind === "table" ? buildTableSegmentSteps(baseSegment.blocks, targetSegment.blocks) : buildBodySteps(baseSegment.blocks, targetSegment.blocks));
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (baseSegment) {
|
|
479
|
+
steps.push(...unpairedSegmentSteps(baseSegment, "base"));
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (targetSegment) steps.push(...unpairedSegmentSteps(targetSegment, "target"));
|
|
483
|
+
}
|
|
484
|
+
if (terminalCarrierPair) steps.push(terminalCarrierPair);
|
|
485
|
+
return steps;
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* Two blocks are in the same container when a paragraph mark between them
|
|
489
|
+
* exists at all: two body paragraphs, or two paragraphs of one table cell. A
|
|
490
|
+
* mark cannot span a cell boundary, so a split or a merge across one is not a
|
|
491
|
+
* paragraph-mark edit however similar the text looks.
|
|
492
|
+
*/
|
|
493
|
+
const shareAContainer = (left, right) => {
|
|
494
|
+
if (!left.table || !right.table) return left.table === void 0 && right.table === void 0;
|
|
495
|
+
return left.table.tableIndex === right.table.tableIndex && left.table.rowIndex === right.table.rowIndex && left.table.cellIndex === right.table.cellIndex;
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* The text between `head` and `tail` inside `whole`, when `whole` is exactly
|
|
499
|
+
* the two joined by whitespace (or by nothing). `null` when it is not: any
|
|
500
|
+
* other difference is a rewrite, not a moved paragraph mark.
|
|
501
|
+
*/
|
|
502
|
+
const separatorBetween = (whole, head, tail) => {
|
|
503
|
+
if (head.length === 0 || tail.length === 0 || whole.length < head.length + tail.length) return null;
|
|
504
|
+
if (!whole.startsWith(head) || !whole.endsWith(tail)) return null;
|
|
505
|
+
const separator = whole.slice(head.length, whole.length - tail.length);
|
|
506
|
+
return separator.length === 0 || /^\s+$/u.test(separator) ? separator : null;
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* Where the alignment produced a rewrite plus an insertion or a deletion that
|
|
510
|
+
* is really one paragraph mark moving, by step index of the PAIR step. The
|
|
511
|
+
* step after it is consumed with it.
|
|
512
|
+
*
|
|
513
|
+
* The alignment cannot see this: it pairs the base paragraph with the target
|
|
514
|
+
* half that still matches it and leaves the other half unpaired, which is a
|
|
515
|
+
* correct alignment and a misleading redline.
|
|
516
|
+
*/
|
|
517
|
+
const detectParagraphMarkEdits = (steps) => {
|
|
518
|
+
const plans = /* @__PURE__ */ new Map();
|
|
519
|
+
for (const [index, step] of steps.entries()) {
|
|
520
|
+
const next = steps[index + 1];
|
|
521
|
+
if (step.type !== "pair" || next === void 0) continue;
|
|
522
|
+
if (next.type === "targetOnly") {
|
|
523
|
+
const separator = separatorBetween(step.baseBlock.text, step.targetBlock.text, next.block.text);
|
|
524
|
+
if (separator !== null && shareAContainer(step.targetBlock, next.block)) plans.set(index, {
|
|
525
|
+
type: "split",
|
|
526
|
+
baseBlock: step.baseBlock,
|
|
527
|
+
targetBlocks: [step.targetBlock, next.block],
|
|
528
|
+
offset: step.targetBlock.text.length,
|
|
529
|
+
separator
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (next.type !== "baseOnly") continue;
|
|
534
|
+
const separator = separatorBetween(step.targetBlock.text, step.baseBlock.text, next.block.text);
|
|
535
|
+
if (separator !== null && shareAContainer(step.baseBlock, next.block)) plans.set(index, {
|
|
536
|
+
type: "merge",
|
|
537
|
+
baseBlocks: [step.baseBlock, next.block],
|
|
538
|
+
targetBlock: step.targetBlock,
|
|
539
|
+
separator
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return plans;
|
|
543
|
+
};
|
|
544
|
+
/** Base block id -> target block id for every relocation the move pass found. */
|
|
545
|
+
const detectMoves = (steps, consumed) => {
|
|
546
|
+
const candidatesByText = /* @__PURE__ */ new Map();
|
|
547
|
+
for (const [index, step] of steps.entries()) {
|
|
548
|
+
if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
549
|
+
const queue = candidatesByText.get(step.block.text);
|
|
550
|
+
if (!queue) {
|
|
551
|
+
candidatesByText.set(step.block.text, [step.block.id]);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (queue.length < MAX_MOVE_CANDIDATES_PER_TEXT) queue.push(step.block.id);
|
|
555
|
+
}
|
|
556
|
+
const unmatched = [];
|
|
557
|
+
for (const [index, step] of steps.entries()) {
|
|
558
|
+
if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
559
|
+
unmatched.push(step.block);
|
|
560
|
+
}
|
|
561
|
+
const movesByBaseBlockId = /* @__PURE__ */ new Map();
|
|
562
|
+
const takenBaseBlockIds = /* @__PURE__ */ new Set();
|
|
563
|
+
let comparisonBudget = MAX_MOVE_SIMILARITY_COMPARISONS;
|
|
564
|
+
for (const [index, step] of steps.entries()) {
|
|
565
|
+
if (consumed.has(index) || step.type !== "targetOnly") continue;
|
|
566
|
+
const exact = candidatesByText.get(step.block.text)?.shift();
|
|
567
|
+
if (exact !== void 0) {
|
|
568
|
+
takenBaseBlockIds.add(exact);
|
|
569
|
+
movesByBaseBlockId.set(exact, step.block.id);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
573
|
+
let best = null;
|
|
574
|
+
for (const candidate of unmatched) {
|
|
575
|
+
if (comparisonBudget <= 0) break;
|
|
576
|
+
if (takenBaseBlockIds.has(candidate.id)) continue;
|
|
577
|
+
comparisonBudget -= 1;
|
|
578
|
+
const similarity = tokenSimilarity(candidate.text, step.block.text);
|
|
579
|
+
if (similarity >= MOVE_SIMILARITY_THRESHOLD && (best === null || similarity > best.similarity)) best = {
|
|
580
|
+
block: candidate,
|
|
581
|
+
similarity
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (best) {
|
|
585
|
+
takenBaseBlockIds.add(best.block.id);
|
|
586
|
+
movesByBaseBlockId.set(best.block.id, step.block.id);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return movesByBaseBlockId;
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* For each step, the id of the next base block at or after it — the anchor a
|
|
593
|
+
* target-only insertion is placed before. `null` once no base block follows.
|
|
594
|
+
*/
|
|
595
|
+
const nextBaseBlockIdByStep = (steps) => {
|
|
596
|
+
const anchors = Array.from({ length: steps.length });
|
|
597
|
+
let next = null;
|
|
598
|
+
for (let index = steps.length - 1; index >= 0; index--) {
|
|
599
|
+
anchors[index] = next;
|
|
600
|
+
const step = steps[index];
|
|
601
|
+
if (step?.type === "pair") next = step.baseBlock.id;
|
|
602
|
+
else if (step?.type === "baseOnly") next = step.block.id;
|
|
603
|
+
else if (step?.type === "baseRow") next = step.blocks[0]?.id ?? next;
|
|
604
|
+
else if (step?.type === "baseColumn") next = step.blocks[0]?.id ?? next;
|
|
605
|
+
}
|
|
606
|
+
return anchors;
|
|
607
|
+
};
|
|
608
|
+
const baseTableBlockOf = (step) => {
|
|
609
|
+
switch (step.type) {
|
|
610
|
+
case "pair": return step.baseBlock.table ? step.baseBlock : null;
|
|
611
|
+
case "baseOnly": return step.block.table ? step.block : null;
|
|
612
|
+
case "baseRow":
|
|
613
|
+
case "baseTable":
|
|
614
|
+
case "baseColumn": return step.blocks[0] ?? null;
|
|
615
|
+
case "targetOnly":
|
|
616
|
+
case "targetRow":
|
|
617
|
+
case "targetTable":
|
|
618
|
+
case "targetColumn": return null;
|
|
619
|
+
default: return panic("Unhandled compare step", { step });
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
/**
|
|
623
|
+
* The base-document row a new row is inserted next to: the nearest base block
|
|
624
|
+
* inside a table, preferring the one before the insertion so a run of new rows
|
|
625
|
+
* keeps its order.
|
|
626
|
+
*/
|
|
627
|
+
const findRowAnchor = (steps, stepIndex) => {
|
|
628
|
+
for (let index = stepIndex - 1; index >= 0; index--) {
|
|
629
|
+
const step = steps[index];
|
|
630
|
+
const block = step ? baseTableBlockOf(step) : null;
|
|
631
|
+
if (block) return {
|
|
632
|
+
blockId: block.id,
|
|
633
|
+
position: "after"
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
for (let index = stepIndex + 1; index < steps.length; index++) {
|
|
637
|
+
const step = steps[index];
|
|
638
|
+
const block = step ? baseTableBlockOf(step) : null;
|
|
639
|
+
if (block) return {
|
|
640
|
+
blockId: block.id,
|
|
641
|
+
position: "before"
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
};
|
|
646
|
+
/**
|
|
647
|
+
* A row's text per physical cell, indexed BY cell so an empty cell keeps its
|
|
648
|
+
* slot. An empty cell carries no block at all, so packing only the cells that
|
|
649
|
+
* have text would shift every later cell one column left.
|
|
650
|
+
*/
|
|
651
|
+
/** One table's cell texts, row by row, for a whole-table change. */
|
|
652
|
+
/**
|
|
653
|
+
* One table's cell texts, row by row, padded to the widest row.
|
|
654
|
+
*
|
|
655
|
+
* A row's own width is its highest occupied cell index, so a table with
|
|
656
|
+
* merged cells or a short last row produces a ragged grid — and a ragged grid
|
|
657
|
+
* is not a table any consumer can lay out. Padding states the grid the table
|
|
658
|
+
* actually occupies; the empty strings are the cells a `w:gridSpan` covers.
|
|
659
|
+
*/
|
|
660
|
+
const tableCellTexts = (blocks) => {
|
|
661
|
+
const rows = groupRows(blocks).map((row) => rowCellTexts(row));
|
|
662
|
+
let width = 0;
|
|
663
|
+
for (const row of rows) width = Math.max(width, row.length);
|
|
664
|
+
for (const row of rows) while (row.length < width) row.push("");
|
|
665
|
+
return rows;
|
|
666
|
+
};
|
|
667
|
+
const rowCellTexts = (blocks) => {
|
|
668
|
+
const byCell = [];
|
|
669
|
+
for (const block of blocks) {
|
|
670
|
+
const cellIndex = block.table?.cellIndex ?? 0;
|
|
671
|
+
const existing = byCell[cellIndex];
|
|
672
|
+
byCell[cellIndex] = existing === void 0 ? block.text : `${existing}\n${block.text}`;
|
|
673
|
+
}
|
|
674
|
+
return Array.from(byCell, (text) => text ?? "");
|
|
675
|
+
};
|
|
676
|
+
const columnCellTexts = (blocks) => {
|
|
677
|
+
const byCell = /* @__PURE__ */ new Map();
|
|
678
|
+
for (const block of blocks) {
|
|
679
|
+
const table = block.table;
|
|
680
|
+
if (!table) continue;
|
|
681
|
+
const key = `${String(table.rowIndex)}:${String(table.cellIndex)}`;
|
|
682
|
+
const existing = byCell.get(key);
|
|
683
|
+
byCell.set(key, existing === void 0 ? block.text : `${existing}\n${block.text}`);
|
|
684
|
+
}
|
|
685
|
+
return [...byCell.values()];
|
|
686
|
+
};
|
|
687
|
+
/**
|
|
688
|
+
* The paragraph properties that differ, or `null` when they agree. Only the
|
|
689
|
+
* ones a block projection can see and an operation can set: a list level and
|
|
690
|
+
* a paragraph style, the two edits that move no words and are invisible in a
|
|
691
|
+
* text diff.
|
|
692
|
+
*/
|
|
693
|
+
const changedParagraphProperties = (baseBlock, targetBlock) => {
|
|
694
|
+
const properties = {};
|
|
695
|
+
if ((baseBlock.styleId ?? null) !== (targetBlock.styleId ?? null)) properties.styleId = targetBlock.styleId ?? null;
|
|
696
|
+
if (baseBlock.listLevel !== targetBlock.listLevel) properties.listLevel = targetBlock.listLevel ?? null;
|
|
697
|
+
return Object.keys(properties).length > 0 ? properties : null;
|
|
698
|
+
};
|
|
699
|
+
const locationOf = (story, block) => block.table ? {
|
|
700
|
+
story,
|
|
701
|
+
cell: block.table
|
|
702
|
+
} : { story };
|
|
703
|
+
/**
|
|
704
|
+
* Plan one story's comparison, or `null` when it needs more operations than
|
|
705
|
+
* `maxOperations`.
|
|
706
|
+
*/
|
|
707
|
+
const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }) => {
|
|
708
|
+
const steps = buildSteps({
|
|
709
|
+
story,
|
|
710
|
+
baseSnapshot,
|
|
711
|
+
targetSnapshot
|
|
712
|
+
});
|
|
713
|
+
const paragraphMarkPlans = detectParagraphMarkEdits(steps);
|
|
714
|
+
const consumedSteps = new Set([...paragraphMarkPlans.keys()].map((index) => index + 1));
|
|
715
|
+
const movesByBaseBlockId = detectMoves(steps, consumedSteps);
|
|
716
|
+
const moveSourceByTargetBlockId = /* @__PURE__ */ new Map();
|
|
717
|
+
for (const [baseBlockId, targetBlockId] of movesByBaseBlockId) moveSourceByTargetBlockId.set(targetBlockId, baseBlockId);
|
|
718
|
+
const anchorIds = nextBaseBlockIdByStep(steps);
|
|
719
|
+
const changes = [];
|
|
720
|
+
const operations = [];
|
|
721
|
+
/**
|
|
722
|
+
* The anchor everything past the base document's content hangs from: its
|
|
723
|
+
* last BODY-LEVEL paragraph, which the format guarantees exists because a
|
|
724
|
+
* table may not be the last child of a body. Anchoring to the last block
|
|
725
|
+
* instead put the anchor inside a table whenever the story ended with one,
|
|
726
|
+
* and an insertion anchored there escapes to the table's boundary, where no
|
|
727
|
+
* paragraph mark can express the break it added.
|
|
728
|
+
*
|
|
729
|
+
* The applier orders insertions that resolve to one position by their order
|
|
730
|
+
* in this array, so the tail is emitted where its step sits rather than
|
|
731
|
+
* collected and appended — a table and a paragraph both added after the last
|
|
732
|
+
* base block otherwise come out in operation order, which is not target
|
|
733
|
+
* order.
|
|
734
|
+
*/
|
|
735
|
+
const tailAnchorId = trailingBodyBlockId(baseSnapshot);
|
|
736
|
+
let operationSequence = 0;
|
|
737
|
+
const nextOperationId = () => `compare-${++operationSequence}`;
|
|
738
|
+
/**
|
|
739
|
+
* The relocation this block belongs to, named so the applier can link the
|
|
740
|
+
* deletion at the source with the insertion at the destination as
|
|
741
|
+
* `w:moveFrom` and `w:moveTo` instead of writing two unrelated revisions.
|
|
742
|
+
*/
|
|
743
|
+
const moveIdOf = (baseBlockId) => `move-${baseBlockId}`;
|
|
744
|
+
const pushInsertOperation = (block, anchorId) => {
|
|
745
|
+
const moveSourceId = moveSourceByTargetBlockId.get(block.id);
|
|
746
|
+
const shared = {
|
|
747
|
+
text: block.text,
|
|
748
|
+
...moveSourceId !== void 0 && { moveId: moveIdOf(moveSourceId) },
|
|
749
|
+
styleId: block.styleId ?? null,
|
|
750
|
+
listLevel: block.listLevel ?? null
|
|
751
|
+
};
|
|
752
|
+
if (anchorId !== null) {
|
|
753
|
+
operations.push({
|
|
754
|
+
id: nextOperationId(),
|
|
755
|
+
type: "insertBeforeBlock",
|
|
756
|
+
blockId: anchorId,
|
|
757
|
+
...shared
|
|
758
|
+
});
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (tailAnchorId === null) return;
|
|
762
|
+
operations.push({
|
|
763
|
+
id: nextOperationId(),
|
|
764
|
+
type: "insertAfterBlock",
|
|
765
|
+
blockId: tailAnchorId,
|
|
766
|
+
...shared
|
|
767
|
+
});
|
|
768
|
+
};
|
|
769
|
+
for (const [stepIndex, step] of steps.entries()) {
|
|
770
|
+
if (consumedSteps.has(stepIndex)) continue;
|
|
771
|
+
const paragraphMarkPlan = paragraphMarkPlans.get(stepIndex);
|
|
772
|
+
if (paragraphMarkPlan?.type === "split") {
|
|
773
|
+
const { baseBlock, targetBlocks: splitInto, offset, separator } = paragraphMarkPlan;
|
|
774
|
+
changes.push({
|
|
775
|
+
kind: "split",
|
|
776
|
+
location: locationOf(story, baseBlock),
|
|
777
|
+
baseBlockId: baseBlock.id,
|
|
778
|
+
targetBlockIds: splitInto.map(({ id }) => id),
|
|
779
|
+
text: baseBlock.text
|
|
780
|
+
});
|
|
781
|
+
operations.push({
|
|
782
|
+
id: nextOperationId(),
|
|
783
|
+
type: "splitBlock",
|
|
784
|
+
blockId: baseBlock.id,
|
|
785
|
+
offset,
|
|
786
|
+
...separator.length > 0 && { separator }
|
|
787
|
+
});
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (paragraphMarkPlan?.type === "merge") {
|
|
791
|
+
const { baseBlocks, targetBlock, separator } = paragraphMarkPlan;
|
|
792
|
+
changes.push({
|
|
793
|
+
kind: "merge",
|
|
794
|
+
location: locationOf(story, baseBlocks[0]),
|
|
795
|
+
baseBlockIds: baseBlocks.map(({ id }) => id),
|
|
796
|
+
targetBlockId: targetBlock.id,
|
|
797
|
+
text: targetBlock.text
|
|
798
|
+
});
|
|
799
|
+
operations.push({
|
|
800
|
+
id: nextOperationId(),
|
|
801
|
+
type: "mergeBlockWithNext",
|
|
802
|
+
blockId: baseBlocks[0].id,
|
|
803
|
+
...separator.length > 0 && { separator }
|
|
804
|
+
});
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
switch (step.type) {
|
|
808
|
+
case "pair": {
|
|
809
|
+
const { baseBlock, targetBlock } = step;
|
|
810
|
+
const properties = changedParagraphProperties(baseBlock, targetBlock);
|
|
811
|
+
if (properties) {
|
|
812
|
+
changes.push({
|
|
813
|
+
kind: "paragraph-format",
|
|
814
|
+
location: locationOf(story, baseBlock),
|
|
815
|
+
baseBlockId: baseBlock.id,
|
|
816
|
+
targetBlockId: targetBlock.id,
|
|
817
|
+
properties
|
|
818
|
+
});
|
|
819
|
+
operations.push({
|
|
820
|
+
id: nextOperationId(),
|
|
821
|
+
type: "setBlockParagraphProperties",
|
|
822
|
+
blockId: baseBlock.id,
|
|
823
|
+
properties
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
if (baseBlock.text !== targetBlock.text) {
|
|
827
|
+
changes.push({
|
|
828
|
+
kind: "replace",
|
|
829
|
+
location: locationOf(story, baseBlock),
|
|
830
|
+
baseBlockId: baseBlock.id,
|
|
831
|
+
targetBlockId: targetBlock.id,
|
|
832
|
+
before: baseBlock.text,
|
|
833
|
+
after: targetBlock.text
|
|
834
|
+
});
|
|
835
|
+
operations.push({
|
|
836
|
+
id: nextOperationId(),
|
|
837
|
+
type: "replaceBlock",
|
|
838
|
+
blockId: baseBlock.id,
|
|
839
|
+
text: targetBlock.text
|
|
840
|
+
});
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
const segments = inlineFormattingSegments({
|
|
844
|
+
baseBlock,
|
|
845
|
+
targetBlock,
|
|
846
|
+
maxSegments: maxOperations
|
|
847
|
+
});
|
|
848
|
+
if (segments === null) return null;
|
|
849
|
+
if (segments.length === 0) break;
|
|
850
|
+
changes.push({
|
|
851
|
+
kind: "format",
|
|
852
|
+
location: locationOf(story, baseBlock),
|
|
853
|
+
baseBlockId: baseBlock.id,
|
|
854
|
+
targetBlockId: targetBlock.id,
|
|
855
|
+
text: baseBlock.text,
|
|
856
|
+
ranges: segments
|
|
857
|
+
});
|
|
858
|
+
for (const { startOffset, endOffset, formatting } of segments) {
|
|
859
|
+
const range = createFolioAITextRangeHandle({
|
|
860
|
+
blockId: baseBlock.id,
|
|
861
|
+
text: baseBlock.text,
|
|
862
|
+
startOffset,
|
|
863
|
+
endOffset
|
|
864
|
+
});
|
|
865
|
+
if (!range) panic("An aligned formatting range could not be represented");
|
|
866
|
+
operations.push({
|
|
867
|
+
id: nextOperationId(),
|
|
868
|
+
type: "formatRange",
|
|
869
|
+
range,
|
|
870
|
+
formatting
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
case "baseOnly": {
|
|
876
|
+
const targetBlockId = movesByBaseBlockId.get(step.block.id);
|
|
877
|
+
if (targetBlockId === void 0) changes.push({
|
|
878
|
+
kind: "delete",
|
|
879
|
+
location: locationOf(story, step.block),
|
|
880
|
+
baseBlockId: step.block.id,
|
|
881
|
+
before: step.block.text
|
|
882
|
+
});
|
|
883
|
+
operations.push({
|
|
884
|
+
id: nextOperationId(),
|
|
885
|
+
type: "deleteBlock",
|
|
886
|
+
blockId: step.block.id,
|
|
887
|
+
...targetBlockId !== void 0 && { moveId: moveIdOf(step.block.id) }
|
|
888
|
+
});
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
case "targetOnly": {
|
|
892
|
+
const baseBlockId = moveSourceByTargetBlockId.get(step.block.id);
|
|
893
|
+
changes.push(baseBlockId === void 0 ? {
|
|
894
|
+
kind: "insert",
|
|
895
|
+
location: locationOf(story, step.block),
|
|
896
|
+
targetBlockId: step.block.id,
|
|
897
|
+
after: step.block.text
|
|
898
|
+
} : {
|
|
899
|
+
kind: "move",
|
|
900
|
+
location: locationOf(story, step.block),
|
|
901
|
+
baseBlockId,
|
|
902
|
+
targetBlockId: step.block.id,
|
|
903
|
+
text: step.block.text
|
|
904
|
+
});
|
|
905
|
+
pushInsertOperation(step.block, anchorIds[stepIndex] ?? null);
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
case "baseRow": {
|
|
909
|
+
const anchorBlockId = step.blocks[0]?.id;
|
|
910
|
+
if (anchorBlockId === void 0) panic("A collapsed table row carried no blocks");
|
|
911
|
+
changes.push({
|
|
912
|
+
kind: "table-row-delete",
|
|
913
|
+
location: {
|
|
914
|
+
story,
|
|
915
|
+
cell: step.location
|
|
916
|
+
},
|
|
917
|
+
tableIndex: step.location.tableIndex,
|
|
918
|
+
rowIndex: step.location.rowIndex,
|
|
919
|
+
cells: rowCellTexts(step.blocks),
|
|
920
|
+
baseBlockIds: step.blocks.map(({ id }) => id)
|
|
921
|
+
});
|
|
922
|
+
operations.push({
|
|
923
|
+
id: nextOperationId(),
|
|
924
|
+
type: "deleteTableRow",
|
|
925
|
+
blockId: anchorBlockId
|
|
926
|
+
});
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
case "baseColumn": {
|
|
930
|
+
const anchorBlockId = step.blocks.at(0)?.id;
|
|
931
|
+
if (anchorBlockId === void 0) panic("A table column carried no blocks");
|
|
932
|
+
changes.push({
|
|
933
|
+
kind: "table-column-delete",
|
|
934
|
+
location: {
|
|
935
|
+
story,
|
|
936
|
+
cell: step.location
|
|
937
|
+
},
|
|
938
|
+
tableIndex: step.location.tableIndex,
|
|
939
|
+
columnIndex: step.columnIndex,
|
|
940
|
+
cells: columnCellTexts(step.blocks),
|
|
941
|
+
baseBlockIds: step.blocks.map(({ id }) => id)
|
|
942
|
+
});
|
|
943
|
+
operations.push({
|
|
944
|
+
id: nextOperationId(),
|
|
945
|
+
type: "deleteTableColumn",
|
|
946
|
+
blockId: anchorBlockId
|
|
947
|
+
});
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
case "baseTable": {
|
|
951
|
+
const anchorBlockId = step.blocks[0]?.id;
|
|
952
|
+
if (anchorBlockId === void 0) panic("An unpaired table segment carried no blocks");
|
|
953
|
+
changes.push({
|
|
954
|
+
kind: "table-delete",
|
|
955
|
+
location: {
|
|
956
|
+
story,
|
|
957
|
+
cell: step.location
|
|
958
|
+
},
|
|
959
|
+
tableIndex: step.location.tableIndex,
|
|
960
|
+
rows: tableCellTexts(step.blocks),
|
|
961
|
+
baseBlockIds: step.blocks.map(({ id }) => id)
|
|
962
|
+
});
|
|
963
|
+
operations.push({
|
|
964
|
+
id: nextOperationId(),
|
|
965
|
+
type: "deleteTable",
|
|
966
|
+
blockId: anchorBlockId
|
|
967
|
+
});
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
case "targetTable": {
|
|
971
|
+
const rows = tableCellTexts(step.blocks);
|
|
972
|
+
changes.push({
|
|
973
|
+
kind: "table-insert",
|
|
974
|
+
location: {
|
|
975
|
+
story,
|
|
976
|
+
cell: step.location
|
|
977
|
+
},
|
|
978
|
+
tableIndex: step.location.tableIndex,
|
|
979
|
+
rows,
|
|
980
|
+
targetBlockIds: step.blocks.map(({ id }) => id)
|
|
981
|
+
});
|
|
982
|
+
const before = anchorIds[stepIndex] ?? null;
|
|
983
|
+
if (before !== null) {
|
|
984
|
+
operations.push({
|
|
985
|
+
id: nextOperationId(),
|
|
986
|
+
type: "insertTable",
|
|
987
|
+
blockId: before,
|
|
988
|
+
position: "before",
|
|
989
|
+
rows
|
|
990
|
+
});
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
if (tailAnchorId !== null) operations.push({
|
|
994
|
+
id: nextOperationId(),
|
|
995
|
+
type: "insertTable",
|
|
996
|
+
blockId: tailAnchorId,
|
|
997
|
+
position: "after",
|
|
998
|
+
rows
|
|
999
|
+
});
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
case "targetRow": {
|
|
1003
|
+
const anchor = findRowAnchor(steps, stepIndex);
|
|
1004
|
+
const cells = rowCellTexts(step.blocks);
|
|
1005
|
+
changes.push({
|
|
1006
|
+
kind: "table-row-insert",
|
|
1007
|
+
location: {
|
|
1008
|
+
story,
|
|
1009
|
+
cell: step.location
|
|
1010
|
+
},
|
|
1011
|
+
tableIndex: step.location.tableIndex,
|
|
1012
|
+
rowIndex: step.location.rowIndex,
|
|
1013
|
+
cells,
|
|
1014
|
+
targetBlockIds: step.blocks.map(({ id }) => id)
|
|
1015
|
+
});
|
|
1016
|
+
if (anchor === null) {
|
|
1017
|
+
for (const block of step.blocks) pushInsertOperation(block, anchorIds[stepIndex] ?? null);
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
operations.push({
|
|
1021
|
+
id: nextOperationId(),
|
|
1022
|
+
type: "insertTableRow",
|
|
1023
|
+
blockId: anchor.blockId,
|
|
1024
|
+
position: anchor.position,
|
|
1025
|
+
cellTexts: cells
|
|
1026
|
+
});
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
case "targetColumn":
|
|
1030
|
+
changes.push({
|
|
1031
|
+
kind: "table-column-insert",
|
|
1032
|
+
location: {
|
|
1033
|
+
story,
|
|
1034
|
+
cell: step.location
|
|
1035
|
+
},
|
|
1036
|
+
tableIndex: step.location.tableIndex,
|
|
1037
|
+
columnIndex: step.columnIndex,
|
|
1038
|
+
cells: columnCellTexts(step.blocks),
|
|
1039
|
+
targetBlockIds: step.blocks.map(({ id }) => id)
|
|
1040
|
+
});
|
|
1041
|
+
operations.push({
|
|
1042
|
+
id: nextOperationId(),
|
|
1043
|
+
type: "insertTableColumn",
|
|
1044
|
+
blockId: step.anchor.blockId,
|
|
1045
|
+
position: step.anchor.position,
|
|
1046
|
+
cellTexts: columnCellTexts(step.blocks)
|
|
1047
|
+
});
|
|
1048
|
+
break;
|
|
1049
|
+
default: panic("Unhandled compare step", { step });
|
|
1050
|
+
}
|
|
1051
|
+
if (operations.length > maxOperations) return null;
|
|
1052
|
+
}
|
|
1053
|
+
return operations.length > maxOperations ? null : {
|
|
1054
|
+
changes,
|
|
1055
|
+
operations
|
|
1056
|
+
};
|
|
1057
|
+
};
|
|
1058
|
+
//#endregion
|
|
1059
|
+
export { planStoryCompare };
|