@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.
Files changed (35) hide show
  1. package/dist/__tests__/playwrightPage.d.ts +6 -3
  2. package/dist/{ensureSelectionPlugin.js → features/ensureValidSelection/ensureSelectionPlugin.js} +71 -81
  3. package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.d.ts +1 -0
  4. package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.js +112 -0
  5. package/dist/features/ensureValidSelection/selectionPosition.d.ts +3 -0
  6. package/dist/features/ensureValidSelection/selectionPosition.js +50 -0
  7. package/dist/features/joinBlocks/index.js +5 -2
  8. package/dist/features/joinBlocks/utils/getZWSPPairsInRange.js +5 -5
  9. package/dist/features/startToStartTextblockDeletion/index.js +3 -0
  10. package/dist/features/transactionShaping/detectSpecialTransactionShape.js +2 -1
  11. package/dist/features/transactionShaping/index.js +5 -0
  12. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.d.ts +20 -0
  13. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.js +66 -0
  14. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.test.d.ts +1 -0
  15. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/detectProseMirrorSplitBlockAfterSelectionDelete.test.js +144 -0
  16. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/handleProseMirrorSplitBlockAfterSelectionDelete.d.ts +3 -0
  17. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/handleProseMirrorSplitBlockAfterSelectionDelete.js +11 -0
  18. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/index.d.ts +2 -0
  19. package/dist/features/transactionShaping/proseMirrorSplitBlockAfterSelectionDelete/index.js +2 -0
  20. package/dist/features/transactionShaping/types.d.ts +6 -1
  21. package/dist/features/wrapUnwrap/__tests__/boundarySelectionRevert.playwright.test.d.ts +1 -0
  22. package/dist/features/wrapUnwrap/buildMaterializedPaths.js +2 -1
  23. package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.js +3 -1
  24. package/dist/features/wrapUnwrap/revert/revertAddOp.js +2 -0
  25. package/dist/features/wrapUnwrap/revert/revertMoveOp.js +5 -2
  26. package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +3 -0
  27. package/dist/features/wrapUnwrap/structureChangesPlugin.js +8 -0
  28. package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.js +5 -3
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/testing/e2eTestSchema.d.ts +1 -1
  32. package/dist/testing/testBuilders.d.ts +1 -1
  33. package/dist/withSuggestChanges.js +7 -0
  34. package/package.json +1 -1
  35. /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, opts?: {
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
  }
@@ -1,6 +1,5 @@
1
1
  import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
2
- import { getSuggestionMarks } from "./utils.js";
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 (_transactions, oldState, newState) {
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(newState.selection.$anchor, getDirection(oldState.selection.$anchor, newState.selection.$anchor, pluginState));
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(newState.selection.$head, getDirection(oldState.selection.$head, newState.selection.$head, pluginState));
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
- // text selection is only valid in nodes that allow inline content
99
- // https://github.com/ProseMirror/prosemirror-state/blob/1.4.4/src/selection.ts#L219
100
- if (!$pos.parent.inlineContent) {
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 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
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 in it
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, 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
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 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
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
- function getNewValidPos($pos, dir) {
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,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
- // 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);
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" | "doc" | "paragraph" | "horizontal_rule" | "heading" | "code_block" | "image" | "hard_break" | "orderedList" | "bulletList" | "listItem" | "hardBreak", "insertion" | "deletion" | "modification" | "structure" | "code" | "em" | "link" | "strong">;
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" | "doc" | "paragraph" | "horizontal_rule" | "heading" | "code_block" | "image" | "hard_break" | "orderedList" | "bulletList" | "listItem", "insertion" | "deletion" | "modification" | "structure" | "code" | "em" | "link" | "strong" | "difficulty">;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magic-marker/prosemirror-suggest-changes",
3
- "version": "0.4.1-wrap-unwrap.1",
3
+ "version": "0.4.1-wrap-unwrap.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "dist/index.js",