@magic-marker/prosemirror-suggest-changes 0.2.1-block-join.3 → 0.2.1-block-join.4

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.
@@ -1,5 +1,13 @@
1
1
  import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
2
2
  import { getSuggestionMarks } from "./utils.js";
3
+ import { ZWSP } from "./constants.js";
4
+ // import { ZWSP } from "./constants.js";
5
+ const TRACE_ENABLED = false;
6
+ function trace(...args) {
7
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
8
+ if (!TRACE_ENABLED) return;
9
+ console.log("[ensureSelectionPlugin]", ...args);
10
+ }
3
11
  export const ensureSelectionKey = new PluginKey("@handlewithcare/prosemirror-suggest-changes-ensure-selection");
4
12
  export function ensureSelection() {
5
13
  return new Plugin({
@@ -41,85 +49,141 @@ export function ensureSelection() {
41
49
  }
42
50
  },
43
51
  appendTransaction (_transactions, oldState, newState) {
44
- const state = newState;
45
- if (!(state.selection instanceof TextSelection)) return null;
46
- if (!(oldState.selection instanceof TextSelection)) return null;
47
- const { $cursor } = state.selection;
48
- if ($cursor == null) return null;
49
- const $oldCursor = oldState.selection.$cursor;
50
- let dir;
51
- if ($oldCursor != null) {
52
- dir = $cursor.pos > $oldCursor.pos ? 1 : -1;
53
- } else {
54
- const { $from, $to } = oldState.selection;
55
- const distToFrom = $cursor.pos - $from.pos;
56
- const distToTo = $to.pos - $cursor.pos;
57
- // if cursor ended up closer to the right side of the selection (to),
58
- // consider direction as 1 - "to the right"
59
- dir = distToTo <= distToFrom ? 1 : -1;
60
- }
52
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
53
+ if (TRACE_ENABLED) console.groupCollapsed("[ensureSelectionPlugin]", "appendTransaction");
61
54
  const pluginState = ensureSelectionKey.getState(newState);
62
- if (pluginState?.handleKeyDown.backspace || pluginState?.handleKeyDown.arrowLeft) {
63
- dir = -1;
64
- } else if (pluginState?.handleKeyDown.delete || pluginState?.handleKeyDown.arrowRight) {
65
- dir = 1;
66
- }
67
- if (!isValidPos($cursor, dir)) {
68
- console.groupCollapsed("$cursor is not valid, find new $cursor", $cursor.pos, {
69
- dir,
70
- oldSelection: oldState.selection,
71
- $cursor
55
+ trace("appendTransaction", "search for new valid $anchor...");
56
+ let $newAnchor = getNewValidPos(newState.selection.$anchor, getDirection(oldState.selection.$anchor, newState.selection.$anchor, pluginState));
57
+ trace("appendTransaction", "new valid $anchor", $newAnchor?.pos, {
58
+ $newAnchor
59
+ });
60
+ trace("appendTransaction", "search for new valid $head...");
61
+ let $newHead = getNewValidPos(newState.selection.$head, getDirection(oldState.selection.$head, newState.selection.$head, pluginState));
62
+ trace("appendTransaction", "new valid $head", $newHead?.pos, {
63
+ $newHead
64
+ });
65
+ $newAnchor = $newAnchor ?? newState.selection.$anchor;
66
+ $newHead = $newHead ?? newState.selection.$head;
67
+ const newSelection = new TextSelection($newAnchor, $newHead);
68
+ if (newSelection.anchor === newState.selection.anchor && newSelection.head === newState.selection.head) {
69
+ trace("appendTransaction", "new selection is the same as old selection, skipping", {
70
+ $newAnchor,
71
+ $newHead,
72
+ selection: newSelection
72
73
  });
73
- let $pos = $cursor;
74
- if (dir > 0) {
75
- $pos = findNextPos($pos, dir);
76
- if (!isValidPos($pos, dir)) {
77
- console.warn("failed to find next valid $cursor, trying prev");
78
- $pos = findPrevPos($pos, dir);
79
- }
80
- } else {
81
- $pos = findPrevPos($pos, dir);
82
- if (!isValidPos($pos, dir)) {
83
- console.warn("failed to find prev valid $cursor, trying next");
84
- $pos = findNextPos($pos, dir);
85
- }
86
- }
87
- if (!isValidPos($pos, dir)) {
88
- console.warn("failed to find valid $cursor after all attempts", $pos.pos, "keeping the original $cursor", $cursor.pos, {
89
- $cursor,
90
- $pos
91
- });
92
- console.groupEnd();
93
- console.log("final $cursor (unchanged)", $cursor);
94
- return null;
95
- }
96
- console.info("found new valid $cursor", $pos.pos, {
97
- $pos
98
- });
99
- console.groupEnd();
100
- console.log("final $cursor", $pos.pos, {
101
- $pos
102
- });
103
- return newState.tr.setSelection(TextSelection.create(newState.doc, $pos.pos, $pos.pos));
74
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
75
+ if (TRACE_ENABLED) console.groupEnd();
76
+ return null;
104
77
  }
105
- return null;
78
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
79
+ if (TRACE_ENABLED) console.groupEnd();
80
+ trace("appendTransaction", "setting new selection", $newAnchor.pos, $newHead.pos, {
81
+ $newAnchor,
82
+ $newHead,
83
+ selection: newSelection
84
+ });
85
+ return newState.tr.setSelection(newSelection);
106
86
  }
107
87
  });
108
88
  }
109
89
  export function isEnsureSelectionEnabled() {
110
90
  return true;
111
91
  }
112
- function findNextPos($initialPos, dir) {
113
- console.groupCollapsed("finding next valid pos from $initialPos =", $initialPos);
92
+ function isPosValid($pos) {
93
+ // text selection is only valid in nodes that allow inline content
94
+ // https://github.com/ProseMirror/prosemirror-state/blob/1.4.4/src/selection.ts#L219
95
+ if (!$pos.parent.inlineContent) {
96
+ trace("isPosValid", $pos.pos, "pos invalid", "reason: not in inlineContent node", {
97
+ $pos
98
+ });
99
+ return false;
100
+ }
101
+ const { deletion, insertion } = getSuggestionMarks($pos.doc.type.schema);
102
+ const deletionBefore = deletion.isInSet($pos.nodeBefore?.marks ?? []);
103
+ const deletionAfter = deletion.isInSet($pos.nodeAfter?.marks ?? []);
104
+ const isAnchorBefore = deletionBefore && deletionBefore.attrs["type"] === "anchor";
105
+ const isAnchorAfter = deletionAfter && deletionAfter.attrs["type"] === "anchor";
106
+ if (isAnchorBefore && deletionAfter && !isAnchorAfter) {
107
+ trace("isPosValid", $pos.pos, "pos invalid", "reason: between deletion anchor and non-anchor deletion", {
108
+ $pos
109
+ });
110
+ return false;
111
+ }
112
+ if (deletionBefore && deletionAfter && !isAnchorBefore && !isAnchorAfter) {
113
+ trace("isPosValid", $pos.pos, "pos invalid", "reason: between two non-anchor deletions", {
114
+ $pos
115
+ });
116
+ return false;
117
+ }
118
+ if ($pos.nodeBefore == null && deletionAfter && !isAnchorAfter) {
119
+ trace("isPosValid", $pos.pos, "pos invalid", "reason: between node boundary and non-anchor deletion", {
120
+ $pos
121
+ });
122
+ return false;
123
+ }
124
+ if (deletionBefore && $pos.nodeAfter == null && !isAnchorBefore) {
125
+ trace("isPosValid", $pos.pos, "pos invalid", "reason: between non-anchor deletion and node boundary", {
126
+ $pos
127
+ });
128
+ return false;
129
+ }
130
+ if (deletionBefore && !isAnchorBefore && $pos.nodeAfter == null) {
131
+ trace("isPosValid", $pos.pos, "pos invalid", "reason: between non-anchor deletion and node boundary", {
132
+ $pos
133
+ });
134
+ return false;
135
+ }
136
+ if (deletionBefore && !isAnchorBefore) {
137
+ trace("isPosValid", $pos.pos, "pos invalid", "reason: between non-anchor deletion and anything", {
138
+ $pos
139
+ });
140
+ return false;
141
+ }
142
+ const insertionBefore = insertion.isInSet($pos.nodeBefore?.marks ?? []);
143
+ const insertionAfter = insertion.isInSet($pos.nodeAfter?.marks ?? []);
144
+ const ZWSP_REGEXP = new RegExp(ZWSP, "g");
145
+ const isZWSPBefore = $pos.nodeBefore && $pos.nodeBefore.textContent.replace(ZWSP_REGEXP, "") === "";
146
+ const isZWSPAfter = $pos.nodeAfter && $pos.nodeAfter.textContent.replace(ZWSP_REGEXP, "") === "";
147
+ if (insertionBefore && insertionAfter && isZWSPBefore && isZWSPAfter) {
148
+ console.log("isPosValid", $pos.pos, "pos invalid", "reason: between two ZWSP insertions", {
149
+ $pos
150
+ });
151
+ return false;
152
+ }
153
+ console.log("ZWSP checks", {
154
+ nodeAfterCheck: $pos.nodeAfter?.textContent.replace(ZWSP_REGEXP, ""),
155
+ nodeBeforeCheck: $pos.nodeBefore?.textContent.replace(ZWSP_REGEXP, ""),
156
+ parentCheck: $pos.parent.textContent.replace(ZWSP_REGEXP, "")
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
+ console.log("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
+ console.log("isPosValid", $pos.pos, "pos invalid", "reason: between ZWSP insertion and left node boundary", {
169
+ $pos
170
+ });
171
+ return false;
172
+ }
173
+ return true;
174
+ }
175
+ function findNextValidPos($initialPos) {
114
176
  let $pos = $initialPos;
115
- while(!isValidPos($pos, dir) && ($pos.nodeAfter != null || $pos.depth > 0)){
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
178
+ while(!isPosValid($pos) && ($pos.nodeAfter != null || $pos.depth > 0)){
179
+ // first check if we can go into nodeAfter
116
180
  if ($pos.nodeAfter != null) {
181
+ // if nodeAfter is inline, we can step into it and search for the valid pos in it
117
182
  if ($pos.nodeAfter.isInline) {
118
183
  // nodeAfter is inline - move in by one
119
184
  $pos = $pos.doc.resolve($pos.pos + 1);
120
- console.log("nodeAfter is inline, move to end of it", $pos);
121
185
  } else {
122
- // nodeAfter is not inline - find first inline descendant in nodeAfter
186
+ // nodeAfter is not inline - find starting position of the first inline descendant in nodeAfter
123
187
  let localStartPos = null;
124
188
  $pos.nodeAfter.descendants((child, pos)=>{
125
189
  if (!child.isInline) return true;
@@ -128,41 +192,33 @@ function findNextPos($initialPos, dir) {
128
192
  return false;
129
193
  });
130
194
  if (localStartPos !== null) {
131
- // we have a local position of a first inline descendant - convert it to global position
195
+ // we have a local starting position of the first inline descendant - convert it to global position
132
196
  // +1 to "enter" the node, and add local pos
133
197
  $pos = $pos.doc.resolve($pos.pos + 1 + localStartPos);
134
- console.log("found first inline descendant, move to start of it", $pos);
135
198
  } else {
136
- // unable to find first inline descendant of nodeAfter - just skip nodeAfter
199
+ // unable to find first inline descendant of nodeAfter - just skip nodeAfter altogether
137
200
  $pos = $pos.doc.resolve($pos.pos + $pos.nodeAfter.nodeSize);
138
- console.log("unable to find first inline descendant, move to end of nodeAfter", $pos);
139
201
  }
140
202
  }
141
203
  } else if ($pos.depth > 0) {
142
204
  // nodeAfter is null - go up
143
205
  $pos = $pos.doc.resolve($pos.after());
144
- console.log("nodeAfter is null, go up", $pos);
145
206
  }
146
207
  }
147
- if (isValidPos($pos, dir)) {
148
- console.log("found next valid $pos", $pos);
149
- } else {
150
- console.warn("failed to find next valid $pos", $pos, "keep initial pos", $initialPos);
151
- }
152
- console.groupEnd();
153
- return isValidPos($pos, dir) ? $pos : $initialPos;
208
+ return isPosValid($pos) ? $pos : null;
154
209
  }
155
- function findPrevPos($initialPos, dir) {
156
- console.groupCollapsed("finding prev valid pos from $initialPos =", $initialPos);
210
+ function findPreviousValidPos($initialPos) {
157
211
  let $pos = $initialPos;
158
- while(!isValidPos($pos, dir) && ($pos.nodeBefore != null || $pos.depth > 0)){
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
213
+ while(!isPosValid($pos) && ($pos.nodeBefore != null || $pos.depth > 0)){
214
+ // first check if we can go into nodeBefore
159
215
  if ($pos.nodeBefore != null) {
216
+ // if nodeBefore is inline, we can step into it and search for the valid pos in it
160
217
  if ($pos.nodeBefore.isInline) {
161
218
  // nodeBefore is inline - move in by one
162
219
  $pos = $pos.doc.resolve($pos.pos - 1);
163
- console.log("nodeBefore is inline, move to start of it", $pos);
164
220
  } else {
165
- // nodeBefore is not inline - find last inline descendant in nodeBefore
221
+ // nodeBefore is not inline - find ending position of the last inline descendant in nodeBefore
166
222
  let localEndPos = null;
167
223
  $pos.nodeBefore.descendants((child, pos)=>{
168
224
  if (!child.isInline) return true;
@@ -170,78 +226,81 @@ function findPrevPos($initialPos, dir) {
170
226
  return false;
171
227
  });
172
228
  if (localEndPos !== null) {
173
- // we have a local position of a last inline descendant - convert it to global position
229
+ // we have a local ending position of the last inline descendant - convert it to global position
174
230
  // move pos to start of node before, add 1 to "enter" nodeBefore, then add local pos
175
231
  $pos = $pos.doc.resolve($pos.pos - $pos.nodeBefore.nodeSize + 1 + localEndPos);
176
- console.log("found last inline descendant, move to end of it", $pos);
177
232
  } else {
178
- // unable to find last inline descendant of nodeBefore - just skip nodeBefore
233
+ // unable to find last inline descendant of nodeBefore - just skip nodeBefore altogether
179
234
  $pos = $pos.doc.resolve($pos.pos - $pos.nodeBefore.nodeSize);
180
- console.log("unable to find last inline descendant, move to start of nodeBefore", $pos);
181
235
  }
182
236
  }
183
237
  } else if ($pos.depth > 0) {
184
238
  // nodeBefore is null - go up
185
239
  $pos = $pos.doc.resolve($pos.before());
186
- console.log("nodeBefore is null, go up", $pos);
187
240
  }
188
241
  }
189
- if (isValidPos($pos, dir)) {
190
- console.log("found prev valid $pos", $pos);
191
- } else {
192
- console.warn("failed to find prev valid $pos", $pos, "keep initial pos", $initialPos);
193
- }
194
- console.groupEnd();
195
- return isValidPos($pos, dir) ? $pos : $initialPos;
242
+ return isPosValid($pos) ? $pos : null;
196
243
  }
197
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
198
- function isValidPos($pos, _dir) {
199
- // text selection is only valid in nodes that allow inline content
200
- // https://github.com/ProseMirror/prosemirror-state/blob/1.4.4/src/selection.ts#L219
201
- if (!$pos.parent.inlineContent) {
202
- console.warn("cursor invalid", "not in inlineContent node", $pos);
203
- return false;
204
- }
205
- const { deletion } = getSuggestionMarks($pos.doc.type.schema);
206
- // moving right - zwsp is on the right - skip forward
207
- // if (dir > 0 && $pos.nodeAfter?.text?.startsWith(ZWSP)) {
208
- // console.warn(
209
- // "cursor invalid",
210
- // "moving right - zwsp is on the right - skip forward",
211
- // $pos,
212
- // );
213
- // return false;
214
- // }
215
- // // moving left - zwsp is on the left - skip backward
216
- // if (dir < 0 && $pos.nodeBefore?.text?.startsWith(ZWSP)) {
217
- // console.warn(
218
- // "cursor invalid",
219
- // "moving left - zwsp is on the left - skip backward",
220
- // $pos,
221
- // );
222
- // return false;
223
- // }
224
- const deletionBefore = deletion.isInSet($pos.nodeBefore?.marks ?? []);
225
- const deletionAfter = deletion.isInSet($pos.nodeAfter?.marks ?? []);
226
- // between two deletions
227
- if (deletionBefore && deletionAfter) {
228
- console.warn("cursor invalid", "between two deletions", $pos);
229
- return false;
230
- }
231
- // between a deletion and a node boundary
232
- if (deletionBefore && $pos.nodeAfter == null) {
233
- console.warn("cursor invalid", "between a deletion and a node boundary", $pos);
234
- return false;
244
+ function getNewValidPos($pos, dir) {
245
+ if (isPosValid($pos)) return $pos;
246
+ trace("getNewValidPos for", $pos.pos, {
247
+ $pos,
248
+ dir
249
+ });
250
+ if (dir === "right") {
251
+ const $nextValidPos = findNextValidPos($pos);
252
+ trace("getNewValidPos", "$nextValidPos", $nextValidPos?.pos, {
253
+ dir,
254
+ $pos,
255
+ $nextValidPos
256
+ });
257
+ if ($nextValidPos != null) return $nextValidPos;
258
+ const $prevValidPos = findPreviousValidPos($pos);
259
+ trace("getNewValidPos", "$prevValidPos", $prevValidPos?.pos, {
260
+ dir,
261
+ $pos,
262
+ $prevValidPos
263
+ });
264
+ if ($prevValidPos != null) return $prevValidPos;
265
+ return null;
235
266
  }
236
- // between a node boundary and a deletion, if deletion is not anchor
237
- if ($pos.nodeBefore == null && deletionAfter && deletionAfter.attrs["type"] !== "anchor") {
238
- console.warn("cursor invalid", "between a node boundary and a deletion", $pos);
239
- return false;
267
+ if (dir === "left") {
268
+ const $prevValidPos = findPreviousValidPos($pos);
269
+ trace("getNewValidPos", "$prevValidPos", $prevValidPos?.pos, {
270
+ dir,
271
+ $pos,
272
+ $prevValidPos
273
+ });
274
+ if ($prevValidPos != null) return $prevValidPos;
275
+ const $nextValidPos = findNextValidPos($pos);
276
+ trace("getNewValidPos", "$nextValidPos", $nextValidPos?.pos, {
277
+ dir,
278
+ $pos,
279
+ $nextValidPos
280
+ });
281
+ if ($nextValidPos != null) return $nextValidPos;
282
+ return null;
240
283
  }
241
- // deletion is on the left, non-deletion is on the right
242
- if (deletionBefore && $pos.nodeAfter != null && !deletionAfter) {
243
- console.warn("cursor invalid", "deletion is on the left, non-deletion is on the right", $pos);
244
- return false;
284
+ const $nextValidPos = findNextValidPos($pos);
285
+ const $prevValidPos = findPreviousValidPos($pos);
286
+ trace("getNewValidPos", "$nextValidPos", $nextValidPos?.pos, "$prevValidPos", $prevValidPos?.pos, {
287
+ dir,
288
+ $pos,
289
+ $nextValidPos,
290
+ $prevValidPos
291
+ });
292
+ if ($nextValidPos == null && $prevValidPos == null) {
293
+ return null;
245
294
  }
246
- return true;
295
+ if ($nextValidPos == null) return $prevValidPos;
296
+ if ($prevValidPos == null) return $nextValidPos;
297
+ const nextDist = Math.abs($pos.pos - $nextValidPos.pos);
298
+ const prevDist = Math.abs($pos.pos - $prevValidPos.pos);
299
+ return nextDist <= prevDist ? $nextValidPos : $prevValidPos;
300
+ }
301
+ function getDirection($oldPos, $newPos, pluginState) {
302
+ if (pluginState?.handleKeyDown.backspace) return "left";
303
+ if ($newPos.pos > $oldPos.pos) return "right";
304
+ if ($newPos.pos < $oldPos.pos) return "left";
305
+ return null;
247
306
  }
@@ -200,15 +200,16 @@ import { collapseZWSPNodes, findJoinMark, joinNodesAndMarkJoinPoints, removeZWSP
200
200
  trackedTransaction.setSelection(TextSelection.near(trackedTransaction.doc.resolve($stepFrom.pos - 1)));
201
201
  }
202
202
  // Handle insertions
203
- // When didBlockJoin is true, only process insertions if the slice contains
203
+ // When didBlockJoin is true, or nodes were joined, only process insertions if the slice contains
204
204
  // actual new content (closed slice) rather than just structural info for the join (open slice).
205
205
  // Open slices have openStart > 0 or openEnd > 0 and represent block structure.
206
206
  // Closed slices have openStart = 0 and openEnd = 0 and contain new user content.
207
207
  // TODO: Done with AI, not 100% sure about the argument but it works. Kind of.
208
208
  // The replaced content is not equivalent to what would happen without suggestions, just with insertions
209
209
  // but it's workable. Only issues are with deleting between different depths ( for ex. between list and root level paragraph )
210
+ const didJoinNodes = didBlockJoin || joinNodesTransform.steps.length > 0;
210
211
  const sliceHasNewContent = step.slice.openStart === 0 && step.slice.openEnd === 0;
211
- const shouldProcessInsertion = step.slice.content.size && (!didBlockJoin || sliceHasNewContent);
212
+ const shouldProcessInsertion = step.slice.content.size && (!didJoinNodes || sliceHasNewContent);
212
213
  if (shouldProcessInsertion) {
213
214
  const $to = trackedTransaction.doc.resolve(stepTo);
214
215
  // Don't allow inserting content within an existing deletion
package/dist/schema.js CHANGED
@@ -49,7 +49,7 @@ export const hiddenDeletion = {
49
49
  const isAnchor = mark.attrs["type"] === "anchor";
50
50
  const blockStyle = `display: block;`;
51
51
  const inlineStyle = `display: inline;`;
52
- const hiddenStyle = `display: inline; font-size: 1px; line-height: 0px; color: transparent;`;
52
+ const hiddenStyle = `display: inline; font-size: 1px; line-height: 0px; color: transparent; letter-spacing: -1px;`;
53
53
  return [
54
54
  "del",
55
55
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magic-marker/prosemirror-suggest-changes",
3
- "version": "0.2.1-block-join.3",
3
+ "version": "0.2.1-block-join.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "dist/index.js",