@magic-marker/prosemirror-suggest-changes 0.3.3-wrap-unwrap.8 → 0.3.3-wrap-unwrap.11
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/__tests__/playwrightPage.d.ts +2 -0
- package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.js +23 -17
- package/dist/features/wrapUnwrap/revert/index.d.ts +2 -2
- package/dist/features/wrapUnwrap/revert/index.js +7 -9
- package/dist/features/wrapUnwrap/revert/revertAddOp.d.ts +1 -1
- package/dist/features/wrapUnwrap/revert/revertAddOp.js +1 -6
- package/dist/features/wrapUnwrap/revert/revertMoveOp.d.ts +5 -3
- package/dist/features/wrapUnwrap/revert/revertMoveOp.js +53 -80
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.d.ts +1 -3
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +99 -51
- package/dist/features/wrapUnwrap/structureChangesPlugin.d.ts +4 -1
- package/dist/features/wrapUnwrap/structureChangesPlugin.js +15 -2
- package/dist/features/wrapUnwrap/types.d.ts +3 -11
- package/dist/features/wrapUnwrap/types.js +0 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/listInputRules.d.ts +2 -0
- package/dist/listInputRules.js +14 -0
- package/dist/withSuggestChanges.js +6 -6
- package/dist/wrappingInputRule.d.ts +4 -0
- package/dist/wrappingInputRule.js +28 -0
- package/package.json +1 -1
- package/src/features/wrapUnwrap/README.md +0 -127
|
@@ -8,6 +8,7 @@ export declare class EditorPage {
|
|
|
8
8
|
getParagraphText(index: number): Promise<string>;
|
|
9
9
|
getParagraphCount(): Promise<number>;
|
|
10
10
|
getProseMirrorMarkCount(name: string): Promise<number>;
|
|
11
|
+
getProseMirrorMarksJSON(): Promise<unknown[]>;
|
|
11
12
|
getProseMirrorSelection(): Promise<{
|
|
12
13
|
anchor: number;
|
|
13
14
|
head: number;
|
|
@@ -19,4 +20,5 @@ export declare class EditorPage {
|
|
|
19
20
|
expectedDoc: import("prosemirror-model").Node;
|
|
20
21
|
}>;
|
|
21
22
|
revertAll(): Promise<void>;
|
|
23
|
+
applyAll(): Promise<void>;
|
|
22
24
|
}
|
|
@@ -1,31 +1,37 @@
|
|
|
1
|
+
import { getNodeId } from "../getNodeId.js";
|
|
2
|
+
const TRACE_ENABLED = true;
|
|
3
|
+
function trace(...args) {
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
5
|
+
if (!TRACE_ENABLED) return;
|
|
6
|
+
console.log("[deleteNodeUpwards]", "\n", ...args);
|
|
7
|
+
}
|
|
1
8
|
// delete a given node, and traverse upwards deleting parent nodes if they are now empty
|
|
2
9
|
export function deleteNodeUpwards(transform, node, pos) {
|
|
3
10
|
let $mappedPos = transform.doc.resolve(pos);
|
|
11
|
+
trace("attempting to delete node", node.type.name, getNodeId(node), "\n", "node subtree:\n", node.toString(), "\n", "node parent:", $mappedPos.parent.type.name, getNodeId($mappedPos.parent), "\n", {
|
|
12
|
+
node,
|
|
13
|
+
parent: $mappedPos.parent
|
|
14
|
+
});
|
|
4
15
|
let deleteFrom = $mappedPos.pos;
|
|
5
16
|
let deleteTo = $mappedPos.pos + node.nodeSize;
|
|
6
|
-
console.log("deleteNodeUpwards", "initial delete range covers node", node.toString(), {
|
|
7
|
-
deleteFrom,
|
|
8
|
-
deleteTo,
|
|
9
|
-
$mappedPos
|
|
10
|
-
});
|
|
11
17
|
while($mappedPos.depth > 0){
|
|
12
18
|
const $nextMappedPos = transform.doc.resolve($mappedPos.before());
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
if ($nextMappedPos.nodeAfter?.childCount !== 1) {
|
|
20
|
+
trace("stopping deletion at non-empty node:", $nextMappedPos.nodeAfter?.type.name, $nextMappedPos.nodeAfter == null ? null : getNodeId($nextMappedPos.nodeAfter), "\n", "non-empty node parent:", $nextMappedPos.parent.type.name, getNodeId($nextMappedPos.parent), "\n", "non-empty node subtree:\n", $nextMappedPos.nodeAfter?.toString(), "\n", "non-empty node subtree direct children:\n", $nextMappedPos.nodeAfter?.children.map((child)=>`${child.type.name} ${getNodeId(child) ?? "null"}`).join(", "), {
|
|
21
|
+
nonEmptyNode: $nextMappedPos.nodeAfter,
|
|
22
|
+
nonEmptyNodeParent: $nextMappedPos.parent
|
|
23
|
+
});
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
16
26
|
$mappedPos = $nextMappedPos;
|
|
17
27
|
deleteFrom = $nextMappedPos.pos;
|
|
18
28
|
deleteTo = $nextMappedPos.pos + $nextMappedPos.nodeAfter.nodeSize;
|
|
19
|
-
console.log("deleteNodeUpwards", "expanded delete range to cover node", $nextMappedPos.nodeAfter.toString(), {
|
|
20
|
-
deleteFrom,
|
|
21
|
-
deleteTo,
|
|
22
|
-
$mappedPos: $nextMappedPos
|
|
23
|
-
});
|
|
24
29
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
$mappedPos
|
|
30
|
+
trace("deleting subtree:\n", $mappedPos.nodeAfter?.toString(), "\n", "subtree root node:", $mappedPos.nodeAfter?.type.name, $mappedPos.nodeAfter == null ? null : getNodeId($mappedPos.nodeAfter), "\n", "subtree root parent node:", $mappedPos.parent.type.name, getNodeId($mappedPos.parent), "\n", "non-empty node subtree direct children:\n", $mappedPos.nodeAfter?.children.map((child)=>`${child.type.name} (${getNodeId(child) ?? "null"}`).join(", "), "\n", {
|
|
31
|
+
node: $mappedPos.nodeAfter,
|
|
32
|
+
parent: $mappedPos.parent,
|
|
33
|
+
$deleteFrom: $mappedPos.doc.resolve(deleteFrom),
|
|
34
|
+
$deleteTo: $mappedPos.doc.resolve(deleteTo)
|
|
29
35
|
});
|
|
30
36
|
transform.delete(deleteFrom, deleteTo);
|
|
31
37
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Transform } from "prosemirror-transform";
|
|
2
2
|
import { type Node } from "prosemirror-model";
|
|
3
3
|
import { type SuggestionId } from "../../../generateId.js";
|
|
4
|
-
export declare function revertAllStructureSuggestions(
|
|
5
|
-
export declare function revertStructureSuggestion(
|
|
4
|
+
export declare function revertAllStructureSuggestions(doc: Node, from?: number, to?: number): Transform;
|
|
5
|
+
export declare function revertStructureSuggestion(doc: Node, suggestionId: SuggestionId): Transform;
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { Transform } from "prosemirror-transform";
|
|
2
|
-
import {
|
|
3
|
-
export function revertAllStructureSuggestions(
|
|
4
|
-
const tr = new Transform(
|
|
5
|
-
|
|
2
|
+
import { revertStructureSuggestionsInDoc } from "./revertStructureSuggestions.js";
|
|
3
|
+
export function revertAllStructureSuggestions(doc, from, to) {
|
|
4
|
+
const tr = new Transform(doc);
|
|
5
|
+
revertStructureSuggestionsInDoc({
|
|
6
6
|
tr,
|
|
7
|
-
node: tr.doc,
|
|
8
7
|
from,
|
|
9
8
|
to
|
|
10
9
|
});
|
|
11
10
|
return tr;
|
|
12
11
|
}
|
|
13
|
-
export function revertStructureSuggestion(
|
|
14
|
-
const tr = new Transform(
|
|
15
|
-
|
|
12
|
+
export function revertStructureSuggestion(doc, suggestionId) {
|
|
13
|
+
const tr = new Transform(doc);
|
|
14
|
+
revertStructureSuggestionsInDoc({
|
|
16
15
|
tr,
|
|
17
|
-
node: tr.doc,
|
|
18
16
|
suggestionId
|
|
19
17
|
});
|
|
20
18
|
return tr;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type Transform } from "prosemirror-transform";
|
|
2
2
|
import { type AddOp } from "../types.js";
|
|
3
3
|
import { type Node } from "prosemirror-model";
|
|
4
|
-
export declare function revertAddOp(
|
|
4
|
+
export declare function revertAddOp(_op: AddOp, tr: Transform, node: Node, pos: number): void;
|
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import { deleteNodeUpwards } from "./deleteNodeUpwards.js";
|
|
2
|
-
export function revertAddOp(
|
|
3
|
-
console.log("revertAddOp", "node", node.toString(), "was added at", pos, {
|
|
4
|
-
op,
|
|
5
|
-
node,
|
|
6
|
-
pos
|
|
7
|
-
});
|
|
2
|
+
export function revertAddOp(_op, tr, node, pos) {
|
|
8
3
|
deleteNodeUpwards(tr, node, pos);
|
|
9
4
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { type Transform } from "prosemirror-transform";
|
|
2
2
|
import { type MoveOp } from "../types.js";
|
|
3
3
|
import { type Node } from "prosemirror-model";
|
|
4
|
-
import { type
|
|
4
|
+
import { type Parent } from "../types.js";
|
|
5
5
|
export declare function revertMoveOp(op: MoveOp, tr: Transform, node: Node, pos: number): void;
|
|
6
|
-
export declare function getNodesWithChildren(doc: Node): Map<string, NodeWithChildren>;
|
|
7
6
|
export declare function getDeepestSurvivingParent(parentChain: Parent[], doc: Node): {
|
|
8
7
|
parent: Parent;
|
|
9
8
|
node: Node;
|
|
@@ -11,4 +10,7 @@ export declare function getDeepestSurvivingParent(parentChain: Parent[], doc: No
|
|
|
11
10
|
remainingChain: Parent[];
|
|
12
11
|
};
|
|
13
12
|
export declare function wrapNodeInParentChain(parentChain: Parent[], node: Node): Node;
|
|
14
|
-
export declare function findInsertionPos(node: Node, pos: number | null, parent: Parent): number
|
|
13
|
+
export declare function findInsertionPos(node: Node, pos: number | null, parent: Parent, child: Node): number | {
|
|
14
|
+
from: number;
|
|
15
|
+
to: number;
|
|
16
|
+
};
|
|
@@ -1,60 +1,17 @@
|
|
|
1
1
|
import { deleteNodeUpwards } from "./deleteNodeUpwards.js";
|
|
2
2
|
import { getNodeId } from "../getNodeId.js";
|
|
3
|
-
import { guardDocParent, guardDocWithChildren } from "../types.js";
|
|
4
3
|
export function revertMoveOp(op, tr, node, pos) {
|
|
5
|
-
console.log("revertMoveOp", "node", node.toString(), "was moved from", op.from, {
|
|
6
|
-
op,
|
|
7
|
-
node
|
|
8
|
-
});
|
|
9
4
|
const parent = getDeepestSurvivingParent(op.from, tr.doc);
|
|
10
|
-
console.log("revertMoveOp", "deepest surviving parent is", parent.node.toString(), {
|
|
11
|
-
parent
|
|
12
|
-
});
|
|
13
5
|
const child = wrapNodeInParentChain(parent.remainingChain, node);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
-
tr.insert(insertionPos, child);
|
|
6
|
+
const insertTo = findInsertionPos(parent.node, parent.pos, parent.parent, child);
|
|
7
|
+
if (typeof insertTo === "number") {
|
|
8
|
+
tr.insert(insertTo, child);
|
|
9
|
+
} else {
|
|
10
|
+
tr.replaceWith(insertTo.from, insertTo.to, child);
|
|
11
|
+
}
|
|
22
12
|
const mappedPos = tr.mapping.map(pos);
|
|
23
13
|
deleteNodeUpwards(tr, node, mappedPos);
|
|
24
14
|
}
|
|
25
|
-
export function getNodesWithChildren(doc) {
|
|
26
|
-
const nodesWithChildren = new Map();
|
|
27
|
-
const docWithChildren = {
|
|
28
|
-
node: doc,
|
|
29
|
-
pos: null,
|
|
30
|
-
children: new Set()
|
|
31
|
-
};
|
|
32
|
-
doc.children.forEach((child)=>{
|
|
33
|
-
const nodeId = getNodeId(child);
|
|
34
|
-
if (nodeId == null) return;
|
|
35
|
-
docWithChildren.children.add(nodeId);
|
|
36
|
-
});
|
|
37
|
-
nodesWithChildren.set("__doc__", docWithChildren);
|
|
38
|
-
doc.descendants((node, pos)=>{
|
|
39
|
-
if (node.isText) return true;
|
|
40
|
-
const nodeId = getNodeId(node);
|
|
41
|
-
if (nodeId == null) return true;
|
|
42
|
-
if (nodesWithChildren.has(nodeId)) return true;
|
|
43
|
-
const children = node.children.reduce((acc, child)=>{
|
|
44
|
-
const childId = getNodeId(child);
|
|
45
|
-
if (childId == null) return acc;
|
|
46
|
-
acc.add(childId);
|
|
47
|
-
return acc;
|
|
48
|
-
}, new Set());
|
|
49
|
-
nodesWithChildren.set(nodeId, {
|
|
50
|
-
node,
|
|
51
|
-
pos,
|
|
52
|
-
children
|
|
53
|
-
});
|
|
54
|
-
return true;
|
|
55
|
-
});
|
|
56
|
-
return nodesWithChildren;
|
|
57
|
-
}
|
|
58
15
|
// given a chain of parent node descriptors, follow the chain from top to bottom as long as nodes exist
|
|
59
16
|
// return the deepest existing parent node descriptor, along with the actual node and the pos in the current document
|
|
60
17
|
// also return the remaining part of the chain
|
|
@@ -62,38 +19,41 @@ export function getDeepestSurvivingParent(parentChain, doc) {
|
|
|
62
19
|
const chain = [
|
|
63
20
|
...parentChain
|
|
64
21
|
].reverse();
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
throw new Error("doc not found in nodesWithChildren");
|
|
22
|
+
const root = chain.shift();
|
|
23
|
+
if (root == null) {
|
|
24
|
+
throw new Error("Parent chain is empty");
|
|
69
25
|
}
|
|
70
|
-
let
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
26
|
+
let result = {
|
|
27
|
+
parent: root,
|
|
28
|
+
node: doc,
|
|
29
|
+
pos: null
|
|
30
|
+
};
|
|
31
|
+
let remainingChain = [
|
|
32
|
+
...chain
|
|
33
|
+
];
|
|
34
|
+
// follow the chain up-down
|
|
35
|
+
// look for the node with the matching id in the children of the previously found node
|
|
36
|
+
for (const [index, item] of chain.entries()){
|
|
37
|
+
let found = false;
|
|
38
|
+
result.node.forEach((child, offset)=>{
|
|
39
|
+
if (found) return;
|
|
40
|
+
if (child.attrs["id"] !== item.nodeId) return;
|
|
41
|
+
found = true;
|
|
42
|
+
const pos = result.pos == null ? offset : result.pos + 1 + offset;
|
|
43
|
+
result = {
|
|
44
|
+
parent: item,
|
|
45
|
+
node: child,
|
|
46
|
+
pos
|
|
47
|
+
};
|
|
48
|
+
remainingChain = chain.slice(index + 1);
|
|
49
|
+
});
|
|
50
|
+
if (!found) break;
|
|
89
51
|
}
|
|
90
52
|
return {
|
|
91
|
-
parent,
|
|
92
|
-
node:
|
|
93
|
-
pos:
|
|
94
|
-
remainingChain:
|
|
95
|
-
...remainingChain
|
|
96
|
-
].reverse()
|
|
53
|
+
parent: result.parent,
|
|
54
|
+
node: result.node,
|
|
55
|
+
pos: result.pos,
|
|
56
|
+
remainingChain: remainingChain.reverse()
|
|
97
57
|
};
|
|
98
58
|
}
|
|
99
59
|
// given a chain of parent node descriptors and a node
|
|
@@ -107,14 +67,17 @@ export function wrapNodeInParentChain(parentChain, node) {
|
|
|
107
67
|
throw new Error(`node type ${parent.nodeType} not found in schema`);
|
|
108
68
|
}
|
|
109
69
|
const marks = parent.nodeMarks.map((mark)=>schema.markFromJSON(mark));
|
|
110
|
-
|
|
70
|
+
const parentNode = nodeType.createAndFill(parent.nodeAttrs, child, marks);
|
|
71
|
+
if (parentNode == null) throw new Error(`Unable to create node ${nodeType.name} with child ${child.toString()}`);
|
|
72
|
+
child = parentNode;
|
|
73
|
+
child.check();
|
|
111
74
|
}
|
|
112
75
|
return child;
|
|
113
76
|
}
|
|
114
77
|
// given a node, its position, and a parent descriptor of this node in some parent chain,
|
|
115
78
|
// use the info from the descriptor to find the insertion position in the node
|
|
116
79
|
// first try to find siblings, fallback to end of node
|
|
117
|
-
export function findInsertionPos(node, pos, parent) {
|
|
80
|
+
export function findInsertionPos(node, pos, parent, child) {
|
|
118
81
|
let leftSibling = null;
|
|
119
82
|
let rightSibling = null;
|
|
120
83
|
node.descendants((child, localChildPos)=>{
|
|
@@ -137,6 +100,16 @@ export function findInsertionPos(node, pos, parent) {
|
|
|
137
100
|
return false;
|
|
138
101
|
});
|
|
139
102
|
if (rightSibling != null) {
|
|
103
|
+
// special case: we need to insert as the first child, but the existing first child is an empty node of the same type
|
|
104
|
+
// in this case, we need to replace the existing first child with the new node
|
|
105
|
+
const firstChild = node.children[0];
|
|
106
|
+
if (parent.childSiblingIds[0] == null && firstChild?.type === child.type && firstChild.textContent === "") {
|
|
107
|
+
const from = pos != null ? pos + 1 : 0;
|
|
108
|
+
return {
|
|
109
|
+
from,
|
|
110
|
+
to: from + firstChild.nodeSize
|
|
111
|
+
};
|
|
112
|
+
}
|
|
140
113
|
// insert before right sibling
|
|
141
114
|
return rightSibling.pos;
|
|
142
115
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { type Node } from "prosemirror-model";
|
|
2
1
|
import { type Mark } from "prosemirror-model";
|
|
3
2
|
import { Transform } from "prosemirror-transform";
|
|
4
3
|
import { type SuggestionId } from "../../../generateId.js";
|
|
5
|
-
export declare function
|
|
4
|
+
export declare function revertStructureSuggestionsInDoc({ tr, suggestionId, from, to, }: {
|
|
6
5
|
tr: Transform;
|
|
7
|
-
node: Node;
|
|
8
6
|
suggestionId?: SuggestionId;
|
|
9
7
|
from?: number | undefined;
|
|
10
8
|
to?: number | undefined;
|
|
@@ -6,52 +6,78 @@ import { sameParentChain } from "../sameParentChain.js";
|
|
|
6
6
|
import { buildMaterializedPaths } from "../buildMaterializedPaths.js";
|
|
7
7
|
import { revertAddOp } from "./revertAddOp.js";
|
|
8
8
|
import { revertMoveOp } from "./revertMoveOp.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
const TRACE_ENABLED = true;
|
|
10
|
+
function trace(...args) {
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
12
|
+
if (!TRACE_ENABLED) return;
|
|
13
|
+
console.log("[revertStructureSuggestions]", ...args);
|
|
14
|
+
}
|
|
15
|
+
export function revertStructureSuggestionsInDoc({ tr, suggestionId, from, to }) {
|
|
16
|
+
if (suggestionId) {
|
|
17
|
+
// when suggestionId is given, just revert it, no other logic required
|
|
18
|
+
revertStructureSuggestionWithPrerequisites(tr, suggestionId);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (from || to) {
|
|
22
|
+
// when range is given, find suggestion ids inside that range,
|
|
23
|
+
// but make sure to revert furthermost suggestions first
|
|
24
|
+
// todo: most likely a better strategy would be to search for the next suggestion in the updated doc after each reversal (and map from and to)
|
|
25
|
+
const { structure } = getSuggestionMarks(tr.doc.type.schema);
|
|
26
|
+
const structureMarks = [];
|
|
27
|
+
tr.doc.descendants((node, pos)=>{
|
|
28
|
+
if (from !== undefined && pos < from) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (to !== undefined && pos > to) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (node.isText) return true;
|
|
35
|
+
if (!structure.isInSet(node.marks)) return true;
|
|
36
|
+
node.marks.forEach((mark)=>{
|
|
37
|
+
if (mark.type !== structure) return;
|
|
38
|
+
structureMarks.push({
|
|
39
|
+
mark,
|
|
40
|
+
node,
|
|
41
|
+
pos
|
|
42
|
+
});
|
|
43
|
+
});
|
|
15
44
|
return true;
|
|
16
|
-
}
|
|
17
|
-
if (to !== undefined && pos > to) {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
if (node.isText) return true;
|
|
21
|
-
if (!structure.isInSet(node.marks)) return true;
|
|
22
|
-
node.marks.forEach((mark)=>{
|
|
23
|
-
if (mark.type !== structure) return;
|
|
24
|
-
const markSuggestionId = mark.attrs["id"];
|
|
25
|
-
if (suggestionId != null && markSuggestionId !== suggestionId) return;
|
|
26
|
-
suggestionIds.add(markSuggestionId);
|
|
27
45
|
});
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
structureMarks.sort((a, b)=>b.pos - a.pos);
|
|
47
|
+
const suggestionIds = new Set();
|
|
48
|
+
structureMarks.forEach(({ mark })=>{
|
|
49
|
+
suggestionIds.add(mark.attrs["id"]);
|
|
50
|
+
});
|
|
51
|
+
for (const suggestionId of suggestionIds){
|
|
52
|
+
revertStructureSuggestionWithPrerequisites(tr, suggestionId);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// if no suggestion id nor range is given, revert all suggestions one by one, always take furthermost first
|
|
57
|
+
// after each reversal, the next suggestion is searched in the new doc after the previous reversal
|
|
58
|
+
let nextSuggestionId = findNextStructureSuggestion(tr.doc);
|
|
59
|
+
while(nextSuggestionId != null){
|
|
60
|
+
revertStructureSuggestionWithPrerequisites(tr, nextSuggestionId);
|
|
61
|
+
nextSuggestionId = findNextStructureSuggestion(tr.doc);
|
|
32
62
|
}
|
|
33
63
|
}
|
|
34
64
|
function revertStructureSuggestionWithPrerequisites(tr, suggestionId) {
|
|
35
|
-
console.group("revertStructureMarkGroupInOrder", "reverting structure mark group", suggestionId);
|
|
36
65
|
const suggestionIds = buildOrderedSuggestionIds(tr.doc, suggestionId, buildMaterializedPaths(tr.doc));
|
|
37
|
-
|
|
66
|
+
trace("reverting structure suggestions: ", suggestionIds);
|
|
38
67
|
for (const suggestionId of suggestionIds){
|
|
39
68
|
revertOneStructureSuggestion(tr, suggestionId);
|
|
40
69
|
}
|
|
41
|
-
console.groupEnd();
|
|
42
70
|
}
|
|
43
71
|
function revertOneStructureSuggestion(tr, suggestionId) {
|
|
44
|
-
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
73
|
+
if (TRACE_ENABLED) console.group("reverting structure suggestion: ", suggestionId);
|
|
45
74
|
let structureMark = findNextStructureMark(tr.doc, suggestionId);
|
|
46
|
-
while(structureMark
|
|
47
|
-
|
|
48
|
-
structureMark
|
|
49
|
-
});
|
|
50
|
-
revertStructureMark(tr, structureMark.mark, structureMark.$pos.pos);
|
|
51
|
-
console.groupEnd();
|
|
75
|
+
while(structureMark !== null){
|
|
76
|
+
revertStructureMark(tr, structureMark.mark, structureMark.pos);
|
|
52
77
|
structureMark = findNextStructureMark(tr.doc, suggestionId);
|
|
53
78
|
}
|
|
54
|
-
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
80
|
+
if (TRACE_ENABLED) console.groupEnd();
|
|
55
81
|
}
|
|
56
82
|
export function revertStructureMark(tr, mark, pos) {
|
|
57
83
|
const transform = new Transform(tr.doc);
|
|
@@ -60,11 +86,6 @@ export function revertStructureMark(tr, mark, pos) {
|
|
|
60
86
|
if (!node) {
|
|
61
87
|
throw new Error(`Node not found at position ${String(pos)}`);
|
|
62
88
|
}
|
|
63
|
-
console.log("revertStructureMark", mark.attrs["id"], "at node", node.toString(), {
|
|
64
|
-
node,
|
|
65
|
-
mark,
|
|
66
|
-
pos
|
|
67
|
-
});
|
|
68
89
|
const attrs = mark.attrs;
|
|
69
90
|
if (!guardStructureMarkAttrs(attrs)) {
|
|
70
91
|
console.warn("revertStructureMark", "invalid shape of structure mark attrs", {
|
|
@@ -73,6 +94,12 @@ export function revertStructureMark(tr, mark, pos) {
|
|
|
73
94
|
return;
|
|
74
95
|
}
|
|
75
96
|
const op = attrs.data.op;
|
|
97
|
+
trace("reverting structure mark with suggestion id", attrs.id, "at node", node.toString(), "at pos", pos, "with op", op.op, {
|
|
98
|
+
mark,
|
|
99
|
+
node,
|
|
100
|
+
$pos: transform.doc.resolve(pos),
|
|
101
|
+
op
|
|
102
|
+
});
|
|
76
103
|
switch(op.op){
|
|
77
104
|
case "add":
|
|
78
105
|
{
|
|
@@ -134,7 +161,6 @@ function buildOrderedSuggestionIds(node, suggestionId, materializedPaths) {
|
|
|
134
161
|
if (mismatch == null) {
|
|
135
162
|
return Array.from(suggestionIds).reverse();
|
|
136
163
|
}
|
|
137
|
-
console.log("findOrderedSuggestionIdsToRevert", "suggestion", suggestionId, "contains mark with a mismatched 'to':", mismatch, "searching a different suggestion with the matching 'to'...");
|
|
138
164
|
// find first mark on the node that does have a matching op.to
|
|
139
165
|
const match = mismatch.node.marks.find((mark)=>{
|
|
140
166
|
if (mark.type !== structure) return false;
|
|
@@ -148,33 +174,55 @@ function buildOrderedSuggestionIds(node, suggestionId, materializedPaths) {
|
|
|
148
174
|
return sameParentChain(attrs.data.op.to, parentChain.chain);
|
|
149
175
|
});
|
|
150
176
|
if (match) {
|
|
151
|
-
console.log("findOrderedSuggestionIdsToRevert", "found suggestin with matching 'to'", match);
|
|
152
177
|
suggestionIds.add(match.attrs["id"]);
|
|
153
178
|
}
|
|
154
179
|
return Array.from(suggestionIds).reverse();
|
|
155
180
|
}
|
|
181
|
+
function findNextStructureSuggestion(doc) {
|
|
182
|
+
const { structure } = getSuggestionMarks(doc.type.schema);
|
|
183
|
+
const structureMarks = [];
|
|
184
|
+
doc.descendants((node, pos)=>{
|
|
185
|
+
if (node.isText) return true;
|
|
186
|
+
if (!structure.isInSet(node.marks)) return true;
|
|
187
|
+
node.marks.forEach((mark)=>{
|
|
188
|
+
if (mark.type !== structure) return;
|
|
189
|
+
structureMarks.push({
|
|
190
|
+
mark,
|
|
191
|
+
node,
|
|
192
|
+
pos
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
structureMarks.sort((a, b)=>b.pos - a.pos);
|
|
198
|
+
return structureMarks[0]?.mark.attrs["id"];
|
|
199
|
+
}
|
|
200
|
+
// given a suggestion id, find next structure mark to revert that belongs to that suggestion
|
|
201
|
+
// always take furthermost mark first
|
|
156
202
|
function findNextStructureMark(node, suggestionId) {
|
|
157
|
-
console.log("findNextStructureMark", suggestionId);
|
|
158
203
|
const { structure } = getSuggestionMarks(node.type.schema);
|
|
159
204
|
const structureMarks = [];
|
|
160
205
|
node.descendants((descendant, pos)=>{
|
|
206
|
+
// the assumption here is that a single node cannot have multiple structure marks with the same suggestion id
|
|
207
|
+
// check the invariant
|
|
208
|
+
const suggestionIds = new Set();
|
|
209
|
+
descendant.marks.forEach((mark)=>{
|
|
210
|
+
if (mark.type !== structure) return;
|
|
211
|
+
const markSuggestionId = mark.attrs["id"];
|
|
212
|
+
if (suggestionIds.has(markSuggestionId)) {
|
|
213
|
+
console.warn("node", node, "has multiple structure marks with the same suggestion id", markSuggestionId);
|
|
214
|
+
}
|
|
215
|
+
suggestionIds.add(markSuggestionId);
|
|
216
|
+
});
|
|
161
217
|
const mark = descendant.marks.find((mark)=>mark.type === structure && mark.attrs["id"] === suggestionId);
|
|
162
218
|
if (mark == null) return true;
|
|
163
219
|
structureMarks.push({
|
|
164
220
|
mark,
|
|
165
221
|
node: descendant,
|
|
166
|
-
|
|
222
|
+
pos
|
|
167
223
|
});
|
|
168
224
|
return true;
|
|
169
225
|
});
|
|
170
|
-
structureMarks.sort((a, b)=>b
|
|
171
|
-
|
|
172
|
-
console.log("findStructureMark", "found structure mark with id", suggestionId, {
|
|
173
|
-
structureMark: structureMarks[0],
|
|
174
|
-
suggestionId
|
|
175
|
-
});
|
|
176
|
-
} else {
|
|
177
|
-
console.log("findStructureMark", "no structure mark found with id", suggestionId);
|
|
178
|
-
}
|
|
179
|
-
return structureMarks[0];
|
|
226
|
+
structureMarks.sort((a, b)=>b.pos - a.pos);
|
|
227
|
+
return structureMarks[0] ?? null;
|
|
180
228
|
}
|
|
@@ -4,4 +4,7 @@ import { type SuggestionId } from "../../generateId.js";
|
|
|
4
4
|
import { Transform } from "prosemirror-transform";
|
|
5
5
|
export declare const structureChangesKey: PluginKey<any>;
|
|
6
6
|
export declare function structureChangesPlugin(generateId?: (schema: Schema, doc?: Node) => SuggestionId): Plugin<any>;
|
|
7
|
-
export declare function suggestStructureChanges(docBefore: Node, docAfter: Node, generateId?: (schema: Schema, doc?: Node) => SuggestionId):
|
|
7
|
+
export declare function suggestStructureChanges(docBefore: Node, docAfter: Node, generateId?: (schema: Schema, doc?: Node) => SuggestionId): {
|
|
8
|
+
handled: boolean;
|
|
9
|
+
transform: Transform;
|
|
10
|
+
};
|
|
@@ -2,6 +2,7 @@ import { Plugin, PluginKey } from "prosemirror-state";
|
|
|
2
2
|
import { getSuggestionMarks } from "../../utils.js";
|
|
3
3
|
import { generateNextNumberId } from "../../generateId.js";
|
|
4
4
|
import { getNodeId } from "./getNodeId.js";
|
|
5
|
+
import { guardStructureMarkAttrs } from "./types.js";
|
|
5
6
|
import { LIST_NODES, STRUCTURE_CHANGES_ADD_MARKS } from "./constants.js";
|
|
6
7
|
import { Transform } from "prosemirror-transform";
|
|
7
8
|
import { isSuggestChangesEnabled, suggestChangesKey } from "../../plugin.js";
|
|
@@ -91,7 +92,7 @@ export function structureChangesPlugin(generateId) {
|
|
|
91
92
|
trace("structureChangesPlugin", "appendTransaction", [
|
|
92
93
|
...transactions
|
|
93
94
|
]);
|
|
94
|
-
const transform = suggestStructureChanges(oldDoc, newDoc, generateId);
|
|
95
|
+
const { transform } = suggestStructureChanges(oldDoc, newDoc, generateId);
|
|
95
96
|
const tr = newState.tr;
|
|
96
97
|
transform.steps.forEach((step)=>{
|
|
97
98
|
tr.step(step);
|
|
@@ -121,7 +122,10 @@ export function suggestStructureChanges(docBefore, docAfter, generateId) {
|
|
|
121
122
|
});
|
|
122
123
|
const transform = new Transform(docAfter);
|
|
123
124
|
addMarks(ops, transform, suggestionId);
|
|
124
|
-
return
|
|
125
|
+
return {
|
|
126
|
+
handled: ops.size > 0,
|
|
127
|
+
transform
|
|
128
|
+
};
|
|
125
129
|
}
|
|
126
130
|
function addMarks(ops, tr, suggestionId) {
|
|
127
131
|
const perfAddMarks = performance.now();
|
|
@@ -132,6 +136,7 @@ function addMarks(ops, tr, suggestionId) {
|
|
|
132
136
|
if (nodeId == null) return true;
|
|
133
137
|
const op = ops.get(nodeId);
|
|
134
138
|
if (op == null) return true;
|
|
139
|
+
if (op.op === "move" && hasStructureAddMark(node)) return true;
|
|
135
140
|
tr.addNodeMark(pos, structure.create({
|
|
136
141
|
id: suggestionId,
|
|
137
142
|
data: {
|
|
@@ -142,6 +147,14 @@ function addMarks(ops, tr, suggestionId) {
|
|
|
142
147
|
});
|
|
143
148
|
trace("perf", "addMarks", "took", Number((performance.now() - perfAddMarks).toFixed(2)), "ms");
|
|
144
149
|
}
|
|
150
|
+
function hasStructureAddMark(node) {
|
|
151
|
+
const { structure } = getSuggestionMarks(node.type.schema);
|
|
152
|
+
return node.marks.some((mark)=>{
|
|
153
|
+
if (mark.type !== structure) return false;
|
|
154
|
+
if (!guardStructureMarkAttrs(mark.attrs)) return false;
|
|
155
|
+
return mark.attrs.data.op.op === "add";
|
|
156
|
+
});
|
|
157
|
+
}
|
|
145
158
|
function getOps(beforePaths, afterPaths) {
|
|
146
159
|
const ops = new Map();
|
|
147
160
|
// first take care of nodes that exist in both
|
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
import { type Attrs
|
|
2
|
-
|
|
3
|
-
node: Node;
|
|
4
|
-
pos: number;
|
|
5
|
-
children: Set<string>;
|
|
6
|
-
}
|
|
7
|
-
export interface DocWithChildren extends Omit<NonDocNodeWithChildren, "pos"> {
|
|
8
|
-
pos: null;
|
|
9
|
-
}
|
|
10
|
-
export type NodeWithChildren = NonDocNodeWithChildren | DocWithChildren;
|
|
1
|
+
import { type Attrs } from "prosemirror-model";
|
|
2
|
+
import { type SuggestionId } from "../../generateId.js";
|
|
11
3
|
interface NodeParent {
|
|
12
4
|
nodeId: string;
|
|
13
5
|
nodeType: string;
|
|
@@ -17,6 +9,7 @@ interface NodeParent {
|
|
|
17
9
|
childIndex: number;
|
|
18
10
|
}
|
|
19
11
|
export interface StructureMarkAttrs {
|
|
12
|
+
id: SuggestionId;
|
|
20
13
|
data: {
|
|
21
14
|
op: Op;
|
|
22
15
|
};
|
|
@@ -41,5 +34,4 @@ export type MaterializedPaths = Map<string, {
|
|
|
41
34
|
}>;
|
|
42
35
|
export declare function guardDocParent(parent: Parent | undefined): parent is DocParent;
|
|
43
36
|
export declare function guardStructureMarkAttrs(attrs: Attrs): attrs is StructureMarkAttrs;
|
|
44
|
-
export declare function guardDocWithChildren(nodeWithChildren: NodeWithChildren | undefined): nodeWithChildren is DocWithChildren;
|
|
45
37
|
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -5,3 +5,4 @@ export { withSuggestChanges, transformToSuggestionTransaction, } from "./withSug
|
|
|
5
5
|
export { ensureSelection as experimental_ensureSelection, ensureSelectionKey as experimental_ensureSelectionKey, isEnsureSelectionEnabled as experimental_isEnsureSelectionEnabled, } from "./ensureSelectionPlugin.js";
|
|
6
6
|
export { guardStructureMarkAttrs } from "./features/wrapUnwrap/types.js";
|
|
7
7
|
export type { Op as StructureOp, StructureMarkAttrs, } from "./features/wrapUnwrap/types.js";
|
|
8
|
+
export { wrappingInputRule as experimental_wrappingInputRule } from "./wrappingInputRule.js";
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { suggestChanges, suggestChangesKey, isSuggestChangesEnabled } from "./pl
|
|
|
4
4
|
export { withSuggestChanges, transformToSuggestionTransaction } from "./withSuggestChanges.js";
|
|
5
5
|
export { ensureSelection as experimental_ensureSelection, ensureSelectionKey as experimental_ensureSelectionKey, isEnsureSelectionEnabled as experimental_isEnsureSelectionEnabled } from "./ensureSelectionPlugin.js";
|
|
6
6
|
export { guardStructureMarkAttrs } from "./features/wrapUnwrap/types.js";
|
|
7
|
+
export { wrappingInputRule as experimental_wrappingInputRule } from "./wrappingInputRule.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { wrappingInputRule } from "./wrappingInputRule.js";
|
|
2
|
+
import { ZWSP } from "./constants.js";
|
|
3
|
+
export function listInputRules(bulletListNodeType, orderedListNodeType) {
|
|
4
|
+
const bulletListInputRule = wrappingInputRule(// ^ string start, [${ZWSP}\\s]* zero or more ZWSP or whitespace, ([-+*]) one of -+* , \\s one whitespace, $ end of string
|
|
5
|
+
// "u" flag treats \u as unicode code points instead of literal "u"
|
|
6
|
+
new RegExp(`^[${ZWSP}\\s]*([-+*])\\s$`, "u"), bulletListNodeType);
|
|
7
|
+
// ^ string start, [${ZWSP}\\s]* zero or more ZWSP or whitespace, ([0-9]+\\.) digit followed by dot, \\s one whitespace, $ end of string
|
|
8
|
+
// "u" flag treats \u as unicode code points instead of literal "u"
|
|
9
|
+
const orderedListInputRule = wrappingInputRule(new RegExp(`^[${ZWSP}\\s]*([0-9]+\\.)\\s$`, "u"), orderedListNodeType);
|
|
10
|
+
return [
|
|
11
|
+
bulletListInputRule,
|
|
12
|
+
orderedListInputRule
|
|
13
|
+
];
|
|
14
|
+
}
|
|
@@ -119,7 +119,7 @@ function getStepHandler(step) {
|
|
|
119
119
|
const isEnabled = isSuggestChangesEnabled(this.state) && !tr.getMeta("history$") && !tr.getMeta("collab$") && !ySyncMeta.isUndoRedoOperation && !ySyncMeta.isChangeOrigin && !("skip" in (tr.getMeta(suggestChangesKey) ?? {}));
|
|
120
120
|
let transaction = tr;
|
|
121
121
|
if (isEnabled) {
|
|
122
|
-
let
|
|
122
|
+
let structureChangesResult = null;
|
|
123
123
|
const docBefore = transaction.docs[0];
|
|
124
124
|
if (transaction.docChanged && docBefore && opts?.experimental_trackStructureChanges && typeof opts.experimental_ensureUniqueNodeIds === "function") {
|
|
125
125
|
trace("trying to track structure changes first...");
|
|
@@ -137,20 +137,20 @@ function getStepHandler(step) {
|
|
|
137
137
|
// if handled, then ignore the main plugin
|
|
138
138
|
// otherwise use the main plugin
|
|
139
139
|
const perfStructure = performance.now();
|
|
140
|
-
|
|
140
|
+
structureChangesResult = suggestStructureChanges(docBefore, docAfter, generateId);
|
|
141
141
|
trace("perf", "structure", "suggestStructureChanges took", Number((performance.now() - perfStructure).toFixed(2)), "ms");
|
|
142
|
-
trace("structure changes transform completed",
|
|
143
|
-
if (
|
|
142
|
+
trace("structure changes transform completed", structureChangesResult.transform);
|
|
143
|
+
if (structureChangesResult.handled) {
|
|
144
144
|
uniqueNodeIdsTransform.steps.forEach((step)=>{
|
|
145
145
|
transaction.step(step);
|
|
146
146
|
});
|
|
147
|
-
|
|
147
|
+
structureChangesResult.transform.steps.forEach((step)=>{
|
|
148
148
|
transaction.step(step);
|
|
149
149
|
});
|
|
150
150
|
trace("applied unique id transform and structure changes transform to the transaction", transaction);
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
-
if (transaction.docChanged &&
|
|
153
|
+
if (transaction.docChanged && structureChangesResult?.handled !== true) {
|
|
154
154
|
trace("running the main suggestions plugin...");
|
|
155
155
|
const perfSuggestions = performance.now();
|
|
156
156
|
transaction = transformToSuggestionTransaction(tr, this.state, generateId);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { InputRule } from "prosemirror-inputrules";
|
|
2
|
+
import { type Node } from "prosemirror-model";
|
|
3
|
+
import { type Attrs, type NodeType } from "prosemirror-model";
|
|
4
|
+
export declare function wrappingInputRule(regexp: RegExp, nodeType: NodeType, getAttrs?: Attrs | null | ((matches: RegExpMatchArray) => Attrs | null), joinPredicate?: (match: RegExpMatchArray, node: Node) => boolean): InputRule;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { InputRule } from "prosemirror-inputrules";
|
|
2
|
+
import { canJoin, findWrapping } from "prosemirror-transform";
|
|
3
|
+
import { getSuggestionMarks } from "./utils.js";
|
|
4
|
+
import { ZWSP } from "./constants.js";
|
|
5
|
+
/// return a boolean to indicate whether a join should happen.
|
|
6
|
+
export function wrappingInputRule(regexp, nodeType, getAttrs = null, joinPredicate) {
|
|
7
|
+
return new InputRule(regexp, (state, match, start, end)=>{
|
|
8
|
+
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
|
|
9
|
+
const tr = state.tr;
|
|
10
|
+
// check and try to preserve zwsp
|
|
11
|
+
const { insertion } = getSuggestionMarks(state.doc.type.schema);
|
|
12
|
+
let $start = tr.doc.resolve(start);
|
|
13
|
+
if (insertion.isInSet($start.nodeAfter?.marks ?? []) && match[0].startsWith(ZWSP)) {
|
|
14
|
+
// preserve a single ZWSP at the start
|
|
15
|
+
tr.delete(start + 1, end);
|
|
16
|
+
} else {
|
|
17
|
+
tr.delete(start, end);
|
|
18
|
+
}
|
|
19
|
+
// the rest of the rule unchanged
|
|
20
|
+
$start = tr.doc.resolve(start);
|
|
21
|
+
const range = $start.blockRange(), wrapping = range && findWrapping(range, nodeType, attrs);
|
|
22
|
+
if (!wrapping) return null;
|
|
23
|
+
tr.wrap(range, wrapping);
|
|
24
|
+
const before = tr.doc.resolve(start - 1).nodeBefore;
|
|
25
|
+
if (before && before.type == nodeType && canJoin(tr.doc, start - 1) && (!joinPredicate || joinPredicate(match, before))) tr.join(start - 1);
|
|
26
|
+
return tr;
|
|
27
|
+
});
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
# ProseMirror Structure Changes Tracking
|
|
2
|
-
|
|
3
|
-
Detect structural changes to list hierarchies and stores revertible operations
|
|
4
|
-
as node marks — enabling suggestion mode for structural edits like wrapping,
|
|
5
|
-
outdenting, and moving items between lists.
|
|
6
|
-
|
|
7
|
-
## Prerequisites
|
|
8
|
-
|
|
9
|
-
- All block nodes must have stable, unique string IDs via an `id` attribute.
|
|
10
|
-
- Lists must follow `ListNode → ListItemNode → ContentNode[]` structure.
|
|
11
|
-
- List item children must be block nodes, not text.
|
|
12
|
-
- Nested lists are supported.
|
|
13
|
-
|
|
14
|
-
## Core Idea
|
|
15
|
-
|
|
16
|
-
The plugin compares two documents and determines what happened to each content
|
|
17
|
-
node by comparing its **materialized path** — its full ancestor chain from
|
|
18
|
-
immediate parent up to the document root.
|
|
19
|
-
|
|
20
|
-
Marks are placed on **content nodes** (paragraphs, headings), not on lists or
|
|
21
|
-
list items. Content nodes are the invariant — they survive wrapping and
|
|
22
|
-
unwrapping. Lists and list items are created and destroyed around them, making
|
|
23
|
-
them unsuitable as mark targets.
|
|
24
|
-
|
|
25
|
-
Each `Parent` entry in the chain stores the parent's ID, type, attrs, marks,
|
|
26
|
-
sibling IDs, and child index — enough to recreate the parent node during
|
|
27
|
-
reversal and place the child at the correct position.
|
|
28
|
-
|
|
29
|
-
## Detection
|
|
30
|
-
|
|
31
|
-
The plugin builds materialized paths for the before-doc and after-doc, then
|
|
32
|
-
cross-references them:
|
|
33
|
-
|
|
34
|
-
- **Node in both, chain changed, list involved** → `move` op. Stores the
|
|
35
|
-
before-chain.
|
|
36
|
-
- **Node only in after-doc, list involved** → `add` op. No positional data.
|
|
37
|
-
- **Node only in before-doc** → deleted, no action.
|
|
38
|
-
|
|
39
|
-
Chain equality means same length and matching node IDs at every level. Sibling
|
|
40
|
-
or index changes alone don't count as a move — this avoids false positives from
|
|
41
|
-
index compaction when a neighbor is removed.
|
|
42
|
-
|
|
43
|
-
All ops from the same transaction share a suggestion ID for grouped revert.
|
|
44
|
-
|
|
45
|
-
## Reversal
|
|
46
|
-
|
|
47
|
-
### Reverting `move`
|
|
48
|
-
|
|
49
|
-
1. Walk the stored before-chain top-down, verifying each ancestor exists **in
|
|
50
|
-
the correct parent** (not just anywhere in the doc). Stop at the first
|
|
51
|
-
mismatch — that's the deepest surviving ancestor.
|
|
52
|
-
2. Recreate missing ancestors below the survivor using stored type/attrs/marks.
|
|
53
|
-
3. Insert the reconstructed subtree into the survivor, positioned using stored
|
|
54
|
-
sibling IDs (right sibling first, then left, then end of parent as fallback).
|
|
55
|
-
4. Delete the content node from its current location and prune empty ancestors
|
|
56
|
-
upward.
|
|
57
|
-
|
|
58
|
-
### Reverting `add`
|
|
59
|
-
|
|
60
|
-
Delete the node and prune empty ancestors.
|
|
61
|
-
|
|
62
|
-
### Empty ancestor pruning
|
|
63
|
-
|
|
64
|
-
After deletion, walk upward. At each level, if the parent has exactly one child
|
|
65
|
-
(the one being removed), expand the deletion to include that parent. This avoids
|
|
66
|
-
leaving behind empty list items or lists, and sidesteps ProseMirror's schema
|
|
67
|
-
constraint that list items must have content.
|
|
68
|
-
|
|
69
|
-
## Performance
|
|
70
|
-
|
|
71
|
-
### Mark application
|
|
72
|
-
|
|
73
|
-
Materialized path construction, operation derivation, and mark application are
|
|
74
|
-
each O(N) where N is the number of nodes in the document. Can be potentially
|
|
75
|
-
improved by not going into textblock nodes (to make sure text nodes are not
|
|
76
|
-
visited). Parent chain comparison at each node is O(D) for nesting depth D
|
|
77
|
-
(practically ≤ 10). **Effectively O(N) - linear.** Skipped entirely when
|
|
78
|
-
`docChanged` is false.
|
|
79
|
-
|
|
80
|
-
### Suggestion revert (explicit user action)
|
|
81
|
-
|
|
82
|
-
**O(M × N)** where M is marks in the group. The dominant cost is rebuilding the
|
|
83
|
-
node-to-children map and rescanning for the next mark after each individual
|
|
84
|
-
revert. Negligible for typical documents; cacheable if needed for large docs
|
|
85
|
-
with many grouped marks.
|
|
86
|
-
|
|
87
|
-
## Known Limitations
|
|
88
|
-
|
|
89
|
-
- **No reorder detection.** Same-list reordering is not tracked yet. The data is
|
|
90
|
-
stored (sibling IDs and indices), but detection requires comparing relative
|
|
91
|
-
order of surviving peers, not raw indices. Planned.
|
|
92
|
-
|
|
93
|
-
- **No topological sorting of reverts.** Marks are reverted in document order.
|
|
94
|
-
If mark A depends on mark B recreating a needed ancestor first, A's revert
|
|
95
|
-
fails gracefully. Future improvement: build a dependency graph and
|
|
96
|
-
topologically sort before reverting.
|
|
97
|
-
|
|
98
|
-
- **However**, a basic single-step dependency check is implemented. When the
|
|
99
|
-
plugin is asked to revert a suggestion X, it will verify that every structure
|
|
100
|
-
mark of this suggestion group has a destination parent chain that matches the
|
|
101
|
-
current parent chain of the node. If mismatch is found, it searches for a
|
|
102
|
-
different structure mark on the same node that has a matching parent chain. If
|
|
103
|
-
such mark is found, it's whole suggestion group Y will be reverted before
|
|
104
|
-
suggestion X. This one-level dependency check is limited (what if there are
|
|
105
|
-
multiple matching marks on the same node? What if the reversal destination is
|
|
106
|
-
unavailable?). A complete topological sort solution is required for that.
|
|
107
|
-
|
|
108
|
-
- **Schema violations fail silently.** If a stored node type no longer accepts
|
|
109
|
-
the child being inserted (e.g., a list changed type), the ProseMirror step
|
|
110
|
-
fails and no rollback is attempted.
|
|
111
|
-
|
|
112
|
-
## Design Decisions
|
|
113
|
-
|
|
114
|
-
**Materialized paths over step interpretation.** ProseMirror's `ReplaceStep` and
|
|
115
|
-
`ReplaceAroundStep` encode positional mutations, not semantic intent. A list
|
|
116
|
-
split is a single `ReplaceStep` that's hard to decompose into "item X was
|
|
117
|
-
outdented." Comparing ancestor chains gives direct semantic answers.
|
|
118
|
-
|
|
119
|
-
**Materialized paths over JSON tree diffing.** Early design explored diffing
|
|
120
|
-
list subtrees with jsondiffpatch. Abandoned because patch paths require
|
|
121
|
-
interpretation to determine affected content nodes, cross-list moves appear as
|
|
122
|
-
unrelated remove/add patches needing correlation, and map comparison answers
|
|
123
|
-
"where was node X" directly.
|
|
124
|
-
|
|
125
|
-
**Sibling IDs over indices for positioning.** Indices shift when neighbors
|
|
126
|
-
change. Sibling IDs are stable. Index is stored alongside for future reorder
|
|
127
|
-
detection but unused during revert.
|