smartrte-core 0.2.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +23 -0
- package/dist/command.d.ts +8 -8
- package/dist/editor.d.ts +32 -0
- package/dist/editor.js +121 -0
- package/dist/foundation/annotations/index.d.ts +2 -0
- package/dist/foundation/annotations/index.js +2 -0
- package/dist/foundation/annotations/range.d.ts +54 -0
- package/dist/foundation/annotations/range.js +158 -0
- package/dist/foundation/annotations/types.d.ts +33 -0
- package/dist/foundation/annotations/types.js +1 -0
- package/dist/foundation/atom/commands.d.ts +11 -0
- package/dist/foundation/atom/commands.js +118 -0
- package/dist/foundation/atom/composition.d.ts +8 -0
- package/dist/foundation/atom/composition.js +22 -0
- package/dist/foundation/atom/declarations.d.ts +2 -0
- package/dist/foundation/atom/declarations.js +17 -0
- package/dist/foundation/atom/formats.d.ts +21 -0
- package/dist/foundation/atom/formats.js +77 -0
- package/dist/foundation/atom/index.d.ts +10 -0
- package/dist/foundation/atom/index.js +10 -0
- package/dist/foundation/atom/lifecycle.d.ts +12 -0
- package/dist/foundation/atom/lifecycle.js +41 -0
- package/dist/foundation/atom/persistence.d.ts +3 -0
- package/dist/foundation/atom/persistence.js +20 -0
- package/dist/foundation/atom/plugin.d.ts +20 -0
- package/dist/foundation/atom/plugin.js +55 -0
- package/dist/foundation/atom/schema.d.ts +3 -0
- package/dist/foundation/atom/schema.js +29 -0
- package/dist/foundation/atom/security.d.ts +10 -0
- package/dist/foundation/atom/security.js +20 -0
- package/dist/foundation/atom/selection.d.ts +3 -0
- package/dist/foundation/atom/selection.js +4 -0
- package/dist/foundation/atom/types.d.ts +47 -0
- package/dist/foundation/atom/types.js +1 -0
- package/dist/foundation/block/commands.d.ts +17 -0
- package/dist/foundation/block/commands.js +197 -0
- package/dist/foundation/block/formats.d.ts +33 -0
- package/dist/foundation/block/formats.js +57 -0
- package/dist/foundation/block/index.d.ts +5 -0
- package/dist/foundation/block/index.js +5 -0
- package/dist/foundation/block/input.d.ts +19 -0
- package/dist/foundation/block/input.js +67 -0
- package/dist/foundation/block/plugin.d.ts +26 -0
- package/dist/foundation/block/plugin.js +79 -0
- package/dist/foundation/block/schema.d.ts +49 -0
- package/dist/foundation/block/schema.js +27 -0
- package/dist/foundation/block/types.d.ts +40 -0
- package/dist/foundation/block/types.js +1 -0
- package/dist/foundation/boundaries.d.ts +11 -0
- package/dist/foundation/boundaries.js +117 -0
- package/dist/foundation/clipboard/detection.d.ts +3 -0
- package/dist/foundation/clipboard/detection.js +29 -0
- package/dist/foundation/clipboard/diagnostics.d.ts +24 -0
- package/dist/foundation/clipboard/diagnostics.js +59 -0
- package/dist/foundation/clipboard/index.d.ts +8 -0
- package/dist/foundation/clipboard/index.js +8 -0
- package/dist/foundation/clipboard/insertion.d.ts +22 -0
- package/dist/foundation/clipboard/insertion.js +210 -0
- package/dist/foundation/clipboard/normalizers.d.ts +8 -0
- package/dist/foundation/clipboard/normalizers.js +229 -0
- package/dist/foundation/clipboard/pipeline.d.ts +13 -0
- package/dist/foundation/clipboard/pipeline.js +58 -0
- package/dist/foundation/clipboard/sanitizer.d.ts +3 -0
- package/dist/foundation/clipboard/sanitizer.js +58 -0
- package/dist/foundation/clipboard/serialization.d.ts +5 -0
- package/dist/foundation/clipboard/serialization.js +33 -0
- package/dist/foundation/clipboard/transfer.d.ts +11 -0
- package/dist/foundation/clipboard/transfer.js +108 -0
- package/dist/foundation/clipboard/types.d.ts +65 -0
- package/dist/foundation/clipboard/types.js +1 -0
- package/dist/foundation/collab/index.d.ts +3 -0
- package/dist/foundation/collab/index.js +3 -0
- package/dist/foundation/collab/rebase.d.ts +28 -0
- package/dist/foundation/collab/rebase.js +82 -0
- package/dist/foundation/collab/transform.d.ts +73 -0
- package/dist/foundation/collab/transform.js +251 -0
- package/dist/foundation/collab/types.d.ts +52 -0
- package/dist/foundation/collab/types.js +1 -0
- package/dist/foundation/comments/fromSelection.d.ts +16 -0
- package/dist/foundation/comments/fromSelection.js +24 -0
- package/dist/foundation/comments/index.d.ts +3 -0
- package/dist/foundation/comments/index.js +3 -0
- package/dist/foundation/comments/rebase.d.ts +31 -0
- package/dist/foundation/comments/rebase.js +43 -0
- package/dist/foundation/comments/types.d.ts +37 -0
- package/dist/foundation/comments/types.js +1 -0
- package/dist/foundation/diff/documentDiff.d.ts +12 -0
- package/dist/foundation/diff/documentDiff.js +148 -0
- package/dist/foundation/diff/index.d.ts +3 -0
- package/dist/foundation/diff/index.js +3 -0
- package/dist/foundation/diff/textDiff.d.ts +12 -0
- package/dist/foundation/diff/textDiff.js +114 -0
- package/dist/foundation/diff/types.d.ts +74 -0
- package/dist/foundation/diff/types.js +1 -0
- package/dist/foundation/editor.d.ts +138 -0
- package/dist/foundation/editor.js +398 -0
- package/dist/foundation/formats/codec.d.ts +45 -0
- package/dist/foundation/formats/codec.js +1 -0
- package/dist/foundation/formats/docx/export.d.ts +4 -0
- package/dist/foundation/formats/docx/export.js +285 -0
- package/dist/foundation/formats/docx/import.d.ts +9 -0
- package/dist/foundation/formats/docx/import.js +37 -0
- package/dist/foundation/formats/docx/nestingGuard.d.ts +40 -0
- package/dist/foundation/formats/docx/nestingGuard.js +66 -0
- package/dist/foundation/formats/docx/portableAtoms.d.ts +4 -0
- package/dist/foundation/formats/docx/portableAtoms.js +66 -0
- package/dist/foundation/formats/docx/styledImport.d.ts +10 -0
- package/dist/foundation/formats/docx/styledImport.js +205 -0
- package/dist/foundation/formats/featureCodecs.d.ts +37 -0
- package/dist/foundation/formats/featureCodecs.js +69 -0
- package/dist/foundation/formats/fidelity.d.ts +31 -0
- package/dist/foundation/formats/fidelity.js +123 -0
- package/dist/foundation/formats/index.d.ts +7 -0
- package/dist/foundation/formats/index.js +7 -0
- package/dist/foundation/formats/pdf/format.d.ts +29 -0
- package/dist/foundation/formats/pdf/format.js +190 -0
- package/dist/foundation/history.d.ts +20 -0
- package/dist/foundation/history.js +121 -0
- package/dist/foundation/identity.d.ts +8 -0
- package/dist/foundation/identity.js +30 -0
- package/dist/foundation/index.d.ts +30 -0
- package/dist/foundation/index.js +30 -0
- package/dist/foundation/list/commands.d.ts +28 -0
- package/dist/foundation/list/commands.js +422 -0
- package/dist/foundation/list/formats.d.ts +20 -0
- package/dist/foundation/list/formats.js +795 -0
- package/dist/foundation/list/index.d.ts +8 -0
- package/dist/foundation/list/index.js +8 -0
- package/dist/foundation/list/input.d.ts +35 -0
- package/dist/foundation/list/input.js +320 -0
- package/dist/foundation/list/plugin.d.ts +21 -0
- package/dist/foundation/list/plugin.js +71 -0
- package/dist/foundation/list/presets.d.ts +16 -0
- package/dist/foundation/list/presets.js +21 -0
- package/dist/foundation/list/schema.d.ts +35 -0
- package/dist/foundation/list/schema.js +29 -0
- package/dist/foundation/list/shadow.d.ts +54 -0
- package/dist/foundation/list/shadow.js +75 -0
- package/dist/foundation/list/shadowCorpus.d.ts +10 -0
- package/dist/foundation/list/shadowCorpus.js +41 -0
- package/dist/foundation/list/types.d.ts +50 -0
- package/dist/foundation/list/types.js +1 -0
- package/dist/foundation/mapping.d.ts +11 -0
- package/dist/foundation/mapping.js +40 -0
- package/dist/foundation/marks/canonical.d.ts +9 -0
- package/dist/foundation/marks/canonical.js +72 -0
- package/dist/foundation/marks/commands.d.ts +21 -0
- package/dist/foundation/marks/commands.js +136 -0
- package/dist/foundation/marks/dom.d.ts +2 -0
- package/dist/foundation/marks/dom.js +46 -0
- package/dist/foundation/marks/editor.d.ts +8 -0
- package/dist/foundation/marks/editor.js +50 -0
- package/dist/foundation/marks/formats.d.ts +37 -0
- package/dist/foundation/marks/formats.js +49 -0
- package/dist/foundation/marks/hardBreak.d.ts +8 -0
- package/dist/foundation/marks/hardBreak.js +80 -0
- package/dist/foundation/marks/index.d.ts +11 -0
- package/dist/foundation/marks/index.js +11 -0
- package/dist/foundation/marks/normalization.d.ts +2 -0
- package/dist/foundation/marks/normalization.js +49 -0
- package/dist/foundation/marks/plugin.d.ts +14 -0
- package/dist/foundation/marks/plugin.js +63 -0
- package/dist/foundation/marks/resolveMarkRun.d.ts +8 -0
- package/dist/foundation/marks/resolveMarkRun.js +47 -0
- package/dist/foundation/marks/schema.d.ts +167 -0
- package/dist/foundation/marks/schema.js +50 -0
- package/dist/foundation/marks/stored.d.ts +2 -0
- package/dist/foundation/marks/stored.js +31 -0
- package/dist/foundation/marks/types.d.ts +40 -0
- package/dist/foundation/marks/types.js +1 -0
- package/dist/foundation/modelDom.d.ts +38 -0
- package/dist/foundation/modelDom.js +327 -0
- package/dist/foundation/normalization.d.ts +22 -0
- package/dist/foundation/normalization.js +132 -0
- package/dist/foundation/operations.d.ts +28 -0
- package/dist/foundation/operations.js +568 -0
- package/dist/foundation/plugin/builtins.d.ts +10 -0
- package/dist/foundation/plugin/builtins.js +27 -0
- package/dist/foundation/plugin/dispatch.d.ts +33 -0
- package/dist/foundation/plugin/dispatch.js +34 -0
- package/dist/foundation/plugin/index.d.ts +4 -0
- package/dist/foundation/plugin/index.js +4 -0
- package/dist/foundation/plugin/registry.d.ts +36 -0
- package/dist/foundation/plugin/registry.js +115 -0
- package/dist/foundation/plugin/types.d.ts +141 -0
- package/dist/foundation/plugin/types.js +13 -0
- package/dist/foundation/positions.d.ts +20 -0
- package/dist/foundation/positions.js +121 -0
- package/dist/foundation/schema.d.ts +57 -0
- package/dist/foundation/schema.js +371 -0
- package/dist/foundation/schemaBuilder.d.ts +41 -0
- package/dist/foundation/schemaBuilder.js +96 -0
- package/dist/foundation/scope/index.d.ts +2 -0
- package/dist/foundation/scope/index.js +2 -0
- package/dist/foundation/scope/resolveScope.d.ts +13 -0
- package/dist/foundation/scope/resolveScope.js +860 -0
- package/dist/foundation/scope/types.d.ts +122 -0
- package/dist/foundation/scope/types.js +1 -0
- package/dist/foundation/security/urlPolicy.d.ts +21 -0
- package/dist/foundation/security/urlPolicy.js +75 -0
- package/dist/foundation/structural/contentTarget.d.ts +21 -0
- package/dist/foundation/structural/contentTarget.js +51 -0
- package/dist/foundation/structural/index.d.ts +2 -0
- package/dist/foundation/structural/index.js +2 -0
- package/dist/foundation/structural/move.d.ts +7 -0
- package/dist/foundation/structural/move.js +35 -0
- package/dist/foundation/suggestions/index.d.ts +3 -0
- package/dist/foundation/suggestions/index.js +3 -0
- package/dist/foundation/suggestions/inline.d.ts +64 -0
- package/dist/foundation/suggestions/inline.js +116 -0
- package/dist/foundation/suggestions/structural.d.ts +40 -0
- package/dist/foundation/suggestions/structural.js +47 -0
- package/dist/foundation/suggestions/types.d.ts +19 -0
- package/dist/foundation/suggestions/types.js +1 -0
- package/dist/foundation/surface/index.d.ts +3 -0
- package/dist/foundation/surface/index.js +3 -0
- package/dist/foundation/surface/input.d.ts +127 -0
- package/dist/foundation/surface/input.js +1761 -0
- package/dist/foundation/surface/renderer.d.ts +84 -0
- package/dist/foundation/surface/renderer.js +974 -0
- package/dist/foundation/surface/types.d.ts +48 -0
- package/dist/foundation/surface/types.js +1 -0
- package/dist/foundation/table/commands.d.ts +31 -0
- package/dist/foundation/table/commands.js +470 -0
- package/dist/foundation/table/formats.d.ts +26 -0
- package/dist/foundation/table/formats.js +40 -0
- package/dist/foundation/table/grid.d.ts +8 -0
- package/dist/foundation/table/grid.js +178 -0
- package/dist/foundation/table/index.d.ts +6 -0
- package/dist/foundation/table/index.js +6 -0
- package/dist/foundation/table/plugin.d.ts +23 -0
- package/dist/foundation/table/plugin.js +119 -0
- package/dist/foundation/table/schema.d.ts +2 -0
- package/dist/foundation/table/schema.js +38 -0
- package/dist/foundation/table/selection.d.ts +7 -0
- package/dist/foundation/table/selection.js +35 -0
- package/dist/foundation/table/types.d.ts +109 -0
- package/dist/foundation/table/types.js +1 -0
- package/dist/foundation/transactions.d.ts +41 -0
- package/dist/foundation/transactions.js +90 -0
- package/dist/foundation/types.d.ts +261 -0
- package/dist/foundation/types.js +1 -0
- package/dist/foundation/versioning/diffVersions.d.ts +5 -0
- package/dist/foundation/versioning/diffVersions.js +3 -0
- package/dist/foundation/versioning/index.d.ts +2 -0
- package/dist/foundation/versioning/index.js +2 -0
- package/dist/foundation/versioning/types.d.ts +17 -0
- package/dist/foundation/versioning/types.js +1 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.js +165 -0
- package/dist/html/compatibility.js +7 -1
- package/dist/index.d.ts +1 -14
- package/dist/index.js +9 -14
- package/dist/legacy/index.d.ts +45 -0
- package/dist/legacy/index.js +45 -0
- package/dist/legacyCommands/alignment.d.ts +20 -0
- package/dist/{commands → legacyCommands}/alignment.js +17 -0
- package/dist/legacyCommands/blocks.d.ts +14 -0
- package/dist/legacyCommands/blocks.js +105 -0
- package/dist/legacyCommands/checklist.d.ts +11 -0
- package/dist/legacyCommands/checklist.js +45 -0
- package/dist/legacyCommands/code.d.ts +15 -0
- package/dist/{commands → legacyCommands}/code.js +25 -1
- package/dist/legacyCommands/formula.d.ts +7 -0
- package/dist/legacyCommands/formula.js +67 -0
- package/dist/legacyCommands/inlineAtoms.d.ts +7 -0
- package/dist/legacyCommands/inlineAtoms.js +58 -0
- package/dist/legacyCommands/list.d.ts +38 -0
- package/dist/legacyCommands/list.js +477 -0
- package/dist/{commands → legacyCommands}/marks.d.ts +3 -0
- package/dist/{commands → legacyCommands}/marks.js +28 -11
- package/dist/legacyCommands/media.d.ts +31 -0
- package/dist/legacyCommands/media.js +205 -0
- package/dist/legacyCommands/move.d.ts +14 -0
- package/dist/legacyCommands/move.js +91 -0
- package/dist/legacyCommands/table.d.ts +91 -0
- package/dist/legacyCommands/table.js +547 -0
- package/dist/listPresets.d.ts +12 -0
- package/dist/listPresets.js +6 -0
- package/dist/listScope.d.ts +27 -0
- package/dist/listScope.js +104 -0
- package/dist/markdown/compatibility.d.ts +2 -0
- package/dist/markdown/compatibility.js +94 -2
- package/dist/marks.d.ts +5 -5
- package/dist/model.d.ts +65 -9
- package/dist/plugin.d.ts +15 -0
- package/dist/plugin.js +31 -0
- package/dist/plugins/alignment.d.ts +3 -0
- package/dist/plugins/alignment.js +6 -0
- package/dist/plugins/basicFormatting.d.ts +9 -0
- package/dist/plugins/basicFormatting.js +24 -0
- package/dist/plugins/blockType.d.ts +3 -0
- package/dist/plugins/blockType.js +6 -0
- package/dist/plugins/blockquote.d.ts +3 -0
- package/dist/plugins/blockquote.js +6 -0
- package/dist/plugins/checklist.d.ts +3 -0
- package/dist/plugins/checklist.js +10 -0
- package/dist/plugins/codeBlock.d.ts +3 -0
- package/dist/plugins/codeBlock.js +6 -0
- package/dist/plugins/formula.d.ts +3 -0
- package/dist/plugins/formula.js +9 -0
- package/dist/plugins/list.d.ts +3 -0
- package/dist/plugins/list.js +10 -0
- package/dist/plugins/media.d.ts +3 -0
- package/dist/plugins/media.js +12 -0
- package/dist/plugins/move.d.ts +3 -0
- package/dist/plugins/move.js +9 -0
- package/dist/plugins/table.d.ts +3 -0
- package/dist/plugins/table.js +22 -0
- package/dist/preset.d.ts +12 -0
- package/dist/preset.js +40 -0
- package/dist/schema.d.ts +22 -0
- package/dist/schema.js +333 -0
- package/dist/security/urlPolicy.d.ts +1 -17
- package/dist/security/urlPolicy.js +3 -69
- package/dist/selection.d.ts +1 -1
- package/dist/selectionMapping.d.ts +4 -0
- package/dist/selectionMapping.js +222 -0
- package/dist/table.d.ts +3 -3
- package/dist/transaction.d.ts +35 -12
- package/dist/transaction.js +174 -28
- package/dist/tree.d.ts +8 -2
- package/dist/tree.js +44 -0
- package/package.json +27 -8
- package/dist/commands/alignment.d.ts +0 -18
- package/dist/commands/code.d.ts +0 -13
- package/dist/commands/list.d.ts +0 -20
- package/dist/commands/list.js +0 -52
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { FoundationTransactionMap } from "../mapping.js";
|
|
2
|
+
import { transformOperation } from "./transform.js";
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic total order for the exact-tie case (see mapOperation's own
|
|
5
|
+
* tieBreakBias doc comment) - lexicographic authorId comparison, computed
|
|
6
|
+
* once per (local, remote) transaction pair so every operation pair within
|
|
7
|
+
* that pairing agrees. Falls back to `1` (mapOperation's existing default)
|
|
8
|
+
* when either side has no authorId, matching this codebase's convention
|
|
9
|
+
* that authorId is optional (single-writer/no-transport usage never sets
|
|
10
|
+
* it, and never hits a real tie to begin with).
|
|
11
|
+
*/
|
|
12
|
+
const tieBreakBias = (localAuthorId, remoteAuthorId) => {
|
|
13
|
+
if (!localAuthorId || !remoteAuthorId || localAuthorId === remoteAuthorId)
|
|
14
|
+
return 1;
|
|
15
|
+
return localAuthorId < remoteAuthorId ? -1 : 1;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Rebases `transaction` (whose `baseRevision` is behind the current
|
|
19
|
+
* revision) through every transaction committed since, in commit order.
|
|
20
|
+
* Each of `transaction`'s own operations is threaded through every
|
|
21
|
+
* operation of every missed transaction in sequence - the standard
|
|
22
|
+
* single-step operational-transform composition, built on `mapOperation`/
|
|
23
|
+
* `transformOperation`.
|
|
24
|
+
*
|
|
25
|
+
* Atomic per transaction: if any one operation conflicts, the whole
|
|
26
|
+
* transaction is rejected as a conflict rather than partially applied -
|
|
27
|
+
* matching `applyTransactionAtomic`'s own existing all-or-nothing
|
|
28
|
+
* semantics, and avoiding a confusing state where only part of a user's
|
|
29
|
+
* intent materializes.
|
|
30
|
+
*
|
|
31
|
+
* Does not check retention/resync - `missed` is whatever the caller
|
|
32
|
+
* supplies; a caller backed by a bounded revision log is responsible for
|
|
33
|
+
* returning `resync-required` itself before ever calling this when the
|
|
34
|
+
* requested history has already been evicted (see editor.ts's dispatch).
|
|
35
|
+
*
|
|
36
|
+
* `newBaseRevision` is the revision the rebased transaction should be
|
|
37
|
+
* stamped with (i.e. the live revision the caller is about to apply
|
|
38
|
+
* against) - taken explicitly from the caller rather than inferred from
|
|
39
|
+
* `missed`'s own entries, so a gap or reordering bug in how the caller
|
|
40
|
+
* assembled `missed` can't silently produce a wrong revision stamp here.
|
|
41
|
+
*/
|
|
42
|
+
export const rebaseTransaction = (transaction, missed, newBaseRevision) => {
|
|
43
|
+
if (!missed.length)
|
|
44
|
+
return { kind: "ok", transaction: { ...transaction, baseRevision: newBaseRevision } };
|
|
45
|
+
const conflicts = [];
|
|
46
|
+
const rebasedOperations = transaction.operations.map((operation, operationIndex) => {
|
|
47
|
+
let current = operation;
|
|
48
|
+
for (const remote of missed) {
|
|
49
|
+
if (current === null)
|
|
50
|
+
break;
|
|
51
|
+
const bias = tieBreakBias(transaction.metadata.authorId, remote.metadata.authorId);
|
|
52
|
+
for (const through of remote.operations) {
|
|
53
|
+
if (current === null)
|
|
54
|
+
break;
|
|
55
|
+
const result = transformOperation(current, through, { tieBreakBias: bias });
|
|
56
|
+
if (result.kind === "conflict") {
|
|
57
|
+
conflicts.push({ operationIndex, reason: result.reason });
|
|
58
|
+
current = null;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
current = result.kind === "dropped" ? null : result.operation;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return current;
|
|
66
|
+
});
|
|
67
|
+
if (conflicts.length)
|
|
68
|
+
return { kind: "conflict", reasons: conflicts };
|
|
69
|
+
const survivors = rebasedOperations.filter((operation) => operation !== null);
|
|
70
|
+
if (!survivors.length)
|
|
71
|
+
return { kind: "dropped" };
|
|
72
|
+
const missedOperations = missed.flatMap((remote) => remote.operations);
|
|
73
|
+
const selectionMap = new FoundationTransactionMap(missedOperations);
|
|
74
|
+
const rebased = {
|
|
75
|
+
...transaction,
|
|
76
|
+
baseRevision: newBaseRevision,
|
|
77
|
+
operations: survivors,
|
|
78
|
+
selectionBefore: selectionMap.mapSelection(transaction.selectionBefore),
|
|
79
|
+
selectionAfter: selectionMap.mapSelection(transaction.selectionAfter),
|
|
80
|
+
};
|
|
81
|
+
return { kind: "ok", transaction: rebased };
|
|
82
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { SmartOperation } from "../types.js";
|
|
2
|
+
import type { TransformResult } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Transforms `operation` through a single concurrent `through` operation,
|
|
5
|
+
* classifying the small number of shapes this codebase's operation model
|
|
6
|
+
* cannot safely auto-resolve rather than silently deferring to
|
|
7
|
+
* `mapOperation`'s uniform `null` for all of them (see this module's
|
|
8
|
+
* `types.ts` doc comment on `TransformResult`).
|
|
9
|
+
*
|
|
10
|
+
* Two conflict shapes, both decided explicitly (2026-08-27, product
|
|
11
|
+
* decision) rather than attempted automatically:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Concurrent moveNode vs. *any* structural operation touching the same
|
|
14
|
+
* parent array as its `from` or `to`.** `moveNode.to` is defined
|
|
15
|
+
* relative to "the array with this move's own source already removed"
|
|
16
|
+
* (operations.ts's applyToSession) - a convention with no well-defined
|
|
17
|
+
* composition once a concurrent structural change *also* touches that
|
|
18
|
+
* same array, even one that doesn't touch the moved node itself.
|
|
19
|
+
* Confirmed empirically via property tests, in increasing generality:
|
|
20
|
+
* two moves of *different* nodes to nearby destinations produced
|
|
21
|
+
* different final orders depending on transform direction; a move
|
|
22
|
+
* composed with a same-node remove crashed outright; and - the broadest
|
|
23
|
+
* case - a moveNode transformed through a plain removeNode of a
|
|
24
|
+
* *completely unrelated* node in the same array still diverged (moving
|
|
25
|
+
* "to offset 6" means different things depending on whether that offset
|
|
26
|
+
* is read before or after the unrelated removal is accounted for).
|
|
27
|
+
* Fixing this in general means redefining `to` for every existing
|
|
28
|
+
* moveNode caller (table row/column reorder, list item reorder) - out of
|
|
29
|
+
* scope for this phase. Flagged as a conflict for *any* concurrent
|
|
30
|
+
* structural operation (insertNode/removeNode/replaceNode/moveNode) on
|
|
31
|
+
* the same parent array, not narrowed to same-node cases only.
|
|
32
|
+
* 2. **Concurrent edit to content a `removeNode.mergedInto`/
|
|
33
|
+
* `replaceNode.retiredInto` operation absorbed.** `mapOperation` already
|
|
34
|
+
* ignores these fields entirely (by design - see the SmartOperation type
|
|
35
|
+
* comment; they were built in Phase 12a only for `rebaseAnnotationRange`,
|
|
36
|
+
* a different, ID-keyed mechanism). Auto-redirecting an *operation*
|
|
37
|
+
* (rather than an annotation range's endpoint) to the survivor has its
|
|
38
|
+
* own correctness risk even for whole-node-targeting ops - e.g. a
|
|
39
|
+
* concurrent `setNodeAttributes` redirected to the survivor would carry
|
|
40
|
+
* a `before` payload captured against the *retired* node's attrs, which
|
|
41
|
+
* `applyOperation`'s strict before-check would then reject against the
|
|
42
|
+
* survivor's actual attrs. Flagged as a conflict, consistent with
|
|
43
|
+
* decision 1, rather than attempting a redirect whose correctness isn't
|
|
44
|
+
* established.
|
|
45
|
+
* 3. **Concurrent mergeNode vs. anything occupying its left (survivor) or
|
|
46
|
+
* right (retired) slot** (2026-08-28, required follow-up to finding #1
|
|
47
|
+
* in mapoperation-position-arithmetic-gaps.md). `applyOperation`'s own
|
|
48
|
+
* mergeNode handling validates `splitOffset === left.children.length`
|
|
49
|
+
* and that left/right are structurally compatible - both checks depend
|
|
50
|
+
* on the actual identity of whatever occupies those two slots.
|
|
51
|
+
* `mapPosThroughOperation`'s mergeNode-as-through branch only tracks a
|
|
52
|
+
* single explicit position (`operation.pos`/`.from`/`.to`), so an
|
|
53
|
+
* ordinary sibling-index shift correctly keeps a *retiring* target
|
|
54
|
+
* reachable but has no way to notice that a node's *identity* changed
|
|
55
|
+
* underneath an *implicit* left-neighbor reference. Confirmed
|
|
56
|
+
* empirically: two mergeNode ops retiring adjacent siblings (A retires
|
|
57
|
+
* node N into its left neighbor, B retires N's *next* sibling into N)
|
|
58
|
+
* crashed in one transform direction and silently produced a different
|
|
59
|
+
* document in the other; a mergeNode composed with a concurrent
|
|
60
|
+
* `removeNode` of its own left/survivor node crashed both ways. A
|
|
61
|
+
* concurrent `splitNode` targeting the *exact* node a mergeNode retires
|
|
62
|
+
* was checked and found *not* to need this treatment - it already gets
|
|
63
|
+
* `mapPosThroughOperation`'s explicit "this position IS the retired
|
|
64
|
+
* node, redirect into the survivor" case, which correctly recomputes an
|
|
65
|
+
* in-bounds position rather than carrying a stale content-derived
|
|
66
|
+
* payload the way `mergeNode.splitOffset` does. Same reasoning as
|
|
67
|
+
* finding 1 above: not narrowed further, since fixing this generally
|
|
68
|
+
* would mean giving `mapOperation` document access it deliberately
|
|
69
|
+
* doesn't have.
|
|
70
|
+
*/
|
|
71
|
+
export declare const transformOperation: (operation: SmartOperation, through: SmartOperation, options?: {
|
|
72
|
+
tieBreakBias?: -1 | 1;
|
|
73
|
+
}) => TransformResult;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { mapOperation } from "../operations.js";
|
|
2
|
+
import { samePath } from "../positions.js";
|
|
3
|
+
/** `[...pos.path, pos.offset]` - the full path to whatever slot/child `pos` addresses, for whole-node operations (insertNode/removeNode/replaceNode/setNodeAttributes/setNodeType). */
|
|
4
|
+
const slotPath = (pos) => [...pos.path, pos.offset];
|
|
5
|
+
/** True when `pos` (a container-relative position, e.g. an insertText/deleteText/addMark endpoint) sits at-or-within the subtree rooted at `subtreeRoot`'s slot. Mirrors the removeNode-vs-descendant check already established in mapPosThroughOperation. */
|
|
6
|
+
const isWithinSubtree = (pos, subtreeRoot) => {
|
|
7
|
+
const root = slotPath(subtreeRoot);
|
|
8
|
+
return samePath(pos.path.slice(0, root.length), root);
|
|
9
|
+
};
|
|
10
|
+
/** True when `pos` addresses the exact same slot as `subtreeRoot` (a whole-node operation targeting that node directly, not something inside it). */
|
|
11
|
+
const isExactlyAtSlot = (pos, subtreeRoot) => samePath(pos.path, subtreeRoot.path) && pos.offset === subtreeRoot.offset;
|
|
12
|
+
/**
|
|
13
|
+
* The two "occupied slot" references a mergeNode operation cares about:
|
|
14
|
+
* the surviving left sibling (`pos.offset - 1`) and the retired right
|
|
15
|
+
* sibling (`pos` itself). `applyOperation`'s own mergeNode handling
|
|
16
|
+
* validates `splitOffset === left.children.length` and that left/right are
|
|
17
|
+
* structurally compatible - both checks depend on the *actual identity* of
|
|
18
|
+
* whatever currently occupies these two slots, not just their positions.
|
|
19
|
+
*/
|
|
20
|
+
const mergeNodeSlots = (op) => [
|
|
21
|
+
{ path: op.pos.path, offset: op.pos.offset - 1 },
|
|
22
|
+
{ path: op.pos.path, offset: op.pos.offset },
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* The exact-slot reference for operation types that identify a node by
|
|
26
|
+
* "parent + child index" (the same convention mergeNode's own slots use) -
|
|
27
|
+
* restricted to the types where an exact offset match genuinely means "the
|
|
28
|
+
* same node," which excludes insertNode (a *gap* reference, not an
|
|
29
|
+
* occupied slot - two different conventions sharing the same {path,
|
|
30
|
+
* offset} shape, the same distinction already established for the
|
|
31
|
+
* removeNode/replaceNode exact-match fix in mapOperation) and excludes
|
|
32
|
+
* splitNode/setNodeAttributes/insertText/deleteText/addMark (deeper or
|
|
33
|
+
* differently-conventioned references that `mapPosThroughOperation`'s own
|
|
34
|
+
* mergeNode-as-through branch already redirects safely - confirmed via a
|
|
35
|
+
* property test that a splitNode targeting the exact node a concurrent
|
|
36
|
+
* mergeNode retires converges correctly without needing to be flagged
|
|
37
|
+
* here).
|
|
38
|
+
*/
|
|
39
|
+
const occupiedSlotsOf = (op) => {
|
|
40
|
+
if (op.type === "removeNode" || op.type === "replaceNode")
|
|
41
|
+
return [op.pos];
|
|
42
|
+
if (op.type === "mergeNode")
|
|
43
|
+
return mergeNodeSlots(op);
|
|
44
|
+
// moveNode.from/.to use the same "occupied slot" convention, but any
|
|
45
|
+
// moveNode paired with a mergeNode already shares a parent array (the
|
|
46
|
+
// merge's own path) and is caught unconditionally by the broader moveNode
|
|
47
|
+
// conflict rule above, before this function is reached - listing it here
|
|
48
|
+
// too would be redundant, not incorrect.
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
const operationTouchesSubtree = (operation, subtreeRoot) => {
|
|
52
|
+
if (operation.type === "insertNode" || operation.type === "removeNode" || operation.type === "replaceNode"
|
|
53
|
+
|| operation.type === "setNodeAttributes" || operation.type === "setNodeType"
|
|
54
|
+
|| operation.type === "splitNode" || operation.type === "mergeNode"
|
|
55
|
+
|| operation.type === "insertText" || operation.type === "deleteText") {
|
|
56
|
+
return { exact: isExactlyAtSlot(operation.pos, subtreeRoot), within: isWithinSubtree(operation.pos, subtreeRoot) };
|
|
57
|
+
}
|
|
58
|
+
if (operation.type === "moveNode") {
|
|
59
|
+
return {
|
|
60
|
+
exact: isExactlyAtSlot(operation.from, subtreeRoot) || isExactlyAtSlot(operation.to, subtreeRoot),
|
|
61
|
+
within: isWithinSubtree(operation.from, subtreeRoot) || isWithinSubtree(operation.to, subtreeRoot),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// addMark / removeMark
|
|
65
|
+
return {
|
|
66
|
+
exact: false,
|
|
67
|
+
within: isWithinSubtree(operation.range.from, subtreeRoot) || isWithinSubtree(operation.range.to, subtreeRoot),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Transforms `operation` through a single concurrent `through` operation,
|
|
72
|
+
* classifying the small number of shapes this codebase's operation model
|
|
73
|
+
* cannot safely auto-resolve rather than silently deferring to
|
|
74
|
+
* `mapOperation`'s uniform `null` for all of them (see this module's
|
|
75
|
+
* `types.ts` doc comment on `TransformResult`).
|
|
76
|
+
*
|
|
77
|
+
* Two conflict shapes, both decided explicitly (2026-08-27, product
|
|
78
|
+
* decision) rather than attempted automatically:
|
|
79
|
+
*
|
|
80
|
+
* 1. **Concurrent moveNode vs. *any* structural operation touching the same
|
|
81
|
+
* parent array as its `from` or `to`.** `moveNode.to` is defined
|
|
82
|
+
* relative to "the array with this move's own source already removed"
|
|
83
|
+
* (operations.ts's applyToSession) - a convention with no well-defined
|
|
84
|
+
* composition once a concurrent structural change *also* touches that
|
|
85
|
+
* same array, even one that doesn't touch the moved node itself.
|
|
86
|
+
* Confirmed empirically via property tests, in increasing generality:
|
|
87
|
+
* two moves of *different* nodes to nearby destinations produced
|
|
88
|
+
* different final orders depending on transform direction; a move
|
|
89
|
+
* composed with a same-node remove crashed outright; and - the broadest
|
|
90
|
+
* case - a moveNode transformed through a plain removeNode of a
|
|
91
|
+
* *completely unrelated* node in the same array still diverged (moving
|
|
92
|
+
* "to offset 6" means different things depending on whether that offset
|
|
93
|
+
* is read before or after the unrelated removal is accounted for).
|
|
94
|
+
* Fixing this in general means redefining `to` for every existing
|
|
95
|
+
* moveNode caller (table row/column reorder, list item reorder) - out of
|
|
96
|
+
* scope for this phase. Flagged as a conflict for *any* concurrent
|
|
97
|
+
* structural operation (insertNode/removeNode/replaceNode/moveNode) on
|
|
98
|
+
* the same parent array, not narrowed to same-node cases only.
|
|
99
|
+
* 2. **Concurrent edit to content a `removeNode.mergedInto`/
|
|
100
|
+
* `replaceNode.retiredInto` operation absorbed.** `mapOperation` already
|
|
101
|
+
* ignores these fields entirely (by design - see the SmartOperation type
|
|
102
|
+
* comment; they were built in Phase 12a only for `rebaseAnnotationRange`,
|
|
103
|
+
* a different, ID-keyed mechanism). Auto-redirecting an *operation*
|
|
104
|
+
* (rather than an annotation range's endpoint) to the survivor has its
|
|
105
|
+
* own correctness risk even for whole-node-targeting ops - e.g. a
|
|
106
|
+
* concurrent `setNodeAttributes` redirected to the survivor would carry
|
|
107
|
+
* a `before` payload captured against the *retired* node's attrs, which
|
|
108
|
+
* `applyOperation`'s strict before-check would then reject against the
|
|
109
|
+
* survivor's actual attrs. Flagged as a conflict, consistent with
|
|
110
|
+
* decision 1, rather than attempting a redirect whose correctness isn't
|
|
111
|
+
* established.
|
|
112
|
+
* 3. **Concurrent mergeNode vs. anything occupying its left (survivor) or
|
|
113
|
+
* right (retired) slot** (2026-08-28, required follow-up to finding #1
|
|
114
|
+
* in mapoperation-position-arithmetic-gaps.md). `applyOperation`'s own
|
|
115
|
+
* mergeNode handling validates `splitOffset === left.children.length`
|
|
116
|
+
* and that left/right are structurally compatible - both checks depend
|
|
117
|
+
* on the actual identity of whatever occupies those two slots.
|
|
118
|
+
* `mapPosThroughOperation`'s mergeNode-as-through branch only tracks a
|
|
119
|
+
* single explicit position (`operation.pos`/`.from`/`.to`), so an
|
|
120
|
+
* ordinary sibling-index shift correctly keeps a *retiring* target
|
|
121
|
+
* reachable but has no way to notice that a node's *identity* changed
|
|
122
|
+
* underneath an *implicit* left-neighbor reference. Confirmed
|
|
123
|
+
* empirically: two mergeNode ops retiring adjacent siblings (A retires
|
|
124
|
+
* node N into its left neighbor, B retires N's *next* sibling into N)
|
|
125
|
+
* crashed in one transform direction and silently produced a different
|
|
126
|
+
* document in the other; a mergeNode composed with a concurrent
|
|
127
|
+
* `removeNode` of its own left/survivor node crashed both ways. A
|
|
128
|
+
* concurrent `splitNode` targeting the *exact* node a mergeNode retires
|
|
129
|
+
* was checked and found *not* to need this treatment - it already gets
|
|
130
|
+
* `mapPosThroughOperation`'s explicit "this position IS the retired
|
|
131
|
+
* node, redirect into the survivor" case, which correctly recomputes an
|
|
132
|
+
* in-bounds position rather than carrying a stale content-derived
|
|
133
|
+
* payload the way `mergeNode.splitOffset` does. Same reasoning as
|
|
134
|
+
* finding 1 above: not narrowed further, since fixing this generally
|
|
135
|
+
* would mean giving `mapOperation` document access it deliberately
|
|
136
|
+
* doesn't have.
|
|
137
|
+
*/
|
|
138
|
+
export const transformOperation = (operation, through, options = {}) => {
|
|
139
|
+
const structuralParentPaths = (candidate) => {
|
|
140
|
+
if (candidate.type === "insertNode" || candidate.type === "removeNode" || candidate.type === "replaceNode")
|
|
141
|
+
return [candidate.pos.path];
|
|
142
|
+
if (candidate.type === "moveNode")
|
|
143
|
+
return [candidate.from.path, candidate.to.path];
|
|
144
|
+
return [];
|
|
145
|
+
};
|
|
146
|
+
if (operation.type === "moveNode" || through.type === "moveNode") {
|
|
147
|
+
const moveOperation = operation.type === "moveNode" ? operation : through.type === "moveNode" ? through : null;
|
|
148
|
+
const other = moveOperation === operation ? through : operation;
|
|
149
|
+
if (moveOperation) {
|
|
150
|
+
const moveParents = structuralParentPaths(moveOperation);
|
|
151
|
+
const otherParents = structuralParentPaths(other);
|
|
152
|
+
const sharesArray = otherParents.some((otherPath) => moveParents.some((movePath) => samePath(otherPath, movePath)));
|
|
153
|
+
if (sharesArray) {
|
|
154
|
+
return { kind: "conflict", reason: `Concurrent structural change shares an array with moveNode of "${moveOperation.nodeId}".` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (operation.type === "mergeNode" || through.type === "mergeNode") {
|
|
159
|
+
const mergeOperation = operation.type === "mergeNode" ? operation : through.type === "mergeNode" ? through : null;
|
|
160
|
+
const other = mergeOperation === operation ? through : operation;
|
|
161
|
+
if (mergeOperation) {
|
|
162
|
+
const mergeSlots = mergeNodeSlots(mergeOperation);
|
|
163
|
+
const otherSlots = occupiedSlotsOf(other);
|
|
164
|
+
const overlaps = otherSlots?.some((otherSlot) => mergeSlots.some((mergeSlot) => isExactlyAtSlot(otherSlot, mergeSlot)));
|
|
165
|
+
if (overlaps) {
|
|
166
|
+
return { kind: "conflict", reason: "Concurrent operation occupies the same slot a mergeNode's left or right side depends on." };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (through.type === "removeNode" && through.mergedInto) {
|
|
171
|
+
const touch = operationTouchesSubtree(operation, through.pos);
|
|
172
|
+
if (touch.within) {
|
|
173
|
+
return { kind: "conflict", reason: `Concurrent operation targets content merged into "${through.mergedInto}".` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (through.type === "replaceNode" && through.retiredInto?.length) {
|
|
177
|
+
const touch = operationTouchesSubtree(operation, through.pos);
|
|
178
|
+
if (touch.within) {
|
|
179
|
+
return { kind: "conflict", reason: "Concurrent operation targets content retired by a merge." };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Symmetric case: `operation` (not `through`) is the merge - e.g.
|
|
183
|
+
// rebasing a table-cell-merge transaction through a concurrent edit to
|
|
184
|
+
// the cell it's about to absorb, the reverse pairing of the two checks
|
|
185
|
+
// above. Naively letting this "succeed" would silently discard the
|
|
186
|
+
// concurrent edit the instant the merge's removeNode/replaceNode
|
|
187
|
+
// actually applies, with nothing in the result signaling that happened.
|
|
188
|
+
if (operation.type === "removeNode" && operation.mergedInto) {
|
|
189
|
+
const touch = operationTouchesSubtree(through, operation.pos);
|
|
190
|
+
if (touch.within) {
|
|
191
|
+
return { kind: "conflict", reason: `A concurrent operation targets content this merge would absorb into "${operation.mergedInto}".` };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (operation.type === "replaceNode" && operation.retiredInto?.length) {
|
|
195
|
+
const touch = operationTouchesSubtree(through, operation.pos);
|
|
196
|
+
if (touch.within) {
|
|
197
|
+
return { kind: "conflict", reason: "A concurrent operation targets content this merge would retire." };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// A deleteText operation carries the exact text it expects to remove, as
|
|
201
|
+
// a self-check (operations.ts's applyToSession rejects it if the live
|
|
202
|
+
// document doesn't match). If a concurrent insertText lands strictly
|
|
203
|
+
// inside that span, the stored text is now stale relative to what's
|
|
204
|
+
// actually there - mapOperation only shifts positions, it never touches
|
|
205
|
+
// this payload (it has no document access to recompute it from), so
|
|
206
|
+
// naively shifting the position and reapplying the original text would
|
|
207
|
+
// either fail this self-check outright or, worse, delete the wrong
|
|
208
|
+
// characters if the lengths happened to still line up. Flagged as a
|
|
209
|
+
// conflict, consistent with the move/merge decisions above, rather than
|
|
210
|
+
// guessing whether the new content should be swept into the deletion or
|
|
211
|
+
// preserved. Deliberately excludes the boundary case (insert exactly at
|
|
212
|
+
// the deletion's start or end) - only a strictly-interior insert is
|
|
213
|
+
// ambiguous.
|
|
214
|
+
if (operation.type === "deleteText" && through.type === "insertText" && samePath(operation.pos.path, through.pos.path)) {
|
|
215
|
+
const deleteEnd = operation.pos.offset + operation.text.length;
|
|
216
|
+
if (through.pos.offset > operation.pos.offset && through.pos.offset < deleteEnd) {
|
|
217
|
+
return { kind: "conflict", reason: "Concurrent insertion lands inside a deleted range." };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (operation.type === "insertText" && through.type === "deleteText" && samePath(operation.pos.path, through.pos.path)) {
|
|
221
|
+
const deleteEnd = through.pos.offset + through.text.length;
|
|
222
|
+
if (operation.pos.offset > through.pos.offset && operation.pos.offset < deleteEnd) {
|
|
223
|
+
return { kind: "conflict", reason: "Insertion lands inside a concurrently deleted range." };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Two overlapping deleteText ranges - same staleness problem as above:
|
|
227
|
+
// whichever one is transformed second no longer has an accurate `.text`
|
|
228
|
+
// payload for however much of its range the other one already removed
|
|
229
|
+
// (found via a property test: two concurrent deletes of "45" and "4" at
|
|
230
|
+
// the same offset - applying one first makes the other's stored text
|
|
231
|
+
// immediately wrong). An *identical* delete (same offset, same text - two
|
|
232
|
+
// authors deleting the exact same span) has no real ambiguity - the
|
|
233
|
+
// second one's intent is already satisfied, so it's dropped as an
|
|
234
|
+
// ordinary no-op rather than surfaced as a conflict. mapOperation itself
|
|
235
|
+
// has no such exact-match handling for deleteText (confirmed via the same
|
|
236
|
+
// property test - it left the position untouched, causing the identical
|
|
237
|
+
// no-op case to crash too), so this is handled explicitly here, not
|
|
238
|
+
// deferred.
|
|
239
|
+
if (operation.type === "deleteText" && through.type === "deleteText" && samePath(operation.pos.path, through.pos.path)) {
|
|
240
|
+
const opEnd = operation.pos.offset + operation.text.length;
|
|
241
|
+
const throughEnd = through.pos.offset + through.text.length;
|
|
242
|
+
const overlaps = operation.pos.offset < throughEnd && through.pos.offset < opEnd;
|
|
243
|
+
const identical = operation.pos.offset === through.pos.offset && operation.text === through.text;
|
|
244
|
+
if (identical)
|
|
245
|
+
return { kind: "dropped" };
|
|
246
|
+
if (overlaps)
|
|
247
|
+
return { kind: "conflict", reason: "Concurrent deletions overlap." };
|
|
248
|
+
}
|
|
249
|
+
const mapped = mapOperation(operation, through, options);
|
|
250
|
+
return mapped ? { kind: "ok", operation: mapped } : { kind: "dropped" };
|
|
251
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SmartOperation, SmartTransaction } from "../types.js";
|
|
2
|
+
export type MoveNodeOperation = Extract<SmartOperation, {
|
|
3
|
+
type: "moveNode";
|
|
4
|
+
}>;
|
|
5
|
+
export type RemoveNodeOperation = Extract<SmartOperation, {
|
|
6
|
+
type: "removeNode";
|
|
7
|
+
}>;
|
|
8
|
+
export type ReplaceNodeOperation = Extract<SmartOperation, {
|
|
9
|
+
type: "replaceNode";
|
|
10
|
+
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Result of transforming one operation through a single concurrent
|
|
13
|
+
* "through" operation - `mapOperation` (operations.ts) only ever returns
|
|
14
|
+
* `SmartOperation | null`, collapsing "target no longer exists, this
|
|
15
|
+
* operation is now a harmless no-op" and "target was folded into something
|
|
16
|
+
* else and this operation cannot be safely, precisely redirected" into the
|
|
17
|
+
* same `null`. Concurrent rebase needs to tell those apart: the first is
|
|
18
|
+
* routine and silent, the second must be surfaced (per this phase's stop
|
|
19
|
+
* condition 4 - a rebase must never silently corrupt state or silently
|
|
20
|
+
* drop a conflicting edit without saying so).
|
|
21
|
+
*/
|
|
22
|
+
export type TransformResult = {
|
|
23
|
+
kind: "ok";
|
|
24
|
+
operation: SmartOperation;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "dropped";
|
|
27
|
+
} | {
|
|
28
|
+
kind: "conflict";
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
/** Rebasing a whole transaction through everything committed since its baseRevision. */
|
|
32
|
+
export type RebaseResult = {
|
|
33
|
+
kind: "ok";
|
|
34
|
+
transaction: SmartTransaction;
|
|
35
|
+
}
|
|
36
|
+
/** Every operation in the transaction turned out to be a no-op against the current state (e.g. it only edited content someone else already deleted outright). Nothing to apply; the transaction is simply dropped. */
|
|
37
|
+
| {
|
|
38
|
+
kind: "dropped";
|
|
39
|
+
}
|
|
40
|
+
/** At least one operation could not be safely transformed - see `reasons` for which operation (by index) and why. The transaction as a whole is rejected; the caller decides what happens to the user's edit (see rebaseTransaction's own doc comment). */
|
|
41
|
+
| {
|
|
42
|
+
kind: "conflict";
|
|
43
|
+
reasons: readonly {
|
|
44
|
+
operationIndex: number;
|
|
45
|
+
reason: string;
|
|
46
|
+
}[];
|
|
47
|
+
}
|
|
48
|
+
/** `sinceRevision` predates what this client has retained - a transform can't be attempted at all without the missing history. The caller must resync (reload the full document at the current revision) rather than attempt a partial rebase. */
|
|
49
|
+
| {
|
|
50
|
+
kind: "resync-required";
|
|
51
|
+
atRevision: number;
|
|
52
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SmartDocument, SmartSelection } from "../types.js";
|
|
2
|
+
import type { AnnotationRange } from "../annotations/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds the AnnotationRange a new comment thread should anchor to from the
|
|
5
|
+
* editor's current selection - the reverse direction of
|
|
6
|
+
* `resolveAnnotationRange` (AnnotationRange -> SmartRange). `resolvePos`'s
|
|
7
|
+
* `nodeId` is exactly the id `resolveAnnotationRange`'s `resolveEndpoint`
|
|
8
|
+
* expects for an offset-bearing endpoint (the owning node, not a text
|
|
9
|
+
* node), so no separate id-lookup mechanism is needed here.
|
|
10
|
+
*
|
|
11
|
+
* Returns null for a collapsed selection (nothing to attach a comment to)
|
|
12
|
+
* or a "none" selection (nothing selected at all). Every other selection
|
|
13
|
+
* type - text, node, cell - resolves the same way, since `SmartPos`/
|
|
14
|
+
* `resolvePos` already treat structural and inline owners uniformly.
|
|
15
|
+
*/
|
|
16
|
+
export declare const commentRangeFromSelection: (document: SmartDocument, selection: SmartSelection) => AnnotationRange | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { comparePos, resolvePos, selectionRange } from "../positions.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds the AnnotationRange a new comment thread should anchor to from the
|
|
4
|
+
* editor's current selection - the reverse direction of
|
|
5
|
+
* `resolveAnnotationRange` (AnnotationRange -> SmartRange). `resolvePos`'s
|
|
6
|
+
* `nodeId` is exactly the id `resolveAnnotationRange`'s `resolveEndpoint`
|
|
7
|
+
* expects for an offset-bearing endpoint (the owning node, not a text
|
|
8
|
+
* node), so no separate id-lookup mechanism is needed here.
|
|
9
|
+
*
|
|
10
|
+
* Returns null for a collapsed selection (nothing to attach a comment to)
|
|
11
|
+
* or a "none" selection (nothing selected at all). Every other selection
|
|
12
|
+
* type - text, node, cell - resolves the same way, since `SmartPos`/
|
|
13
|
+
* `resolvePos` already treat structural and inline owners uniformly.
|
|
14
|
+
*/
|
|
15
|
+
export const commentRangeFromSelection = (document, selection) => {
|
|
16
|
+
if (selection.type === "none")
|
|
17
|
+
return null;
|
|
18
|
+
const { from, to } = selectionRange(selection);
|
|
19
|
+
if (comparePos(from, to) === 0)
|
|
20
|
+
return null;
|
|
21
|
+
const start = resolvePos(document, from);
|
|
22
|
+
const end = resolvePos(document, to);
|
|
23
|
+
return { startId: start.nodeId, startOffset: from.offset, endId: end.nodeId, endOffset: to.offset };
|
|
24
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SmartDocument, SmartSchema, SmartTransaction } from "../types.js";
|
|
2
|
+
import type { MergeOrphanPolicy } from "../annotations/types.js";
|
|
3
|
+
import type { CommentThread } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Comments must never silently disappear from a merge the way a caller
|
|
6
|
+
* might reasonably choose for some other annotation consumer - a lost
|
|
7
|
+
* comment is lost user-authored text, not just a lost position. Tries the
|
|
8
|
+
* standard snap-to-survivor policy first; if there's genuinely no
|
|
9
|
+
* preceding sibling to snap to (a real edge case `defaultMergeOrphanPolicy`
|
|
10
|
+
* itself can return null for), keeps the range's ids exactly as they were
|
|
11
|
+
* rather than dropping it. That range will simply fail to resolve once
|
|
12
|
+
* the retired id is gone from the document - the same "orphaned, not
|
|
13
|
+
* lost" state a `removeNode` with no `mergedInto` already produces
|
|
14
|
+
* naturally - so the thread's data survives and the UI can detect and
|
|
15
|
+
* surface "comment on deleted content" by the standard resolve-and-check
|
|
16
|
+
* pattern, not a special stored flag.
|
|
17
|
+
*/
|
|
18
|
+
export declare const neverOrphanCommentPolicy: (document: SmartDocument) => MergeOrphanPolicy;
|
|
19
|
+
/**
|
|
20
|
+
* Rebases every thread's range through one committed transaction, in
|
|
21
|
+
* operation order. `rebaseAnnotationRange`/`rebaseAnnotationRangeThroughOperations`
|
|
22
|
+
* need a fresh `PositionLookup` resolved against the document as it
|
|
23
|
+
* existed immediately before each individual operation (not the whole
|
|
24
|
+
* transaction) - since a caller subscribing to a runtime's transaction
|
|
25
|
+
* stream only receives the transaction after it has fully committed, this
|
|
26
|
+
* reconstructs those intermediate states itself by replaying operations
|
|
27
|
+
* one at a time against a running copy, starting from `beforeDocument`
|
|
28
|
+
* (whatever the document was immediately before this transaction, i.e.
|
|
29
|
+
* exactly what the caller already had from the previous change event).
|
|
30
|
+
*/
|
|
31
|
+
export declare const rebaseCommentThreadsThroughTransaction: (threads: readonly CommentThread[], beforeDocument: SmartDocument, transaction: SmartTransaction, schema: SmartSchema) => readonly CommentThread[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { applyOperation } from "../operations.js";
|
|
2
|
+
import { createScopeIndex } from "../scope/resolveScope.js";
|
|
3
|
+
import { defaultMergeOrphanPolicy, rebaseAnnotationRange } from "../annotations/range.js";
|
|
4
|
+
/**
|
|
5
|
+
* Comments must never silently disappear from a merge the way a caller
|
|
6
|
+
* might reasonably choose for some other annotation consumer - a lost
|
|
7
|
+
* comment is lost user-authored text, not just a lost position. Tries the
|
|
8
|
+
* standard snap-to-survivor policy first; if there's genuinely no
|
|
9
|
+
* preceding sibling to snap to (a real edge case `defaultMergeOrphanPolicy`
|
|
10
|
+
* itself can return null for), keeps the range's ids exactly as they were
|
|
11
|
+
* rather than dropping it. That range will simply fail to resolve once
|
|
12
|
+
* the retired id is gone from the document - the same "orphaned, not
|
|
13
|
+
* lost" state a `removeNode` with no `mergedInto` already produces
|
|
14
|
+
* naturally - so the thread's data survives and the UI can detect and
|
|
15
|
+
* surface "comment on deleted content" by the standard resolve-and-check
|
|
16
|
+
* pattern, not a special stored flag.
|
|
17
|
+
*/
|
|
18
|
+
export const neverOrphanCommentPolicy = (document) => (range, operation, positions) => defaultMergeOrphanPolicy(document)(range, operation, positions) ?? range;
|
|
19
|
+
/**
|
|
20
|
+
* Rebases every thread's range through one committed transaction, in
|
|
21
|
+
* operation order. `rebaseAnnotationRange`/`rebaseAnnotationRangeThroughOperations`
|
|
22
|
+
* need a fresh `PositionLookup` resolved against the document as it
|
|
23
|
+
* existed immediately before each individual operation (not the whole
|
|
24
|
+
* transaction) - since a caller subscribing to a runtime's transaction
|
|
25
|
+
* stream only receives the transaction after it has fully committed, this
|
|
26
|
+
* reconstructs those intermediate states itself by replaying operations
|
|
27
|
+
* one at a time against a running copy, starting from `beforeDocument`
|
|
28
|
+
* (whatever the document was immediately before this transaction, i.e.
|
|
29
|
+
* exactly what the caller already had from the previous change event).
|
|
30
|
+
*/
|
|
31
|
+
export const rebaseCommentThreadsThroughTransaction = (threads, beforeDocument, transaction, schema) => {
|
|
32
|
+
if (!threads.length || !transaction.operations.length)
|
|
33
|
+
return threads;
|
|
34
|
+
let runningDocument = beforeDocument;
|
|
35
|
+
let ranges = threads.map((thread) => thread.range);
|
|
36
|
+
for (const operation of transaction.operations) {
|
|
37
|
+
const positions = createScopeIndex().positions(runningDocument, schema);
|
|
38
|
+
const onOrphan = neverOrphanCommentPolicy(runningDocument);
|
|
39
|
+
ranges = ranges.map((range) => rebaseAnnotationRange(range, operation, positions, onOrphan) ?? range);
|
|
40
|
+
runningDocument = applyOperation(runningDocument, operation);
|
|
41
|
+
}
|
|
42
|
+
return threads.map((thread, index) => ranges[index] === thread.range ? thread : { ...thread, range: ranges[index] });
|
|
43
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AnnotationRange } from "../annotations/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* One message in a comment thread. The first reply (index 0) is the
|
|
4
|
+
* thread's opening comment, not a distinct "comment" type layered on top -
|
|
5
|
+
* a thread with exactly one reply is what most people mean by "a comment,"
|
|
6
|
+
* and every subsequent reply is a normal continuation of the same
|
|
7
|
+
* conversation. Modeling it as one list, not "comment + replies[]", means
|
|
8
|
+
* there is exactly one code path for "add a message to a thread" instead
|
|
9
|
+
* of two slightly-different ones.
|
|
10
|
+
*/
|
|
11
|
+
export interface CommentReply {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly authorId: string;
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly createdAt: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A comment thread anchored to a Phase 8c `AnnotationRange`. Threaded
|
|
19
|
+
* replies (single flat list, see `CommentReply`'s own doc comment);
|
|
20
|
+
* resolution is thread-level state (`resolved: true`), not deletion - a
|
|
21
|
+
* resolved thread's replies remain in `list()` for a host that wants to
|
|
22
|
+
* show a resolved-comments history, and only an explicit `remove()` call
|
|
23
|
+
* (a distinct, more destructive action) actually discards a thread.
|
|
24
|
+
*
|
|
25
|
+
* `range` is always a well-formed `AnnotationRange` - never null, even
|
|
26
|
+
* once the ids it names no longer exist in the document (see
|
|
27
|
+
* `rebase.ts`'s orphan handling). Whether a thread is currently anchored
|
|
28
|
+
* to real content is derived on demand via `resolveAnnotationRange`,
|
|
29
|
+
* exactly like every other `AnnotationRange` consumer - not stored as a
|
|
30
|
+
* separate flag that could drift out of sync with the document.
|
|
31
|
+
*/
|
|
32
|
+
export interface CommentThread {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly range: AnnotationRange;
|
|
35
|
+
readonly resolved: boolean;
|
|
36
|
+
readonly replies: readonly CommentReply[];
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SmartDocument, SmartSchema } from "../types.js";
|
|
2
|
+
import type { DocumentDiff } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every `SmartElementNode` keeps a stable id across split/merge/move/type-
|
|
5
|
+
* change/undo (Phase 8c collab-readiness gate, assertion 1). That turns the
|
|
6
|
+
* structural half of a document diff into a hash-map join - id present in
|
|
7
|
+
* both/only-before/only-after - instead of a generic tree-alignment problem
|
|
8
|
+
* a Myers/patience-style algorithm would otherwise be needed to solve.
|
|
9
|
+
* `SmartTextNode` has no id, so text content is the one place a real
|
|
10
|
+
* (word-level, see textDiff.ts) diff algorithm is still needed.
|
|
11
|
+
*/
|
|
12
|
+
export declare const diffDocuments: (before: SmartDocument, after: SmartDocument, schema: SmartSchema) => DocumentDiff;
|