@magic-marker/prosemirror-suggest-changes 0.4.1-wrap-unwrap.1 → 0.4.1-wrap-unwrap.3
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 +6 -3
- package/dist/{ensureSelectionPlugin.js → features/ensureValidSelection/ensureSelectionPlugin.js} +71 -81
- package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.d.ts +1 -0
- package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.js +112 -0
- package/dist/features/ensureValidSelection/selectionPosition.d.ts +3 -0
- package/dist/features/ensureValidSelection/selectionPosition.js +50 -0
- package/dist/features/joinBlocks/index.js +5 -2
- package/dist/features/joinBlocks/utils/getZWSPPairsInRange.js +5 -5
- package/dist/features/startToStartTextblockDeletion/index.js +3 -0
- package/dist/features/transactionShaping/detectSpecialTransactionShape.js +2 -1
- package/dist/features/transactionShaping/index.js +5 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.d.ts +20 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.js +66 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.test.d.ts +1 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.test.js +144 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/handleProseMirrorSplitBlockAfterSelectionDelete.d.ts +3 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/handleProseMirrorSplitBlockAfterSelectionDelete.js +11 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/index.d.ts +2 -0
- package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/index.js +2 -0
- package/dist/features/transactionShaping/types.d.ts +6 -1
- package/dist/features/wrapUnwrap/__tests__/boundarySelectionRevert.playwright.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/buildMaterializedPaths.js +2 -1
- package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.js +3 -1
- package/dist/features/wrapUnwrap/revert/revertAddOp.js +2 -0
- package/dist/features/wrapUnwrap/revert/revertMoveOp.js +5 -2
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +3 -0
- package/dist/features/wrapUnwrap/structureChangesPlugin.js +8 -0
- package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.js +5 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/testing/e2eTestSchema.d.ts +1 -1
- package/dist/testing/testBuilders.d.ts +1 -1
- package/dist/withSuggestChanges.js +7 -0
- package/package.json +1 -1
- /package/dist/{ensureSelectionPlugin.d.ts → features/ensureValidSelection/ensureSelectionPlugin.d.ts} +0 -0
|
@@ -50,9 +50,7 @@ export declare class EditorPage {
|
|
|
50
50
|
* editor.view.state is a different object (ProseMirror creates a new
|
|
51
51
|
* immutable state on every transaction).
|
|
52
52
|
*/
|
|
53
|
-
insertText(text: string
|
|
54
|
-
waitForSelectionChange?: boolean;
|
|
55
|
-
}): Promise<void>;
|
|
53
|
+
insertText(text: string): Promise<void>;
|
|
56
54
|
/**
|
|
57
55
|
* Press a key multiple times, waiting for editor state update after each press.
|
|
58
56
|
*/
|
|
@@ -60,4 +58,9 @@ export declare class EditorPage {
|
|
|
60
58
|
waitForSelectionChange?: boolean;
|
|
61
59
|
}): Promise<void>;
|
|
62
60
|
setNextNodeId(nextNodeId: number): Promise<void>;
|
|
61
|
+
enableTrackChanges(): Promise<void>;
|
|
62
|
+
disableTrackChanges(): Promise<void>;
|
|
63
|
+
focusEditor(): Promise<void>;
|
|
64
|
+
setCursorToStart(): Promise<void>;
|
|
65
|
+
setCursorToEnd(): Promise<void>;
|
|
63
66
|
}
|
package/dist/{ensureSelectionPlugin.js → features/ensureValidSelection/ensureSelectionPlugin.js}
RENAMED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
|
|
2
|
-
import {
|
|
3
|
-
import { ZWSP } from "./constants.js";
|
|
2
|
+
import { getInvalidSelectionPositionReason } from "./selectionPosition.js";
|
|
4
3
|
const TRACE_ENABLED = false;
|
|
5
4
|
function trace(...args) {
|
|
6
5
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
@@ -37,6 +36,8 @@ export function ensureSelection() {
|
|
|
37
36
|
const newState = {
|
|
38
37
|
...state
|
|
39
38
|
};
|
|
39
|
+
// remember if one of the keys we care about was pressed
|
|
40
|
+
// this is needed for appendTransaction
|
|
40
41
|
newState.handleKeyDown.backspace = event.key === "Backspace";
|
|
41
42
|
newState.handleKeyDown.delete = event.key === "Delete";
|
|
42
43
|
newState.handleKeyDown.arrowLeft = event.key === "ArrowLeft";
|
|
@@ -47,23 +48,26 @@ export function ensureSelection() {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
},
|
|
50
|
-
appendTransaction (
|
|
51
|
+
appendTransaction (transactions, oldState, newState) {
|
|
51
52
|
const pluginState = ensureSelectionKey.getState(newState);
|
|
53
|
+
const isSelectionOnly = transactions.every((tr)=>!tr.docChanged);
|
|
52
54
|
if (!(newState.selection instanceof TextSelection)) {
|
|
53
55
|
return null;
|
|
54
56
|
}
|
|
55
57
|
if (isPosValid(newState.selection.$anchor) && isPosValid(newState.selection.$head)) {
|
|
58
|
+
// both selection positions are valid, no action needed
|
|
56
59
|
return null;
|
|
57
60
|
}
|
|
61
|
+
// find new valid selection anchor and head
|
|
58
62
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
59
63
|
if (TRACE_ENABLED) console.groupCollapsed("[ensureSelectionPlugin]", "appendTransaction");
|
|
60
64
|
trace("appendTransaction", "search for new valid $anchor...");
|
|
61
|
-
let $newAnchor = getNewValidPos(
|
|
65
|
+
let $newAnchor = getNewValidPos(oldState.selection.$anchor, newState.selection.$anchor, isSelectionOnly, pluginState);
|
|
62
66
|
trace("appendTransaction", "new valid $anchor", $newAnchor?.pos, {
|
|
63
67
|
$newAnchor
|
|
64
68
|
});
|
|
65
69
|
trace("appendTransaction", "search for new valid $head...");
|
|
66
|
-
let $newHead = newState.selection.empty ? $newAnchor : getNewValidPos(
|
|
70
|
+
let $newHead = newState.selection.empty ? $newAnchor : getNewValidPos(oldState.selection.$head, newState.selection.$head, isSelectionOnly, pluginState);
|
|
67
71
|
trace("appendTransaction", "new valid $head", $newHead?.pos, {
|
|
68
72
|
$newHead
|
|
69
73
|
});
|
|
@@ -71,6 +75,7 @@ export function ensureSelection() {
|
|
|
71
75
|
$newHead = $newHead ?? newState.selection.$head;
|
|
72
76
|
const newSelection = new TextSelection($newAnchor, $newHead);
|
|
73
77
|
if (newSelection.anchor === newState.selection.anchor && newSelection.head === newState.selection.head) {
|
|
78
|
+
// new selection is the same as old selection, no action needed
|
|
74
79
|
trace("appendTransaction", "new selection is the same as old selection, skipping", {
|
|
75
80
|
$newAnchor,
|
|
76
81
|
$newHead,
|
|
@@ -95,77 +100,9 @@ export function isEnsureSelectionEnabled() {
|
|
|
95
100
|
return true;
|
|
96
101
|
}
|
|
97
102
|
function isPosValid($pos) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: not in inlineContent node", {
|
|
102
|
-
$pos
|
|
103
|
-
});
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
const { deletion, insertion } = getSuggestionMarks($pos.doc.type.schema);
|
|
107
|
-
const deletionBefore = deletion.isInSet($pos.nodeBefore?.marks ?? []);
|
|
108
|
-
const deletionAfter = deletion.isInSet($pos.nodeAfter?.marks ?? []);
|
|
109
|
-
const isAnchorBefore = deletionBefore && deletionBefore.attrs["type"] === "anchor";
|
|
110
|
-
const isAnchorAfter = deletionAfter && deletionAfter.attrs["type"] === "anchor";
|
|
111
|
-
if (isAnchorBefore && deletionAfter && !isAnchorAfter) {
|
|
112
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between deletion anchor and non-anchor deletion", {
|
|
113
|
-
$pos
|
|
114
|
-
});
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
if (deletionBefore && deletionAfter && !isAnchorBefore && !isAnchorAfter) {
|
|
118
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between two non-anchor deletions", {
|
|
119
|
-
$pos
|
|
120
|
-
});
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
if ($pos.nodeBefore == null && deletionAfter && !isAnchorAfter) {
|
|
124
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between node boundary and non-anchor deletion", {
|
|
125
|
-
$pos
|
|
126
|
-
});
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
if (deletionBefore && $pos.nodeAfter == null && !isAnchorBefore) {
|
|
130
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between non-anchor deletion and node boundary", {
|
|
131
|
-
$pos
|
|
132
|
-
});
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
if (deletionBefore && !isAnchorBefore && $pos.nodeAfter == null) {
|
|
136
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between non-anchor deletion and node boundary", {
|
|
137
|
-
$pos
|
|
138
|
-
});
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
if (deletionBefore && !isAnchorBefore) {
|
|
142
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between non-anchor deletion and anything", {
|
|
143
|
-
$pos
|
|
144
|
-
});
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
const insertionBefore = insertion.isInSet($pos.nodeBefore?.marks ?? []);
|
|
148
|
-
const insertionAfter = insertion.isInSet($pos.nodeAfter?.marks ?? []);
|
|
149
|
-
const ZWSP_REGEXP = new RegExp(ZWSP, "g");
|
|
150
|
-
const isZWSPBefore = $pos.nodeBefore && $pos.nodeBefore.isText && $pos.nodeBefore.textContent.replace(ZWSP_REGEXP, "") === "";
|
|
151
|
-
const isZWSPAfter = $pos.nodeAfter && $pos.nodeAfter.isText && $pos.nodeAfter.textContent.replace(ZWSP_REGEXP, "") === "";
|
|
152
|
-
if (insertionBefore && insertionAfter && isZWSPBefore && isZWSPAfter) {
|
|
153
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between two ZWSP insertions", {
|
|
154
|
-
$pos
|
|
155
|
-
});
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
if (insertionBefore && isZWSPBefore && $pos.nodeAfter == null && // a position like this:
|
|
159
|
-
// <p><insertion>ZWSP</insertion>|</p>
|
|
160
|
-
// because it means this paragraph was just created and it's empty
|
|
161
|
-
$pos.parent.textContent.replace(ZWSP_REGEXP, "") !== "") {
|
|
162
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between ZWSP insertion and right node boundary", {
|
|
163
|
-
$pos
|
|
164
|
-
});
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
if (insertionAfter && isZWSPAfter && $pos.nodeBefore == null) {
|
|
168
|
-
trace("isPosValid", $pos.pos, "pos invalid", "reason: between ZWSP insertion and left node boundary", {
|
|
103
|
+
const invalidReason = getInvalidSelectionPositionReason($pos);
|
|
104
|
+
if (invalidReason) {
|
|
105
|
+
trace("isPosValid", $pos.pos, "pos invalid", `reason: ${invalidReason}`, {
|
|
169
106
|
$pos
|
|
170
107
|
});
|
|
171
108
|
return false;
|
|
@@ -174,11 +111,13 @@ function isPosValid($pos) {
|
|
|
174
111
|
}
|
|
175
112
|
function findNextValidPos($initialPos) {
|
|
176
113
|
let $pos = $initialPos;
|
|
177
|
-
// to keep searching for the next valid pos
|
|
114
|
+
// to keep searching for the next valid pos,
|
|
115
|
+
// we need a non-null nodeAfter so we can go right
|
|
116
|
+
// or a non-root depth so we can go up
|
|
178
117
|
while(!isPosValid($pos) && ($pos.nodeAfter != null || $pos.depth > 0)){
|
|
179
118
|
// first check if we can go into nodeAfter
|
|
180
119
|
if ($pos.nodeAfter != null) {
|
|
181
|
-
// if nodeAfter is inline, we can step into it and search for the valid pos
|
|
120
|
+
// if nodeAfter is inline, we can step into it and search for the valid pos inside
|
|
182
121
|
if ($pos.nodeAfter.isInline) {
|
|
183
122
|
// nodeAfter is inline - move in by one
|
|
184
123
|
$pos = $pos.doc.resolve($pos.pos + 1);
|
|
@@ -209,7 +148,9 @@ function findNextValidPos($initialPos) {
|
|
|
209
148
|
}
|
|
210
149
|
function findPreviousValidPos($initialPos) {
|
|
211
150
|
let $pos = $initialPos;
|
|
212
|
-
// in order to be able to keep searching,
|
|
151
|
+
// in order to be able to keep searching,
|
|
152
|
+
// we need either a nodeBefore so we can go left,
|
|
153
|
+
// or a non-root depth so we can go up
|
|
213
154
|
while(!isPosValid($pos) && ($pos.nodeBefore != null || $pos.depth > 0)){
|
|
214
155
|
// first check if we can go into nodeBefore
|
|
215
156
|
if ($pos.nodeBefore != null) {
|
|
@@ -227,7 +168,7 @@ function findPreviousValidPos($initialPos) {
|
|
|
227
168
|
});
|
|
228
169
|
if (localEndPos !== null) {
|
|
229
170
|
// we have a local ending position of the last inline descendant - convert it to global position
|
|
230
|
-
// move pos to start of
|
|
171
|
+
// move pos to start of nodeBefore, add 1 to "enter" nodeBefore, then add local pos
|
|
231
172
|
$pos = $pos.doc.resolve($pos.pos - $pos.nodeBefore.nodeSize + 1 + localEndPos);
|
|
232
173
|
} else {
|
|
233
174
|
// unable to find last inline descendant of nodeBefore - just skip nodeBefore altogether
|
|
@@ -241,7 +182,49 @@ function findPreviousValidPos($initialPos) {
|
|
|
241
182
|
}
|
|
242
183
|
return isPosValid($pos) ? $pos : null;
|
|
243
184
|
}
|
|
244
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Given a ResolvedPos, find closest valid pos within the same parent
|
|
187
|
+
*
|
|
188
|
+
* @param $initialPos
|
|
189
|
+
* @returns
|
|
190
|
+
*/ function findNearestValidPosInSameParent($initialPos) {
|
|
191
|
+
if (!$initialPos.parent.inlineContent) return null;
|
|
192
|
+
const start = $initialPos.start();
|
|
193
|
+
const end = $initialPos.end();
|
|
194
|
+
// take larger distance - either to the start or to the end of the parent node
|
|
195
|
+
const maxDistance = Math.max($initialPos.pos - start, end - $initialPos.pos);
|
|
196
|
+
// for each distance in range [0, maxDistance],
|
|
197
|
+
// check if position within that distance from both sides is valid
|
|
198
|
+
for(let distance = 0; distance <= maxDistance; distance++){
|
|
199
|
+
const nextPos = $initialPos.pos + distance;
|
|
200
|
+
if (nextPos <= end) {
|
|
201
|
+
const $nextPos = $initialPos.doc.resolve(nextPos);
|
|
202
|
+
if (isPosValid($nextPos)) return $nextPos;
|
|
203
|
+
}
|
|
204
|
+
const prevPos = $initialPos.pos - distance;
|
|
205
|
+
if (distance > 0 && prevPos >= start) {
|
|
206
|
+
const $prevPos = $initialPos.doc.resolve(prevPos);
|
|
207
|
+
if (isPosValid($prevPos)) return $prevPos;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function getNewValidPosInSelectionDestinationParent($oldPos, $newPos) {
|
|
213
|
+
if (isPosValid($newPos)) return null;
|
|
214
|
+
if ($oldPos.parent === $newPos.parent) return null;
|
|
215
|
+
if (!$newPos.parent.inlineContent) return null;
|
|
216
|
+
// For selection-only transactions, keep the cursor inside the destination
|
|
217
|
+
// textblock when possible. Jumping across block boundaries makes arrow-key
|
|
218
|
+
// navigation feel like it skipped content.
|
|
219
|
+
const $sameParentPos = findNearestValidPosInSameParent($newPos);
|
|
220
|
+
trace("getNewValidPosInSelectionDestinationParent", "$sameParentPos", $sameParentPos?.pos, {
|
|
221
|
+
$oldPos,
|
|
222
|
+
$newPos,
|
|
223
|
+
$sameParentPos
|
|
224
|
+
});
|
|
225
|
+
return $sameParentPos;
|
|
226
|
+
}
|
|
227
|
+
function getNewValidPosByDirection($pos, dir) {
|
|
245
228
|
if (isPosValid($pos)) return $pos;
|
|
246
229
|
trace("getNewValidPos for", $pos.pos, {
|
|
247
230
|
$pos,
|
|
@@ -298,7 +281,14 @@ function getNewValidPos($pos, dir) {
|
|
|
298
281
|
const prevDist = Math.abs($pos.pos - $prevValidPos.pos);
|
|
299
282
|
return nextDist <= prevDist ? $nextValidPos : $prevValidPos;
|
|
300
283
|
}
|
|
284
|
+
function getNewValidPos($oldPos, $newPos, isSelectionOnly, pluginState) {
|
|
285
|
+
const $sameParentPos = isSelectionOnly ? getNewValidPosInSelectionDestinationParent($oldPos, $newPos) : null;
|
|
286
|
+
return $sameParentPos ?? getNewValidPosByDirection($newPos, getDirection($oldPos, $newPos, pluginState));
|
|
287
|
+
}
|
|
301
288
|
function getDirection($oldPos, $newPos, pluginState) {
|
|
289
|
+
// Position movement does not always reveal user intent. Backspace can leave
|
|
290
|
+
// the mapped selection at the same or unexpected position after suggestion
|
|
291
|
+
// markers are inserted, so preserve the keydown direction and search left.
|
|
302
292
|
if (pluginState?.handleKeyDown.backspace) return "left";
|
|
303
293
|
if ($newPos.pos > $oldPos.pos) return "right";
|
|
304
294
|
if ($newPos.pos < $oldPos.pos) return "left";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
|
2
|
+
import { assert, describe, it } from "vitest";
|
|
3
|
+
import { ZWSP } from "../../constants.js";
|
|
4
|
+
import { testBuilders } from "../../testing/testBuilders.js";
|
|
5
|
+
import { ensureSelection } from "./ensureSelectionPlugin.js";
|
|
6
|
+
function createState(doc, selection) {
|
|
7
|
+
return EditorState.create({
|
|
8
|
+
doc,
|
|
9
|
+
selection,
|
|
10
|
+
plugins: [
|
|
11
|
+
ensureSelection()
|
|
12
|
+
]
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function applyTextSelection(state, anchor, head = anchor) {
|
|
16
|
+
const selection = TextSelection.create(state.doc, anchor, head);
|
|
17
|
+
const transaction = state.tr.setSelection(selection);
|
|
18
|
+
return state.apply(transaction);
|
|
19
|
+
}
|
|
20
|
+
function assertTextSelection(state, anchor, head = anchor) {
|
|
21
|
+
assert(state.selection instanceof TextSelection);
|
|
22
|
+
assert.equal(state.selection.anchor, anchor);
|
|
23
|
+
assert.equal(state.selection.head, head);
|
|
24
|
+
}
|
|
25
|
+
function getTag(doc, tag) {
|
|
26
|
+
const pos = doc.tag[tag];
|
|
27
|
+
assert(pos !== undefined, `Expected tag "${tag}" to exist`);
|
|
28
|
+
return pos;
|
|
29
|
+
}
|
|
30
|
+
describe("ensureSelection", ()=>{
|
|
31
|
+
it("leaves valid text selections unchanged", ()=>{
|
|
32
|
+
const doc = testBuilders.doc(testBuilders.paragraph("one<from>"), testBuilders.paragraph("two<to>"));
|
|
33
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "from")));
|
|
34
|
+
const nextState = applyTextSelection(state, getTag(doc, "to"));
|
|
35
|
+
assertTextSelection(nextState, getTag(doc, "to"));
|
|
36
|
+
});
|
|
37
|
+
it("ignores non-text selections", ()=>{
|
|
38
|
+
const doc = testBuilders.doc(testBuilders.image({
|
|
39
|
+
src: "https://example.com/image.png"
|
|
40
|
+
}), testBuilders.paragraph("after<cursor>"));
|
|
41
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "cursor")));
|
|
42
|
+
const nextState = state.apply(state.tr.setSelection(NodeSelection.create(doc, 0)));
|
|
43
|
+
assert(nextState.selection instanceof NodeSelection);
|
|
44
|
+
assert.equal(nextState.selection.anchor, 0);
|
|
45
|
+
});
|
|
46
|
+
it("prefers a valid position in the destination textblock for selection-only movement", ()=>{
|
|
47
|
+
const doc = testBuilders.doc(testBuilders.paragraph("Item 2<old>"), testBuilders.paragraph(testBuilders.insertion({
|
|
48
|
+
id: 1
|
|
49
|
+
}, "<invalid>" + ZWSP + "<valid>")), testBuilders.paragraph(testBuilders.insertion({
|
|
50
|
+
id: 1
|
|
51
|
+
}, ZWSP), "Item 3"));
|
|
52
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
|
|
53
|
+
const nextState = applyTextSelection(state, getTag(doc, "invalid"));
|
|
54
|
+
assertTextSelection(nextState, getTag(doc, "valid"));
|
|
55
|
+
});
|
|
56
|
+
it("uses direction fallback when the transaction changes the document", ()=>{
|
|
57
|
+
const doc = testBuilders.doc(testBuilders.paragraph("<markStart>A<expected><markEnd>"), testBuilders.paragraph(testBuilders.insertion({
|
|
58
|
+
id: 1
|
|
59
|
+
}, "<invalid>" + ZWSP + "<sameParent>")), testBuilders.paragraph(testBuilders.insertion({
|
|
60
|
+
id: 1
|
|
61
|
+
}, ZWSP), "<old>Item 3"));
|
|
62
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
|
|
63
|
+
const transaction = state.tr;
|
|
64
|
+
transaction.addMark(getTag(doc, "markStart"), getTag(doc, "markEnd"), testBuilders.schema.marks.strong.create());
|
|
65
|
+
transaction.setSelection(TextSelection.create(transaction.doc, getTag(doc, "invalid")));
|
|
66
|
+
const nextState = state.apply(transaction);
|
|
67
|
+
assertTextSelection(nextState, getTag(doc, "expected"));
|
|
68
|
+
});
|
|
69
|
+
it("uses direction fallback for invalid positions in the same textblock", ()=>{
|
|
70
|
+
const doc = testBuilders.doc(testBuilders.paragraph("A", testBuilders.deletion({
|
|
71
|
+
id: 1
|
|
72
|
+
}, "<expected>B<invalid>"), "C<old>"));
|
|
73
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
|
|
74
|
+
const nextState = applyTextSelection(state, getTag(doc, "invalid"));
|
|
75
|
+
assertTextSelection(nextState, getTag(doc, "expected"));
|
|
76
|
+
});
|
|
77
|
+
it("keeps deletion anchor and non-anchor deletion positions continuous", ()=>{
|
|
78
|
+
const doc = testBuilders.doc(testBuilders.paragraph(testBuilders.deletion({
|
|
79
|
+
id: 1,
|
|
80
|
+
type: "anchor"
|
|
81
|
+
}, "<expected>" + ZWSP + "<invalid>"), testBuilders.deletion({
|
|
82
|
+
id: 1
|
|
83
|
+
}, "deleted"), "visible<old>"));
|
|
84
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
|
|
85
|
+
const nextState = applyTextSelection(state, getTag(doc, "invalid"));
|
|
86
|
+
assertTextSelection(nextState, getTag(doc, "expected"));
|
|
87
|
+
});
|
|
88
|
+
it("moves hidden-deletion-style boundary positions to visible content", ()=>{
|
|
89
|
+
const doc = testBuilders.doc(testBuilders.paragraph("before<old>"), testBuilders.paragraph(testBuilders.deletion({
|
|
90
|
+
id: 1
|
|
91
|
+
}, "<invalid>deleted"), "v<expected>isible"));
|
|
92
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
|
|
93
|
+
const nextState = applyTextSelection(state, getTag(doc, "invalid"));
|
|
94
|
+
assertTextSelection(nextState, getTag(doc, "expected"));
|
|
95
|
+
});
|
|
96
|
+
it("keeps paired split marker positions attached to textblock boundaries", ()=>{
|
|
97
|
+
const doc = testBuilders.doc(testBuilders.paragraph("before<old>"), testBuilders.paragraph("content<expected>", testBuilders.insertion({
|
|
98
|
+
id: 1
|
|
99
|
+
}, ZWSP + "<invalid>")));
|
|
100
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
|
|
101
|
+
const nextState = applyTextSelection(state, getTag(doc, "invalid"));
|
|
102
|
+
assertTextSelection(nextState, getTag(doc, "expected"));
|
|
103
|
+
});
|
|
104
|
+
it("moves positions between adjacent insertion ZWSPs to a valid neighbor", ()=>{
|
|
105
|
+
const doc = testBuilders.doc(testBuilders.paragraph("before<old>", testBuilders.insertion({
|
|
106
|
+
id: 1
|
|
107
|
+
}, "<expected>" + ZWSP + "<invalid>" + ZWSP), "<valid>after"));
|
|
108
|
+
const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
|
|
109
|
+
const nextState = applyTextSelection(state, getTag(doc, "invalid"));
|
|
110
|
+
assertTextSelection(nextState, getTag(doc, "valid"));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type ResolvedPos } from "prosemirror-model";
|
|
2
|
+
export type InvalidSelectionPositionReason = "not-in-inline-content" | "between-deletion-anchor-and-non-anchor-deletion" | "between-two-non-anchor-deletions" | "between-node-boundary-and-non-anchor-deletion" | "between-non-anchor-deletion-and-node-boundary" | "between-non-anchor-deletion-and-anything" | "between-two-zwsp-insertions" | "between-zwsp-insertion-and-right-node-boundary" | "between-zwsp-insertion-and-left-node-boundary";
|
|
3
|
+
export declare function getInvalidSelectionPositionReason($pos: ResolvedPos): InvalidSelectionPositionReason | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ZWSP } from "../../constants.js";
|
|
2
|
+
import { getSuggestionMarks } from "../../utils.js";
|
|
3
|
+
export function getInvalidSelectionPositionReason($pos) {
|
|
4
|
+
// Text selection is only valid in nodes that allow inline content.
|
|
5
|
+
// https://github.com/ProseMirror/prosemirror-state/blob/1.4.4/src/selection.ts#L219
|
|
6
|
+
if (!$pos.parent.inlineContent) {
|
|
7
|
+
return "not-in-inline-content";
|
|
8
|
+
}
|
|
9
|
+
const { deletion, insertion } = getSuggestionMarks($pos.doc.type.schema);
|
|
10
|
+
const deletionBefore = deletion.isInSet($pos.nodeBefore?.marks ?? []);
|
|
11
|
+
const deletionAfter = deletion.isInSet($pos.nodeAfter?.marks ?? []);
|
|
12
|
+
const isAnchorBefore = deletionBefore && deletionBefore.attrs["type"] === "anchor";
|
|
13
|
+
const isAnchorAfter = deletionAfter && deletionAfter.attrs["type"] === "anchor";
|
|
14
|
+
if (isAnchorBefore && deletionAfter && !isAnchorAfter) {
|
|
15
|
+
return "between-deletion-anchor-and-non-anchor-deletion";
|
|
16
|
+
}
|
|
17
|
+
if (deletionBefore && deletionAfter && !isAnchorBefore && !isAnchorAfter) {
|
|
18
|
+
return "between-two-non-anchor-deletions";
|
|
19
|
+
}
|
|
20
|
+
if ($pos.nodeBefore == null && deletionAfter && !isAnchorAfter) {
|
|
21
|
+
return "between-node-boundary-and-non-anchor-deletion";
|
|
22
|
+
}
|
|
23
|
+
if (deletionBefore && $pos.nodeAfter == null && !isAnchorBefore) {
|
|
24
|
+
return "between-non-anchor-deletion-and-node-boundary";
|
|
25
|
+
}
|
|
26
|
+
if (deletionBefore && !isAnchorBefore && $pos.nodeAfter == null) {
|
|
27
|
+
return "between-non-anchor-deletion-and-node-boundary";
|
|
28
|
+
}
|
|
29
|
+
if (deletionBefore && !isAnchorBefore) {
|
|
30
|
+
return "between-non-anchor-deletion-and-anything";
|
|
31
|
+
}
|
|
32
|
+
const insertionBefore = insertion.isInSet($pos.nodeBefore?.marks ?? []);
|
|
33
|
+
const insertionAfter = insertion.isInSet($pos.nodeAfter?.marks ?? []);
|
|
34
|
+
const ZWSP_REGEXP = new RegExp(ZWSP, "g");
|
|
35
|
+
const isZWSPBefore = $pos.nodeBefore && $pos.nodeBefore.isText && $pos.nodeBefore.textContent.replace(ZWSP_REGEXP, "") === "";
|
|
36
|
+
const isZWSPAfter = $pos.nodeAfter && $pos.nodeAfter.isText && $pos.nodeAfter.textContent.replace(ZWSP_REGEXP, "") === "";
|
|
37
|
+
if (insertionBefore && insertionAfter && isZWSPBefore && isZWSPAfter) {
|
|
38
|
+
return "between-two-zwsp-insertions";
|
|
39
|
+
}
|
|
40
|
+
if (insertionBefore && isZWSPBefore && $pos.nodeAfter == null && // A trailing inserted ZWSP is invalid only when it is acting as a boundary
|
|
41
|
+
// marker beside real content. In an otherwise empty paragraph it is the
|
|
42
|
+
// only editable anchor, so the cursor must be allowed there.
|
|
43
|
+
$pos.parent.textContent.replace(ZWSP_REGEXP, "") !== "") {
|
|
44
|
+
return "between-zwsp-insertion-and-right-node-boundary";
|
|
45
|
+
}
|
|
46
|
+
if (insertionAfter && isZWSPAfter && $pos.nodeBefore == null) {
|
|
47
|
+
return "between-zwsp-insertion-and-left-node-boundary";
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
@@ -40,9 +40,10 @@ export const joinBlocks = (trackedTransaction, stepFrom, stepTo, insertionMarkTy
|
|
|
40
40
|
to: from + 1
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
|
+
// Join from the deepest/rightmost boundary first so earlier joins do not
|
|
44
|
+
// invalidate the recorded positions for later joins and insertion cleanup.
|
|
43
45
|
pairs.reverse();
|
|
44
46
|
insertedRanges.reverse();
|
|
45
|
-
// step 3: join blocks at multiple depths as needed
|
|
46
47
|
for (const p of pairs){
|
|
47
48
|
const fromPos = p.left?.pos;
|
|
48
49
|
const toPos = p.right?.pos;
|
|
@@ -54,7 +55,9 @@ export const joinBlocks = (trackedTransaction, stepFrom, stepTo, insertionMarkTy
|
|
|
54
55
|
trackedTransaction.join(pos, depth);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
|
-
//
|
|
58
|
+
// Insertion-marked content is accepted by deletion, so it is physically
|
|
59
|
+
// removed after joins. Map through the join steps because those positions were
|
|
60
|
+
// collected in the pre-join document.
|
|
58
61
|
const joinSteps = trackedTransaction.steps.slice(startStep);
|
|
59
62
|
const joinMapping = new Mapping(joinSteps.map((s)=>s.getMap()));
|
|
60
63
|
for (const range of insertedRanges){
|
|
@@ -6,12 +6,13 @@ export const getZWSPPairsInRange = (doc, from, to, insertionMarkType)=>{
|
|
|
6
6
|
const pairs = [];
|
|
7
7
|
let currentEndingZWSP = previousZWSP;
|
|
8
8
|
doc.nodesBetween(from, to, (node, pos, parent, index)=>{
|
|
9
|
-
//
|
|
9
|
+
// A split block is represented by matching insertion ZWSP anchors at the
|
|
10
|
+
// end of the left block and the start of the right block. Pair only anchors
|
|
11
|
+
// with the same suggestion ID so unrelated insertion marks are not joined.
|
|
10
12
|
const insertionMarkId = insertionMarkType.isInSet(node.marks)?.attrs["id"];
|
|
11
13
|
if (currentEndingZWSP && index === 0 && node.text?.[0] === ZWSP && insertionMarkId && currentEndingZWSP.id === insertionMarkId && // Prevent self-pairing: don't pair a ZWSP with itself when both
|
|
12
14
|
// the previousZWSP and the current node's ZWSP are at the same position
|
|
13
15
|
currentEndingZWSP.pos !== pos) {
|
|
14
|
-
// maybe it's pos + 1
|
|
15
16
|
pairs.push({
|
|
16
17
|
left: currentEndingZWSP,
|
|
17
18
|
right: {
|
|
@@ -21,11 +22,10 @@ export const getZWSPPairsInRange = (doc, from, to, insertionMarkType)=>{
|
|
|
21
22
|
id: insertionMarkId
|
|
22
23
|
}
|
|
23
24
|
});
|
|
24
|
-
// don't return yet, we'll have to check if this text node contains
|
|
25
25
|
}
|
|
26
26
|
if (node.isText) {
|
|
27
|
-
//
|
|
28
|
-
//
|
|
27
|
+
// Once real text is encountered, the previous ending anchor can no longer
|
|
28
|
+
// describe the next block boundary.
|
|
29
29
|
currentEndingZWSP = undefined;
|
|
30
30
|
}
|
|
31
31
|
const lastTextInParent = node.isText && parent?.childCount === index + 1;
|
|
@@ -16,6 +16,9 @@ export function adjustForStartToStartTextblockDeletion(selection, step, prevStep
|
|
|
16
16
|
to: step.to
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
// ProseMirror can represent a start-to-start textblock selection as a block
|
|
20
|
+
// boundary deletion. The user-visible deletion ends at the selection head,
|
|
21
|
+
// not at the start token of the right textblock.
|
|
19
22
|
const { $from, $to } = selection;
|
|
20
23
|
if (!$from.parent.isTextblock || !$to.parent.isTextblock) {
|
|
21
24
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import { detectProseMirrorSplitBlockAfterSelectionDelete } from "./proseMirrorSplitBlockAfterSelectionDelete/index.js";
|
|
1
2
|
import { detectTipTapParagraphIntoListJoin } from "./tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.js";
|
|
2
3
|
export function detectSpecialTransactionShape(transaction) {
|
|
3
|
-
return detectTipTapParagraphIntoListJoin(transaction);
|
|
4
|
+
return detectProseMirrorSplitBlockAfterSelectionDelete(transaction) ?? detectTipTapParagraphIntoListJoin(transaction);
|
|
4
5
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { handleProseMirrorSplitBlockAfterSelectionDelete } from "./proseMirrorSplitBlockAfterSelectionDelete/index.js";
|
|
1
2
|
import { handleTipTapParagraphIntoListJoin } from "./tipTapParagraphIntoListJoin/index.js";
|
|
2
3
|
const specialTransactionShapeHandlers = [
|
|
4
|
+
// Handlers are ordered from most direct fallthrough to partial reshaping.
|
|
5
|
+
// A whole-transaction main-route escape should claim its shape before any
|
|
6
|
+
// handler tries to split a compound transaction into structure/main phases.
|
|
7
|
+
handleProseMirrorSplitBlockAfterSelectionDelete,
|
|
3
8
|
handleTipTapParagraphIntoListJoin
|
|
4
9
|
];
|
|
5
10
|
export function handleSpecialTransactionShape(args) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Transaction } from "prosemirror-state";
|
|
2
|
+
import { type ProseMirrorSplitBlockAfterSelectionDeleteShape } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detect a Transaction of a certain shape
|
|
5
|
+
* The shape that occurs when there is a non empty selection across multiple nodes,
|
|
6
|
+
* followed by an Enter keypress
|
|
7
|
+
*
|
|
8
|
+
* A two-step transaction is produced - first step deletes the selection,
|
|
9
|
+
* second step splits the final selection-containing node
|
|
10
|
+
*
|
|
11
|
+
* That second split triggers the structure tracking code path - not the main code path
|
|
12
|
+
* which causes the selection deletion to be lost in time
|
|
13
|
+
*
|
|
14
|
+
* So we bail this transaction shape out of the structure tracking,
|
|
15
|
+
* and let the main code path handle it with deletion + insertion marks
|
|
16
|
+
*
|
|
17
|
+
* @param transaction
|
|
18
|
+
* @returns
|
|
19
|
+
*/
|
|
20
|
+
export declare function detectProseMirrorSplitBlockAfterSelectionDelete(transaction: Transaction): ProseMirrorSplitBlockAfterSelectionDeleteShape | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ReplaceStep, Transform } from "prosemirror-transform";
|
|
2
|
+
import { getNodeId } from "../../wrapUnwrap/getNodeId.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detect a Transaction of a certain shape
|
|
5
|
+
* The shape that occurs when there is a non empty selection across multiple nodes,
|
|
6
|
+
* followed by an Enter keypress
|
|
7
|
+
*
|
|
8
|
+
* A two-step transaction is produced - first step deletes the selection,
|
|
9
|
+
* second step splits the final selection-containing node
|
|
10
|
+
*
|
|
11
|
+
* That second split triggers the structure tracking code path - not the main code path
|
|
12
|
+
* which causes the selection deletion to be lost in time
|
|
13
|
+
*
|
|
14
|
+
* So we bail this transaction shape out of the structure tracking,
|
|
15
|
+
* and let the main code path handle it with deletion + insertion marks
|
|
16
|
+
*
|
|
17
|
+
* @param transaction
|
|
18
|
+
* @returns
|
|
19
|
+
*/ export function detectProseMirrorSplitBlockAfterSelectionDelete(transaction) {
|
|
20
|
+
// a 2-step transaction
|
|
21
|
+
if (transaction.steps.length !== 2) return null;
|
|
22
|
+
const [deleteStep, splitStep] = transaction.steps;
|
|
23
|
+
if (!(deleteStep instanceof ReplaceStep) || !(splitStep instanceof ReplaceStep)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
// non-empty deletion step
|
|
27
|
+
if (deleteStep.from >= deleteStep.to) return null;
|
|
28
|
+
if (deleteStep.slice.content.size !== 0) return null;
|
|
29
|
+
if (hasStructureFlag(deleteStep)) return null;
|
|
30
|
+
// there is a docBefore, and step boundaries are in the different textblocks
|
|
31
|
+
const docBefore = transaction.docs[0];
|
|
32
|
+
if (!docBefore) return null;
|
|
33
|
+
if (!docBefore.resolve(deleteStep.from).parent.isTextblock) return null;
|
|
34
|
+
if (!docBefore.resolve(deleteStep.to).parent.isTextblock) return null;
|
|
35
|
+
// non empty split step
|
|
36
|
+
if (splitStep.from !== splitStep.to) return null;
|
|
37
|
+
// split step happens at the deletion step "from"
|
|
38
|
+
if (splitStep.from !== deleteStep.from) return null;
|
|
39
|
+
if (!hasStructureFlag(splitStep)) return null;
|
|
40
|
+
if (splitStep.slice.openStart !== 1 || splitStep.slice.openEnd !== 1) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (splitStep.slice.content.childCount !== 2) return null;
|
|
44
|
+
const leftSplitChild = splitStep.slice.content.child(0);
|
|
45
|
+
const rightSplitChild = splitStep.slice.content.child(1);
|
|
46
|
+
if (!leftSplitChild.isTextblock || !rightSplitChild.isTextblock) return null;
|
|
47
|
+
const leftSplitChildId = getNodeId(leftSplitChild);
|
|
48
|
+
if (!leftSplitChildId || getNodeId(rightSplitChild) !== leftSplitChildId) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const preview = new Transform(docBefore);
|
|
53
|
+
preview.step(deleteStep);
|
|
54
|
+
preview.step(splitStep);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
type: "proseMirrorSplitBlockAfterSelectionDelete",
|
|
60
|
+
deleteStep,
|
|
61
|
+
splitStep
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function hasStructureFlag(step) {
|
|
65
|
+
return step.structure === true;
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
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 { detectProseMirrorSplitBlockAfterSelectionDelete } from "./detectProseMirrorSplitBlockAfterSelectionDelete.js";
|
|
6
|
+
const schema = createSchema();
|
|
7
|
+
const LIST_BOUNDARY_DOC = {
|
|
8
|
+
type: "doc",
|
|
9
|
+
content: [
|
|
10
|
+
{
|
|
11
|
+
type: "orderedList",
|
|
12
|
+
attrs: {
|
|
13
|
+
id: "node-0"
|
|
14
|
+
},
|
|
15
|
+
content: [
|
|
16
|
+
1,
|
|
17
|
+
2,
|
|
18
|
+
3,
|
|
19
|
+
4,
|
|
20
|
+
5
|
|
21
|
+
].map((index)=>({
|
|
22
|
+
type: "listItem",
|
|
23
|
+
attrs: {
|
|
24
|
+
id: `node-${String(index * 2 - 1)}`
|
|
25
|
+
},
|
|
26
|
+
content: [
|
|
27
|
+
{
|
|
28
|
+
type: "paragraph",
|
|
29
|
+
attrs: {
|
|
30
|
+
id: `node-${String(index * 2)}`
|
|
31
|
+
},
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: `Item ${String(index)}`
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
};
|
|
44
|
+
const DELETE_STEP = {
|
|
45
|
+
stepType: "replace",
|
|
46
|
+
from: 17,
|
|
47
|
+
to: 27
|
|
48
|
+
};
|
|
49
|
+
const SPLIT_STEP = {
|
|
50
|
+
stepType: "replace",
|
|
51
|
+
from: 17,
|
|
52
|
+
to: 17,
|
|
53
|
+
slice: {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: "paragraph",
|
|
57
|
+
attrs: {
|
|
58
|
+
id: "node-4"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "paragraph",
|
|
63
|
+
attrs: {
|
|
64
|
+
id: "node-4"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
openStart: 1,
|
|
69
|
+
openEnd: 1
|
|
70
|
+
},
|
|
71
|
+
structure: true
|
|
72
|
+
};
|
|
73
|
+
function createTransaction(stepsJSON) {
|
|
74
|
+
const doc = schema.nodeFromJSON(LIST_BOUNDARY_DOC);
|
|
75
|
+
const transaction = EditorState.create({
|
|
76
|
+
doc
|
|
77
|
+
}).tr;
|
|
78
|
+
stepsJSON.forEach((stepJSON)=>{
|
|
79
|
+
transaction.step(Step.fromJSON(schema, stepJSON));
|
|
80
|
+
});
|
|
81
|
+
return transaction;
|
|
82
|
+
}
|
|
83
|
+
describe("detectProseMirrorSplitBlockAfterSelectionDelete", ()=>{
|
|
84
|
+
it("detects ProseMirror's deleteSelection plus splitBlock transaction shape", ()=>{
|
|
85
|
+
const transaction = createTransaction([
|
|
86
|
+
DELETE_STEP,
|
|
87
|
+
SPLIT_STEP
|
|
88
|
+
]);
|
|
89
|
+
const shape = detectProseMirrorSplitBlockAfterSelectionDelete(transaction);
|
|
90
|
+
expect(shape).toMatchObject({
|
|
91
|
+
type: "proseMirrorSplitBlockAfterSelectionDelete",
|
|
92
|
+
deleteStep: {
|
|
93
|
+
from: 17,
|
|
94
|
+
to: 27
|
|
95
|
+
},
|
|
96
|
+
splitStep: {
|
|
97
|
+
from: 17,
|
|
98
|
+
to: 17
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it("rejects the deletion half by itself", ()=>{
|
|
103
|
+
const transaction = createTransaction([
|
|
104
|
+
DELETE_STEP
|
|
105
|
+
]);
|
|
106
|
+
expect(detectProseMirrorSplitBlockAfterSelectionDelete(transaction)).toBe(null);
|
|
107
|
+
});
|
|
108
|
+
it("rejects a split step that is not structural", ()=>{
|
|
109
|
+
const transaction = createTransaction([
|
|
110
|
+
DELETE_STEP,
|
|
111
|
+
{
|
|
112
|
+
...SPLIT_STEP,
|
|
113
|
+
structure: false
|
|
114
|
+
}
|
|
115
|
+
]);
|
|
116
|
+
expect(detectProseMirrorSplitBlockAfterSelectionDelete(transaction)).toBe(null);
|
|
117
|
+
});
|
|
118
|
+
it("rejects split children without a shared node id", ()=>{
|
|
119
|
+
const transaction = createTransaction([
|
|
120
|
+
DELETE_STEP,
|
|
121
|
+
{
|
|
122
|
+
...SPLIT_STEP,
|
|
123
|
+
slice: {
|
|
124
|
+
...SPLIT_STEP.slice,
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: "paragraph",
|
|
128
|
+
attrs: {
|
|
129
|
+
id: "node-4"
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
type: "paragraph",
|
|
134
|
+
attrs: {
|
|
135
|
+
id: "node-5"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
]);
|
|
142
|
+
expect(detectProseMirrorSplitBlockAfterSelectionDelete(transaction)).toBe(null);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { transformToSuggestionTransaction } from "../../../transformToSuggestionTransaction.js";
|
|
2
|
+
import { detectProseMirrorSplitBlockAfterSelectionDelete } from "./detectProseMirrorSplitBlockAfterSelectionDelete.js";
|
|
3
|
+
export function handleProseMirrorSplitBlockAfterSelectionDelete(args) {
|
|
4
|
+
if (!detectProseMirrorSplitBlockAfterSelectionDelete(args.transaction)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
// Unlike the TipTap join shape, both steps belong to the main suggestion
|
|
8
|
+
// route: deletion marks preserve the selected content, and split handling
|
|
9
|
+
// marks the created split side. Structure tracking would drop the deletion.
|
|
10
|
+
return transformToSuggestionTransaction(args.transaction, args.state, args.generateId);
|
|
11
|
+
}
|
|
@@ -10,7 +10,12 @@ export interface TipTapParagraphIntoListJoinShape {
|
|
|
10
10
|
joinStep: ReplaceStep;
|
|
11
11
|
movedNode: Node;
|
|
12
12
|
}
|
|
13
|
-
export
|
|
13
|
+
export interface ProseMirrorSplitBlockAfterSelectionDeleteShape {
|
|
14
|
+
type: "proseMirrorSplitBlockAfterSelectionDelete";
|
|
15
|
+
deleteStep: ReplaceStep;
|
|
16
|
+
splitStep: ReplaceStep;
|
|
17
|
+
}
|
|
18
|
+
export type SpecialTransactionShape = TipTapParagraphIntoListJoinShape | ProseMirrorSplitBlockAfterSelectionDeleteShape;
|
|
14
19
|
export interface HandleSpecialTransactionShapeArgs {
|
|
15
20
|
transaction: Transaction;
|
|
16
21
|
state: EditorState;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -54,7 +54,8 @@ export function buildMaterializedPaths(doc) {
|
|
|
54
54
|
const leftSiblingId = leftSibling ? getNodeId(leftSibling) : null;
|
|
55
55
|
const rightSibling = parent.children[childIndex + 1];
|
|
56
56
|
const rightSiblingId = rightSibling ? getNodeId(rightSibling) : null;
|
|
57
|
-
//
|
|
57
|
+
// Store sibling IDs with each parent because revert needs an insertion
|
|
58
|
+
// anchor that survives unrelated edits better than a raw child index.
|
|
58
59
|
const parentDesc = {
|
|
59
60
|
nodeId: parentId,
|
|
60
61
|
nodeType: parent.type.name,
|
|
@@ -5,7 +5,6 @@ function trace(...args) {
|
|
|
5
5
|
if (!TRACE_ENABLED) return;
|
|
6
6
|
console.log("[deleteNodeUpwards]", "\n", ...args);
|
|
7
7
|
}
|
|
8
|
-
// delete a given node, and traverse upwards deleting parent nodes if they are now empty
|
|
9
8
|
export function deleteNodeUpwards(transform, node, pos) {
|
|
10
9
|
let $mappedPos = transform.doc.resolve(pos);
|
|
11
10
|
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", {
|
|
@@ -14,6 +13,9 @@ export function deleteNodeUpwards(transform, node, pos) {
|
|
|
14
13
|
});
|
|
15
14
|
let deleteFrom = $mappedPos.pos;
|
|
16
15
|
let deleteTo = $mappedPos.pos + node.nodeSize;
|
|
16
|
+
// Structure marks live on content nodes, but added wrappers can become empty
|
|
17
|
+
// after that content is removed. Delete upward until the next parent still has
|
|
18
|
+
// another child that should survive.
|
|
17
19
|
while($mappedPos.depth > 0){
|
|
18
20
|
const $nextMappedPos = transform.doc.resolve($mappedPos.before());
|
|
19
21
|
if ($nextMappedPos.nodeAfter?.childCount !== 1) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { deleteNodeUpwards } from "./deleteNodeUpwards.js";
|
|
2
2
|
export function revertAddOp(_op, tr, node, pos) {
|
|
3
|
+
// A Structure add marks the content node, but reverting it may also need to
|
|
4
|
+
// remove now-empty structural parents that only existed to contain it.
|
|
3
5
|
deleteNodeUpwards(tr, node, pos);
|
|
4
6
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { deleteNodeUpwards } from "./deleteNodeUpwards.js";
|
|
2
2
|
import { getNodeId } from "../getNodeId.js";
|
|
3
3
|
export function revertMoveOp(op, tr, node, pos) {
|
|
4
|
+
// The original parent chain may have been partially removed since the move.
|
|
5
|
+
// Reuse the deepest surviving parent and recreate only the missing wrappers.
|
|
4
6
|
const parent = getDeepestSurvivingParent(op.from, tr.doc);
|
|
5
7
|
const child = wrapNodeInParentChain(parent.remainingChain, node);
|
|
6
8
|
const insertTo = findInsertionPos(parent.node, parent.pos, parent.parent, child);
|
|
@@ -100,8 +102,9 @@ export function findInsertionPos(node, pos, parent, child) {
|
|
|
100
102
|
return false;
|
|
101
103
|
});
|
|
102
104
|
if (rightSibling != null) {
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
+
// If the original first-child slot is now occupied by an empty placeholder
|
|
106
|
+
// of the same type, replace it instead of inserting beside it. This avoids
|
|
107
|
+
// leaving a blank node where the moved content should be restored.
|
|
105
108
|
const firstChild = node.children[0];
|
|
106
109
|
if (parent.childSiblingIds[0] == null && firstChild?.type === child.type && firstChild.textContent === "") {
|
|
107
110
|
const from = pos != null ? pos + 1 : 0;
|
|
@@ -154,6 +154,9 @@ function buildOrderedSuggestionIds(node, suggestionId, materializedPaths) {
|
|
|
154
154
|
});
|
|
155
155
|
const suggestionIds = new Set();
|
|
156
156
|
suggestionIds.add(suggestionId);
|
|
157
|
+
// If this move is no longer at its expected destination, another Structure
|
|
158
|
+
// move must be reverted first. Build the dependency chain so reverts happen
|
|
159
|
+
// from the current outermost state back toward the requested suggestion.
|
|
157
160
|
// find first mark that doesn't have a matching op.to
|
|
158
161
|
const mismatch = markGroup.find((mark)=>{
|
|
159
162
|
const nodeId = getNodeId(mark.node);
|
|
@@ -119,6 +119,9 @@ export function suggestStructureChanges(docBefore, docAfter, structuralContextPa
|
|
|
119
119
|
});
|
|
120
120
|
const transform = new Transform(docAfter);
|
|
121
121
|
if (reason) {
|
|
122
|
+
// A reason is an intentional fallthrough, not an error. The Structure
|
|
123
|
+
// detector recognized a shape that should be handled by normal suggestion
|
|
124
|
+
// tracking.
|
|
122
125
|
return {
|
|
123
126
|
handled: false,
|
|
124
127
|
transform,
|
|
@@ -147,6 +150,9 @@ function addMarks(ops, tr, suggestionId) {
|
|
|
147
150
|
const op = ops.get(nodeId);
|
|
148
151
|
if (op == null) return true;
|
|
149
152
|
if (op.op === "move") {
|
|
153
|
+
// A provisional Structure add already means this node is not accepted yet.
|
|
154
|
+
// Moving it should not create a second review artifact until the add is
|
|
155
|
+
// accepted.
|
|
150
156
|
if (hasStructureAddMark(node)) return true;
|
|
151
157
|
const inverseMoveMark = findInverseStructureMoveMark(node, op);
|
|
152
158
|
if (inverseMoveMark) {
|
|
@@ -259,6 +265,8 @@ function containsContiguousPath(chainNodeTypes, structuralContextPath) {
|
|
|
259
265
|
// and see if it combines into a single node in the old document
|
|
260
266
|
function isSplitDerivedAdd(newNodeId, beforePaths, afterPaths, contextNodeTypes) {
|
|
261
267
|
const newNode = afterPaths.get(newNodeId)?.node;
|
|
268
|
+
// Empty after-only nodes are not considered split-derived in whole-doc diff
|
|
269
|
+
// mode. Without a step-local proof of a split, keep them as Structure adds.
|
|
262
270
|
if (!newNode || newNode.textContent === "") return false;
|
|
263
271
|
if (matchesSplitDerivedPair(newNodeId, newNode.textContent, beforePaths, afterPaths)) {
|
|
264
272
|
return true;
|
|
@@ -18,7 +18,8 @@ export function uniqueNodeIdsPlugin({ attributeName, generateID }) {
|
|
|
18
18
|
appendTransaction (transactions, oldState, newState) {
|
|
19
19
|
trace("appendTransaction");
|
|
20
20
|
const pluginState = uniqueNodeIdsPluginKey.getState(newState);
|
|
21
|
-
//
|
|
21
|
+
// Structure tracking relies on stable node IDs even for the initial
|
|
22
|
+
// document. Run once without a doc change, then only rerun after edits.
|
|
22
23
|
const docChanged = transactions.some((transaction)=>transaction.docChanged);
|
|
23
24
|
if (!docChanged && pluginState?.completedInitialRun) {
|
|
24
25
|
trace("doc not changed, skipping", [
|
|
@@ -68,12 +69,13 @@ export function ensureUniqueNodeIds(_transactions, _oldDoc, newDoc, options) {
|
|
|
68
69
|
tr.doc.descendants((node, pos)=>{
|
|
69
70
|
if (node.isText) return false;
|
|
70
71
|
const nodeId = getNodeId(node);
|
|
71
|
-
// nodeId is set and is not duplicated
|
|
72
72
|
if (nodeId != null && !nodeIds.has(nodeId)) {
|
|
73
73
|
nodeIds.add(nodeId);
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
|
-
//
|
|
76
|
+
// ProseMirror commands such as split can copy attrs onto new nodes. Duplicates
|
|
77
|
+
// must be rewritten immediately or structure diffing cannot tell which node
|
|
78
|
+
// moved and which node was newly materialized.
|
|
77
79
|
if (nodeId != null && nodeIds.has(nodeId)) {
|
|
78
80
|
const id = options.generateID();
|
|
79
81
|
nodeIds.add(id);
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { addSuggestionMarks, insertion, deletion, modification, hiddenDeletion,
|
|
|
2
2
|
export { selectSuggestion, revertSuggestion, revertSuggestions, applySuggestion, applySuggestions, enableSuggestChanges, disableSuggestChanges, toggleSuggestChanges, } from "./commands.js";
|
|
3
3
|
export { suggestChanges, suggestChangesKey, isSuggestChangesEnabled, } from "./plugin.js";
|
|
4
4
|
export { withSuggestChanges, transformToSuggestionTransaction, suggestStructureChanges as experimental_suggestStructureChanges, } from "./withSuggestChanges.js";
|
|
5
|
-
export { ensureSelection as experimental_ensureSelection, ensureSelectionKey as experimental_ensureSelectionKey, isEnsureSelectionEnabled as experimental_isEnsureSelectionEnabled, } from "./ensureSelectionPlugin.js";
|
|
5
|
+
export { ensureSelection as experimental_ensureSelection, ensureSelectionKey as experimental_ensureSelectionKey, isEnsureSelectionEnabled as experimental_isEnsureSelectionEnabled, } from "./features/ensureValidSelection/ensureSelectionPlugin.js";
|
|
6
6
|
export { guardStructureMarkAttrs } from "./features/wrapUnwrap/types.js";
|
|
7
7
|
export type { Op as StructureOp, StructureMarkAttrs, StructuralContextPath, } from "./features/wrapUnwrap/types.js";
|
|
8
8
|
export { wrappingInputRule as experimental_wrappingInputRule } from "./wrappingInputRule.js";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,6 @@ export { addSuggestionMarks, insertion, deletion, modification, hiddenDeletion,
|
|
|
2
2
|
export { selectSuggestion, revertSuggestion, revertSuggestions, applySuggestion, applySuggestions, enableSuggestChanges, disableSuggestChanges, toggleSuggestChanges } from "./commands.js";
|
|
3
3
|
export { suggestChanges, suggestChangesKey, isSuggestChangesEnabled } from "./plugin.js";
|
|
4
4
|
export { withSuggestChanges, transformToSuggestionTransaction, suggestStructureChanges as experimental_suggestStructureChanges } from "./withSuggestChanges.js";
|
|
5
|
-
export { ensureSelection as experimental_ensureSelection, ensureSelectionKey as experimental_ensureSelectionKey, isEnsureSelectionEnabled as experimental_isEnsureSelectionEnabled } from "./ensureSelectionPlugin.js";
|
|
5
|
+
export { ensureSelection as experimental_ensureSelection, ensureSelectionKey as experimental_ensureSelectionKey, isEnsureSelectionEnabled as experimental_isEnsureSelectionEnabled } from "./features/ensureValidSelection/ensureSelectionPlugin.js";
|
|
6
6
|
export { guardStructureMarkAttrs } from "./features/wrapUnwrap/types.js";
|
|
7
7
|
export { wrappingInputRule as experimental_wrappingInputRule } from "./wrappingInputRule.js";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { Schema } from "prosemirror-model";
|
|
2
|
-
export declare function createSchema(deletionMarksVisibility?: "hidden" | "visible"): Schema<"blockquote" | "text" | "
|
|
2
|
+
export declare function createSchema(deletionMarksVisibility?: "hidden" | "visible"): Schema<"blockquote" | "text" | "image" | "doc" | "orderedList" | "bulletList" | "listItem" | "paragraph" | "horizontal_rule" | "heading" | "code_block" | "hard_break" | "hardBreak", "insertion" | "deletion" | "modification" | "structure" | "code" | "em" | "link" | "strong">;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Schema, type Node } from "prosemirror-model";
|
|
2
2
|
import { type MarkBuilder, type NodeBuilder } from "prosemirror-test-builder";
|
|
3
|
-
export declare const schema: Schema<"blockquote" | "text" | "
|
|
3
|
+
export declare const schema: Schema<"blockquote" | "text" | "image" | "doc" | "orderedList" | "bulletList" | "listItem" | "paragraph" | "horizontal_rule" | "heading" | "code_block" | "hard_break", "insertion" | "deletion" | "modification" | "structure" | "code" | "em" | "link" | "strong" | "difficulty">;
|
|
4
4
|
export declare const testBuilders: { [NodeTypeName in keyof (typeof schema)["nodes"]]: NodeBuilder; } & { [MarkTypeName in keyof (typeof schema)["marks"]]: MarkBuilder; } & {
|
|
5
5
|
schema: typeof schema;
|
|
6
6
|
};
|
|
@@ -34,6 +34,10 @@ function trace(...args) {
|
|
|
34
34
|
const docBefore = transaction.docs[0];
|
|
35
35
|
const structuralContextPaths = opts?.experimental_trackStructureChanges ? getRequiredStructuralContextPaths(opts.experimental_trackStructures) : null;
|
|
36
36
|
const ensureUniqueNodeIds = opts?.experimental_ensureUniqueNodeIds;
|
|
37
|
+
// Some editor commands emit compound transactions whose final doc diff looks
|
|
38
|
+
// structural, but whose user-visible edit needs normal suggestion tracking or
|
|
39
|
+
// a split structure/main route. Detect those before Structure tracking gets
|
|
40
|
+
// first claim on the transaction.
|
|
37
41
|
const shapedTransaction = transaction.docChanged ? handleSpecialTransactionShape({
|
|
38
42
|
transaction,
|
|
39
43
|
state: this.state,
|
|
@@ -61,6 +65,9 @@ function trace(...args) {
|
|
|
61
65
|
trace("perf", "structure", "suggestStructureChanges took", Number((performance.now() - perfStructure).toFixed(2)), "ms");
|
|
62
66
|
trace("structure changes transform completed", structureChangesResult.transform);
|
|
63
67
|
if (structureChangesResult.handled) {
|
|
68
|
+
// Structure tracking is exclusive: once it handles a transaction, the
|
|
69
|
+
// main suggestion transformer will not run. Split-like false positives
|
|
70
|
+
// must bail out before this branch.
|
|
64
71
|
uniqueNodeIdsTransform.steps.forEach((step)=>{
|
|
65
72
|
transaction.step(step);
|
|
66
73
|
});
|
package/package.json
CHANGED
|
File without changes
|