@magic-marker/prosemirror-suggest-changes 0.3.3-wrap-unwrap.15 → 0.3.3-wrap-unwrap.16

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.
@@ -5,8 +5,9 @@ export declare class EditorPage {
5
5
  private readonly selectors;
6
6
  constructor(page: Page, deletionMarksVisibility?: "hidden" | "visible");
7
7
  get editor(): Locator;
8
- getParagraphText(index: number): Promise<string>;
8
+ getParagraphText(index: number, childIndexes?: number[]): Promise<string>;
9
9
  getParagraphCount(): Promise<number>;
10
+ getListItemCount(): Promise<number>;
10
11
  getProseMirrorMarkCount(name: string): Promise<number>;
11
12
  getProseMirrorMarksJSON(): Promise<unknown[]>;
12
13
  getProseMirrorSelection(): Promise<{
@@ -2,19 +2,18 @@ import { Mark, type Node, type Attrs, type MarkType, type ResolvedPos } from "pr
2
2
  import { Transform } from "prosemirror-transform";
3
3
  import { type Transaction } from "prosemirror-state";
4
4
  import { type SuggestionId } from "../../generateId.js";
5
+ interface SerializedJoinNode {
6
+ type: string;
7
+ attrs: object;
8
+ marks: object[];
9
+ }
5
10
  interface JoinMarkAttrs {
6
11
  type: "join";
7
12
  data: {
8
- leftNode: {
9
- type: string;
10
- attrs: object;
11
- marks: object[];
12
- };
13
- rightNode: {
14
- type: string;
15
- attrs: object;
16
- marks: object[];
17
- };
13
+ leftNode?: SerializedJoinNode;
14
+ rightNode?: SerializedJoinNode;
15
+ leftNodes?: SerializedJoinNode[];
16
+ rightNodes?: SerializedJoinNode[];
18
17
  };
19
18
  }
20
19
  export declare function isJoinMarkAttrs(attrs: Attrs): attrs is JoinMarkAttrs;
@@ -3,40 +3,93 @@ import { canJoin, Transform } from "prosemirror-transform";
3
3
  import { ZWSP } from "../../constants.js";
4
4
  import { getSuggestionMarks } from "../../utils.js";
5
5
  import { guardStructureMarkAttrs } from "../wrapUnwrap/types.js";
6
- export function isJoinMarkAttrs(attrs) {
6
+ // Block join suggestion metadata/revert currently supports the depth TipTap uses
7
+ // when Backspace joins both list-item paragraphs and their parent list items.
8
+ const MAX_BLOCK_JOIN_DEPTH = 2;
9
+ function isSerializedJoinNode(node) {
10
+ if (node === null || typeof node !== "object") return false;
11
+ const data = node;
12
+ return typeof data.type === "string" && typeof data.attrs === "object" && data.attrs !== null && Array.isArray(data.marks);
13
+ }
14
+ function normalizeJoinNodes(attrs) {
7
15
  if (attrs["type"] !== "join") return false;
8
16
  if (attrs["data"] == null) return false;
9
17
  const data = attrs["data"];
10
- if (data.leftNode == null || data.rightNode == null) return false;
11
- if (typeof data.leftNode.type !== "string" || typeof data.rightNode.type !== "string") return false;
12
- if (typeof data.leftNode.attrs !== "object" || typeof data.rightNode.attrs !== "object") return false;
13
- if (!Array.isArray(data.leftNode.marks) || !Array.isArray(data.rightNode.marks)) return false;
18
+ // Normalize legacy metadata so revert can use the same array path.
19
+ const leftNodes = data.leftNodes ?? (data.leftNode ? [
20
+ data.leftNode
21
+ ] : null);
22
+ const rightNodes = data.rightNodes ?? (data.rightNode ? [
23
+ data.rightNode
24
+ ] : null);
25
+ if (!Array.isArray(leftNodes) || !Array.isArray(rightNodes)) return false;
26
+ if (leftNodes.length === 0 || leftNodes.length !== rightNodes.length) return false;
27
+ // Reject unsupported depths instead of partially reverting unknown structure.
28
+ if (leftNodes.length > MAX_BLOCK_JOIN_DEPTH) return false;
29
+ if (!leftNodes.every(isSerializedJoinNode)) return false;
30
+ if (!rightNodes.every(isSerializedJoinNode)) return false;
31
+ return {
32
+ leftNodes,
33
+ rightNodes
34
+ };
35
+ }
36
+ export function isJoinMarkAttrs(attrs) {
37
+ return normalizeJoinNodes(attrs) !== false;
38
+ }
39
+ function serializeJoinNode(node) {
40
+ return {
41
+ type: node.type.name,
42
+ attrs: node.attrs,
43
+ marks: node.marks.map((mark)=>mark.toJSON())
44
+ };
45
+ }
46
+ function marksFromJSON(schema, markData) {
47
+ return markData.map((markData)=>Mark.fromJSON(schema, markData));
48
+ }
49
+ function restoreNodeMarkup(tr, pos, node) {
50
+ const nodeType = tr.doc.type.schema.nodes[node.type];
51
+ if (!nodeType) return false;
52
+ tr.setNodeMarkup(pos, nodeType, node.attrs, marksFromJSON(tr.doc.type.schema, node.marks));
14
53
  return true;
15
54
  }
16
55
  export function maybeRevertJoinMark(tr, from, to, node, markType) {
17
56
  const mark = node.marks.find((mark)=>mark.type === markType);
18
57
  if (!mark || mark.attrs["type"] !== "join" || node.text !== ZWSP) return false;
19
- // this is a mark of type join
20
- // split the current node at this mark position
21
- // delete this mark together with its zwsp content
22
- // assign left and right node (after the split) properties from the mark's data
58
+ const joinNodes = normalizeJoinNodes(mark.attrs);
59
+ if (!joinNodes) return false;
60
+ for (const node of [
61
+ ...joinNodes.leftNodes,
62
+ ...joinNodes.rightNodes
63
+ ]){
64
+ const nodeType = tr.doc.type.schema.nodes[node.type];
65
+ if (!nodeType) return false;
66
+ try {
67
+ marksFromJSON(tr.doc.type.schema, node.marks);
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+ // Reverting a join marker removes its ZWSP anchor, splits at that position,
73
+ // and restores markup because ProseMirror split creates nodes with defaults.
74
+ const joinDepth = joinNodes.leftNodes.length;
23
75
  tr.delete(from, to);
24
- tr.split(from);
25
- // insertionFrom is now at the end of the left node (but before the closing token)
26
- // after() will give us the position after the closing token (but before the next opening token) - exactly the split pos
27
- const $insertionFrom = tr.doc.resolve(from);
28
- const $splitPos = tr.doc.resolve($insertionFrom.after());
29
- const { attrs } = mark;
30
- if (!isJoinMarkAttrs(attrs) || !$splitPos.nodeBefore || !$splitPos.nodeAfter) return false;
31
- // restore left and right node type, attrs and marks, as they were before the join
32
- const { leftNode, rightNode } = attrs.data;
33
- const leftNodeType = tr.doc.type.schema.nodes[leftNode.type];
34
- const rightNodeType = tr.doc.type.schema.nodes[rightNode.type];
35
- const leftNodeMarks = leftNode.marks.map((markData)=>Mark.fromJSON(tr.doc.type.schema, markData));
36
- const rightNodeMarks = rightNode.marks.map((markData)=>Mark.fromJSON(tr.doc.type.schema, markData));
37
- if (!leftNodeType || !rightNodeType) return false;
38
- tr.setNodeMarkup($splitPos.pos - $splitPos.nodeBefore.nodeSize, leftNodeType, leftNode.attrs, leftNodeMarks);
39
- tr.setNodeMarkup($splitPos.pos, rightNodeType, rightNode.attrs, rightNodeMarks);
76
+ tr.split(from, joinDepth);
77
+ const $splitFrom = tr.doc.resolve(from);
78
+ const baseDepth = $splitFrom.depth;
79
+ let rightPos = $splitFrom.after(baseDepth - joinDepth + 1);
80
+ // Metadata is stored child-first, but markup must be restored outer-first so
81
+ // positions inside the newly split structure remain addressable.
82
+ for(let index = joinDepth - 1; index >= 0; index -= 1){
83
+ const leftNode = joinNodes.leftNodes[index];
84
+ const rightNode = joinNodes.rightNodes[index];
85
+ if (!leftNode || !rightNode) return false;
86
+ const leftPos = $splitFrom.before(baseDepth - index);
87
+ if (!restoreNodeMarkup(tr, leftPos, leftNode) || !restoreNodeMarkup(tr, rightPos, rightNode)) {
88
+ return false;
89
+ }
90
+ // Each deeper right node starts one position inside the right node restored before it.
91
+ rightPos += 1;
92
+ }
40
93
  return true;
41
94
  }
42
95
  /**
@@ -73,47 +126,87 @@ export function maybeRevertJoinMark(tr, from, to, node, markType) {
73
126
  doc.nodesBetween(blockRange.start, blockRange.end, (node, pos)=>{
74
127
  if (node.isInline) return false;
75
128
  const endOfNode = pos + node.nodeSize;
76
- // make sure the node ends within the range
77
129
  if (endOfNode >= blockRange.$to.pos) return true;
78
- const $endOfNode = doc.resolve(endOfNode);
79
- // make sure we are between two nodes
80
- if (!$endOfNode.nodeBefore || !$endOfNode.nodeAfter) return false;
81
- // we cannot insert zwsp text nodes into non-textblock nodes
82
- if (!$endOfNode.nodeBefore.isTextblock || !$endOfNode.nodeAfter.isTextblock) return true;
83
- const mappedEndOfNode = transform.mapping.map(endOfNode);
84
- const $mappedEndOfNode = transform.doc.resolve(mappedEndOfNode);
85
- if (!canJoin(transform.doc, mappedEndOfNode) || !$mappedEndOfNode.nodeBefore || !$mappedEndOfNode.nodeAfter) {
130
+ // List-item joins can start between non-textblock nodes; expand inward to
131
+ // capture the visible textblock pair and its structural parent pair.
132
+ const joinCandidate = getJoinCandidateAtBoundary(doc, endOfNode, from, to, MAX_BLOCK_JOIN_DEPTH);
133
+ if (!joinCandidate) return true;
134
+ const mappedJoinPos = transform.mapping.map(joinCandidate.joinPos);
135
+ if (!canApplyJoinCandidate(transform.doc, mappedJoinPos, joinCandidate.leftNodes.length)) {
86
136
  return true;
87
137
  }
88
- const leftNode = {
89
- type: $endOfNode.nodeBefore.type.name,
90
- attrs: $endOfNode.nodeBefore.attrs,
91
- marks: $endOfNode.nodeBefore.marks.map((mark)=>mark.toJSON())
92
- };
93
- const rightNode = {
94
- type: $endOfNode.nodeAfter.type.name,
95
- attrs: $endOfNode.nodeAfter.attrs,
96
- marks: $endOfNode.nodeAfter.marks.map((mark)=>mark.toJSON())
97
- };
98
- const shouldSuppressJoinMark = hasStructureAddMark($endOfNode.nodeBefore) || hasStructureAddMark($endOfNode.nodeAfter);
99
- transform.join(mappedEndOfNode);
138
+ const shouldSuppressJoinMark = [
139
+ ...joinCandidate.leftNodes,
140
+ ...joinCandidate.rightNodes
141
+ ].some((node)=>hasStructureAddMark(node));
142
+ transform.join(mappedJoinPos, joinCandidate.leftNodes.length);
143
+ // Joining provisional structure cancels its pending add instead of creating
144
+ // a second review artifact for the same user action.
100
145
  if (shouldSuppressJoinMark) return false;
101
146
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
102
147
  const joinStep = transform.steps[transform.steps.length - 1];
103
- const joinPos = joinStep.getMap().map(mappedEndOfNode);
148
+ const joinPos = joinStep.getMap().map(mappedJoinPos);
104
149
  transform.insert(joinPos, transform.doc.type.schema.text(ZWSP));
105
150
  transform.addMark(joinPos, joinPos + 1, deletion.create({
106
151
  id: markId,
107
152
  type: "join",
108
153
  data: {
109
- leftNode,
110
- rightNode
154
+ leftNodes: joinCandidate.leftNodes.map(serializeJoinNode),
155
+ rightNodes: joinCandidate.rightNodes.map(serializeJoinNode)
111
156
  }
112
157
  }));
113
158
  return false;
114
159
  });
115
160
  return transform;
116
161
  }
162
+ function getJoinCandidateAtBoundary(doc, boundaryPos, from, to, maxJoinDepth) {
163
+ const $boundary = doc.resolve(boundaryPos);
164
+ const leftNode = $boundary.nodeBefore;
165
+ const rightNode = $boundary.nodeAfter;
166
+ // Multi-depth list joins can begin between structural nodes, not just textblocks.
167
+ if (!leftNode || !rightNode) return null;
168
+ if (leftNode.isInline || rightNode.isInline) return null;
169
+ if (!canJoin(doc, boundaryPos)) return null;
170
+ const pairs = [
171
+ {
172
+ leftNode,
173
+ rightNode
174
+ }
175
+ ];
176
+ for(let expandBy = 1; pairs.length < maxJoinDepth; expandBy += 1){
177
+ const left = boundaryPos - expandBy;
178
+ const right = boundaryPos + expandBy;
179
+ if (left < from || right > to) break;
180
+ const $left = doc.resolve(left);
181
+ const $right = doc.resolve(right);
182
+ // Once text is adjacent, there is no deeper block pair to capture.
183
+ if ($left.nodeBefore?.isText || $right.nodeAfter?.isText) break;
184
+ if ($left.nodeAfter === null && $left.nodeBefore && !$left.nodeBefore.isInline && $right.nodeBefore === null && $right.nodeAfter && !$right.nodeAfter.isInline) {
185
+ pairs.push({
186
+ leftNode: $left.nodeBefore,
187
+ rightNode: $right.nodeAfter
188
+ });
189
+ continue;
190
+ }
191
+ break;
192
+ }
193
+ // canJoin only checks the boundary; the temporary transform verifies depth.
194
+ if (!canApplyJoinCandidate(doc, boundaryPos, pairs.length)) return null;
195
+ return {
196
+ joinPos: boundaryPos,
197
+ leftNodes: pairs.map((pair)=>pair.leftNode).reverse(),
198
+ rightNodes: pairs.map((pair)=>pair.rightNode).reverse()
199
+ };
200
+ }
201
+ function canApplyJoinCandidate(doc, joinPos, depth) {
202
+ if (!canJoin(doc, joinPos)) return false;
203
+ try {
204
+ new Transform(doc).join(joinPos, depth);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
117
210
  function hasStructureAddMark(node) {
118
211
  const { structure } = getSuggestionMarks(node.type.schema);
119
212
  return node.marks.some((mark)=>{
@@ -1,2 +1,2 @@
1
1
  import { type NodeSpec } from "prosemirror-model";
2
- export declare const addIdAttr: (nodeSpec: NodeSpec, key: string) => NodeSpec;
2
+ export declare const addIdAttr: (nodeSpec: NodeSpec) => NodeSpec;
@@ -1,7 +1,6 @@
1
- export const addIdAttr = (nodeSpec, key)=>{
1
+ export const addIdAttr = (nodeSpec)=>{
2
2
  const { toDOM, parseDOM } = nodeSpec;
3
3
  if (!toDOM || !parseDOM) {
4
- console.warn("addIdAttr", "ignored node", key, "with nodeSpec", nodeSpec);
5
4
  return nodeSpec;
6
5
  }
7
6
  const newNodeSpec = {
@@ -57,6 +56,5 @@ export const addIdAttr = (nodeSpec, key)=>{
57
56
  }))
58
57
  ]
59
58
  };
60
- console.info("addIdAttr", "adding id to node", key, "with nodeSpec", nodeSpec, "new nodeSpec", newNodeSpec);
61
59
  return newNodeSpec;
62
60
  };
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.15",
3
+ "version": "0.3.3-wrap-unwrap.16",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "dist/index.js",
@@ -1,8 +0,0 @@
1
- When nodes are joined using backspace or delete, let the join happen normally,
2
- but mark former node boundaries with deletion mark of type="join".
3
-
4
- Save information about what nodes were at each side of the boundary before the
5
- join.
6
-
7
- Then on restore, use marks at join points to split the node and restore the node
8
- markup at each side of the split.