@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.
@@ -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
- console.log("deleteNodeUpwards", "considering", $nextMappedPos.nodeAfter?.toString(), "for deletion", "childCount is", $nextMappedPos.nodeAfter?.childCount);
14
- if ($nextMappedPos.nodeAfter?.childCount !== 1) break;
15
- console.log("deleteNodeUpwards", "expanding to deleting node", $nextMappedPos.nodeAfter.toString());
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
- console.log("deleteNodeUpwards", "final delete range covers node", $mappedPos.nodeAfter?.toString(), {
26
- deleteFrom,
27
- deleteTo,
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(node: Node, from?: number, to?: number): Transform;
5
- export declare function revertStructureSuggestion(node: Node, suggestionId: SuggestionId): Transform;
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 { revertStructureSuggestionsInNode } from "./revertStructureSuggestions.js";
3
- export function revertAllStructureSuggestions(node, from, to) {
4
- const tr = new Transform(node);
5
- revertStructureSuggestionsInNode({
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(node, suggestionId) {
14
- const tr = new Transform(node);
15
- revertStructureSuggestionsInNode({
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(op: AddOp, tr: Transform, node: Node, pos: number): void;
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(op, tr, node, pos) {
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 NodeWithChildren, type Parent } from "../types.js";
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
- console.log("revertMoveOp", "wrapped node in parent chain is", child.toString(), {
15
- child
16
- });
17
- const insertionPos = findInsertionPos(parent.node, parent.pos, parent.parent);
18
- console.log("revertMoveOp", "insertion pos", {
19
- insertionPos
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 currentNodes = getNodesWithChildren(doc);
66
- let currentNode = currentNodes.get("__doc__"); // get doc node with children from current doc
67
- if (!guardDocWithChildren(currentNode)) {
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 parent = chain.shift(); // get doc node descriptor from parent chain
71
- if (!guardDocParent(parent)) {
72
- throw new Error("doc parent not found in op chain");
73
- }
74
- let remainingChain = [];
75
- for (const [index, nextParent] of chain.entries()){
76
- const nextNodeWithChildren = currentNodes.get(nextParent.nodeId);
77
- // nextParent does not exist in the current document at all
78
- if (nextNodeWithChildren == null) {
79
- remainingChain = chain.slice(index);
80
- break;
81
- }
82
- // nextParent exists in the document, but in a different parent chain
83
- if (!currentNode.children.has(nextParent.nodeId)) {
84
- remainingChain = chain.slice(index);
85
- break;
86
- }
87
- currentNode = nextNodeWithChildren;
88
- parent = nextParent;
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: currentNode.node,
93
- pos: currentNode.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
- child = nodeType.create(parent.nodeAttrs, child, marks);
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 revertStructureSuggestionsInNode({ tr, node, suggestionId, from, to, }: {
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
- export function revertStructureSuggestionsInNode({ tr, node, suggestionId, from, to }) {
10
- const { structure } = getSuggestionMarks(node.type.schema);
11
- // collect all structure mark ids
12
- const suggestionIds = new Set();
13
- node.descendants((node, pos)=>{
14
- if (from !== undefined && pos < from) {
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
- return true;
29
- });
30
- for (const suggestionId of suggestionIds){
31
- revertStructureSuggestionWithPrerequisites(tr, suggestionId);
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
- console.log("revertStructureMarkGroupInOrder", "suggestion groups to revert", suggestionIds);
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
- console.group("revertStructureSuggestion", "reverting structure suggestion", suggestionId);
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 != null){
47
- console.groupCollapsed("revertStructureSuggestion", "reverting structure mark", structureMark.mark.attrs["id"], "at pos", structureMark.$pos.pos, "at node", structureMark.node.toString(), {
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
- console.groupEnd();
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
- $pos: node.resolve(pos)
222
+ pos
167
223
  });
168
224
  return true;
169
225
  });
170
- structureMarks.sort((a, b)=>b.$pos.depth - a.$pos.depth);
171
- if (structureMarks[0]) {
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): Transform;
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 transform;
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, type Node } from "prosemirror-model";
2
- interface NonDocNodeWithChildren {
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 {};
@@ -8,6 +8,3 @@ export function guardStructureMarkAttrs(attrs) {
8
8
  if (!("op" in data)) return false;
9
9
  return true;
10
10
  }
11
- export function guardDocWithChildren(nodeWithChildren) {
12
- return nodeWithChildren != null && nodeWithChildren.pos === null;
13
- }
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,2 @@
1
+ import { type NodeType } from "prosemirror-model";
2
+ export declare function listInputRules(bulletListNodeType: NodeType, orderedListNodeType: NodeType): import("prosemirror-inputrules").InputRule[];
@@ -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 structureChangesTransform = null;
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
- structureChangesTransform = suggestStructureChanges(docBefore, docAfter, generateId);
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", structureChangesTransform);
143
- if (structureChangesTransform.steps.length > 0) {
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
- structureChangesTransform.steps.forEach((step)=>{
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 && (structureChangesTransform == null || structureChangesTransform.steps.length === 0)) {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@magic-marker/prosemirror-suggest-changes",
3
- "version": "0.3.3-wrap-unwrap.8",
3
+ "version": "0.3.3-wrap-unwrap.11",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "dist/index.js",
@@ -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.