@magic-marker/prosemirror-suggest-changes 0.4.0 → 0.4.1-wrap-unwrap.1
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__/playwrightHelpers.d.ts +2 -2
- package/dist/__tests__/playwrightPage.d.ts +50 -2
- package/dist/commands.js +222 -43
- package/dist/ensureSelectionPlugin.js +3 -3
- package/dist/features/joinOnDelete/__tests__/joinOnDeleteInLists.playwright.test.d.ts +1 -0
- package/dist/features/joinOnDelete/__tests__/joinOnDeleteInListsTipTapStyle.playwright.test.d.ts +1 -0
- package/dist/features/joinOnDelete/__tests__/listWithJoinsAndStructureMarks.playwright.test.d.ts +1 -0
- package/dist/features/joinOnDelete/index.d.ts +4 -19
- package/dist/features/joinOnDelete/index.js +166 -53
- package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.d.ts +6 -0
- package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.js +24 -0
- package/dist/features/joinOnDelete/types.d.ts +36 -0
- package/dist/features/joinOnDelete/types.js +23 -0
- package/dist/features/transactionShaping/detectSpecialTransactionShape.d.ts +3 -0
- package/dist/features/transactionShaping/detectSpecialTransactionShape.js +4 -0
- package/dist/features/transactionShaping/index.d.ts +3 -0
- package/dist/features/transactionShaping/index.js +11 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.d.ts +3 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.js +48 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.d.ts +1 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.js +188 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.d.ts +3 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.js +69 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.d.ts +2 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.js +2 -0
- package/dist/features/transactionShaping/types.d.ts +20 -0
- package/dist/features/transactionShaping/types.js +1 -0
- package/dist/features/wrapUnwrap/__tests__/blockquoteStructure.playwright.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/buildMaterializedPaths.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/listStructure.playwright.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/listStructureTextEdits.playwright.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/splitDetection.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/addIdAttr.d.ts +2 -0
- package/dist/features/wrapUnwrap/addIdAttr.js +60 -0
- package/dist/features/wrapUnwrap/apply/applyStructureSuggestions.d.ts +10 -0
- package/dist/features/wrapUnwrap/apply/applyStructureSuggestions.js +41 -0
- package/dist/features/wrapUnwrap/apply/index.d.ts +5 -0
- package/dist/features/wrapUnwrap/apply/index.js +21 -0
- package/dist/features/wrapUnwrap/areEquivalentStructureMarks.d.ts +2 -0
- package/dist/features/wrapUnwrap/areEquivalentStructureMarks.js +17 -0
- package/dist/features/wrapUnwrap/buildMaterializedPaths.d.ts +3 -0
- package/dist/features/wrapUnwrap/buildMaterializedPaths.js +82 -0
- package/dist/features/wrapUnwrap/constants.d.ts +3 -0
- package/dist/features/wrapUnwrap/constants.js +3 -0
- package/dist/features/wrapUnwrap/generateUniqueNodeId.d.ts +1 -0
- package/dist/features/wrapUnwrap/generateUniqueNodeId.js +6 -0
- package/dist/features/wrapUnwrap/getNodeId.d.ts +2 -0
- package/dist/features/wrapUnwrap/getNodeId.js +5 -0
- package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.d.ts +3 -0
- package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.js +37 -0
- package/dist/features/wrapUnwrap/revert/index.d.ts +5 -0
- package/dist/features/wrapUnwrap/revert/index.js +19 -0
- package/dist/features/wrapUnwrap/revert/revertAddOp.d.ts +4 -0
- package/dist/features/wrapUnwrap/revert/revertAddOp.js +4 -0
- package/dist/features/wrapUnwrap/revert/revertMoveOp.d.ts +16 -0
- package/dist/features/wrapUnwrap/revert/revertMoveOp.js +122 -0
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.d.ts +10 -0
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +236 -0
- package/dist/features/wrapUnwrap/sameParentChain.d.ts +2 -0
- package/dist/features/wrapUnwrap/sameParentChain.js +4 -0
- package/dist/features/wrapUnwrap/structureChangesPlugin.d.ts +17 -0
- package/dist/features/wrapUnwrap/structureChangesPlugin.js +299 -0
- package/dist/features/wrapUnwrap/types.d.ts +54 -0
- package/dist/features/wrapUnwrap/types.js +23 -0
- package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.d.ts +17 -0
- package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.js +106 -0
- package/dist/generateId.js +31 -4
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/listInputRules.d.ts +2 -0
- package/dist/listInputRules.js +14 -0
- package/dist/rebaseStep.d.ts +9 -0
- package/dist/rebaseStep.js +11 -0
- package/dist/replaceStep.d.ts +1 -1
- package/dist/replaceStep.js +1 -0
- package/dist/schema.d.ts +2 -1
- package/dist/schema.js +37 -1
- package/dist/testing/e2eTestSchema.d.ts +2 -0
- package/dist/testing/testBuilders.d.ts +1 -2
- package/dist/transformToSuggestionTransaction.d.ts +22 -0
- package/dist/transformToSuggestionTransaction.js +101 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +6 -2
- package/dist/withSuggestChanges.d.ts +11 -14
- package/dist/withSuggestChanges.js +64 -94
- package/dist/wrappingInputRule.d.ts +4 -0
- package/dist/wrappingInputRule.js +28 -0
- package/package.json +1 -1
- package/src/features/joinOnDelete/README.md +0 -8
|
@@ -2,41 +2,104 @@ import { Mark } from "prosemirror-model";
|
|
|
2
2
|
import { canJoin, Transform } from "prosemirror-transform";
|
|
3
3
|
import { ZWSP } from "../../constants.js";
|
|
4
4
|
import { getSuggestionMarks } from "../../utils.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
import { areEquivalentStructureMarks } from "../wrapUnwrap/areEquivalentStructureMarks.js";
|
|
6
|
+
import { guardStructureMarkAttrs } from "../wrapUnwrap/types.js";
|
|
7
|
+
import { MAX_BLOCK_JOIN_DEPTH, normalizeJoinNodesMetadata } from "./normalizeJoinNodesMetadata.js";
|
|
8
|
+
import { isJoinMark } from "./types.js";
|
|
9
|
+
function serializeJoinNode(node) {
|
|
10
|
+
return {
|
|
11
|
+
type: node.type.name,
|
|
12
|
+
attrs: node.attrs,
|
|
13
|
+
marks: node.marks.map((mark)=>mark.toJSON())
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function marksFromJSON(schema, markData) {
|
|
17
|
+
return markData.map((markData)=>Mark.fromJSON(schema, markData));
|
|
18
|
+
}
|
|
19
|
+
// when we revert a block join deletion mark, we restore nodes on both sides of the mark using leftNodes rightNodes metadata
|
|
20
|
+
// but existing left and right nodes may have structure marks that we need to preserve
|
|
21
|
+
function mergeSerializedMarksWithCurrentStructureMarks(tr, pos, serializedMarks) {
|
|
22
|
+
const currentNode = tr.doc.nodeAt(pos);
|
|
23
|
+
if (!currentNode) return serializedMarks;
|
|
24
|
+
const { structure } = getSuggestionMarks(tr.doc.type.schema);
|
|
25
|
+
const mergedMarks = [
|
|
26
|
+
...serializedMarks
|
|
27
|
+
];
|
|
28
|
+
for (const currentMark of currentNode.marks){
|
|
29
|
+
if (currentMark.type !== structure) continue;
|
|
30
|
+
const alreadyIncluded = mergedMarks.some((mark)=>mark.type === structure && areEquivalentStructureMarks(mark, currentMark));
|
|
31
|
+
if (!alreadyIncluded) {
|
|
32
|
+
mergedMarks.push(currentMark);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return mergedMarks;
|
|
36
|
+
}
|
|
37
|
+
function restoreNodeMarkup(tr, pos, node) {
|
|
38
|
+
const nodeType = tr.doc.type.schema.nodes[node.type];
|
|
39
|
+
if (!nodeType) return false;
|
|
40
|
+
const marks = mergeSerializedMarksWithCurrentStructureMarks(tr, pos, marksFromJSON(tr.doc.type.schema, node.marks));
|
|
41
|
+
tr.setNodeMarkup(pos, nodeType, node.attrs, marks);
|
|
13
42
|
return true;
|
|
14
43
|
}
|
|
44
|
+
// collect structure suggestion IDs that will be re-introduced into the document after the join is reverted
|
|
45
|
+
function getRestoredStructureSuggestionIds(joinNodes, schema) {
|
|
46
|
+
const { structure } = getSuggestionMarks(schema);
|
|
47
|
+
const restoredStructureSuggestionIds = new Set();
|
|
48
|
+
for (const node of [
|
|
49
|
+
...joinNodes.leftNodes,
|
|
50
|
+
...joinNodes.rightNodes
|
|
51
|
+
]){
|
|
52
|
+
const marks = marksFromJSON(schema, node.marks);
|
|
53
|
+
for (const mark of marks){
|
|
54
|
+
if (mark.type !== structure) continue;
|
|
55
|
+
if (!guardStructureMarkAttrs(mark.attrs)) continue;
|
|
56
|
+
restoredStructureSuggestionIds.add(mark.attrs.id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return restoredStructureSuggestionIds;
|
|
60
|
+
}
|
|
15
61
|
export function maybeRevertJoinMark(tr, from, to, node, markType) {
|
|
16
62
|
const mark = node.marks.find((mark)=>mark.type === markType);
|
|
17
|
-
if (!mark || mark
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
63
|
+
if (!mark || !isJoinMark(mark) || node.text !== ZWSP) return false;
|
|
64
|
+
const joinNodes = normalizeJoinNodesMetadata(mark.attrs);
|
|
65
|
+
if (!joinNodes) return false;
|
|
66
|
+
for (const node of [
|
|
67
|
+
...joinNodes.leftNodes,
|
|
68
|
+
...joinNodes.rightNodes
|
|
69
|
+
]){
|
|
70
|
+
const nodeType = tr.doc.type.schema.nodes[node.type];
|
|
71
|
+
if (!nodeType) return false;
|
|
72
|
+
try {
|
|
73
|
+
marksFromJSON(tr.doc.type.schema, node.marks);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const restoredStructureSuggestionIds = getRestoredStructureSuggestionIds(joinNodes, tr.doc.type.schema);
|
|
79
|
+
// Reverting a join marker removes its ZWSP anchor, splits at that position,
|
|
80
|
+
// and restores markup because ProseMirror split creates nodes with defaults.
|
|
81
|
+
const joinDepth = joinNodes.leftNodes.length;
|
|
22
82
|
tr.delete(from, to);
|
|
23
|
-
tr.split(from);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
83
|
+
tr.split(from, joinDepth);
|
|
84
|
+
const $splitFrom = tr.doc.resolve(from);
|
|
85
|
+
const baseDepth = $splitFrom.depth;
|
|
86
|
+
let rightPos = $splitFrom.after(baseDepth - joinDepth + 1);
|
|
87
|
+
// Metadata is stored child-first, but markup must be restored outer-first so
|
|
88
|
+
// positions inside the newly split structure remain addressable.
|
|
89
|
+
for(let index = joinDepth - 1; index >= 0; index -= 1){
|
|
90
|
+
const leftNode = joinNodes.leftNodes[index];
|
|
91
|
+
const rightNode = joinNodes.rightNodes[index];
|
|
92
|
+
if (!leftNode || !rightNode) return false;
|
|
93
|
+
const leftPos = $splitFrom.before(baseDepth - index);
|
|
94
|
+
if (!restoreNodeMarkup(tr, leftPos, leftNode) || !restoreNodeMarkup(tr, rightPos, rightNode)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
// Each deeper right node starts one position inside the right node restored before it.
|
|
98
|
+
rightPos += 1;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
restoredStructureSuggestionIds
|
|
102
|
+
};
|
|
40
103
|
}
|
|
41
104
|
/**
|
|
42
105
|
* Remove ZWSP text nodes marked as deletions (except for type=join) from the given range
|
|
@@ -72,45 +135,95 @@ export function maybeRevertJoinMark(tr, from, to, node, markType) {
|
|
|
72
135
|
doc.nodesBetween(blockRange.start, blockRange.end, (node, pos)=>{
|
|
73
136
|
if (node.isInline) return false;
|
|
74
137
|
const endOfNode = pos + node.nodeSize;
|
|
75
|
-
// make sure the node ends within the range
|
|
76
138
|
if (endOfNode >= blockRange.$to.pos) return true;
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const $mappedEndOfNode = transform.doc.resolve(mappedEndOfNode);
|
|
84
|
-
if (!canJoin(transform.doc, mappedEndOfNode) || !$mappedEndOfNode.nodeBefore || !$mappedEndOfNode.nodeAfter) {
|
|
139
|
+
// List-item joins can start between non-textblock nodes; expand inward to
|
|
140
|
+
// capture the visible textblock pair and its structural parent pair.
|
|
141
|
+
const joinCandidate = getJoinCandidateAtBoundary(doc, endOfNode, from, to, MAX_BLOCK_JOIN_DEPTH);
|
|
142
|
+
if (!joinCandidate) return true;
|
|
143
|
+
const mappedJoinPos = transform.mapping.map(joinCandidate.joinPos);
|
|
144
|
+
if (!canApplyJoinCandidate(transform.doc, mappedJoinPos, joinCandidate.leftNodes.length)) {
|
|
85
145
|
return true;
|
|
86
146
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
marks: $endOfNode.nodeAfter.marks.map((mark)=>mark.toJSON())
|
|
96
|
-
};
|
|
97
|
-
transform.join(mappedEndOfNode);
|
|
147
|
+
const shouldSuppressJoinMark = [
|
|
148
|
+
...joinCandidate.leftNodes,
|
|
149
|
+
...joinCandidate.rightNodes
|
|
150
|
+
].some((node)=>hasStructureAddMark(node));
|
|
151
|
+
transform.join(mappedJoinPos, joinCandidate.leftNodes.length);
|
|
152
|
+
// Joining provisional structure cancels its pending add instead of creating
|
|
153
|
+
// a second review artifact for the same user action.
|
|
154
|
+
if (shouldSuppressJoinMark) return false;
|
|
98
155
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
99
156
|
const joinStep = transform.steps[transform.steps.length - 1];
|
|
100
|
-
const joinPos = joinStep.getMap().map(
|
|
157
|
+
const joinPos = joinStep.getMap().map(mappedJoinPos);
|
|
101
158
|
transform.insert(joinPos, transform.doc.type.schema.text(ZWSP));
|
|
102
159
|
transform.addMark(joinPos, joinPos + 1, deletion.create({
|
|
103
160
|
id: markId,
|
|
104
161
|
type: "join",
|
|
105
162
|
data: {
|
|
106
|
-
|
|
107
|
-
|
|
163
|
+
leftNodes: joinCandidate.leftNodes.map(serializeJoinNode),
|
|
164
|
+
rightNodes: joinCandidate.rightNodes.map(serializeJoinNode)
|
|
108
165
|
}
|
|
109
166
|
}));
|
|
110
167
|
return false;
|
|
111
168
|
});
|
|
112
169
|
return transform;
|
|
113
170
|
}
|
|
171
|
+
function getJoinCandidateAtBoundary(doc, boundaryPos, from, to, maxJoinDepth) {
|
|
172
|
+
const $boundary = doc.resolve(boundaryPos);
|
|
173
|
+
const leftNode = $boundary.nodeBefore;
|
|
174
|
+
const rightNode = $boundary.nodeAfter;
|
|
175
|
+
// Multi-depth list joins can begin between structural nodes, not just textblocks.
|
|
176
|
+
if (!leftNode || !rightNode) return null;
|
|
177
|
+
if (leftNode.isInline || rightNode.isInline) return null;
|
|
178
|
+
if (!canJoin(doc, boundaryPos)) return null;
|
|
179
|
+
const pairs = [
|
|
180
|
+
{
|
|
181
|
+
leftNode,
|
|
182
|
+
rightNode
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
for(let expandBy = 1; pairs.length < maxJoinDepth; expandBy += 1){
|
|
186
|
+
const left = boundaryPos - expandBy;
|
|
187
|
+
const right = boundaryPos + expandBy;
|
|
188
|
+
if (left < from || right > to) break;
|
|
189
|
+
const $left = doc.resolve(left);
|
|
190
|
+
const $right = doc.resolve(right);
|
|
191
|
+
// Once text is adjacent, there is no deeper block pair to capture.
|
|
192
|
+
if ($left.nodeBefore?.isText || $right.nodeAfter?.isText) break;
|
|
193
|
+
if ($left.nodeAfter === null && $left.nodeBefore && !$left.nodeBefore.isInline && $right.nodeBefore === null && $right.nodeAfter && !$right.nodeAfter.isInline) {
|
|
194
|
+
pairs.push({
|
|
195
|
+
leftNode: $left.nodeBefore,
|
|
196
|
+
rightNode: $right.nodeAfter
|
|
197
|
+
});
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
// canJoin only checks the boundary; the temporary transform verifies depth.
|
|
203
|
+
if (!canApplyJoinCandidate(doc, boundaryPos, pairs.length)) return null;
|
|
204
|
+
return {
|
|
205
|
+
joinPos: boundaryPos,
|
|
206
|
+
leftNodes: pairs.map((pair)=>pair.leftNode).reverse(),
|
|
207
|
+
rightNodes: pairs.map((pair)=>pair.rightNode).reverse()
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function canApplyJoinCandidate(doc, joinPos, depth) {
|
|
211
|
+
if (!canJoin(doc, joinPos)) return false;
|
|
212
|
+
try {
|
|
213
|
+
new Transform(doc).join(joinPos, depth);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function hasStructureAddMark(node) {
|
|
220
|
+
const { structure } = getSuggestionMarks(node.type.schema);
|
|
221
|
+
return node.marks.some((mark)=>{
|
|
222
|
+
if (mark.type !== structure) return false;
|
|
223
|
+
if (!guardStructureMarkAttrs(mark.attrs)) return false;
|
|
224
|
+
return mark.attrs.data.op.op === "add";
|
|
225
|
+
});
|
|
226
|
+
}
|
|
114
227
|
/**
|
|
115
228
|
* Find ZWSP nodes marked as insertions and deletions with the same mark id
|
|
116
229
|
* Delete them from the given range
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type JoinMarkAttrs } from "./types.js";
|
|
2
|
+
export declare const MAX_BLOCK_JOIN_DEPTH = 2;
|
|
3
|
+
export declare function normalizeJoinNodesMetadata(attrs: JoinMarkAttrs): false | {
|
|
4
|
+
leftNodes: import("./types.js").SerializedJoinNode[];
|
|
5
|
+
rightNodes: import("./types.js").SerializedJoinNode[];
|
|
6
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isSerializedJoinNode } from "./types.js";
|
|
2
|
+
// Block join suggestion metadata/revert currently supports the depth TipTap uses
|
|
3
|
+
// when Backspace joins both list-item paragraphs and their parent list items.
|
|
4
|
+
export const MAX_BLOCK_JOIN_DEPTH = 2;
|
|
5
|
+
export function normalizeJoinNodesMetadata(attrs) {
|
|
6
|
+
const data = attrs.data;
|
|
7
|
+
// Normalize legacy metadata so revert can use the same array path.
|
|
8
|
+
const leftNodes = data.leftNodes ?? (data.leftNode ? [
|
|
9
|
+
data.leftNode
|
|
10
|
+
] : null);
|
|
11
|
+
const rightNodes = data.rightNodes ?? (data.rightNode ? [
|
|
12
|
+
data.rightNode
|
|
13
|
+
] : null);
|
|
14
|
+
if (!Array.isArray(leftNodes) || !Array.isArray(rightNodes)) return false;
|
|
15
|
+
if (leftNodes.length === 0 || leftNodes.length !== rightNodes.length) return false;
|
|
16
|
+
// Reject unsupported depths instead of partially reverting unknown structure.
|
|
17
|
+
if (leftNodes.length > MAX_BLOCK_JOIN_DEPTH) return false;
|
|
18
|
+
if (!leftNodes.every(isSerializedJoinNode)) return false;
|
|
19
|
+
if (!rightNodes.every(isSerializedJoinNode)) return false;
|
|
20
|
+
return {
|
|
21
|
+
leftNodes,
|
|
22
|
+
rightNodes
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Mark, type Attrs, type Node } from "prosemirror-model";
|
|
2
|
+
import { type SuggestionId } from "../../generateId.js";
|
|
3
|
+
export interface SerializedJoinNode {
|
|
4
|
+
type: string;
|
|
5
|
+
attrs: object;
|
|
6
|
+
marks: {
|
|
7
|
+
attrs: Record<string, unknown>;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
export interface JoinMarkAttrs {
|
|
11
|
+
id: SuggestionId;
|
|
12
|
+
type: "join";
|
|
13
|
+
data: {
|
|
14
|
+
leftNode?: SerializedJoinNode;
|
|
15
|
+
rightNode?: SerializedJoinNode;
|
|
16
|
+
leftNodes?: SerializedJoinNode[];
|
|
17
|
+
rightNodes?: SerializedJoinNode[];
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface JoinPair {
|
|
21
|
+
leftNode: Node;
|
|
22
|
+
rightNode: Node;
|
|
23
|
+
}
|
|
24
|
+
export interface JoinCandidate {
|
|
25
|
+
joinPos: number;
|
|
26
|
+
leftNodes: Node[];
|
|
27
|
+
rightNodes: Node[];
|
|
28
|
+
}
|
|
29
|
+
export declare function isSerializedJoinNode(node: unknown): node is SerializedJoinNode;
|
|
30
|
+
export declare function isJoinMarkAttrs(attrs: Attrs): attrs is JoinMarkAttrs;
|
|
31
|
+
export declare function isJoinMarkObject(mark: unknown): mark is Omit<Mark, "attrs"> & {
|
|
32
|
+
attrs: JoinMarkAttrs;
|
|
33
|
+
};
|
|
34
|
+
export declare function isJoinMark(mark: Mark): mark is Omit<Mark, "attrs"> & {
|
|
35
|
+
attrs: JoinMarkAttrs;
|
|
36
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getSuggestionMarks } from "../../utils.js";
|
|
2
|
+
export function isSerializedJoinNode(node) {
|
|
3
|
+
if (node === null || typeof node !== "object") return false;
|
|
4
|
+
const data = node;
|
|
5
|
+
return typeof data.type === "string" && typeof data.attrs === "object" && data.attrs !== null && Array.isArray(data.marks);
|
|
6
|
+
}
|
|
7
|
+
export function isJoinMarkAttrs(attrs) {
|
|
8
|
+
if (typeof attrs["id"] !== "string" && typeof attrs["id"] !== "number") {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
if (attrs["type"] !== "join") return false;
|
|
12
|
+
if (attrs["data"] == null) return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
export function isJoinMarkObject(mark) {
|
|
16
|
+
if (mark === null || typeof mark !== "object") return false;
|
|
17
|
+
if (!("attrs" in mark)) return false;
|
|
18
|
+
return isJoinMarkAttrs(mark.attrs);
|
|
19
|
+
}
|
|
20
|
+
export function isJoinMark(mark) {
|
|
21
|
+
const { deletion } = getSuggestionMarks(mark.type.schema);
|
|
22
|
+
return mark.type === deletion && isJoinMarkAttrs(mark.attrs);
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { handleTipTapParagraphIntoListJoin } from "./tipTapParagraphIntoListJoin/index.js";
|
|
2
|
+
const specialTransactionShapeHandlers = [
|
|
3
|
+
handleTipTapParagraphIntoListJoin
|
|
4
|
+
];
|
|
5
|
+
export function handleSpecialTransactionShape(args) {
|
|
6
|
+
for (const handler of specialTransactionShapeHandlers){
|
|
7
|
+
const transaction = handler(args);
|
|
8
|
+
if (transaction) return transaction;
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ReplaceStep, Transform } from "prosemirror-transform";
|
|
2
|
+
import { getNodeId } from "../../wrapUnwrap/getNodeId.js";
|
|
3
|
+
// detect a very specific TipTap pattern where joining a paragraph into a list above it
|
|
4
|
+
// causes a 3-step transaction, which is different from normal prosemirror where you just get 1 step
|
|
5
|
+
// see unit test for details
|
|
6
|
+
export function detectTipTapParagraphIntoListJoin(transaction) {
|
|
7
|
+
if (transaction.steps.length !== 3) return null;
|
|
8
|
+
const [deleteStep, insertStep, joinStep] = transaction.steps;
|
|
9
|
+
if (!(deleteStep instanceof ReplaceStep) || !(insertStep instanceof ReplaceStep) || !(joinStep instanceof ReplaceStep)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
if (deleteStep.slice.content.size !== 0) return null;
|
|
13
|
+
if (insertStep.from !== insertStep.to) return null;
|
|
14
|
+
if (insertStep.slice.openStart !== 0 || insertStep.slice.openEnd !== 0) return null;
|
|
15
|
+
if (insertStep.slice.content.childCount !== 1) return null;
|
|
16
|
+
if (joinStep.slice.content.size !== 0) return null;
|
|
17
|
+
if (joinStep.structure !== true) return null;
|
|
18
|
+
const docBefore = transaction.docs[0];
|
|
19
|
+
if (!docBefore) return null;
|
|
20
|
+
const movedNode = docBefore.nodeAt(deleteStep.from);
|
|
21
|
+
if (!movedNode || !movedNode.isTextblock || movedNode.nodeSize !== deleteStep.to - deleteStep.from) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const insertedNode = insertStep.slice.content.firstChild;
|
|
25
|
+
if (!insertedNode?.isTextblock) return null;
|
|
26
|
+
if (insertedNode.type !== movedNode.type) return null;
|
|
27
|
+
if (insertedNode.textContent !== movedNode.textContent) return null;
|
|
28
|
+
const movedNodeId = getNodeId(movedNode);
|
|
29
|
+
if (!movedNodeId || getNodeId(insertedNode) !== movedNodeId) return null;
|
|
30
|
+
const previousSibling = docBefore.resolve(deleteStep.from).nodeBefore;
|
|
31
|
+
if (!previousSibling || previousSibling.isInline) return null;
|
|
32
|
+
try {
|
|
33
|
+
const preview = new Transform(docBefore);
|
|
34
|
+
preview.step(deleteStep);
|
|
35
|
+
preview.step(insertStep);
|
|
36
|
+
new Transform(preview.doc).step(joinStep);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
console.warn("[prosemirror-suggest-changes]", "detected TipTap paragraph into list join shape");
|
|
41
|
+
return {
|
|
42
|
+
type: "tipTapParagraphIntoListJoin",
|
|
43
|
+
deleteStep,
|
|
44
|
+
insertStep,
|
|
45
|
+
joinStep,
|
|
46
|
+
movedNode
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { EditorState } from "prosemirror-state";
|
|
2
|
+
import { Step } from "prosemirror-transform";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { createSchema } from "../../../testing/e2eTestSchema.js";
|
|
5
|
+
import { detectTipTapParagraphIntoListJoin } from "./detectTipTapParagraphIntoListJoin.js";
|
|
6
|
+
const schema = createSchema();
|
|
7
|
+
// when joining a paragraph into a list above it,
|
|
8
|
+
// TipTap List extension overrides the default ProseMirror behavior
|
|
9
|
+
// by default, ProseMirror puts the paragraph to the end of list as a separate list item
|
|
10
|
+
// TipTap instead joins the paragraph with the paragraph of the last list item
|
|
11
|
+
const TIPTAP_PARAGRAPH_INTO_LIST_STEPS = [
|
|
12
|
+
// first step deletes the paragraph that we're joining
|
|
13
|
+
{
|
|
14
|
+
stepType: "replace",
|
|
15
|
+
from: 42,
|
|
16
|
+
to: 60
|
|
17
|
+
},
|
|
18
|
+
// second step inserts that paragraph into the last list item
|
|
19
|
+
{
|
|
20
|
+
stepType: "replace",
|
|
21
|
+
from: 40,
|
|
22
|
+
to: 40,
|
|
23
|
+
slice: {
|
|
24
|
+
content: [
|
|
25
|
+
{
|
|
26
|
+
type: "paragraph",
|
|
27
|
+
attrs: {
|
|
28
|
+
id: "node-9",
|
|
29
|
+
textAlign: null
|
|
30
|
+
},
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: "sample paragraph"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
// third step joins the two paragraphs in the list item
|
|
42
|
+
{
|
|
43
|
+
stepType: "replace",
|
|
44
|
+
from: 39,
|
|
45
|
+
to: 41,
|
|
46
|
+
structure: true
|
|
47
|
+
}
|
|
48
|
+
];
|
|
49
|
+
const TIPTAP_PARAGRAPH_INTO_LIST_DOC = {
|
|
50
|
+
type: "doc",
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
type: "orderedList",
|
|
54
|
+
attrs: {
|
|
55
|
+
order: 1,
|
|
56
|
+
id: "node-0"
|
|
57
|
+
},
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: "listItem",
|
|
61
|
+
attrs: {
|
|
62
|
+
id: "node-1"
|
|
63
|
+
},
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: "paragraph",
|
|
67
|
+
attrs: {
|
|
68
|
+
id: "node-2"
|
|
69
|
+
},
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: "Item 1"
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: "listItem",
|
|
81
|
+
attrs: {
|
|
82
|
+
id: "node-3"
|
|
83
|
+
},
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "paragraph",
|
|
87
|
+
attrs: {
|
|
88
|
+
id: "node-4"
|
|
89
|
+
},
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: "text",
|
|
93
|
+
text: "Item 2"
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: "listItem",
|
|
101
|
+
attrs: {
|
|
102
|
+
id: "node-5"
|
|
103
|
+
},
|
|
104
|
+
content: [
|
|
105
|
+
{
|
|
106
|
+
type: "paragraph",
|
|
107
|
+
attrs: {
|
|
108
|
+
id: "node-6"
|
|
109
|
+
},
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: "Item 3"
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "listItem",
|
|
121
|
+
attrs: {
|
|
122
|
+
id: "node-7"
|
|
123
|
+
},
|
|
124
|
+
content: [
|
|
125
|
+
{
|
|
126
|
+
type: "paragraph",
|
|
127
|
+
attrs: {
|
|
128
|
+
id: "node-8"
|
|
129
|
+
},
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: "Item 4"
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: "paragraph",
|
|
143
|
+
attrs: {
|
|
144
|
+
id: "node-9"
|
|
145
|
+
},
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: "sample paragraph"
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
};
|
|
155
|
+
describe("detectTipTapParagraphIntoListJoin", ()=>{
|
|
156
|
+
it("detects TipTap's three-step paragraph-into-list join shape", ()=>{
|
|
157
|
+
const doc = schema.nodeFromJSON(TIPTAP_PARAGRAPH_INTO_LIST_DOC);
|
|
158
|
+
const state = EditorState.create({
|
|
159
|
+
doc
|
|
160
|
+
});
|
|
161
|
+
const transaction = state.tr;
|
|
162
|
+
TIPTAP_PARAGRAPH_INTO_LIST_STEPS.forEach((stepJSON)=>{
|
|
163
|
+
transaction.step(Step.fromJSON(schema, stepJSON));
|
|
164
|
+
});
|
|
165
|
+
const shape = detectTipTapParagraphIntoListJoin(transaction);
|
|
166
|
+
expect(shape).toMatchObject({
|
|
167
|
+
type: "tipTapParagraphIntoListJoin",
|
|
168
|
+
deleteStep: {
|
|
169
|
+
from: 42,
|
|
170
|
+
to: 60
|
|
171
|
+
},
|
|
172
|
+
insertStep: {
|
|
173
|
+
from: 40,
|
|
174
|
+
to: 40
|
|
175
|
+
},
|
|
176
|
+
joinStep: {
|
|
177
|
+
from: 39,
|
|
178
|
+
to: 41
|
|
179
|
+
},
|
|
180
|
+
movedNode: {
|
|
181
|
+
attrs: {
|
|
182
|
+
id: "node-9"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
expect(shape?.movedNode.textContent).toBe("sample paragraph");
|
|
187
|
+
});
|
|
188
|
+
});
|