@magic-marker/prosemirror-suggest-changes 0.4.0 → 0.4.1-wrap-unwrap.2

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 (94) hide show
  1. package/dist/__tests__/playwrightHelpers.d.ts +2 -2
  2. package/dist/__tests__/playwrightPage.d.ts +53 -2
  3. package/dist/commands.js +222 -43
  4. package/dist/{ensureSelectionPlugin.js → features/ensureValidSelection/ensureSelectionPlugin.js} +44 -77
  5. package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.d.ts +1 -0
  6. package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.js +112 -0
  7. package/dist/features/ensureValidSelection/selectionPosition.d.ts +3 -0
  8. package/dist/features/ensureValidSelection/selectionPosition.js +50 -0
  9. package/dist/features/joinOnDelete/__tests__/joinOnDeleteInLists.playwright.test.d.ts +1 -0
  10. package/dist/features/joinOnDelete/__tests__/joinOnDeleteInListsTipTapStyle.playwright.test.d.ts +1 -0
  11. package/dist/features/joinOnDelete/__tests__/listWithJoinsAndStructureMarks.playwright.test.d.ts +1 -0
  12. package/dist/features/joinOnDelete/index.d.ts +4 -19
  13. package/dist/features/joinOnDelete/index.js +166 -53
  14. package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.d.ts +6 -0
  15. package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.js +24 -0
  16. package/dist/features/joinOnDelete/types.d.ts +36 -0
  17. package/dist/features/joinOnDelete/types.js +23 -0
  18. package/dist/features/transactionShaping/detectSpecialTransactionShape.d.ts +3 -0
  19. package/dist/features/transactionShaping/detectSpecialTransactionShape.js +4 -0
  20. package/dist/features/transactionShaping/index.d.ts +3 -0
  21. package/dist/features/transactionShaping/index.js +11 -0
  22. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.d.ts +3 -0
  23. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.js +48 -0
  24. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.d.ts +1 -0
  25. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.js +188 -0
  26. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.d.ts +3 -0
  27. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.js +69 -0
  28. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.d.ts +2 -0
  29. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.js +2 -0
  30. package/dist/features/transactionShaping/types.d.ts +20 -0
  31. package/dist/features/transactionShaping/types.js +1 -0
  32. package/dist/features/wrapUnwrap/__tests__/blockquoteStructure.playwright.test.d.ts +1 -0
  33. package/dist/features/wrapUnwrap/__tests__/buildMaterializedPaths.test.d.ts +1 -0
  34. package/dist/features/wrapUnwrap/__tests__/listStructure.playwright.test.d.ts +1 -0
  35. package/dist/features/wrapUnwrap/__tests__/listStructureTextEdits.playwright.test.d.ts +1 -0
  36. package/dist/features/wrapUnwrap/__tests__/splitDetection.test.d.ts +1 -0
  37. package/dist/features/wrapUnwrap/addIdAttr.d.ts +2 -0
  38. package/dist/features/wrapUnwrap/addIdAttr.js +60 -0
  39. package/dist/features/wrapUnwrap/apply/applyStructureSuggestions.d.ts +10 -0
  40. package/dist/features/wrapUnwrap/apply/applyStructureSuggestions.js +41 -0
  41. package/dist/features/wrapUnwrap/apply/index.d.ts +5 -0
  42. package/dist/features/wrapUnwrap/apply/index.js +21 -0
  43. package/dist/features/wrapUnwrap/areEquivalentStructureMarks.d.ts +2 -0
  44. package/dist/features/wrapUnwrap/areEquivalentStructureMarks.js +17 -0
  45. package/dist/features/wrapUnwrap/buildMaterializedPaths.d.ts +3 -0
  46. package/dist/features/wrapUnwrap/buildMaterializedPaths.js +82 -0
  47. package/dist/features/wrapUnwrap/constants.d.ts +3 -0
  48. package/dist/features/wrapUnwrap/constants.js +3 -0
  49. package/dist/features/wrapUnwrap/generateUniqueNodeId.d.ts +1 -0
  50. package/dist/features/wrapUnwrap/generateUniqueNodeId.js +6 -0
  51. package/dist/features/wrapUnwrap/getNodeId.d.ts +2 -0
  52. package/dist/features/wrapUnwrap/getNodeId.js +5 -0
  53. package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.d.ts +3 -0
  54. package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.js +37 -0
  55. package/dist/features/wrapUnwrap/revert/index.d.ts +5 -0
  56. package/dist/features/wrapUnwrap/revert/index.js +19 -0
  57. package/dist/features/wrapUnwrap/revert/revertAddOp.d.ts +4 -0
  58. package/dist/features/wrapUnwrap/revert/revertAddOp.js +4 -0
  59. package/dist/features/wrapUnwrap/revert/revertMoveOp.d.ts +16 -0
  60. package/dist/features/wrapUnwrap/revert/revertMoveOp.js +122 -0
  61. package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.d.ts +10 -0
  62. package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +236 -0
  63. package/dist/features/wrapUnwrap/sameParentChain.d.ts +2 -0
  64. package/dist/features/wrapUnwrap/sameParentChain.js +4 -0
  65. package/dist/features/wrapUnwrap/structureChangesPlugin.d.ts +17 -0
  66. package/dist/features/wrapUnwrap/structureChangesPlugin.js +299 -0
  67. package/dist/features/wrapUnwrap/types.d.ts +54 -0
  68. package/dist/features/wrapUnwrap/types.js +23 -0
  69. package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.d.ts +17 -0
  70. package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.js +106 -0
  71. package/dist/generateId.js +31 -4
  72. package/dist/index.d.ts +6 -3
  73. package/dist/index.js +5 -3
  74. package/dist/listInputRules.d.ts +2 -0
  75. package/dist/listInputRules.js +14 -0
  76. package/dist/rebaseStep.d.ts +9 -0
  77. package/dist/rebaseStep.js +11 -0
  78. package/dist/replaceStep.d.ts +1 -1
  79. package/dist/replaceStep.js +1 -0
  80. package/dist/schema.d.ts +2 -1
  81. package/dist/schema.js +37 -1
  82. package/dist/testing/e2eTestSchema.d.ts +2 -0
  83. package/dist/testing/testBuilders.d.ts +1 -2
  84. package/dist/transformToSuggestionTransaction.d.ts +22 -0
  85. package/dist/transformToSuggestionTransaction.js +101 -0
  86. package/dist/utils.d.ts +1 -0
  87. package/dist/utils.js +6 -2
  88. package/dist/withSuggestChanges.d.ts +11 -14
  89. package/dist/withSuggestChanges.js +64 -94
  90. package/dist/wrappingInputRule.d.ts +4 -0
  91. package/dist/wrappingInputRule.js +28 -0
  92. package/package.json +1 -1
  93. package/src/features/joinOnDelete/README.md +0 -8
  94. /package/dist/{ensureSelectionPlugin.d.ts → features/ensureValidSelection/ensureSelectionPlugin.d.ts} +0 -0
@@ -140,9 +140,9 @@ export declare function assertReverted(finalState: EditorState, initialState: Ed
140
140
  * This helper replaces the editor document with the provided JSON,
141
141
  * clears transactions, and returns initial state.
142
142
  */
143
- export declare function setupDocFromJSON(page: Page, docJSON: unknown): Promise<{
143
+ export declare function setupDocFromJSON(page: Page, docJSON: object): Promise<{
144
144
  initialState: EditorState;
145
- initialDoc: unknown;
145
+ initialDoc: object;
146
146
  }>;
147
147
  /**
148
148
  * Assert that document fully reverted to initial state.
@@ -1,15 +1,66 @@
1
+ import { type Node } from "prosemirror-model";
1
2
  import { type Locator, type Page } from "@playwright/test";
2
3
  export declare class EditorPage {
3
4
  readonly page: Page;
5
+ readonly deletionMarksVisibility: "hidden" | "visible";
4
6
  private readonly selectors;
5
- constructor(page: Page);
7
+ constructor(page: Page, deletionMarksVisibility?: "hidden" | "visible");
6
8
  get editor(): Locator;
7
- getParagraphText(index: number): Promise<string>;
9
+ getParagraphText(index: number, childIndexes?: number[]): Promise<string>;
10
+ getParagraphAt(index: number): Locator;
8
11
  getParagraphCount(): Promise<number>;
12
+ getListItems(): Locator;
13
+ getParagraphs(): Locator;
14
+ getListItemCount(): Promise<number>;
9
15
  getProseMirrorMarkCount(name: string): Promise<number>;
16
+ getProseMirrorMarksJSON(): Promise<unknown[]>;
10
17
  getProseMirrorSelection(): Promise<{
11
18
  anchor: number;
12
19
  head: number;
13
20
  }>;
14
21
  getDOMTextContentOfChildAtIndex(index: number): Promise<string>;
22
+ getDocJSON(): Promise<object>;
23
+ getCurrentDoc(): Promise<Node>;
24
+ getCurrentAndExpectedDoc(expectedDocJSON: object): Promise<{
25
+ currentDoc: Node;
26
+ expectedDoc: Node;
27
+ }>;
28
+ revertSuggestion(suggestionId: number): Promise<void>;
29
+ revertAll(): Promise<void>;
30
+ applyAll(): Promise<void>;
31
+ /**
32
+ * Perform an action and wait for ProseMirror state to update.
33
+ * Stores the current state reference before pressing, then polls until
34
+ * editor.view.state is a different object (ProseMirror creates a new
35
+ * immutable state on every transaction).
36
+ */
37
+ private doActionAndWaitForState;
38
+ /**
39
+ * Press a key and wait for ProseMirror state to update.
40
+ * Stores the current state reference before pressing, then polls until
41
+ * editor.view.state is a different object (ProseMirror creates a new
42
+ * immutable state on every transaction).
43
+ */
44
+ pressKey(key: string, opts?: {
45
+ waitForSelectionChange?: boolean;
46
+ }): Promise<void>;
47
+ /**
48
+ * Insert text and wait for ProseMirror state to update.
49
+ * Stores the current state reference before pressing, then polls until
50
+ * editor.view.state is a different object (ProseMirror creates a new
51
+ * immutable state on every transaction).
52
+ */
53
+ insertText(text: string): Promise<void>;
54
+ /**
55
+ * Press a key multiple times, waiting for editor state update after each press.
56
+ */
57
+ pressKeyMultiple(key: string, count: number, opts?: {
58
+ waitForSelectionChange?: boolean;
59
+ }): Promise<void>;
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>;
15
66
  }
package/dist/commands.js CHANGED
@@ -5,6 +5,9 @@ import { suggestChangesKey } from "./plugin.js";
5
5
  import { getSuggestionMarks } from "./utils.js";
6
6
  import { ZWSP } from "./constants.js";
7
7
  import { maybeRevertJoinMark } from "./features/joinOnDelete/index.js";
8
+ import { isJoinMark } from "./features/joinOnDelete/types.js";
9
+ import { revertAllStructureSuggestions, revertStructureSuggestion } from "./features/wrapUnwrap/revert/index.js";
10
+ import { applyAllStructureSuggestions, applyStructureSuggestion } from "./features/wrapUnwrap/apply/index.js";
8
11
  /**
9
12
  * Given a node and a transform, add a set of steps to the
10
13
  * transform that applies all marks of type markTypeToApply
@@ -29,6 +32,7 @@ import { maybeRevertJoinMark } from "./features/joinOnDelete/index.js";
29
32
  tr.removeNodeMark(0, markTypeToApply);
30
33
  }
31
34
  }
35
+ const restoredStructureSuggestionIds = new Set();
32
36
  node.descendants((child, pos)=>{
33
37
  if (from !== undefined && pos < from) {
34
38
  return true;
@@ -60,8 +64,12 @@ import { maybeRevertJoinMark } from "./features/joinOnDelete/index.js";
60
64
  const insertionTo = insertionFrom + child.nodeSize;
61
65
  if (child.isInline) {
62
66
  tr.removeMark(insertionFrom, insertionTo, markTypeToApply);
63
- const reverted = maybeRevertJoinMark(tr, insertionFrom, insertionTo, child, markTypeToApply);
64
- if (!reverted && child.text === ZWSP) {
67
+ const joinRevertResult = maybeRevertJoinMark(tr, insertionFrom, insertionTo, child, markTypeToApply);
68
+ // reverting a join mark may produce new structure marks that were serialized in the join metadata
69
+ if (joinRevertResult) {
70
+ joinRevertResult.restoredStructureSuggestionIds.forEach((id)=>restoredStructureSuggestionIds.add(id));
71
+ }
72
+ if (!joinRevertResult && child.text === ZWSP) {
65
73
  tr.delete(insertionFrom, insertionTo);
66
74
  }
67
75
  } else {
@@ -69,6 +77,50 @@ import { maybeRevertJoinMark } from "./features/joinOnDelete/index.js";
69
77
  }
70
78
  return true;
71
79
  });
80
+ return {
81
+ restoredStructureSuggestionIds
82
+ };
83
+ }
84
+ /**
85
+ * Collect suggestion IDs of join marks in the node
86
+ *
87
+ * @param node
88
+ * @param deletion
89
+ * @returns an array of suggestion IDs
90
+ */ function findJoinSuggestionIds(node, deletion) {
91
+ const joinSuggestionIds = [];
92
+ node.descendants((child)=>{
93
+ const mark = deletion.isInSet(child.marks);
94
+ if (!mark || !isJoinMark(mark)) return true;
95
+ if (!child.isText || child.text !== ZWSP) return true;
96
+ joinSuggestionIds.push(mark.attrs.id);
97
+ return true;
98
+ });
99
+ return joinSuggestionIds.reverse();
100
+ }
101
+ /**
102
+ * Revert suggestions revealed by reverting a join mark
103
+ * Prioritize reverting revealed suggestions with the same id as the join mark
104
+ * (that means they are linked to the join mark and has to be reverted together as one)
105
+ * Revert other revealed suggestions as well so the user doesn't have to revert multiple times at the same place
106
+ *
107
+ * @param tr
108
+ * @param suggestionId
109
+ * @param restoredStructureSuggestionIds
110
+ */ function revertRestoredStructureSuggestions(tr, suggestionId, restoredStructureSuggestionIds) {
111
+ if (restoredStructureSuggestionIds.has(suggestionId)) {
112
+ const restoredStructureTransform = revertStructureSuggestion(tr.doc, suggestionId);
113
+ restoredStructureTransform.steps.forEach((step)=>{
114
+ tr.step(step);
115
+ });
116
+ }
117
+ restoredStructureSuggestionIds.forEach((restoredSuggestionId)=>{
118
+ if (restoredSuggestionId === suggestionId) return;
119
+ const restoredStructureTransform = revertStructureSuggestion(tr.doc, restoredSuggestionId);
120
+ restoredStructureTransform.steps.forEach((step)=>{
121
+ tr.step(step);
122
+ });
123
+ });
72
124
  }
73
125
  function revertModifications(node, pos, tr) {
74
126
  const { modification } = getSuggestionMarks(node.type.schema);
@@ -134,20 +186,42 @@ function applyModificationsToTransform(node, tr, dir, suggestionId, from, to) {
134
186
  }
135
187
  export function applySuggestionsToNode(node) {
136
188
  const { deletion, insertion } = getSuggestionMarks(node.type.schema);
137
- const transform = new Transform(node);
138
- applySuggestionsToTransform(node, transform, insertion, deletion);
139
- applyModificationsToTransform(node, transform, 1);
140
- return transform.doc;
189
+ // first, create a structure transform that applies all structure changes on the given node
190
+ const structureTransform = applyAllStructureSuggestions(node);
191
+ // then start a clear transform from the document where the structure changes are applied
192
+ const suggestionsTransform = new Transform(structureTransform.doc);
193
+ applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, insertion, deletion);
194
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, 1);
195
+ // replay suggestion transform on top of the structure transform
196
+ suggestionsTransform.steps.forEach((step)=>{
197
+ structureTransform.step(step);
198
+ });
199
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc);
200
+ secondStructureTransform.steps.forEach((step)=>{
201
+ structureTransform.step(step);
202
+ });
203
+ return structureTransform.doc;
141
204
  }
142
205
  export function applySuggestionsToRange(doc, from, to) {
206
+ const { deletion, insertion } = getSuggestionMarks(doc.type.schema);
143
207
  // blockRange can only return null if a predicate is provided
144
208
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
145
209
  const nodeRange = doc.resolve(from).blockRange(doc.resolve(to));
146
- const { deletion, insertion } = getSuggestionMarks(doc.type.schema);
147
- const transform = new Transform(doc);
148
- applySuggestionsToTransform(doc, transform, insertion, deletion, undefined, nodeRange.start, nodeRange.end);
149
- applyModificationsToTransform(doc, transform, 1, undefined, nodeRange.start, nodeRange.end);
150
- return transform.doc.slice(transform.mapping.map(from), transform.mapping.map(to));
210
+ // create a structure transform that applies all structure changes on the given node range
211
+ const structureTransform = applyAllStructureSuggestions(doc, nodeRange.start, nodeRange.end);
212
+ // then start a clear transform from the document where the structure changes are applied
213
+ const suggestionsTransform = new Transform(structureTransform.doc);
214
+ applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, insertion, deletion, undefined, nodeRange.start, nodeRange.end);
215
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, 1, undefined, nodeRange.start, nodeRange.end);
216
+ // replay suggestion transform on top of the structure transform
217
+ suggestionsTransform.steps.forEach((step)=>{
218
+ structureTransform.step(step);
219
+ });
220
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc, structureTransform.mapping.map(from), structureTransform.mapping.map(to));
221
+ secondStructureTransform.steps.forEach((step)=>{
222
+ structureTransform.step(step);
223
+ });
224
+ return structureTransform.doc.slice(structureTransform.mapping.map(from), structureTransform.mapping.map(to));
151
225
  }
152
226
  /**
153
227
  * Command that applies all tracked changes in a document.
@@ -157,13 +231,30 @@ export function applySuggestionsToRange(doc, from, to) {
157
231
  * contents left in the doc.
158
232
  */ export function applySuggestions(state, dispatch) {
159
233
  const { deletion, insertion } = getSuggestionMarks(state.schema);
160
- const tr = state.tr;
161
- applySuggestionsToTransform(state.doc, tr, insertion, deletion);
162
- applyModificationsToTransform(tr.doc, tr, 1);
163
- tr.setMeta(suggestChangesKey, {
234
+ // create a structure transform that applies all structure changes on the given document
235
+ const structureTransform = applyAllStructureSuggestions(state.doc);
236
+ // then start a clear transform from the document where the structure changes are applied
237
+ const suggestionsTransform = new Transform(structureTransform.doc);
238
+ applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, insertion, deletion);
239
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, 1);
240
+ // replay suggestion transform on top of the structure transform
241
+ suggestionsTransform.steps.forEach((step)=>{
242
+ structureTransform.step(step);
243
+ });
244
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc);
245
+ secondStructureTransform.steps.forEach((step)=>{
246
+ structureTransform.step(step);
247
+ });
248
+ // apply the structure transform to the transaction
249
+ const transaction = state.tr;
250
+ structureTransform.steps.forEach((step)=>{
251
+ transaction.step(step);
252
+ });
253
+ if (!transaction.steps.length) return false;
254
+ transaction.setMeta(suggestChangesKey, {
164
255
  skip: true
165
256
  });
166
- dispatch?.(tr);
257
+ dispatch?.(transaction);
167
258
  return true;
168
259
  }
169
260
  /**
@@ -175,13 +266,30 @@ export function applySuggestionsToRange(doc, from, to) {
175
266
  */ export function applySuggestionsInRange(from, to) {
176
267
  return (state, dispatch)=>{
177
268
  const { deletion, insertion } = getSuggestionMarks(state.schema);
178
- const tr = state.tr;
179
- applySuggestionsToTransform(state.doc, tr, insertion, deletion, undefined, from, to);
180
- applyModificationsToTransform(tr.doc, tr, 1, undefined, from, to);
181
- tr.setMeta(suggestChangesKey, {
269
+ // create a structure transform that applies all structure changes on the given node range
270
+ const structureTransform = applyAllStructureSuggestions(state.doc, from, to);
271
+ // then start a clear transform from the document where the structure changes are applied
272
+ const suggestionsTransform = new Transform(structureTransform.doc);
273
+ applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, insertion, deletion, undefined, from, to);
274
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, 1, undefined, from, to);
275
+ // replay suggestion transform on top of the structure transform
276
+ suggestionsTransform.steps.forEach((step)=>{
277
+ structureTransform.step(step);
278
+ });
279
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc, from === undefined ? undefined : structureTransform.mapping.map(from), to === undefined ? undefined : structureTransform.mapping.map(to));
280
+ secondStructureTransform.steps.forEach((step)=>{
281
+ structureTransform.step(step);
282
+ });
283
+ // apply the structure transform to the transaction
284
+ const transaction = state.tr;
285
+ structureTransform.steps.forEach((step)=>{
286
+ transaction.step(step);
287
+ });
288
+ if (!transaction.steps.length) return false;
289
+ transaction.setMeta(suggestChangesKey, {
182
290
  skip: true
183
291
  });
184
- dispatch?.(tr);
292
+ dispatch?.(transaction);
185
293
  return true;
186
294
  };
187
295
  }
@@ -194,14 +302,26 @@ export function applySuggestionsToRange(doc, from, to) {
194
302
  */ export function applySuggestion(suggestionId, from, to) {
195
303
  return (state, dispatch)=>{
196
304
  const { deletion, insertion } = getSuggestionMarks(state.schema);
197
- const tr = state.tr;
198
- applySuggestionsToTransform(state.doc, tr, insertion, deletion, suggestionId, from, to);
199
- applyModificationsToTransform(tr.doc, tr, 1, undefined, from, to);
200
- if (!tr.steps.length) return false;
201
- tr.setMeta(suggestChangesKey, {
305
+ // create a structure transform that applies the given structure change on the given node
306
+ const structureTransform = applyStructureSuggestion(state.doc, suggestionId);
307
+ // then start a clear transform from the document where the structure changes are applied
308
+ const suggestionsTransform = new Transform(structureTransform.doc);
309
+ applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, insertion, deletion, suggestionId, from, to);
310
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, 1, undefined, from, to);
311
+ // replay suggestion transform on top of the structure transform
312
+ suggestionsTransform.steps.forEach((step)=>{
313
+ structureTransform.step(step);
314
+ });
315
+ // apply the structure transform to the transaction
316
+ const transaction = state.tr;
317
+ structureTransform.steps.forEach((step)=>{
318
+ transaction.step(step);
319
+ });
320
+ if (!transaction.steps.length) return false;
321
+ transaction.setMeta(suggestChangesKey, {
202
322
  skip: true
203
323
  });
204
- dispatch?.(tr);
324
+ dispatch?.(transaction);
205
325
  return true;
206
326
  };
207
327
  }
@@ -213,13 +333,40 @@ export function applySuggestionsToRange(doc, from, to) {
213
333
  * Modifications tracked in modification marks will be reverted.
214
334
  */ export function revertSuggestions(state, dispatch) {
215
335
  const { deletion, insertion } = getSuggestionMarks(state.schema);
216
- const tr = state.tr;
217
- applySuggestionsToTransform(state.doc, tr, deletion, insertion);
218
- applyModificationsToTransform(tr.doc, tr, -1);
219
- tr.setMeta(suggestChangesKey, {
336
+ // create a structure transform that reverts all structure changes on the given document
337
+ const structureTransform = revertAllStructureSuggestions(state.doc);
338
+ // revert all join marks first as well as any structure marks they contain serialized
339
+ const joinSuggestionIds = findJoinSuggestionIds(structureTransform.doc, deletion);
340
+ joinSuggestionIds.forEach((suggestionId)=>{
341
+ const suggestionsTransform = new Transform(structureTransform.doc);
342
+ const { restoredStructureSuggestionIds } = applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, deletion, insertion, suggestionId);
343
+ suggestionsTransform.steps.forEach((step)=>{
344
+ structureTransform.step(step);
345
+ });
346
+ revertRestoredStructureSuggestions(structureTransform, suggestionId, restoredStructureSuggestionIds);
347
+ });
348
+ // then start a clear transform from the document where the structure changes and join marks are reverted
349
+ const suggestionsTransform = new Transform(structureTransform.doc);
350
+ applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, deletion, insertion);
351
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, -1);
352
+ // replay suggestion transform on top of the structure transform
353
+ suggestionsTransform.steps.forEach((step)=>{
354
+ structureTransform.step(step);
355
+ });
356
+ const secondStructureTransform = revertAllStructureSuggestions(structureTransform.doc);
357
+ secondStructureTransform.steps.forEach((step)=>{
358
+ structureTransform.step(step);
359
+ });
360
+ // apply the structure transform to the transaction
361
+ const transaction = state.tr;
362
+ structureTransform.steps.forEach((step)=>{
363
+ transaction.step(step);
364
+ });
365
+ if (!transaction.steps.length) return false;
366
+ transaction.setMeta(suggestChangesKey, {
220
367
  skip: true
221
368
  });
222
- dispatch?.(tr);
369
+ dispatch?.(transaction);
223
370
  return true;
224
371
  }
225
372
  /**
@@ -231,13 +378,30 @@ export function applySuggestionsToRange(doc, from, to) {
231
378
  */ export function revertSuggestionsInRange(from, to) {
232
379
  return (state, dispatch)=>{
233
380
  const { deletion, insertion } = getSuggestionMarks(state.schema);
234
- const tr = state.tr;
235
- applySuggestionsToTransform(state.doc, tr, deletion, insertion, undefined, from, to);
236
- applyModificationsToTransform(tr.doc, tr, -1, undefined, from, to);
237
- tr.setMeta(suggestChangesKey, {
381
+ // create a structure transform that reverts all structure changes on the given node range
382
+ const structureTransform = revertAllStructureSuggestions(state.doc, from, to);
383
+ // then start a clear transform from the document where the structure changes are reverted
384
+ const suggestionsTransform = new Transform(structureTransform.doc);
385
+ applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, deletion, insertion, undefined, from, to);
386
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, -1, undefined, from, to);
387
+ // replay suggestion transform on top of the structure transform
388
+ suggestionsTransform.steps.forEach((step)=>{
389
+ structureTransform.step(step);
390
+ });
391
+ const secondStructureTransform = revertAllStructureSuggestions(structureTransform.doc, from === undefined ? undefined : structureTransform.mapping.map(from), to === undefined ? undefined : structureTransform.mapping.map(to));
392
+ secondStructureTransform.steps.forEach((step)=>{
393
+ structureTransform.step(step);
394
+ });
395
+ // apply the structure transform to the transaction
396
+ const transaction = state.tr;
397
+ structureTransform.steps.forEach((step)=>{
398
+ transaction.step(step);
399
+ });
400
+ if (!transaction.steps.length) return false;
401
+ transaction.setMeta(suggestChangesKey, {
238
402
  skip: true
239
403
  });
240
- dispatch?.(tr);
404
+ dispatch?.(transaction);
241
405
  return true;
242
406
  };
243
407
  }
@@ -250,14 +414,29 @@ export function applySuggestionsToRange(doc, from, to) {
250
414
  */ export function revertSuggestion(suggestionId, from, to) {
251
415
  return (state, dispatch)=>{
252
416
  const { deletion, insertion } = getSuggestionMarks(state.schema);
253
- const tr = state.tr;
254
- applySuggestionsToTransform(state.doc, tr, deletion, insertion, suggestionId, from, to);
255
- if (!tr.steps.length) return false;
256
- tr.setMeta(suggestChangesKey, {
417
+ // create a structure transform that reverts the given structure change on the given node
418
+ const structureTransform = revertStructureSuggestion(state.doc, suggestionId);
419
+ // then start a clear transform from the document where the structure changes are reverted
420
+ const suggestionsTransform = new Transform(structureTransform.doc);
421
+ const { restoredStructureSuggestionIds } = applySuggestionsToTransform(suggestionsTransform.doc, suggestionsTransform, deletion, insertion, suggestionId, from, to);
422
+ applyModificationsToTransform(suggestionsTransform.doc, suggestionsTransform, -1, undefined, from, to);
423
+ // replay suggestion transform on top of the structure transform
424
+ suggestionsTransform.steps.forEach((step)=>{
425
+ structureTransform.step(step);
426
+ });
427
+ // in case there are structure marks revealed after join mark revert,
428
+ // revert them as well
429
+ revertRestoredStructureSuggestions(structureTransform, suggestionId, restoredStructureSuggestionIds);
430
+ // apply the structure transform to the transaction
431
+ const transaction = state.tr;
432
+ structureTransform.steps.forEach((step)=>{
433
+ transaction.step(step);
434
+ });
435
+ if (!transaction.steps.length) return false;
436
+ transaction.setMeta(suggestChangesKey, {
257
437
  skip: true
258
438
  });
259
- applyModificationsToTransform(tr.doc, tr, -1, undefined, from, to);
260
- dispatch?.(tr);
439
+ dispatch?.(transaction);
261
440
  return true;
262
441
  };
263
442
  }
@@ -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
@@ -47,8 +46,9 @@ export function ensureSelection() {
47
46
  }
48
47
  }
49
48
  },
50
- appendTransaction (_transactions, oldState, newState) {
49
+ appendTransaction (transactions, oldState, newState) {
51
50
  const pluginState = ensureSelectionKey.getState(newState);
51
+ const isSelectionOnly = transactions.every((tr)=>!tr.docChanged);
52
52
  if (!(newState.selection instanceof TextSelection)) {
53
53
  return null;
54
54
  }
@@ -58,12 +58,12 @@ export function ensureSelection() {
58
58
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
59
59
  if (TRACE_ENABLED) console.groupCollapsed("[ensureSelectionPlugin]", "appendTransaction");
60
60
  trace("appendTransaction", "search for new valid $anchor...");
61
- let $newAnchor = getNewValidPos(newState.selection.$anchor, getDirection(oldState.selection.$anchor, newState.selection.$anchor, pluginState));
61
+ let $newAnchor = getNewValidPos(oldState.selection.$anchor, newState.selection.$anchor, isSelectionOnly, pluginState);
62
62
  trace("appendTransaction", "new valid $anchor", $newAnchor?.pos, {
63
63
  $newAnchor
64
64
  });
65
65
  trace("appendTransaction", "search for new valid $head...");
66
- let $newHead = getNewValidPos(newState.selection.$head, getDirection(oldState.selection.$head, newState.selection.$head, pluginState));
66
+ let $newHead = newState.selection.empty ? $newAnchor : getNewValidPos(oldState.selection.$head, newState.selection.$head, isSelectionOnly, pluginState);
67
67
  trace("appendTransaction", "new valid $head", $newHead?.pos, {
68
68
  $newHead
69
69
  });
@@ -95,77 +95,9 @@ export function isEnsureSelectionEnabled() {
95
95
  return true;
96
96
  }
97
97
  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.textContent.replace(ZWSP_REGEXP, "") === "";
151
- const isZWSPAfter = $pos.nodeAfter && $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", {
98
+ const invalidReason = getInvalidSelectionPositionReason($pos);
99
+ if (invalidReason) {
100
+ trace("isPosValid", $pos.pos, "pos invalid", `reason: ${invalidReason}`, {
169
101
  $pos
170
102
  });
171
103
  return false;
@@ -241,7 +173,38 @@ function findPreviousValidPos($initialPos) {
241
173
  }
242
174
  return isPosValid($pos) ? $pos : null;
243
175
  }
244
- function getNewValidPos($pos, dir) {
176
+ function findNearestValidPosInSameParent($initialPos) {
177
+ if (!$initialPos.parent.inlineContent) return null;
178
+ const start = $initialPos.start();
179
+ const end = $initialPos.end();
180
+ const maxDistance = Math.max($initialPos.pos - start, end - $initialPos.pos);
181
+ for(let distance = 0; distance <= maxDistance; distance++){
182
+ const nextPos = $initialPos.pos + distance;
183
+ if (nextPos <= end) {
184
+ const $nextPos = $initialPos.doc.resolve(nextPos);
185
+ if (isPosValid($nextPos)) return $nextPos;
186
+ }
187
+ const prevPos = $initialPos.pos - distance;
188
+ if (distance > 0 && prevPos >= start) {
189
+ const $prevPos = $initialPos.doc.resolve(prevPos);
190
+ if (isPosValid($prevPos)) return $prevPos;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+ function getNewValidPosInSelectionDestinationParent($oldPos, $newPos) {
196
+ if (isPosValid($newPos)) return null;
197
+ if ($oldPos.parent === $newPos.parent) return null;
198
+ if (!$newPos.parent.inlineContent) return null;
199
+ const $sameParentPos = findNearestValidPosInSameParent($newPos);
200
+ trace("getNewValidPosInSelectionDestinationParent", "$sameParentPos", $sameParentPos?.pos, {
201
+ $oldPos,
202
+ $newPos,
203
+ $sameParentPos
204
+ });
205
+ return $sameParentPos;
206
+ }
207
+ function getNewValidPosByDirection($pos, dir) {
245
208
  if (isPosValid($pos)) return $pos;
246
209
  trace("getNewValidPos for", $pos.pos, {
247
210
  $pos,
@@ -298,6 +261,10 @@ function getNewValidPos($pos, dir) {
298
261
  const prevDist = Math.abs($pos.pos - $prevValidPos.pos);
299
262
  return nextDist <= prevDist ? $nextValidPos : $prevValidPos;
300
263
  }
264
+ function getNewValidPos($oldPos, $newPos, isSelectionOnly, pluginState) {
265
+ const $sameParentPos = isSelectionOnly ? getNewValidPosInSelectionDestinationParent($oldPos, $newPos) : null;
266
+ return $sameParentPos ?? getNewValidPosByDirection($newPos, getDirection($oldPos, $newPos, pluginState));
267
+ }
301
268
  function getDirection($oldPos, $newPos, pluginState) {
302
269
  if (pluginState?.handleKeyDown.backspace) return "left";
303
270
  if ($newPos.pos > $oldPos.pos) return "right";