@magic-marker/prosemirror-suggest-changes 0.1.8
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/LICENSE +21 -0
- package/README.md +425 -0
- package/dist/addMarkStep.d.ts +12 -0
- package/dist/addMarkStep.js +17 -0
- package/dist/addNodeMarkStep.d.ts +11 -0
- package/dist/addNodeMarkStep.js +36 -0
- package/dist/attrStep.d.ts +11 -0
- package/dist/attrStep.js +33 -0
- package/dist/commands.d.ts +64 -0
- package/dist/commands.js +314 -0
- package/dist/decorations.d.ts +3 -0
- package/dist/decorations.js +73 -0
- package/dist/features/joinBlocks/__tests__/blockJoin.playwright.test.d.ts +14 -0
- package/dist/features/joinBlocks/__tests__/crossBlockReplace.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/getZWSPPairsInRange.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/multiStepBlockJoin.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/nestedBlockJoin.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/paragraphBackspace.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/playwrightHelpers.d.ts +143 -0
- package/dist/features/joinBlocks/__tests__/testHelpers.d.ts +106 -0
- package/dist/features/joinBlocks/index.d.ts +3 -0
- package/dist/features/joinBlocks/index.js +64 -0
- package/dist/features/joinBlocks/types.d.ts +11 -0
- package/dist/features/joinBlocks/types.js +1 -0
- package/dist/features/joinBlocks/utils/boundary.d.ts +10 -0
- package/dist/features/joinBlocks/utils/boundary.js +147 -0
- package/dist/features/joinBlocks/utils/getZWSPPairsInRange.d.ts +10 -0
- package/dist/features/joinBlocks/utils/getZWSPPairsInRange.js +57 -0
- package/dist/findSuggestionMarkEnd.d.ts +2 -0
- package/dist/findSuggestionMarkEnd.js +38 -0
- package/dist/generateId.d.ts +5 -0
- package/dist/generateId.js +24 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.js +38 -0
- package/dist/rebasePos.d.ts +9 -0
- package/dist/rebasePos.js +10 -0
- package/dist/removeMarkStep.d.ts +12 -0
- package/dist/removeMarkStep.js +17 -0
- package/dist/removeNodeMarkStep.d.ts +11 -0
- package/dist/removeNodeMarkStep.js +30 -0
- package/dist/replaceAroundStep.d.ts +12 -0
- package/dist/replaceAroundStep.js +96 -0
- package/dist/replaceStep.d.ts +35 -0
- package/dist/replaceStep.js +247 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +139 -0
- package/dist/testing/difficultyMark.d.ts +2 -0
- package/dist/testing/testBuilders.d.ts +11 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +20 -0
- package/dist/withSuggestChanges.d.ts +27 -0
- package/dist/withSuggestChanges.js +114 -0
- package/package.json +83 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { TextSelection } from "prosemirror-state";
|
|
2
|
+
import { Transform } from "prosemirror-transform";
|
|
3
|
+
import { findSuggestionMarkEnd } from "./findSuggestionMarkEnd.js";
|
|
4
|
+
import { suggestChangesKey } from "./plugin.js";
|
|
5
|
+
import { getSuggestionMarks } from "./utils.js";
|
|
6
|
+
/**
|
|
7
|
+
* Given a node and a transform, add a set of steps to the
|
|
8
|
+
* transform that applies all marks of type markTypeToApply
|
|
9
|
+
* and reverts all marks of type markTypeToRevert.
|
|
10
|
+
*
|
|
11
|
+
* If suggestionId is provided, will only add steps that impact
|
|
12
|
+
* deletions, insertions, and modifications with that id.
|
|
13
|
+
*/ function applySuggestionsToTransform(node, tr, markTypeToApply, markTypeToRevert, suggestionId, from, to) {
|
|
14
|
+
const toApplyIsInSet = suggestionId === undefined ? (marks)=>markTypeToApply.isInSet(marks) : (marks)=>{
|
|
15
|
+
const mark = markTypeToApply.isInSet(marks);
|
|
16
|
+
return mark && mark.attrs["id"] === suggestionId ? mark : undefined;
|
|
17
|
+
};
|
|
18
|
+
const toRevertIsInSet = suggestionId === undefined ? (marks)=>markTypeToRevert.isInSet(marks) : (marks)=>{
|
|
19
|
+
const mark = markTypeToRevert.isInSet(marks);
|
|
20
|
+
return mark && mark.attrs["id"] === suggestionId ? mark : undefined;
|
|
21
|
+
};
|
|
22
|
+
const isToApply = toApplyIsInSet(node.marks);
|
|
23
|
+
if (isToApply) {
|
|
24
|
+
if (node.isInline) {
|
|
25
|
+
tr.removeMark(0, node.nodeSize, markTypeToApply);
|
|
26
|
+
} else {
|
|
27
|
+
tr.removeNodeMark(0, markTypeToApply);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
node.descendants((child, pos)=>{
|
|
31
|
+
if (from !== undefined && pos < from) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (to !== undefined && pos > to) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const isToRevert = toRevertIsInSet(child.marks);
|
|
38
|
+
const isToApply = toApplyIsInSet(child.marks);
|
|
39
|
+
if (!isToRevert && !isToApply) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (isToRevert) {
|
|
43
|
+
const { pos: deletionFrom, deleted } = tr.mapping.mapResult(pos);
|
|
44
|
+
if (deleted) return false;
|
|
45
|
+
const deletionTo = findSuggestionMarkEnd(tr.doc.resolve(deletionFrom + child.nodeSize), markTypeToRevert);
|
|
46
|
+
// check if the previous and the next text part is a space
|
|
47
|
+
// if so, we can delete the whole text part
|
|
48
|
+
const prevChar = tr.doc.textBetween(deletionFrom - 1, deletionFrom, "x", "x");
|
|
49
|
+
const nextChar = // textBetween is fine with negative positions (??),
|
|
50
|
+
// but it errors if passed a position greater than the
|
|
51
|
+
// size of the doc
|
|
52
|
+
deletionTo <= tr.doc.content.size ? tr.doc.textBetween(deletionTo, deletionTo + 1, "x", "x") : "";
|
|
53
|
+
const addedRange = prevChar === " " && nextChar === " " ? 1 : 0;
|
|
54
|
+
tr.deleteRange(deletionFrom, deletionTo + addedRange);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const insertionFrom = tr.mapping.map(pos);
|
|
58
|
+
const insertionTo = insertionFrom + child.nodeSize;
|
|
59
|
+
if (child.isInline) {
|
|
60
|
+
tr.removeMark(insertionFrom, insertionTo, markTypeToApply);
|
|
61
|
+
if (child.text === "\u200B") {
|
|
62
|
+
tr.delete(insertionFrom, insertionTo);
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
tr.removeNodeMark(insertionFrom, markTypeToApply);
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function revertModifications(node, pos, tr) {
|
|
71
|
+
const { modification } = getSuggestionMarks(node.type.schema);
|
|
72
|
+
const existingMods = node.marks.filter((mark)=>mark.type === modification);
|
|
73
|
+
for (const mod of existingMods){
|
|
74
|
+
if (mod.attrs["type"] === "attr" && typeof mod.attrs["attrName"] === "string") {
|
|
75
|
+
tr.setNodeAttribute(pos, mod.attrs["attrName"], mod.attrs["previousValue"]);
|
|
76
|
+
} else if (mod.attrs["type"] === "mark") {
|
|
77
|
+
if (mod.attrs["previousValue"]) {
|
|
78
|
+
tr.addNodeMark(0, node.type.schema.markFromJSON(mod.attrs["previousValue"]));
|
|
79
|
+
} else {
|
|
80
|
+
tr.removeNodeMark(pos, node.type.schema.markFromJSON(mod.attrs["previousValue"]));
|
|
81
|
+
}
|
|
82
|
+
} else if (mod.attrs["type"] === "nodeType") {
|
|
83
|
+
tr.setNodeMarkup(pos, node.type.schema.nodes[mod.attrs["previousValue"]], null);
|
|
84
|
+
} else {
|
|
85
|
+
throw new Error("Unknown modification type");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function modificationIsInSet(modification, id, marks) {
|
|
90
|
+
const mark = modification.isInSet(marks);
|
|
91
|
+
if (id === undefined) return mark;
|
|
92
|
+
if (mark?.attrs["id"] === id) return mark;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function applyModificationsToTransform(node, tr, dir, suggestionId, from, to) {
|
|
96
|
+
const { modification } = getSuggestionMarks(node.type.schema);
|
|
97
|
+
const isModification = modificationIsInSet(modification, suggestionId, node.marks);
|
|
98
|
+
if (isModification) {
|
|
99
|
+
let prevLength;
|
|
100
|
+
do {
|
|
101
|
+
// https://github.com/ProseMirror/prosemirror/issues/1525
|
|
102
|
+
prevLength = tr.steps.length;
|
|
103
|
+
tr.removeNodeMark(0, modification);
|
|
104
|
+
}while (tr.steps.length > prevLength);
|
|
105
|
+
if (dir < 0) {
|
|
106
|
+
revertModifications(node, 0, tr);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
node.descendants((child, pos)=>{
|
|
110
|
+
if (from !== undefined && pos < from) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (to !== undefined && pos > to) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const isModification = modificationIsInSet(modification, suggestionId, child.marks);
|
|
117
|
+
if (!isModification) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
let prevLength;
|
|
121
|
+
do {
|
|
122
|
+
// https://github.com/ProseMirror/prosemirror/issues/1525
|
|
123
|
+
prevLength = tr.steps.length;
|
|
124
|
+
tr.removeNodeMark(pos, modification);
|
|
125
|
+
}while (tr.steps.length > prevLength);
|
|
126
|
+
if (dir < 0) {
|
|
127
|
+
revertModifications(child, pos, tr);
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
export function applySuggestionsToNode(node) {
|
|
133
|
+
const { deletion, insertion } = getSuggestionMarks(node.type.schema);
|
|
134
|
+
const transform = new Transform(node);
|
|
135
|
+
applySuggestionsToTransform(node, transform, insertion, deletion);
|
|
136
|
+
applyModificationsToTransform(node, transform, 1);
|
|
137
|
+
return transform.doc;
|
|
138
|
+
}
|
|
139
|
+
export function applySuggestionsToRange(doc, from, to) {
|
|
140
|
+
// blockRange can only return null if a predicate is provided
|
|
141
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
142
|
+
const nodeRange = doc.resolve(from).blockRange(doc.resolve(to));
|
|
143
|
+
const { deletion, insertion } = getSuggestionMarks(doc.type.schema);
|
|
144
|
+
const transform = new Transform(doc);
|
|
145
|
+
applySuggestionsToTransform(doc, transform, insertion, deletion, undefined, nodeRange.start, nodeRange.end);
|
|
146
|
+
applyModificationsToTransform(doc, transform, 1, undefined, nodeRange.start, nodeRange.end);
|
|
147
|
+
return transform.doc.slice(transform.mapping.map(from), transform.mapping.map(to));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Command that applies all tracked changes in a document.
|
|
151
|
+
*
|
|
152
|
+
* This means that all content within deletion marks will be deleted.
|
|
153
|
+
* Insertion marks and modification marks will be removed, and their
|
|
154
|
+
* contents left in the doc.
|
|
155
|
+
*/ export function applySuggestions(state, dispatch) {
|
|
156
|
+
const { deletion, insertion } = getSuggestionMarks(state.schema);
|
|
157
|
+
const tr = state.tr;
|
|
158
|
+
applySuggestionsToTransform(state.doc, tr, insertion, deletion);
|
|
159
|
+
applyModificationsToTransform(tr.doc, tr, 1);
|
|
160
|
+
tr.setMeta(suggestChangesKey, {
|
|
161
|
+
skip: true
|
|
162
|
+
});
|
|
163
|
+
dispatch?.(tr);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Command that applies all tracked changes in specified range.
|
|
168
|
+
*
|
|
169
|
+
* This means that all content within deletion marks will be deleted.
|
|
170
|
+
* Insertion marks and modification marks will be removed, and their
|
|
171
|
+
* contents left in the doc.
|
|
172
|
+
*/ export function applySuggestionsInRange(from, to) {
|
|
173
|
+
return (state, dispatch)=>{
|
|
174
|
+
const { deletion, insertion } = getSuggestionMarks(state.schema);
|
|
175
|
+
const tr = state.tr;
|
|
176
|
+
applySuggestionsToTransform(state.doc, tr, insertion, deletion, undefined, from, to);
|
|
177
|
+
applyModificationsToTransform(tr.doc, tr, 1, undefined, from, to);
|
|
178
|
+
tr.setMeta(suggestChangesKey, {
|
|
179
|
+
skip: true
|
|
180
|
+
});
|
|
181
|
+
dispatch?.(tr);
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Command that applies a given tracked change to a document.
|
|
187
|
+
*
|
|
188
|
+
* This means that all content within the deletion mark will be deleted.
|
|
189
|
+
* The insertion mark and modification mark will be removed, and their
|
|
190
|
+
* contents left in the doc.
|
|
191
|
+
*/ export function applySuggestion(suggestionId, from, to) {
|
|
192
|
+
return (state, dispatch)=>{
|
|
193
|
+
const { deletion, insertion } = getSuggestionMarks(state.schema);
|
|
194
|
+
const tr = state.tr;
|
|
195
|
+
applySuggestionsToTransform(state.doc, tr, insertion, deletion, suggestionId, from, to);
|
|
196
|
+
applyModificationsToTransform(tr.doc, tr, 1, undefined, from, to);
|
|
197
|
+
if (!tr.steps.length) return false;
|
|
198
|
+
tr.setMeta(suggestChangesKey, {
|
|
199
|
+
skip: true
|
|
200
|
+
});
|
|
201
|
+
dispatch?.(tr);
|
|
202
|
+
return true;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Command that reverts all tracked changes in a document.
|
|
207
|
+
*
|
|
208
|
+
* This means that all content within insertion marks will be deleted.
|
|
209
|
+
* Deletion marks will be removed, and their contents left in the doc.
|
|
210
|
+
* Modifications tracked in modification marks will be reverted.
|
|
211
|
+
*/ export function revertSuggestions(state, dispatch) {
|
|
212
|
+
const { deletion, insertion } = getSuggestionMarks(state.schema);
|
|
213
|
+
const tr = state.tr;
|
|
214
|
+
applySuggestionsToTransform(state.doc, tr, deletion, insertion);
|
|
215
|
+
applyModificationsToTransform(tr.doc, tr, -1);
|
|
216
|
+
tr.setMeta(suggestChangesKey, {
|
|
217
|
+
skip: true
|
|
218
|
+
});
|
|
219
|
+
dispatch?.(tr);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Command that reverts all tracked changes in specified range.
|
|
224
|
+
*
|
|
225
|
+
* This means that all content within insertion marks will be deleted.
|
|
226
|
+
* Deletion marks will be removed, and their contents left in the doc.
|
|
227
|
+
* Modifications tracked in modification marks will be reverted.
|
|
228
|
+
*/ export function revertSuggestionsInRange(from, to) {
|
|
229
|
+
return (state, dispatch)=>{
|
|
230
|
+
const { deletion, insertion } = getSuggestionMarks(state.schema);
|
|
231
|
+
const tr = state.tr;
|
|
232
|
+
applySuggestionsToTransform(state.doc, tr, deletion, insertion, undefined, from, to);
|
|
233
|
+
applyModificationsToTransform(tr.doc, tr, -1, undefined, from, to);
|
|
234
|
+
tr.setMeta(suggestChangesKey, {
|
|
235
|
+
skip: true
|
|
236
|
+
});
|
|
237
|
+
dispatch?.(tr);
|
|
238
|
+
return true;
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Command that reverts a given tracked change in a document.
|
|
243
|
+
*
|
|
244
|
+
* This means that all content within the insertion mark will be deleted.
|
|
245
|
+
* The deletion mark will be removed, and their contents left in the doc.
|
|
246
|
+
* Modifications tracked in modification marks will be reverted.
|
|
247
|
+
*/ export function revertSuggestion(suggestionId, from, to) {
|
|
248
|
+
return (state, dispatch)=>{
|
|
249
|
+
const { deletion, insertion } = getSuggestionMarks(state.schema);
|
|
250
|
+
const tr = state.tr;
|
|
251
|
+
applySuggestionsToTransform(state.doc, tr, deletion, insertion, suggestionId, from, to);
|
|
252
|
+
if (!tr.steps.length) return false;
|
|
253
|
+
tr.setMeta(suggestChangesKey, {
|
|
254
|
+
skip: true
|
|
255
|
+
});
|
|
256
|
+
applyModificationsToTransform(tr.doc, tr, -1, undefined, from, to);
|
|
257
|
+
dispatch?.(tr);
|
|
258
|
+
return true;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Command that updates the selection to cover an existing change.
|
|
263
|
+
*/ export function selectSuggestion(suggestionId) {
|
|
264
|
+
return (state, dispatch)=>{
|
|
265
|
+
const { deletion, insertion, modification } = getSuggestionMarks(state.schema);
|
|
266
|
+
let changeStart = null;
|
|
267
|
+
let changeEnd = null;
|
|
268
|
+
state.doc.descendants((node, pos)=>{
|
|
269
|
+
const mark = node.marks.find((mark)=>mark.type === insertion || mark.type === deletion || mark.type === modification);
|
|
270
|
+
if (mark?.attrs["id"] !== suggestionId) return true;
|
|
271
|
+
if (changeStart === null) {
|
|
272
|
+
changeStart = pos;
|
|
273
|
+
changeEnd = pos + node.nodeSize;
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
changeEnd = pos + node.nodeSize;
|
|
277
|
+
return false;
|
|
278
|
+
});
|
|
279
|
+
if (changeStart === null || changeEnd === null) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
if (!dispatch) return true;
|
|
283
|
+
dispatch(state.tr.setSelection(TextSelection.create(state.doc, changeStart, changeEnd)).scrollIntoView());
|
|
284
|
+
return true;
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** Command that enables suggest changes */ export function enableSuggestChanges(state, dispatch) {
|
|
288
|
+
if (!suggestChangesKey.getState(state)) return false;
|
|
289
|
+
if (!dispatch) return true;
|
|
290
|
+
dispatch(state.tr.setMeta(suggestChangesKey, {
|
|
291
|
+
skip: true,
|
|
292
|
+
enabled: true
|
|
293
|
+
}));
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
/** Command that disables suggest changes */ export function disableSuggestChanges(state, dispatch) {
|
|
297
|
+
if (!suggestChangesKey.getState(state)) return false;
|
|
298
|
+
if (!dispatch) return true;
|
|
299
|
+
dispatch(state.tr.setMeta(suggestChangesKey, {
|
|
300
|
+
skip: true,
|
|
301
|
+
enabled: false
|
|
302
|
+
}));
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
/** Command that toggles suggest changes on or off */ export function toggleSuggestChanges(state, dispatch) {
|
|
306
|
+
const pluginState = suggestChangesKey.getState(state);
|
|
307
|
+
if (!pluginState) return false;
|
|
308
|
+
if (!dispatch) return true;
|
|
309
|
+
dispatch(state.tr.setMeta(suggestChangesKey, {
|
|
310
|
+
skip: true,
|
|
311
|
+
enabled: !pluginState.enabled
|
|
312
|
+
}));
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
2
|
+
import { getSuggestionMarks } from "./utils.js";
|
|
3
|
+
function pilcrow() {
|
|
4
|
+
const span = document.createElement("span");
|
|
5
|
+
span.appendChild(document.createTextNode("¶"));
|
|
6
|
+
return span;
|
|
7
|
+
}
|
|
8
|
+
export function getSuggestionDecorations(state) {
|
|
9
|
+
const { deletion, insertion } = getSuggestionMarks(state.schema);
|
|
10
|
+
const changeDecorations = [];
|
|
11
|
+
let lastParentNode = null;
|
|
12
|
+
let lastTextNode = null;
|
|
13
|
+
let lastTextNodeEndPos = 0;
|
|
14
|
+
state.doc.descendants((node, pos, parent)=>{
|
|
15
|
+
if (node.isTextblock && node.childCount) {
|
|
16
|
+
if (node.children.every((child)=>deletion.isInSet(child.marks))) {
|
|
17
|
+
changeDecorations.push(Decoration.node(pos, pos + node.nodeSize, {
|
|
18
|
+
"data-node-deletion": "true"
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
if (node.children.every((child)=>insertion.isInSet(child.marks))) {
|
|
22
|
+
changeDecorations.push(Decoration.node(pos, pos + node.nodeSize, {
|
|
23
|
+
"data-node-insertion": "true"
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (node.type.name !== "text") return true;
|
|
28
|
+
const currentDeletionMark = node.marks.find((mark)=>mark.type === deletion);
|
|
29
|
+
const currentInsertionMark = node.marks.find((mark)=>mark.type === insertion);
|
|
30
|
+
const lastDeletionMark = lastTextNode?.marks.find((mark)=>mark.type === deletion);
|
|
31
|
+
const lastInsertionMark = lastTextNode?.marks.find((mark)=>mark.type === insertion);
|
|
32
|
+
const widgetPos = lastTextNodeEndPos;
|
|
33
|
+
lastTextNode = node;
|
|
34
|
+
lastTextNodeEndPos = pos + node.nodeSize;
|
|
35
|
+
if (parent === lastParentNode) {
|
|
36
|
+
lastParentNode = parent;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
lastParentNode = parent;
|
|
40
|
+
if ((!currentDeletionMark || !lastDeletionMark) && (!currentInsertionMark || !lastInsertionMark)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (currentDeletionMark?.attrs["id"] !== lastDeletionMark?.attrs["id"] && currentInsertionMark?.attrs["id"] !== lastInsertionMark?.attrs["id"]) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (currentDeletionMark) {
|
|
47
|
+
changeDecorations.push(Decoration.widget(widgetPos, pilcrow, {
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
49
|
+
key: currentDeletionMark.attrs["id"],
|
|
50
|
+
marks: [
|
|
51
|
+
deletion.create({
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
53
|
+
id: currentDeletionMark.attrs["id"]
|
|
54
|
+
})
|
|
55
|
+
]
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
if (currentInsertionMark) {
|
|
59
|
+
changeDecorations.push(Decoration.widget(widgetPos, pilcrow, {
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
61
|
+
key: currentInsertionMark.attrs["id"],
|
|
62
|
+
marks: [
|
|
63
|
+
insertion.create({
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
65
|
+
id: currentInsertionMark.attrs["id"]
|
|
66
|
+
})
|
|
67
|
+
]
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
return DecorationSet.create(state.doc, changeDecorations);
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright E2E Tests for Block Join Behavior
|
|
3
|
+
*
|
|
4
|
+
* These tests use real keyboard events in a browser environment to verify
|
|
5
|
+
* that the suggestChanges plugin correctly handles block splitting and joining
|
|
6
|
+
* operations.
|
|
7
|
+
*
|
|
8
|
+
* Key differences from JSDOM tests:
|
|
9
|
+
* - Uses real browser (via Playwright)
|
|
10
|
+
* - Uses real keyboard events (page.keyboard.press)
|
|
11
|
+
* - Tests actual user interactions
|
|
12
|
+
* - Catches bugs that manual transactions would hide
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper functions for Playwright E2E tests.
|
|
3
|
+
* These helpers reduce test duplication while keeping actual keyboard events.
|
|
4
|
+
*/
|
|
5
|
+
import type { Page } from "@playwright/test";
|
|
6
|
+
export interface EditorState {
|
|
7
|
+
paragraphCount: number;
|
|
8
|
+
textContent: string;
|
|
9
|
+
cursorFrom: number;
|
|
10
|
+
cursorTo: number;
|
|
11
|
+
blockCount?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Common test pattern: Enter then Backspace
|
|
15
|
+
*/
|
|
16
|
+
export declare function testEnterThenBackspace(page: Page): Promise<{
|
|
17
|
+
initialState: {
|
|
18
|
+
paragraphCount: number;
|
|
19
|
+
blockCount: number;
|
|
20
|
+
textContent: string;
|
|
21
|
+
cursorFrom: number;
|
|
22
|
+
cursorTo: number;
|
|
23
|
+
};
|
|
24
|
+
afterEnterState: {
|
|
25
|
+
paragraphCount: number;
|
|
26
|
+
blockCount: number;
|
|
27
|
+
textContent: string;
|
|
28
|
+
cursorFrom: number;
|
|
29
|
+
cursorTo: number;
|
|
30
|
+
};
|
|
31
|
+
finalState: {
|
|
32
|
+
paragraphCount: number;
|
|
33
|
+
blockCount: number;
|
|
34
|
+
textContent: string;
|
|
35
|
+
cursorFrom: number;
|
|
36
|
+
cursorTo: number;
|
|
37
|
+
};
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Common test pattern: Enter twice then Backspace twice
|
|
41
|
+
*/
|
|
42
|
+
export declare function testDoubleEnterDoubleBackspace(page: Page): Promise<{
|
|
43
|
+
initialState: {
|
|
44
|
+
paragraphCount: number;
|
|
45
|
+
blockCount: number;
|
|
46
|
+
textContent: string;
|
|
47
|
+
cursorFrom: number;
|
|
48
|
+
cursorTo: number;
|
|
49
|
+
};
|
|
50
|
+
afterFirstEnter: {
|
|
51
|
+
paragraphCount: number;
|
|
52
|
+
blockCount: number;
|
|
53
|
+
textContent: string;
|
|
54
|
+
cursorFrom: number;
|
|
55
|
+
cursorTo: number;
|
|
56
|
+
};
|
|
57
|
+
afterSecondEnter: {
|
|
58
|
+
paragraphCount: number;
|
|
59
|
+
blockCount: number;
|
|
60
|
+
textContent: string;
|
|
61
|
+
cursorFrom: number;
|
|
62
|
+
cursorTo: number;
|
|
63
|
+
};
|
|
64
|
+
afterFirstBackspace: {
|
|
65
|
+
paragraphCount: number;
|
|
66
|
+
blockCount: number;
|
|
67
|
+
textContent: string;
|
|
68
|
+
cursorFrom: number;
|
|
69
|
+
cursorTo: number;
|
|
70
|
+
};
|
|
71
|
+
finalState: {
|
|
72
|
+
paragraphCount: number;
|
|
73
|
+
blockCount: number;
|
|
74
|
+
textContent: string;
|
|
75
|
+
cursorFrom: number;
|
|
76
|
+
cursorTo: number;
|
|
77
|
+
};
|
|
78
|
+
}>;
|
|
79
|
+
/**
|
|
80
|
+
* Common test pattern: Enter then Delete from first block
|
|
81
|
+
*/
|
|
82
|
+
export declare function testEnterThenDeleteFromFirst(page: Page): Promise<{
|
|
83
|
+
initialState: {
|
|
84
|
+
paragraphCount: number;
|
|
85
|
+
blockCount: number;
|
|
86
|
+
textContent: string;
|
|
87
|
+
cursorFrom: number;
|
|
88
|
+
cursorTo: number;
|
|
89
|
+
};
|
|
90
|
+
afterEnter: {
|
|
91
|
+
paragraphCount: number;
|
|
92
|
+
blockCount: number;
|
|
93
|
+
textContent: string;
|
|
94
|
+
cursorFrom: number;
|
|
95
|
+
cursorTo: number;
|
|
96
|
+
};
|
|
97
|
+
finalState: {
|
|
98
|
+
paragraphCount: number;
|
|
99
|
+
blockCount: number;
|
|
100
|
+
textContent: string;
|
|
101
|
+
cursorFrom: number;
|
|
102
|
+
cursorTo: number;
|
|
103
|
+
};
|
|
104
|
+
}>;
|
|
105
|
+
/**
|
|
106
|
+
* Helper to get current editor state
|
|
107
|
+
*/
|
|
108
|
+
export declare function getEditorState(page: Page): Promise<EditorState>;
|
|
109
|
+
/**
|
|
110
|
+
* Helper to perform Enter at position
|
|
111
|
+
*/
|
|
112
|
+
export declare function pressEnterAt(page: Page, position: number): Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Helper to perform Backspace
|
|
115
|
+
*/
|
|
116
|
+
export declare function pressBackspace(page: Page): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Helper to perform Delete
|
|
119
|
+
*/
|
|
120
|
+
export declare function pressDelete(page: Page): Promise<void>;
|
|
121
|
+
/**
|
|
122
|
+
* Common assertion: verify document reverted to original
|
|
123
|
+
*/
|
|
124
|
+
export declare function assertReverted(finalState: EditorState, initialState: EditorState): void;
|
|
125
|
+
/**
|
|
126
|
+
* Setup document from JSON and get initial state.
|
|
127
|
+
* This helper replaces the editor document with the provided JSON,
|
|
128
|
+
* clears transactions, and returns initial state.
|
|
129
|
+
*/
|
|
130
|
+
export declare function setupDocFromJSON(page: Page, docJSON: unknown): Promise<{
|
|
131
|
+
initialState: EditorState;
|
|
132
|
+
initialDoc: unknown;
|
|
133
|
+
}>;
|
|
134
|
+
/**
|
|
135
|
+
* Assert that document fully reverted to initial state.
|
|
136
|
+
* Combines text content and JSON structure assertions.
|
|
137
|
+
*/
|
|
138
|
+
export declare function assertDocFullyReverted(finalState: EditorState, finalDoc: unknown, initialState: EditorState, initialDoc: unknown): void;
|
|
139
|
+
/**
|
|
140
|
+
* Perform a single Enter/Backspace cycle.
|
|
141
|
+
* Useful for testing multiple rapid sequences.
|
|
142
|
+
*/
|
|
143
|
+
export declare function performEnterBackspaceCycle(page: Page): Promise<void>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper functions for creating pre-split documents with ZWSPs.
|
|
3
|
+
* These helpers create documents that are already in a split state,
|
|
4
|
+
* eliminating the need for tests to programmatically create splits.
|
|
5
|
+
*/
|
|
6
|
+
import { EditorState } from "prosemirror-state";
|
|
7
|
+
import type { MarkType } from "prosemirror-model";
|
|
8
|
+
import { type TaggedNode } from "../../../testing/testBuilders.js";
|
|
9
|
+
export interface SplitDocResult {
|
|
10
|
+
doc: TaggedNode;
|
|
11
|
+
state: EditorState;
|
|
12
|
+
insertion: MarkType;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Creates two paragraphs with a ZWSP marker at the boundary between them.
|
|
16
|
+
* This simulates the state after an Enter key press at the end of the first paragraph.
|
|
17
|
+
*
|
|
18
|
+
* @param text1 - Text for the first paragraph
|
|
19
|
+
* @param text2 - Text for the second paragraph (optional, defaults to empty)
|
|
20
|
+
* @param suggestionId - The suggestion ID for the ZWSPs and split content
|
|
21
|
+
* @returns Object containing the document, editor state, and insertion mark type
|
|
22
|
+
*/
|
|
23
|
+
export declare const createSplitParagraphs: (text1: string, text2?: string, suggestionId?: number) => {
|
|
24
|
+
doc: import("prosemirror-model").Node & {
|
|
25
|
+
tag: {
|
|
26
|
+
[tag: string]: number;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
state: EditorState;
|
|
30
|
+
insertion: MarkType;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Creates a list (bullet or ordered) with two list items and ZWSP markers at the boundary.
|
|
34
|
+
* This simulates the state after an Enter key press at the end of the first list item.
|
|
35
|
+
*
|
|
36
|
+
* @param listType - Type of list to create ('bullet' or 'ordered')
|
|
37
|
+
* @param item1Text - Text for the first list item
|
|
38
|
+
* @param item2Text - Text for the second list item (optional, defaults to empty)
|
|
39
|
+
* @param suggestionId - The suggestion ID for the ZWSPs and split content
|
|
40
|
+
* @returns Object containing the document, editor state, and insertion mark type
|
|
41
|
+
*/
|
|
42
|
+
export declare const createSplitList: (listType: "bullet" | "ordered", item1Text: string, item2Text?: string, suggestionId?: number) => {
|
|
43
|
+
doc: import("prosemirror-model").Node & {
|
|
44
|
+
tag: {
|
|
45
|
+
[tag: string]: number;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
state: EditorState;
|
|
49
|
+
insertion: MarkType;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Creates a bullet list with three list items after two sequential splits.
|
|
53
|
+
* This simulates pressing Enter twice, creating three list items with all ZWSPs in place.
|
|
54
|
+
*
|
|
55
|
+
* @param item1Text - Text for the first list item
|
|
56
|
+
* @param suggestionId - The suggestion ID for all ZWSPs
|
|
57
|
+
* @returns Object containing the document, editor state, and insertion mark type
|
|
58
|
+
*/
|
|
59
|
+
export declare const createDoubleSplitBulletList: (item1Text: string, suggestionId?: number) => {
|
|
60
|
+
doc: import("prosemirror-model").Node & {
|
|
61
|
+
tag: {
|
|
62
|
+
[tag: string]: number;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
state: EditorState;
|
|
66
|
+
insertion: MarkType;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Creates three paragraphs after two sequential splits.
|
|
70
|
+
* This simulates pressing Enter twice, creating three paragraphs with all ZWSPs in place.
|
|
71
|
+
*
|
|
72
|
+
* @param text1 - Text for the first paragraph
|
|
73
|
+
* @param suggestionId - The suggestion ID for all ZWSPs
|
|
74
|
+
* @returns Object containing the document, editor state, and insertion mark type
|
|
75
|
+
*/
|
|
76
|
+
export declare const createDoubleSplitParagraphs: (text1: string, suggestionId?: number) => {
|
|
77
|
+
doc: import("prosemirror-model").Node & {
|
|
78
|
+
tag: {
|
|
79
|
+
[tag: string]: number;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
state: EditorState;
|
|
83
|
+
insertion: MarkType;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Applies a backspace step at a tagged position in the document.
|
|
87
|
+
* This helper eliminates boilerplate for the common pattern of backspacing at a ZWSP marker.
|
|
88
|
+
*
|
|
89
|
+
* @param doc - The document with a tag marking the backspace position
|
|
90
|
+
* @param state - The editor state
|
|
91
|
+
* @param tagName - The tag name to backspace at (defaults to "zwsp")
|
|
92
|
+
* @param suggestionId - The suggestion ID for this operation
|
|
93
|
+
* @returns The new editor state after applying the backspace
|
|
94
|
+
*/
|
|
95
|
+
export declare const applyBackspaceAtTag: (doc: TaggedNode, state: EditorState, tagName?: string, suggestionId?: number) => EditorState;
|
|
96
|
+
/**
|
|
97
|
+
* Applies a forward delete step at a tagged position in the document.
|
|
98
|
+
* This helper eliminates boilerplate for the common pattern of deleting at a position.
|
|
99
|
+
*
|
|
100
|
+
* @param doc - The document with a tag marking the delete position
|
|
101
|
+
* @param state - The editor state
|
|
102
|
+
* @param tagName - The tag name to delete at
|
|
103
|
+
* @param suggestionId - The suggestion ID for this operation
|
|
104
|
+
* @returns The new editor state after applying the delete
|
|
105
|
+
*/
|
|
106
|
+
export declare const applyDeleteAtTag: (doc: TaggedNode, state: EditorState, tagName: string, suggestionId?: number) => EditorState;
|