@magic-marker/prosemirror-suggest-changes 0.4.1-wrap-unwrap.2 → 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/features/ensureValidSelection/ensureSelectionPlugin.js +28 -5
- package/dist/features/ensureValidSelection/selectionPosition.js +3 -3
- 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/withSuggestChanges.js +7 -0
- package/package.json +1 -1
|
@@ -36,6 +36,8 @@ export function ensureSelection() {
|
|
|
36
36
|
const newState = {
|
|
37
37
|
...state
|
|
38
38
|
};
|
|
39
|
+
// remember if one of the keys we care about was pressed
|
|
40
|
+
// this is needed for appendTransaction
|
|
39
41
|
newState.handleKeyDown.backspace = event.key === "Backspace";
|
|
40
42
|
newState.handleKeyDown.delete = event.key === "Delete";
|
|
41
43
|
newState.handleKeyDown.arrowLeft = event.key === "ArrowLeft";
|
|
@@ -53,8 +55,10 @@ export function ensureSelection() {
|
|
|
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...");
|
|
@@ -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,
|
|
@@ -106,11 +111,13 @@ function isPosValid($pos) {
|
|
|
106
111
|
}
|
|
107
112
|
function findNextValidPos($initialPos) {
|
|
108
113
|
let $pos = $initialPos;
|
|
109
|
-
// 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
|
|
110
117
|
while(!isPosValid($pos) && ($pos.nodeAfter != null || $pos.depth > 0)){
|
|
111
118
|
// first check if we can go into nodeAfter
|
|
112
119
|
if ($pos.nodeAfter != null) {
|
|
113
|
-
// 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
|
|
114
121
|
if ($pos.nodeAfter.isInline) {
|
|
115
122
|
// nodeAfter is inline - move in by one
|
|
116
123
|
$pos = $pos.doc.resolve($pos.pos + 1);
|
|
@@ -141,7 +148,9 @@ function findNextValidPos($initialPos) {
|
|
|
141
148
|
}
|
|
142
149
|
function findPreviousValidPos($initialPos) {
|
|
143
150
|
let $pos = $initialPos;
|
|
144
|
-
// 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
|
|
145
154
|
while(!isPosValid($pos) && ($pos.nodeBefore != null || $pos.depth > 0)){
|
|
146
155
|
// first check if we can go into nodeBefore
|
|
147
156
|
if ($pos.nodeBefore != null) {
|
|
@@ -159,7 +168,7 @@ function findPreviousValidPos($initialPos) {
|
|
|
159
168
|
});
|
|
160
169
|
if (localEndPos !== null) {
|
|
161
170
|
// we have a local ending position of the last inline descendant - convert it to global position
|
|
162
|
-
// move pos to start of
|
|
171
|
+
// move pos to start of nodeBefore, add 1 to "enter" nodeBefore, then add local pos
|
|
163
172
|
$pos = $pos.doc.resolve($pos.pos - $pos.nodeBefore.nodeSize + 1 + localEndPos);
|
|
164
173
|
} else {
|
|
165
174
|
// unable to find last inline descendant of nodeBefore - just skip nodeBefore altogether
|
|
@@ -173,11 +182,19 @@ function findPreviousValidPos($initialPos) {
|
|
|
173
182
|
}
|
|
174
183
|
return isPosValid($pos) ? $pos : null;
|
|
175
184
|
}
|
|
176
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Given a ResolvedPos, find closest valid pos within the same parent
|
|
187
|
+
*
|
|
188
|
+
* @param $initialPos
|
|
189
|
+
* @returns
|
|
190
|
+
*/ function findNearestValidPosInSameParent($initialPos) {
|
|
177
191
|
if (!$initialPos.parent.inlineContent) return null;
|
|
178
192
|
const start = $initialPos.start();
|
|
179
193
|
const end = $initialPos.end();
|
|
194
|
+
// take larger distance - either to the start or to the end of the parent node
|
|
180
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
|
|
181
198
|
for(let distance = 0; distance <= maxDistance; distance++){
|
|
182
199
|
const nextPos = $initialPos.pos + distance;
|
|
183
200
|
if (nextPos <= end) {
|
|
@@ -196,6 +213,9 @@ function getNewValidPosInSelectionDestinationParent($oldPos, $newPos) {
|
|
|
196
213
|
if (isPosValid($newPos)) return null;
|
|
197
214
|
if ($oldPos.parent === $newPos.parent) return null;
|
|
198
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.
|
|
199
219
|
const $sameParentPos = findNearestValidPosInSameParent($newPos);
|
|
200
220
|
trace("getNewValidPosInSelectionDestinationParent", "$sameParentPos", $sameParentPos?.pos, {
|
|
201
221
|
$oldPos,
|
|
@@ -266,6 +286,9 @@ function getNewValidPos($oldPos, $newPos, isSelectionOnly, pluginState) {
|
|
|
266
286
|
return $sameParentPos ?? getNewValidPosByDirection($newPos, getDirection($oldPos, $newPos, pluginState));
|
|
267
287
|
}
|
|
268
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.
|
|
269
292
|
if (pluginState?.handleKeyDown.backspace) return "left";
|
|
270
293
|
if ($newPos.pos > $oldPos.pos) return "right";
|
|
271
294
|
if ($newPos.pos < $oldPos.pos) return "left";
|
|
@@ -37,9 +37,9 @@ export function getInvalidSelectionPositionReason($pos) {
|
|
|
37
37
|
if (insertionBefore && insertionAfter && isZWSPBefore && isZWSPAfter) {
|
|
38
38
|
return "between-two-zwsp-insertions";
|
|
39
39
|
}
|
|
40
|
-
if (insertionBefore && isZWSPBefore && $pos.nodeAfter == null && // A
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
43
|
$pos.parent.textContent.replace(ZWSP_REGEXP, "") !== "") {
|
|
44
44
|
return "between-zwsp-insertion-and-right-node-boundary";
|
|
45
45
|
}
|
|
@@ -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);
|
|
@@ -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
|
});
|