@magic-marker/prosemirror-suggest-changes 0.3.3-wrap-unwrap.10 → 0.3.3-wrap-unwrap.12
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.js +0 -7
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.d.ts +1 -3
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +96 -40
- package/dist/features/wrapUnwrap/structureChangesPlugin.d.ts +4 -1
- package/dist/features/wrapUnwrap/structureChangesPlugin.js +32 -2
- package/dist/features/wrapUnwrap/types.d.ts +2 -0
- package/dist/withSuggestChanges.js +6 -6
- 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,14 +1,7 @@
|
|
|
1
1
|
import { deleteNodeUpwards } from "./deleteNodeUpwards.js";
|
|
2
2
|
import { getNodeId } from "../getNodeId.js";
|
|
3
3
|
export function revertMoveOp(op, tr, node, pos) {
|
|
4
|
-
console.log("revertMoveOp", "node", node.toString(), "was moved from", op.from, {
|
|
5
|
-
op,
|
|
6
|
-
node
|
|
7
|
-
});
|
|
8
4
|
const parent = getDeepestSurvivingParent(op.from, tr.doc);
|
|
9
|
-
console.log("revertMoveOp", "deepest surviving parent is", parent.node.toString(), {
|
|
10
|
-
parent
|
|
11
|
-
});
|
|
12
5
|
const child = wrapNodeInParentChain(parent.remainingChain, node);
|
|
13
6
|
const insertTo = findInsertionPos(parent.node, parent.pos, parent.parent, child);
|
|
14
7
|
if (typeof insertTo === "number") {
|
|
@@ -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
75
|
while(structureMark !== null){
|
|
47
|
-
console.groupCollapsed("revertStructureSuggestion", "reverting structure mark", structureMark.mark.attrs["id"], "at pos", structureMark.pos, "at node", structureMark.node.toString(), {
|
|
48
|
-
structureMark
|
|
49
|
-
});
|
|
50
76
|
revertStructureMark(tr, structureMark.mark, structureMark.pos);
|
|
51
|
-
console.groupEnd();
|
|
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,25 +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
203
|
const { structure } = getSuggestionMarks(node.type.schema);
|
|
158
204
|
const structureMarks = [];
|
|
159
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
|
+
});
|
|
160
217
|
const mark = descendant.marks.find((mark)=>mark.type === structure && mark.attrs["id"] === suggestionId);
|
|
161
218
|
if (mark == null) return true;
|
|
162
219
|
structureMarks.push({
|
|
163
220
|
mark,
|
|
164
221
|
node: descendant,
|
|
165
|
-
pos
|
|
166
|
-
$pos: node.resolve(pos)
|
|
222
|
+
pos
|
|
167
223
|
});
|
|
168
224
|
return true;
|
|
169
225
|
});
|
|
170
|
-
structureMarks.sort((a, b)=>b
|
|
226
|
+
structureMarks.sort((a, b)=>b.pos - a.pos);
|
|
171
227
|
return structureMarks[0] ?? null;
|
|
172
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,14 @@ 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") {
|
|
140
|
+
if (hasStructureAddMark(node)) return true;
|
|
141
|
+
const inverseMoveMark = findInverseStructureMoveMark(node, op);
|
|
142
|
+
if (inverseMoveMark) {
|
|
143
|
+
tr.removeNodeMark(pos, inverseMoveMark);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
135
147
|
tr.addNodeMark(pos, structure.create({
|
|
136
148
|
id: suggestionId,
|
|
137
149
|
data: {
|
|
@@ -142,6 +154,24 @@ function addMarks(ops, tr, suggestionId) {
|
|
|
142
154
|
});
|
|
143
155
|
trace("perf", "addMarks", "took", Number((performance.now() - perfAddMarks).toFixed(2)), "ms");
|
|
144
156
|
}
|
|
157
|
+
function findInverseStructureMoveMark(node, op) {
|
|
158
|
+
const { structure } = getSuggestionMarks(node.type.schema);
|
|
159
|
+
return node.marks.find((mark)=>{
|
|
160
|
+
if (mark.type !== structure) return false;
|
|
161
|
+
if (!guardStructureMarkAttrs(mark.attrs)) return false;
|
|
162
|
+
const existingOp = mark.attrs.data.op;
|
|
163
|
+
if (existingOp.op !== "move") return false;
|
|
164
|
+
return sameParentChain(existingOp.from, op.to) && sameParentChain(existingOp.to, op.from);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function hasStructureAddMark(node) {
|
|
168
|
+
const { structure } = getSuggestionMarks(node.type.schema);
|
|
169
|
+
return node.marks.some((mark)=>{
|
|
170
|
+
if (mark.type !== structure) return false;
|
|
171
|
+
if (!guardStructureMarkAttrs(mark.attrs)) return false;
|
|
172
|
+
return mark.attrs.data.op.op === "add";
|
|
173
|
+
});
|
|
174
|
+
}
|
|
145
175
|
function getOps(beforePaths, afterPaths) {
|
|
146
176
|
const ops = new Map();
|
|
147
177
|
// first take care of nodes that exist in both
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Attrs } from "prosemirror-model";
|
|
2
|
+
import { type SuggestionId } from "../../generateId.js";
|
|
2
3
|
interface NodeParent {
|
|
3
4
|
nodeId: string;
|
|
4
5
|
nodeType: string;
|
|
@@ -8,6 +9,7 @@ interface NodeParent {
|
|
|
8
9
|
childIndex: number;
|
|
9
10
|
}
|
|
10
11
|
export interface StructureMarkAttrs {
|
|
12
|
+
id: SuggestionId;
|
|
11
13
|
data: {
|
|
12
14
|
op: Op;
|
|
13
15
|
};
|
|
@@ -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);
|
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.
|