@magic-marker/prosemirror-suggest-changes 0.3.3-wrap-unwrap.17 → 0.3.3-wrap-unwrap.19

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 (34) hide show
  1. package/dist/__tests__/playwrightPage.d.ts +31 -0
  2. package/dist/commands.js +24 -0
  3. package/dist/features/joinOnDelete/__tests__/joinOnDeleteInLists.playwright.test.d.ts +1 -0
  4. package/dist/features/joinOnDelete/__tests__/joinOnDeleteInListsTipTapStyle.playwright.test.d.ts +1 -0
  5. package/dist/features/joinOnDelete/index.d.ts +1 -17
  6. package/dist/features/joinOnDelete/index.js +25 -36
  7. package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.d.ts +6 -0
  8. package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.js +24 -0
  9. package/dist/features/joinOnDelete/types.d.ts +34 -0
  10. package/dist/features/joinOnDelete/types.js +20 -0
  11. package/dist/features/transactionShaping/detectSpecialTransactionShape.d.ts +3 -0
  12. package/dist/features/transactionShaping/detectSpecialTransactionShape.js +4 -0
  13. package/dist/features/transactionShaping/index.d.ts +3 -0
  14. package/dist/features/transactionShaping/index.js +11 -0
  15. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.d.ts +3 -0
  16. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.js +48 -0
  17. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.d.ts +1 -0
  18. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.js +188 -0
  19. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.d.ts +3 -0
  20. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.js +80 -0
  21. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.d.ts +2 -0
  22. package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.js +2 -0
  23. package/dist/features/transactionShaping/types.d.ts +20 -0
  24. package/dist/features/transactionShaping/types.js +1 -0
  25. package/dist/features/wrapUnwrap/areEquivalentStructureMarks.d.ts +2 -0
  26. package/dist/features/wrapUnwrap/areEquivalentStructureMarks.js +17 -0
  27. package/dist/features/wrapUnwrap/types.d.ts +5 -0
  28. package/dist/features/wrapUnwrap/types.js +6 -0
  29. package/dist/generateId.js +30 -3
  30. package/dist/transformToSuggestionTransaction.d.ts +22 -0
  31. package/dist/transformToSuggestionTransaction.js +101 -0
  32. package/dist/withSuggestChanges.d.ts +3 -13
  33. package/dist/withSuggestChanges.js +14 -95
  34. package/package.json +1 -1
@@ -24,4 +24,35 @@ export declare class EditorPage {
24
24
  }>;
25
25
  revertAll(): Promise<void>;
26
26
  applyAll(): Promise<void>;
27
+ /**
28
+ * Perform an action and wait for ProseMirror state to update.
29
+ * Stores the current state reference before pressing, then polls until
30
+ * editor.view.state is a different object (ProseMirror creates a new
31
+ * immutable state on every transaction).
32
+ */
33
+ private doActionAndWaitForState;
34
+ /**
35
+ * Press a key and wait for ProseMirror state to update.
36
+ * Stores the current state reference before pressing, then polls until
37
+ * editor.view.state is a different object (ProseMirror creates a new
38
+ * immutable state on every transaction).
39
+ */
40
+ pressKey(key: string, opts?: {
41
+ waitForSelectionChange?: boolean;
42
+ }): Promise<void>;
43
+ /**
44
+ * Insert text and wait for ProseMirror state to update.
45
+ * Stores the current state reference before pressing, then polls until
46
+ * editor.view.state is a different object (ProseMirror creates a new
47
+ * immutable state on every transaction).
48
+ */
49
+ insertText(text: string, opts?: {
50
+ waitForSelectionChange?: boolean;
51
+ }): Promise<void>;
52
+ /**
53
+ * Press a key multiple times, waiting for editor state update after each press.
54
+ */
55
+ pressKeyMultiple(key: string, count: number, opts?: {
56
+ waitForSelectionChange?: boolean;
57
+ }): Promise<void>;
27
58
  }
package/dist/commands.js CHANGED
@@ -146,6 +146,10 @@ export function applySuggestionsToNode(node) {
146
146
  suggestionsTransform.steps.forEach((step)=>{
147
147
  structureTransform.step(step);
148
148
  });
149
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc);
150
+ secondStructureTransform.steps.forEach((step)=>{
151
+ structureTransform.step(step);
152
+ });
149
153
  return structureTransform.doc;
150
154
  }
151
155
  export function applySuggestionsToRange(doc, from, to) {
@@ -163,6 +167,10 @@ export function applySuggestionsToRange(doc, from, to) {
163
167
  suggestionsTransform.steps.forEach((step)=>{
164
168
  structureTransform.step(step);
165
169
  });
170
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc, structureTransform.mapping.map(from), structureTransform.mapping.map(to));
171
+ secondStructureTransform.steps.forEach((step)=>{
172
+ structureTransform.step(step);
173
+ });
166
174
  return structureTransform.doc.slice(structureTransform.mapping.map(from), structureTransform.mapping.map(to));
167
175
  }
168
176
  /**
@@ -183,6 +191,10 @@ export function applySuggestionsToRange(doc, from, to) {
183
191
  suggestionsTransform.steps.forEach((step)=>{
184
192
  structureTransform.step(step);
185
193
  });
194
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc);
195
+ secondStructureTransform.steps.forEach((step)=>{
196
+ structureTransform.step(step);
197
+ });
186
198
  // apply the structure transform to the transaction
187
199
  const transaction = state.tr;
188
200
  structureTransform.steps.forEach((step)=>{
@@ -214,6 +226,10 @@ export function applySuggestionsToRange(doc, from, to) {
214
226
  suggestionsTransform.steps.forEach((step)=>{
215
227
  structureTransform.step(step);
216
228
  });
229
+ const secondStructureTransform = applyAllStructureSuggestions(structureTransform.doc, from === undefined ? undefined : structureTransform.mapping.map(from), to === undefined ? undefined : structureTransform.mapping.map(to));
230
+ secondStructureTransform.steps.forEach((step)=>{
231
+ structureTransform.step(step);
232
+ });
217
233
  // apply the structure transform to the transaction
218
234
  const transaction = state.tr;
219
235
  structureTransform.steps.forEach((step)=>{
@@ -277,6 +293,10 @@ export function applySuggestionsToRange(doc, from, to) {
277
293
  suggestionsTransform.steps.forEach((step)=>{
278
294
  structureTransform.step(step);
279
295
  });
296
+ const secondStructureTransform = revertAllStructureSuggestions(structureTransform.doc);
297
+ secondStructureTransform.steps.forEach((step)=>{
298
+ structureTransform.step(step);
299
+ });
280
300
  // apply the structure transform to the transaction
281
301
  const transaction = state.tr;
282
302
  structureTransform.steps.forEach((step)=>{
@@ -308,6 +328,10 @@ export function applySuggestionsToRange(doc, from, to) {
308
328
  suggestionsTransform.steps.forEach((step)=>{
309
329
  structureTransform.step(step);
310
330
  });
331
+ const secondStructureTransform = revertAllStructureSuggestions(structureTransform.doc, from === undefined ? undefined : structureTransform.mapping.map(from), to === undefined ? undefined : structureTransform.mapping.map(to));
332
+ secondStructureTransform.steps.forEach((step)=>{
333
+ structureTransform.step(step);
334
+ });
311
335
  // apply the structure transform to the transaction
312
336
  const transaction = state.tr;
313
337
  structureTransform.steps.forEach((step)=>{
@@ -1,22 +1,7 @@
1
- import { Mark, type Node, type Attrs, type MarkType, type ResolvedPos } from "prosemirror-model";
1
+ import { Mark, type Node, type MarkType, type ResolvedPos } from "prosemirror-model";
2
2
  import { Transform } from "prosemirror-transform";
3
3
  import { type Transaction } from "prosemirror-state";
4
4
  import { type SuggestionId } from "../../generateId.js";
5
- interface SerializedJoinNode {
6
- type: string;
7
- attrs: object;
8
- marks: object[];
9
- }
10
- interface JoinMarkAttrs {
11
- type: "join";
12
- data: {
13
- leftNode?: SerializedJoinNode;
14
- rightNode?: SerializedJoinNode;
15
- leftNodes?: SerializedJoinNode[];
16
- rightNodes?: SerializedJoinNode[];
17
- };
18
- }
19
- export declare function isJoinMarkAttrs(attrs: Attrs): attrs is JoinMarkAttrs;
20
5
  export declare function maybeRevertJoinMark(tr: Transform, from: number, to: number, node: Node, markType: MarkType): boolean;
21
6
  /**
22
7
  * Remove ZWSP text nodes marked as deletions (except for type=join) from the given range
@@ -33,4 +18,3 @@ export declare function joinNodesAndMarkJoinPoints(trackedTransaction: Transacti
33
18
  */
34
19
  export declare function collapseZWSPNodes(trackedTransaction: Transaction, from: number, to: number): Transform;
35
20
  export declare function findJoinMark(pos: ResolvedPos): Mark | null;
36
- export {};
@@ -2,40 +2,10 @@ import { Mark } from "prosemirror-model";
2
2
  import { canJoin, Transform } from "prosemirror-transform";
3
3
  import { ZWSP } from "../../constants.js";
4
4
  import { getSuggestionMarks } from "../../utils.js";
5
+ import { areEquivalentStructureMarks } from "../wrapUnwrap/areEquivalentStructureMarks.js";
5
6
  import { guardStructureMarkAttrs } from "../wrapUnwrap/types.js";
6
- // Block join suggestion metadata/revert currently supports the depth TipTap uses
7
- // when Backspace joins both list-item paragraphs and their parent list items.
8
- const MAX_BLOCK_JOIN_DEPTH = 2;
9
- function isSerializedJoinNode(node) {
10
- if (node === null || typeof node !== "object") return false;
11
- const data = node;
12
- return typeof data.type === "string" && typeof data.attrs === "object" && data.attrs !== null && Array.isArray(data.marks);
13
- }
14
- function normalizeJoinNodes(attrs) {
15
- if (attrs["type"] !== "join") return false;
16
- if (attrs["data"] == null) return false;
17
- const data = attrs["data"];
18
- // Normalize legacy metadata so revert can use the same array path.
19
- const leftNodes = data.leftNodes ?? (data.leftNode ? [
20
- data.leftNode
21
- ] : null);
22
- const rightNodes = data.rightNodes ?? (data.rightNode ? [
23
- data.rightNode
24
- ] : null);
25
- if (!Array.isArray(leftNodes) || !Array.isArray(rightNodes)) return false;
26
- if (leftNodes.length === 0 || leftNodes.length !== rightNodes.length) return false;
27
- // Reject unsupported depths instead of partially reverting unknown structure.
28
- if (leftNodes.length > MAX_BLOCK_JOIN_DEPTH) return false;
29
- if (!leftNodes.every(isSerializedJoinNode)) return false;
30
- if (!rightNodes.every(isSerializedJoinNode)) return false;
31
- return {
32
- leftNodes,
33
- rightNodes
34
- };
35
- }
36
- export function isJoinMarkAttrs(attrs) {
37
- return normalizeJoinNodes(attrs) !== false;
38
- }
7
+ import { MAX_BLOCK_JOIN_DEPTH, normalizeJoinNodesMetadata } from "./normalizeJoinNodesMetadata.js";
8
+ import { isJoinMark } from "./types.js";
39
9
  function serializeJoinNode(node) {
40
10
  return {
41
11
  type: node.type.name,
@@ -46,16 +16,35 @@ function serializeJoinNode(node) {
46
16
  function marksFromJSON(schema, markData) {
47
17
  return markData.map((markData)=>Mark.fromJSON(schema, markData));
48
18
  }
19
+ // when we revert a block join deletion mark, we restore nodes on both sides of the mark using leftNodes rightNodes metadata
20
+ // but existing left and right nodes may have structure marks that we need to preserve
21
+ function mergeSerializedMarksWithCurrentStructureMarks(tr, pos, serializedMarks) {
22
+ const currentNode = tr.doc.nodeAt(pos);
23
+ if (!currentNode) return serializedMarks;
24
+ const { structure } = getSuggestionMarks(tr.doc.type.schema);
25
+ const mergedMarks = [
26
+ ...serializedMarks
27
+ ];
28
+ for (const currentMark of currentNode.marks){
29
+ if (currentMark.type !== structure) continue;
30
+ const alreadyIncluded = mergedMarks.some((mark)=>mark.type === structure && areEquivalentStructureMarks(mark, currentMark));
31
+ if (!alreadyIncluded) {
32
+ mergedMarks.push(currentMark);
33
+ }
34
+ }
35
+ return mergedMarks;
36
+ }
49
37
  function restoreNodeMarkup(tr, pos, node) {
50
38
  const nodeType = tr.doc.type.schema.nodes[node.type];
51
39
  if (!nodeType) return false;
52
- tr.setNodeMarkup(pos, nodeType, node.attrs, marksFromJSON(tr.doc.type.schema, node.marks));
40
+ const marks = mergeSerializedMarksWithCurrentStructureMarks(tr, pos, marksFromJSON(tr.doc.type.schema, node.marks));
41
+ tr.setNodeMarkup(pos, nodeType, node.attrs, marks);
53
42
  return true;
54
43
  }
55
44
  export function maybeRevertJoinMark(tr, from, to, node, markType) {
56
45
  const mark = node.marks.find((mark)=>mark.type === markType);
57
- if (!mark || mark.attrs["type"] !== "join" || node.text !== ZWSP) return false;
58
- const joinNodes = normalizeJoinNodes(mark.attrs);
46
+ if (!mark || !isJoinMark(mark) || node.text !== ZWSP) return false;
47
+ const joinNodes = normalizeJoinNodesMetadata(mark.attrs);
59
48
  if (!joinNodes) return false;
60
49
  for (const node of [
61
50
  ...joinNodes.leftNodes,
@@ -0,0 +1,6 @@
1
+ import { type JoinMarkAttrs } from "./types.js";
2
+ export declare const MAX_BLOCK_JOIN_DEPTH = 2;
3
+ export declare function normalizeJoinNodesMetadata(attrs: JoinMarkAttrs): false | {
4
+ leftNodes: import("./types.js").SerializedJoinNode[];
5
+ rightNodes: import("./types.js").SerializedJoinNode[];
6
+ };
@@ -0,0 +1,24 @@
1
+ import { isSerializedJoinNode } from "./types.js";
2
+ // Block join suggestion metadata/revert currently supports the depth TipTap uses
3
+ // when Backspace joins both list-item paragraphs and their parent list items.
4
+ export const MAX_BLOCK_JOIN_DEPTH = 2;
5
+ export function normalizeJoinNodesMetadata(attrs) {
6
+ const data = attrs.data;
7
+ // Normalize legacy metadata so revert can use the same array path.
8
+ const leftNodes = data.leftNodes ?? (data.leftNode ? [
9
+ data.leftNode
10
+ ] : null);
11
+ const rightNodes = data.rightNodes ?? (data.rightNode ? [
12
+ data.rightNode
13
+ ] : null);
14
+ if (!Array.isArray(leftNodes) || !Array.isArray(rightNodes)) return false;
15
+ if (leftNodes.length === 0 || leftNodes.length !== rightNodes.length) return false;
16
+ // Reject unsupported depths instead of partially reverting unknown structure.
17
+ if (leftNodes.length > MAX_BLOCK_JOIN_DEPTH) return false;
18
+ if (!leftNodes.every(isSerializedJoinNode)) return false;
19
+ if (!rightNodes.every(isSerializedJoinNode)) return false;
20
+ return {
21
+ leftNodes,
22
+ rightNodes
23
+ };
24
+ }
@@ -0,0 +1,34 @@
1
+ import { type Mark, type Attrs, type Node } from "prosemirror-model";
2
+ export interface SerializedJoinNode {
3
+ type: string;
4
+ attrs: object;
5
+ marks: {
6
+ attrs: Record<string, unknown>;
7
+ }[];
8
+ }
9
+ export interface JoinMarkAttrs {
10
+ type: "join";
11
+ data: {
12
+ leftNode?: SerializedJoinNode;
13
+ rightNode?: SerializedJoinNode;
14
+ leftNodes?: SerializedJoinNode[];
15
+ rightNodes?: SerializedJoinNode[];
16
+ };
17
+ }
18
+ export interface JoinPair {
19
+ leftNode: Node;
20
+ rightNode: Node;
21
+ }
22
+ export interface JoinCandidate {
23
+ joinPos: number;
24
+ leftNodes: Node[];
25
+ rightNodes: Node[];
26
+ }
27
+ export declare function isSerializedJoinNode(node: unknown): node is SerializedJoinNode;
28
+ export declare function isJoinMarkAttrs(attrs: Attrs): attrs is JoinMarkAttrs;
29
+ export declare function isJoinMarkObject(mark: unknown): mark is Omit<Mark, "attrs"> & {
30
+ attrs: JoinMarkAttrs;
31
+ };
32
+ export declare function isJoinMark(mark: Mark): mark is Omit<Mark, "attrs"> & {
33
+ attrs: JoinMarkAttrs;
34
+ };
@@ -0,0 +1,20 @@
1
+ import { getSuggestionMarks } from "../../utils.js";
2
+ export function isSerializedJoinNode(node) {
3
+ if (node === null || typeof node !== "object") return false;
4
+ const data = node;
5
+ return typeof data.type === "string" && typeof data.attrs === "object" && data.attrs !== null && Array.isArray(data.marks);
6
+ }
7
+ export function isJoinMarkAttrs(attrs) {
8
+ if (attrs["type"] !== "join") return false;
9
+ if (attrs["data"] == null) return false;
10
+ return true;
11
+ }
12
+ export function isJoinMarkObject(mark) {
13
+ if (mark === null || typeof mark !== "object") return false;
14
+ if (!("attrs" in mark)) return false;
15
+ return isJoinMarkAttrs(mark.attrs);
16
+ }
17
+ export function isJoinMark(mark) {
18
+ const { deletion } = getSuggestionMarks(mark.type.schema);
19
+ return mark.type === deletion && isJoinMarkAttrs(mark.attrs);
20
+ }
@@ -0,0 +1,3 @@
1
+ import { type Transaction } from "prosemirror-state";
2
+ import { type SpecialTransactionShape } from "./types.js";
3
+ export declare function detectSpecialTransactionShape(transaction: Transaction): SpecialTransactionShape | null;
@@ -0,0 +1,4 @@
1
+ import { detectTipTapParagraphIntoListJoin } from "./tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.js";
2
+ export function detectSpecialTransactionShape(transaction) {
3
+ return detectTipTapParagraphIntoListJoin(transaction);
4
+ }
@@ -0,0 +1,3 @@
1
+ import { type Transaction } from "prosemirror-state";
2
+ import { type HandleSpecialTransactionShapeArgs } from "./types.js";
3
+ export declare function handleSpecialTransactionShape(args: HandleSpecialTransactionShapeArgs): Transaction | null;
@@ -0,0 +1,11 @@
1
+ import { handleTipTapParagraphIntoListJoin } from "./tipTapParagraphIntoListJoin/index.js";
2
+ const specialTransactionShapeHandlers = [
3
+ handleTipTapParagraphIntoListJoin
4
+ ];
5
+ export function handleSpecialTransactionShape(args) {
6
+ for (const handler of specialTransactionShapeHandlers){
7
+ const transaction = handler(args);
8
+ if (transaction) return transaction;
9
+ }
10
+ return null;
11
+ }
@@ -0,0 +1,3 @@
1
+ import { type Transaction } from "prosemirror-state";
2
+ import { type TipTapParagraphIntoListJoinShape } from "../types.js";
3
+ export declare function detectTipTapParagraphIntoListJoin(transaction: Transaction): TipTapParagraphIntoListJoinShape | null;
@@ -0,0 +1,48 @@
1
+ import { ReplaceStep, Transform } from "prosemirror-transform";
2
+ import { getNodeId } from "../../wrapUnwrap/getNodeId.js";
3
+ // detect a very specific TipTap pattern where joining a paragraph into a list above it
4
+ // causes a 3-step transaction, which is different from normal prosemirror where you just get 1 step
5
+ // see unit test for details
6
+ export function detectTipTapParagraphIntoListJoin(transaction) {
7
+ if (transaction.steps.length !== 3) return null;
8
+ const [deleteStep, insertStep, joinStep] = transaction.steps;
9
+ if (!(deleteStep instanceof ReplaceStep) || !(insertStep instanceof ReplaceStep) || !(joinStep instanceof ReplaceStep)) {
10
+ return null;
11
+ }
12
+ if (deleteStep.slice.content.size !== 0) return null;
13
+ if (insertStep.from !== insertStep.to) return null;
14
+ if (insertStep.slice.openStart !== 0 || insertStep.slice.openEnd !== 0) return null;
15
+ if (insertStep.slice.content.childCount !== 1) return null;
16
+ if (joinStep.slice.content.size !== 0) return null;
17
+ if (joinStep.structure !== true) return null;
18
+ const docBefore = transaction.docs[0];
19
+ if (!docBefore) return null;
20
+ const movedNode = docBefore.nodeAt(deleteStep.from);
21
+ if (!movedNode || !movedNode.isTextblock || movedNode.nodeSize !== deleteStep.to - deleteStep.from) {
22
+ return null;
23
+ }
24
+ const insertedNode = insertStep.slice.content.firstChild;
25
+ if (!insertedNode?.isTextblock) return null;
26
+ if (insertedNode.type !== movedNode.type) return null;
27
+ if (insertedNode.textContent !== movedNode.textContent) return null;
28
+ const movedNodeId = getNodeId(movedNode);
29
+ if (!movedNodeId || getNodeId(insertedNode) !== movedNodeId) return null;
30
+ const previousSibling = docBefore.resolve(deleteStep.from).nodeBefore;
31
+ if (!previousSibling || previousSibling.isInline) return null;
32
+ try {
33
+ const preview = new Transform(docBefore);
34
+ preview.step(deleteStep);
35
+ preview.step(insertStep);
36
+ new Transform(preview.doc).step(joinStep);
37
+ } catch {
38
+ return null;
39
+ }
40
+ console.warn("[prosemirror-suggest-changes]", "detected TipTap paragraph into list join shape");
41
+ return {
42
+ type: "tipTapParagraphIntoListJoin",
43
+ deleteStep,
44
+ insertStep,
45
+ joinStep,
46
+ movedNode
47
+ };
48
+ }
@@ -0,0 +1,188 @@
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 { detectTipTapParagraphIntoListJoin } from "./detectTipTapParagraphIntoListJoin.js";
6
+ const schema = createSchema();
7
+ // when joining a paragraph into a list above it,
8
+ // TipTap List extension overrides the default ProseMirror behavior
9
+ // by default, ProseMirror puts the paragraph to the end of list as a separate list item
10
+ // TipTap instead joins the paragraph with the paragraph of the last list item
11
+ const TIPTAP_PARAGRAPH_INTO_LIST_STEPS = [
12
+ // first step deletes the paragraph that we're joining
13
+ {
14
+ stepType: "replace",
15
+ from: 42,
16
+ to: 60
17
+ },
18
+ // second step inserts that paragraph into the last list item
19
+ {
20
+ stepType: "replace",
21
+ from: 40,
22
+ to: 40,
23
+ slice: {
24
+ content: [
25
+ {
26
+ type: "paragraph",
27
+ attrs: {
28
+ id: "node-9",
29
+ textAlign: null
30
+ },
31
+ content: [
32
+ {
33
+ type: "text",
34
+ text: "sample paragraph"
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+ }
40
+ },
41
+ // third step joins the two paragraphs in the list item
42
+ {
43
+ stepType: "replace",
44
+ from: 39,
45
+ to: 41,
46
+ structure: true
47
+ }
48
+ ];
49
+ const TIPTAP_PARAGRAPH_INTO_LIST_DOC = {
50
+ type: "doc",
51
+ content: [
52
+ {
53
+ type: "orderedList",
54
+ attrs: {
55
+ order: 1,
56
+ id: "node-0"
57
+ },
58
+ content: [
59
+ {
60
+ type: "listItem",
61
+ attrs: {
62
+ id: "node-1"
63
+ },
64
+ content: [
65
+ {
66
+ type: "paragraph",
67
+ attrs: {
68
+ id: "node-2"
69
+ },
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: "Item 1"
74
+ }
75
+ ]
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ type: "listItem",
81
+ attrs: {
82
+ id: "node-3"
83
+ },
84
+ content: [
85
+ {
86
+ type: "paragraph",
87
+ attrs: {
88
+ id: "node-4"
89
+ },
90
+ content: [
91
+ {
92
+ type: "text",
93
+ text: "Item 2"
94
+ }
95
+ ]
96
+ }
97
+ ]
98
+ },
99
+ {
100
+ type: "listItem",
101
+ attrs: {
102
+ id: "node-5"
103
+ },
104
+ content: [
105
+ {
106
+ type: "paragraph",
107
+ attrs: {
108
+ id: "node-6"
109
+ },
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: "Item 3"
114
+ }
115
+ ]
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ type: "listItem",
121
+ attrs: {
122
+ id: "node-7"
123
+ },
124
+ content: [
125
+ {
126
+ type: "paragraph",
127
+ attrs: {
128
+ id: "node-8"
129
+ },
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: "Item 4"
134
+ }
135
+ ]
136
+ }
137
+ ]
138
+ }
139
+ ]
140
+ },
141
+ {
142
+ type: "paragraph",
143
+ attrs: {
144
+ id: "node-9"
145
+ },
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: "sample paragraph"
150
+ }
151
+ ]
152
+ }
153
+ ]
154
+ };
155
+ describe("detectTipTapParagraphIntoListJoin", ()=>{
156
+ it("detects TipTap's three-step paragraph-into-list join shape", ()=>{
157
+ const doc = schema.nodeFromJSON(TIPTAP_PARAGRAPH_INTO_LIST_DOC);
158
+ const state = EditorState.create({
159
+ doc
160
+ });
161
+ const transaction = state.tr;
162
+ TIPTAP_PARAGRAPH_INTO_LIST_STEPS.forEach((stepJSON)=>{
163
+ transaction.step(Step.fromJSON(schema, stepJSON));
164
+ });
165
+ const shape = detectTipTapParagraphIntoListJoin(transaction);
166
+ expect(shape).toMatchObject({
167
+ type: "tipTapParagraphIntoListJoin",
168
+ deleteStep: {
169
+ from: 42,
170
+ to: 60
171
+ },
172
+ insertStep: {
173
+ from: 40,
174
+ to: 40
175
+ },
176
+ joinStep: {
177
+ from: 39,
178
+ to: 41
179
+ },
180
+ movedNode: {
181
+ attrs: {
182
+ id: "node-9"
183
+ }
184
+ }
185
+ });
186
+ expect(shape?.movedNode.textContent).toBe("sample paragraph");
187
+ });
188
+ });
@@ -0,0 +1,3 @@
1
+ import { type Transaction } from "prosemirror-state";
2
+ import { type HandleSpecialTransactionShapeArgs } from "../types.js";
3
+ export declare function handleTipTapParagraphIntoListJoin(args: HandleSpecialTransactionShapeArgs): Transaction | null;
@@ -0,0 +1,80 @@
1
+ import { EditorState } from "prosemirror-state";
2
+ import { preserveTransactionData, transformToSuggestionTransaction } from "../../../transformToSuggestionTransaction.js";
3
+ import { getSuggestionMarks } from "../../../utils.js";
4
+ import { getNodeId } from "../../wrapUnwrap/getNodeId.js";
5
+ import { suggestStructureChanges } from "../../wrapUnwrap/structureChangesPlugin.js";
6
+ import { guardStructureMarkAttrs } from "../../wrapUnwrap/types.js";
7
+ import { detectSpecialTransactionShape } from "../detectSpecialTransactionShape.js";
8
+ // handle the specific TipTap pattern when backspacing from a paragraph into a list above
9
+ // causes a transaction with 3 steps
10
+ // "slice" the transaction into two pieces, first piece is the first 2 steps, second piece is the last step
11
+ // first piece "moves" the paragraph - handled by structure change tracking - a structure "move" suggestion
12
+ // second piece joins two paragraphs - handled by the main plugin join-on-delete feature - deletion type="join" mark
13
+ export function handleTipTapParagraphIntoListJoin(args) {
14
+ const shape = detectSpecialTransactionShape(args.transaction);
15
+ if (!isTipTapParagraphIntoListJoinShape(shape)) return null;
16
+ if (!args.structuralContextPaths || !args.ensureUniqueNodeIds) return null;
17
+ const docBefore = args.transaction.docs[0];
18
+ if (!docBefore) return null;
19
+ const movedNodeId = getNodeId(shape.movedNode);
20
+ if (!movedNodeId) return null;
21
+ const trackedTransaction = args.state.tr;
22
+ try {
23
+ trackedTransaction.step(shape.deleteStep);
24
+ trackedTransaction.step(shape.insertStep);
25
+ } catch {
26
+ return null;
27
+ }
28
+ const uniqueNodeIdsTransform = args.ensureUniqueNodeIds([
29
+ args.transaction
30
+ ], docBefore, trackedTransaction.doc);
31
+ uniqueNodeIdsTransform.steps.forEach((step)=>{
32
+ trackedTransaction.step(step);
33
+ });
34
+ const structureChangesResult = suggestStructureChanges(docBefore, uniqueNodeIdsTransform.doc, args.structuralContextPaths, args.generateId);
35
+ if (!structureChangesResult.handled || !hasMoveStructureMark(structureChangesResult, movedNodeId)) {
36
+ return null;
37
+ }
38
+ structureChangesResult.transform.steps.forEach((step)=>{
39
+ trackedTransaction.step(step);
40
+ });
41
+ const intermediateState = EditorState.create({
42
+ schema: args.state.schema,
43
+ doc: trackedTransaction.doc
44
+ });
45
+ const joinTransaction = intermediateState.tr;
46
+ try {
47
+ joinTransaction.step(shape.joinStep);
48
+ } catch {
49
+ return null;
50
+ }
51
+ const trackedJoinTransaction = transformToSuggestionTransaction(joinTransaction, intermediateState, args.generateId);
52
+ trackedJoinTransaction.steps.forEach((step)=>{
53
+ trackedTransaction.step(step);
54
+ });
55
+ preserveTransactionData(trackedTransaction, trackedJoinTransaction, {
56
+ selection: "currentDocument",
57
+ preserveScroll: false,
58
+ preserveStoredMarks: false,
59
+ preserveMeta: false
60
+ });
61
+ preserveTransactionData(trackedTransaction, args.transaction);
62
+ return trackedTransaction;
63
+ }
64
+ function isTipTapParagraphIntoListJoinShape(shape) {
65
+ return shape?.type === "tipTapParagraphIntoListJoin";
66
+ }
67
+ function hasMoveStructureMark(structureChangesResult, movedNodeId) {
68
+ const { structure } = getSuggestionMarks(structureChangesResult.transform.doc.type.schema);
69
+ let found = false;
70
+ structureChangesResult.transform.doc.descendants((node)=>{
71
+ if (getNodeId(node) !== movedNodeId) return true;
72
+ found = node.marks.some((mark)=>{
73
+ if (mark.type !== structure) return false;
74
+ if (!guardStructureMarkAttrs(mark.attrs)) return false;
75
+ return mark.attrs.data.op.op === "move";
76
+ });
77
+ return !found;
78
+ });
79
+ return found;
80
+ }
@@ -0,0 +1,2 @@
1
+ export { detectTipTapParagraphIntoListJoin } from "./detectTipTapParagraphIntoListJoin.js";
2
+ export { handleTipTapParagraphIntoListJoin } from "./handleTipTapParagraphIntoListJoin.js";
@@ -0,0 +1,2 @@
1
+ export { detectTipTapParagraphIntoListJoin } from "./detectTipTapParagraphIntoListJoin.js";
2
+ export { handleTipTapParagraphIntoListJoin } from "./handleTipTapParagraphIntoListJoin.js";
@@ -0,0 +1,20 @@
1
+ import { type Node, type Schema } from "prosemirror-model";
2
+ import { type EditorState, type Transaction } from "prosemirror-state";
3
+ import { type ReplaceStep, type Transform } from "prosemirror-transform";
4
+ import { type SuggestionId } from "../../generateId.js";
5
+ import { type StructuralContextPath } from "../wrapUnwrap/types.js";
6
+ export interface TipTapParagraphIntoListJoinShape {
7
+ type: "tipTapParagraphIntoListJoin";
8
+ deleteStep: ReplaceStep;
9
+ insertStep: ReplaceStep;
10
+ joinStep: ReplaceStep;
11
+ movedNode: Node;
12
+ }
13
+ export type SpecialTransactionShape = TipTapParagraphIntoListJoinShape;
14
+ export interface HandleSpecialTransactionShapeArgs {
15
+ transaction: Transaction;
16
+ state: EditorState;
17
+ generateId: ((schema: Schema, doc?: Node) => SuggestionId) | undefined;
18
+ structuralContextPaths: StructuralContextPath[] | null;
19
+ ensureUniqueNodeIds: ((transactions: Transaction[], oldDoc: Node, newDoc: Node) => Transform) | undefined;
20
+ }
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,2 @@
1
+ import { type Mark } from "prosemirror-model";
2
+ export declare function areEquivalentStructureMarks(a: Mark, b: Mark): boolean;
@@ -0,0 +1,17 @@
1
+ import { sameParentChain } from "./sameParentChain.js";
2
+ import { guardStructureMarkAttrs } from "./types.js";
3
+ export function areEquivalentStructureMarks(a, b) {
4
+ if (a.type !== b.type) return false;
5
+ if (!guardStructureMarkAttrs(a.attrs)) return false;
6
+ if (!guardStructureMarkAttrs(b.attrs)) return false;
7
+ const aOp = a.attrs.data.op;
8
+ const bOp = b.attrs.data.op;
9
+ if (aOp.op !== bOp.op) return false;
10
+ if (aOp.op === "add" && bOp.op === "add") {
11
+ return a.attrs.id === b.attrs.id;
12
+ }
13
+ if (aOp.op === "move" && bOp.op === "move") {
14
+ return sameParentChain(aOp.from, bOp.from) && sameParentChain(aOp.to, bOp.to);
15
+ }
16
+ return false;
17
+ }
@@ -37,4 +37,9 @@ export type MaterializedPaths = Map<string, {
37
37
  }>;
38
38
  export declare function guardDocParent(parent: Parent | undefined): parent is DocParent;
39
39
  export declare function guardStructureMarkAttrs(attrs: Attrs): attrs is StructureMarkAttrs;
40
+ export interface StructureMarkObject {
41
+ type: "structure";
42
+ attrs: StructureMarkAttrs;
43
+ }
44
+ export declare function isStructureMarkObject(mark: unknown): mark is StructureMarkObject;
40
45
  export {};
@@ -9,3 +9,9 @@ export function guardStructureMarkAttrs(attrs) {
9
9
  if (!("op" in data)) return false;
10
10
  return true;
11
11
  }
12
+ export function isStructureMarkObject(mark) {
13
+ if (mark === null || typeof mark !== "object") return false;
14
+ if (!("type" in mark) || mark.type !== "structure") return false;
15
+ if (!("attrs" in mark)) return false;
16
+ return guardStructureMarkAttrs(mark.attrs);
17
+ }
@@ -1,4 +1,6 @@
1
1
  import { getSuggestionMarks } from "./utils.js";
2
+ import { isJoinMark } from "./features/joinOnDelete/types.js";
3
+ import { normalizeJoinNodesMetadata } from "./features/joinOnDelete/normalizeJoinNodesMetadata.js";
2
4
  export const suggestionIdValidate = "number|string";
3
5
  export function parseSuggestionId(id) {
4
6
  const parsed = parseInt(id, 10);
@@ -13,9 +15,34 @@ export function generateNextNumberId(schema, doc) {
13
15
  // and use that as the starting point for new changes
14
16
  let suggestionId = 0;
15
17
  doc?.descendants((node)=>{
16
- const mark = node.marks.find((mark)=>mark.type === insertion || mark.type === deletion || mark.type === modification || mark.type === structure);
17
- if (mark) {
18
- suggestionId = Math.max(suggestionId, mark.attrs["id"]);
18
+ // find max suggestion id across all suggestion marks and across all suggestion marks that are serialized into metadata of the join marks
19
+ const marks = node.marks.filter((mark)=>mark.type === insertion || mark.type === deletion || mark.type === modification || mark.type === structure);
20
+ const markIds = marks.map((mark)=>mark.attrs["id"]);
21
+ // collect suggestion ids of marks that are serialized into join marks metadata
22
+ const joinMarks = marks.filter((mark)=>isJoinMark(mark));
23
+ const joinMetadataMarkIds = [];
24
+ joinMarks.forEach((mark)=>{
25
+ const joinMetadata = normalizeJoinNodesMetadata(mark.attrs);
26
+ if (!joinMetadata) return;
27
+ joinMetadata.leftNodes.forEach((node)=>{
28
+ node.marks.forEach((mark)=>{
29
+ if (!mark.attrs["id"]) return;
30
+ joinMetadataMarkIds.push(mark.attrs["id"]);
31
+ });
32
+ });
33
+ joinMetadata.rightNodes.forEach((node)=>{
34
+ node.marks.forEach((mark)=>{
35
+ if (!mark.attrs["id"]) return;
36
+ joinMetadataMarkIds.push(mark.attrs["id"]);
37
+ });
38
+ });
39
+ });
40
+ const allMarkIds = [
41
+ ...markIds,
42
+ ...joinMetadataMarkIds
43
+ ];
44
+ if (allMarkIds.length > 0) {
45
+ suggestionId = Math.max(suggestionId, ...allMarkIds);
19
46
  return false;
20
47
  }
21
48
  return true;
@@ -0,0 +1,22 @@
1
+ import { type Node, type Schema } from "prosemirror-model";
2
+ import { type EditorState, type Transaction } from "prosemirror-state";
3
+ import { type SuggestionId } from "./generateId.js";
4
+ interface PreserveTransactionDataOptions {
5
+ selection?: "mapFromOriginalTransaction" | "currentDocument" | false;
6
+ preserveScroll?: boolean;
7
+ preserveStoredMarks?: boolean;
8
+ preserveMeta?: boolean;
9
+ }
10
+ export declare function preserveTransactionData(transaction: Transaction, originalTransaction: Transaction, options?: PreserveTransactionDataOptions): void;
11
+ /**
12
+ * Given a standard transaction from ProseMirror, produce
13
+ * a new transaction that tracks the changes from the original,
14
+ * rather than applying them.
15
+ *
16
+ * For each type of step, we implement custom behavior to prevent
17
+ * deletions from being removed from the document, instead adding
18
+ * deletion marks, and ensuring that all insertions have insertion
19
+ * marks.
20
+ */
21
+ export declare function transformToSuggestionTransaction(originalTransaction: Transaction, state: EditorState, generateId?: (schema: Schema, doc?: Node) => SuggestionId): Transaction;
22
+ export {};
@@ -0,0 +1,101 @@
1
+ import { AddMarkStep, AddNodeMarkStep, AttrStep, Mapping, RemoveMarkStep, RemoveNodeMarkStep, ReplaceAroundStep, ReplaceStep } from "prosemirror-transform";
2
+ import { trackAddMarkStep } from "./addMarkStep.js";
3
+ import { trackAddNodeMarkStep } from "./addNodeMarkStep.js";
4
+ import { trackAttrStep } from "./attrStep.js";
5
+ import { generateNextNumberId } from "./generateId.js";
6
+ import { suggestRemoveMarkStep } from "./removeMarkStep.js";
7
+ import { suggestRemoveNodeMarkStep } from "./removeNodeMarkStep.js";
8
+ import { suggestReplaceAroundStep } from "./replaceAroundStep.js";
9
+ import { suggestReplaceStep } from "./replaceStep.js";
10
+ import { getSuggestionMarks } from "./utils.js";
11
+ function getStepHandler(step) {
12
+ if (step instanceof ReplaceStep) {
13
+ return suggestReplaceStep;
14
+ }
15
+ if (step instanceof ReplaceAroundStep) {
16
+ return suggestReplaceAroundStep;
17
+ }
18
+ if (step instanceof AddMarkStep) {
19
+ return trackAddMarkStep;
20
+ }
21
+ if (step instanceof RemoveMarkStep) {
22
+ return suggestRemoveMarkStep;
23
+ }
24
+ if (step instanceof AddNodeMarkStep) {
25
+ return trackAddNodeMarkStep;
26
+ }
27
+ if (step instanceof RemoveNodeMarkStep) {
28
+ return suggestRemoveNodeMarkStep;
29
+ }
30
+ if (step instanceof AttrStep) {
31
+ return trackAttrStep;
32
+ }
33
+ // Default handler — simply rebase the step onto the
34
+ // tracked transaction and apply it.
35
+ return (trackedTransaction, _state, _doc, step, prevSteps)=>{
36
+ const reset = prevSteps.slice().reverse().reduce((acc, step)=>acc?.map(step.getMap().invert()) ?? null, step);
37
+ const rebased = trackedTransaction.steps.reduce((acc, step)=>acc?.map(step.getMap()) ?? null, reset);
38
+ if (rebased) {
39
+ trackedTransaction.step(rebased);
40
+ }
41
+ return false;
42
+ };
43
+ }
44
+ export function preserveTransactionData(transaction, originalTransaction, options = {}) {
45
+ const { selection = "mapFromOriginalTransaction", preserveScroll = true, preserveStoredMarks = true, preserveMeta = true } = options;
46
+ if (selection && originalTransaction.selectionSet && !transaction.selectionSet) {
47
+ if (selection === "currentDocument") {
48
+ transaction.setSelection(originalTransaction.selection.map(transaction.doc, new Mapping()));
49
+ } else {
50
+ const originalBaseDoc = originalTransaction.docs[0];
51
+ const base = originalBaseDoc ? originalTransaction.selection.map(originalBaseDoc, originalTransaction.mapping.invert()) : originalTransaction.selection;
52
+ transaction.setSelection(base.map(transaction.doc, transaction.mapping));
53
+ }
54
+ }
55
+ if (preserveScroll && originalTransaction.scrolledIntoView) {
56
+ transaction.scrollIntoView();
57
+ }
58
+ if (preserveStoredMarks && originalTransaction.storedMarksSet) {
59
+ transaction.setStoredMarks(originalTransaction.storedMarks);
60
+ }
61
+ if (preserveMeta) {
62
+ // @ts-expect-error Preserve original transaction meta exactly as-is
63
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
64
+ transaction.meta = originalTransaction.meta;
65
+ }
66
+ }
67
+ /**
68
+ * Given a standard transaction from ProseMirror, produce
69
+ * a new transaction that tracks the changes from the original,
70
+ * rather than applying them.
71
+ *
72
+ * For each type of step, we implement custom behavior to prevent
73
+ * deletions from being removed from the document, instead adding
74
+ * deletion marks, and ensuring that all insertions have insertion
75
+ * marks.
76
+ */ export function transformToSuggestionTransaction(originalTransaction, state, generateId) {
77
+ getSuggestionMarks(state.schema);
78
+ let suggestionId = generateId ? generateId(state.schema, originalTransaction.docs[0]) : generateNextNumberId(state.schema, originalTransaction.docs[0]);
79
+ // Create a new transaction from scratch. The original transaction
80
+ // is going to be dropped in favor of this one.
81
+ const trackedTransaction = state.tr;
82
+ for(let i = 0; i < originalTransaction.steps.length; i++){
83
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
84
+ const step = originalTransaction.steps[i];
85
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
86
+ const doc = originalTransaction.docs[i];
87
+ const stepTracker = getStepHandler(step);
88
+ if (stepTracker(trackedTransaction, state, doc, step, originalTransaction.steps.slice(0, i), suggestionId) && i < originalTransaction.steps.length - 1) {
89
+ // If the suggestionId was used by one of the step handlers,
90
+ // increment it so that it's not reused.
91
+ if (generateId) {
92
+ suggestionId = generateId(state.schema, trackedTransaction.doc);
93
+ } else if (typeof suggestionId === "number") {
94
+ suggestionId = suggestionId + 1;
95
+ }
96
+ }
97
+ continue;
98
+ }
99
+ preserveTransactionData(trackedTransaction, originalTransaction);
100
+ return trackedTransaction;
101
+ }
@@ -1,20 +1,10 @@
1
- import { type Schema, type Node } from "prosemirror-model";
2
- import { type EditorState, type Transaction } from "prosemirror-state";
1
+ import { type Node, type Schema } from "prosemirror-model";
2
+ import { type Transaction } from "prosemirror-state";
3
3
  import { type Transform } from "prosemirror-transform";
4
4
  import { type EditorView } from "prosemirror-view";
5
5
  import { type SuggestionId } from "./generateId.js";
6
6
  import { type StructuralContextPath } from "./features/wrapUnwrap/types.js";
7
- /**
8
- * Given a standard transaction from ProseMirror, produce
9
- * a new transaction that tracks the changes from the original,
10
- * rather than applying them.
11
- *
12
- * For each type of step, we implement custom behavior to prevent
13
- * deletions from being removed from the document, instead adding
14
- * deletion marks, and ensuring that all insertions have insertion
15
- * marks.
16
- */
17
- export declare function transformToSuggestionTransaction(originalTransaction: Transaction, state: EditorState, generateId?: (schema: Schema, doc?: Node) => SuggestionId): Transaction;
7
+ export { transformToSuggestionTransaction } from "./transformToSuggestionTransaction.js";
18
8
  /**
19
9
  * A `dispatchTransaction` decorator. Wrap your existing `dispatchTransaction`
20
10
  * function with `withSuggestChanges`, or pass no arguments to use the default
@@ -1,105 +1,15 @@
1
- import { AddMarkStep, AddNodeMarkStep, AttrStep, RemoveMarkStep, RemoveNodeMarkStep, ReplaceAroundStep, ReplaceStep } from "prosemirror-transform";
2
- import { trackAddMarkStep } from "./addMarkStep.js";
3
- import { trackAddNodeMarkStep } from "./addNodeMarkStep.js";
4
- import { trackAttrStep } from "./attrStep.js";
5
- import { suggestRemoveMarkStep } from "./removeMarkStep.js";
6
- import { suggestRemoveNodeMarkStep } from "./removeNodeMarkStep.js";
7
- import { suggestReplaceAroundStep } from "./replaceAroundStep.js";
8
- import { suggestReplaceStep } from "./replaceStep.js";
9
1
  import { isSuggestChangesEnabled, suggestChangesKey } from "./plugin.js";
10
- import { generateNextNumberId } from "./generateId.js";
11
- import { getSuggestionMarks } from "./utils.js";
12
2
  import { prependDeletionsWithZWSP } from "./prependDeletionsWithZWSP.js";
13
3
  import { getRequiredStructuralContextPaths, suggestStructureChanges } from "./features/wrapUnwrap/structureChangesPlugin.js";
4
+ import { handleSpecialTransactionShape } from "./features/transactionShaping/index.js";
5
+ import { transformToSuggestionTransaction } from "./transformToSuggestionTransaction.js";
6
+ export { transformToSuggestionTransaction } from "./transformToSuggestionTransaction.js";
14
7
  const TRACE_ENABLED = true;
15
8
  function trace(...args) {
16
9
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
17
10
  if (!TRACE_ENABLED) return;
18
11
  console.log("[withSuggestChanges]", ...args);
19
12
  }
20
- function getStepHandler(step) {
21
- if (step instanceof ReplaceStep) {
22
- return suggestReplaceStep;
23
- }
24
- if (step instanceof ReplaceAroundStep) {
25
- return suggestReplaceAroundStep;
26
- }
27
- if (step instanceof AddMarkStep) {
28
- return trackAddMarkStep;
29
- }
30
- if (step instanceof RemoveMarkStep) {
31
- return suggestRemoveMarkStep;
32
- }
33
- if (step instanceof AddNodeMarkStep) {
34
- return trackAddNodeMarkStep;
35
- }
36
- if (step instanceof RemoveNodeMarkStep) {
37
- return suggestRemoveNodeMarkStep;
38
- }
39
- if (step instanceof AttrStep) {
40
- return trackAttrStep;
41
- }
42
- // Default handler — simply rebase the step onto the
43
- // tracked transaction and apply it.
44
- return (trackedTransaction, _state, _doc, step, prevSteps)=>{
45
- const reset = prevSteps.slice().reverse().reduce((acc, step)=>acc?.map(step.getMap().invert()) ?? null, step);
46
- const rebased = trackedTransaction.steps.reduce((acc, step)=>acc?.map(step.getMap()) ?? null, reset);
47
- if (rebased) {
48
- trackedTransaction.step(rebased);
49
- }
50
- return false;
51
- };
52
- }
53
- /**
54
- * Given a standard transaction from ProseMirror, produce
55
- * a new transaction that tracks the changes from the original,
56
- * rather than applying them.
57
- *
58
- * For each type of step, we implement custom behavior to prevent
59
- * deletions from being removed from the document, instead adding
60
- * deletion marks, and ensuring that all insertions have insertion
61
- * marks.
62
- */ export function transformToSuggestionTransaction(originalTransaction, state, generateId) {
63
- getSuggestionMarks(state.schema);
64
- let suggestionId = generateId ? generateId(state.schema, originalTransaction.docs[0]) : generateNextNumberId(state.schema, originalTransaction.docs[0]);
65
- // Create a new transaction from scratch. The original transaction
66
- // is going to be dropped in favor of this one.
67
- const trackedTransaction = state.tr;
68
- for(let i = 0; i < originalTransaction.steps.length; i++){
69
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
70
- const step = originalTransaction.steps[i];
71
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
72
- const doc = originalTransaction.docs[i];
73
- const stepTracker = getStepHandler(step);
74
- if (stepTracker(trackedTransaction, state, doc, step, originalTransaction.steps.slice(0, i), suggestionId) && i < originalTransaction.steps.length - 1) {
75
- // If the suggestionId was used by one of the step handlers,
76
- // increment it so that it's not reused.
77
- if (generateId) {
78
- suggestionId = generateId(state.schema, trackedTransaction.doc);
79
- } else if (typeof suggestionId === "number") {
80
- suggestionId = suggestionId + 1;
81
- }
82
- }
83
- continue;
84
- }
85
- if (originalTransaction.selectionSet && !trackedTransaction.selectionSet) {
86
- // Map the original selection backwards through the original transaction,
87
- // and then forwards through the new one.
88
- const originalBaseDoc = originalTransaction.docs[0];
89
- const base = originalBaseDoc ? originalTransaction.selection.map(originalBaseDoc, originalTransaction.mapping.invert()) : originalTransaction.selection;
90
- trackedTransaction.setSelection(base.map(trackedTransaction.doc, trackedTransaction.mapping));
91
- }
92
- if (originalTransaction.scrolledIntoView) {
93
- trackedTransaction.scrollIntoView();
94
- }
95
- if (originalTransaction.storedMarksSet) {
96
- trackedTransaction.setStoredMarks(originalTransaction.storedMarks);
97
- }
98
- // @ts-expect-error Preserve original transaction meta exactly as-is
99
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
100
- trackedTransaction.meta = originalTransaction.meta;
101
- return trackedTransaction;
102
- }
103
13
  /**
104
14
  * A `dispatchTransaction` decorator. Wrap your existing `dispatchTransaction`
105
15
  * function with `withSuggestChanges`, or pass no arguments to use the default
@@ -123,7 +33,14 @@ function getStepHandler(step) {
123
33
  const docBefore = transaction.docs[0];
124
34
  const structuralContextPaths = opts?.experimental_trackStructureChanges ? getRequiredStructuralContextPaths(opts.experimental_trackStructures) : null;
125
35
  const ensureUniqueNodeIds = opts?.experimental_ensureUniqueNodeIds;
126
- if (transaction.docChanged && docBefore && structuralContextPaths && typeof ensureUniqueNodeIds === "function") {
36
+ const shapedTransaction = transaction.docChanged ? handleSpecialTransactionShape({
37
+ transaction,
38
+ state: this.state,
39
+ generateId,
40
+ structuralContextPaths,
41
+ ensureUniqueNodeIds
42
+ }) : null;
43
+ if (!shapedTransaction && transaction.docChanged && docBefore && structuralContextPaths && typeof ensureUniqueNodeIds === "function") {
127
44
  trace("trying to track structure changes first...");
128
45
  // after a transaction, some nodes may not yet have unique ids (they were just added, and the unique id plugin has not yet run)
129
46
  // this hook allows to "post-process" the transaction and add the missing ids
@@ -152,7 +69,9 @@ function getStepHandler(step) {
152
69
  trace("applied unique id transform and structure changes transform to the transaction", transaction);
153
70
  }
154
71
  }
155
- if (transaction.docChanged && structureChangesResult?.handled !== true) {
72
+ if (shapedTransaction) {
73
+ transaction = shapedTransaction;
74
+ } else if (transaction.docChanged && structureChangesResult?.handled !== true) {
156
75
  trace("running the main suggestions plugin...");
157
76
  const perfSuggestions = performance.now();
158
77
  transaction = transformToSuggestionTransaction(tr, this.state, generateId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magic-marker/prosemirror-suggest-changes",
3
- "version": "0.3.3-wrap-unwrap.17",
3
+ "version": "0.3.3-wrap-unwrap.19",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "dist/index.js",