@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.
- package/dist/__tests__/playwrightPage.d.ts +2 -1
- package/dist/features/joinOnDelete/index.d.ts +9 -10
- package/dist/features/joinOnDelete/index.js +142 -49
- package/dist/features/wrapUnwrap/addIdAttr.d.ts +1 -1
- package/dist/features/wrapUnwrap/addIdAttr.js +1 -3
- package/package.json +1 -1
- package/src/features/joinOnDelete/README.md +0 -8
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
2
|
+
export declare const addIdAttr: (nodeSpec: NodeSpec) => NodeSpec;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
export const addIdAttr = (nodeSpec
|
|
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,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.
|