@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.
Files changed (26) hide show
  1. package/dist/features/ensureValidSelection/ensureSelectionPlugin.js +28 -5
  2. package/dist/features/ensureValidSelection/selectionPosition.js +3 -3
  3. package/dist/features/joinBlocks/index.js +5 -2
  4. package/dist/features/joinBlocks/utils/getZWSPPairsInRange.js +5 -5
  5. package/dist/features/startToStartTextblockDeletion/index.js +3 -0
  6. package/dist/features/transactionShaping/detectSpecialTransactionShape.js +2 -1
  7. package/dist/features/transactionShaping/index.js +5 -0
  8. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.d.ts +20 -0
  9. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.js +66 -0
  10. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.test.d.ts +1 -0
  11. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.test.js +144 -0
  12. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/handleProseMirrorSplitBlockAfterSelectionDelete.d.ts +3 -0
  13. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/handleProseMirrorSplitBlockAfterSelectionDelete.js +11 -0
  14. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/index.d.ts +2 -0
  15. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/index.js +2 -0
  16. package/dist/features/transactionShaping/types.d.ts +6 -1
  17. package/dist/features/wrapUnwrap/__tests__/boundarySelectionRevert.playwright.test.d.ts +1 -0
  18. package/dist/features/wrapUnwrap/buildMaterializedPaths.js +2 -1
  19. package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.js +3 -1
  20. package/dist/features/wrapUnwrap/revert/revertAddOp.js +2 -0
  21. package/dist/features/wrapUnwrap/revert/revertMoveOp.js +5 -2
  22. package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +3 -0
  23. package/dist/features/wrapUnwrap/structureChangesPlugin.js +8 -0
  24. package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.js +5 -3
  25. package/dist/withSuggestChanges.js +7 -0
  26. 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 we need non-null nodeAfter so we can go right or non-root depth so we can go up
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 in it
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, we need either nodeBefore so we can go left, or non-root depth so we can go up
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 node before, add 1 to "enter" nodeBefore, then add local pos
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
- function findNearestValidPosInSameParent($initialPos) {
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 position like this:
41
- // <p><insertion>ZWSP</insertion>|</p>
42
- // because it means this paragraph was just created and it's empty.
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
- // step 4: remove inserted ranges
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
- // two cases: we have a left in currentPair and we're at 0 index
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
- // WE HAVE TO remove ending zwsp anyway. Either we found a pair, on the beginning of the next block
28
- // or we did not, but then we don't have a matching pair on a block boundary.
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,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,3 @@
1
+ import { type Transaction } from "prosemirror-state";
2
+ import { type HandleSpecialTransactionShapeArgs } from "../types.js";
3
+ export declare function handleProseMirrorSplitBlockAfterSelectionDelete(args: HandleSpecialTransactionShapeArgs): Transaction | null;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { detectProseMirrorSplitBlockAfterSelectionDelete } from "./detectProseMirrorSplitBlockAfterSelectionDelete.js";
2
+ export { handleProseMirrorSplitBlockAfterSelectionDelete } from "./handleProseMirrorSplitBlockAfterSelectionDelete.js";
@@ -0,0 +1,2 @@
1
+ export { detectProseMirrorSplitBlockAfterSelectionDelete } from "./detectProseMirrorSplitBlockAfterSelectionDelete.js";
2
+ export { handleProseMirrorSplitBlockAfterSelectionDelete } from "./handleProseMirrorSplitBlockAfterSelectionDelete.js";
@@ -10,7 +10,12 @@ export interface TipTapParagraphIntoListJoinShape {
10
10
  joinStep: ReplaceStep;
11
11
  movedNode: Node;
12
12
  }
13
- export type SpecialTransactionShape = TipTapParagraphIntoListJoinShape;
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;
@@ -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
- // (this node parent chain) is (parent chain of the parent node) + (the parent node itself)
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
- // special case: we need to insert as the first child, but the existing first child is an empty node of the same type
104
- // in this case, we need to replace the existing first child with the new node
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
- // do nothing if doc hasn't changed (but make sure it runs initially)
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
- // nodeId is set and it is duplicated
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magic-marker/prosemirror-suggest-changes",
3
- "version": "0.4.1-wrap-unwrap.2",
3
+ "version": "0.4.1-wrap-unwrap.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "dist/index.js",