@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
@@ -0,0 +1,112 @@
1
+ import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
2
+ import { assert, describe, it } from "vitest";
3
+ import { ZWSP } from "../../constants.js";
4
+ import { testBuilders } from "../../testing/testBuilders.js";
5
+ import { ensureSelection } from "./ensureSelectionPlugin.js";
6
+ function createState(doc, selection) {
7
+ return EditorState.create({
8
+ doc,
9
+ selection,
10
+ plugins: [
11
+ ensureSelection()
12
+ ]
13
+ });
14
+ }
15
+ function applyTextSelection(state, anchor, head = anchor) {
16
+ const selection = TextSelection.create(state.doc, anchor, head);
17
+ const transaction = state.tr.setSelection(selection);
18
+ return state.apply(transaction);
19
+ }
20
+ function assertTextSelection(state, anchor, head = anchor) {
21
+ assert(state.selection instanceof TextSelection);
22
+ assert.equal(state.selection.anchor, anchor);
23
+ assert.equal(state.selection.head, head);
24
+ }
25
+ function getTag(doc, tag) {
26
+ const pos = doc.tag[tag];
27
+ assert(pos !== undefined, `Expected tag "${tag}" to exist`);
28
+ return pos;
29
+ }
30
+ describe("ensureSelection", ()=>{
31
+ it("leaves valid text selections unchanged", ()=>{
32
+ const doc = testBuilders.doc(testBuilders.paragraph("one<from>"), testBuilders.paragraph("two<to>"));
33
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "from")));
34
+ const nextState = applyTextSelection(state, getTag(doc, "to"));
35
+ assertTextSelection(nextState, getTag(doc, "to"));
36
+ });
37
+ it("ignores non-text selections", ()=>{
38
+ const doc = testBuilders.doc(testBuilders.image({
39
+ src: "https://example.com/image.png"
40
+ }), testBuilders.paragraph("after<cursor>"));
41
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "cursor")));
42
+ const nextState = state.apply(state.tr.setSelection(NodeSelection.create(doc, 0)));
43
+ assert(nextState.selection instanceof NodeSelection);
44
+ assert.equal(nextState.selection.anchor, 0);
45
+ });
46
+ it("prefers a valid position in the destination textblock for selection-only movement", ()=>{
47
+ const doc = testBuilders.doc(testBuilders.paragraph("Item 2<old>"), testBuilders.paragraph(testBuilders.insertion({
48
+ id: 1
49
+ }, "<invalid>" + ZWSP + "<valid>")), testBuilders.paragraph(testBuilders.insertion({
50
+ id: 1
51
+ }, ZWSP), "Item 3"));
52
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
53
+ const nextState = applyTextSelection(state, getTag(doc, "invalid"));
54
+ assertTextSelection(nextState, getTag(doc, "valid"));
55
+ });
56
+ it("uses direction fallback when the transaction changes the document", ()=>{
57
+ const doc = testBuilders.doc(testBuilders.paragraph("<markStart>A<expected><markEnd>"), testBuilders.paragraph(testBuilders.insertion({
58
+ id: 1
59
+ }, "<invalid>" + ZWSP + "<sameParent>")), testBuilders.paragraph(testBuilders.insertion({
60
+ id: 1
61
+ }, ZWSP), "<old>Item 3"));
62
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
63
+ const transaction = state.tr;
64
+ transaction.addMark(getTag(doc, "markStart"), getTag(doc, "markEnd"), testBuilders.schema.marks.strong.create());
65
+ transaction.setSelection(TextSelection.create(transaction.doc, getTag(doc, "invalid")));
66
+ const nextState = state.apply(transaction);
67
+ assertTextSelection(nextState, getTag(doc, "expected"));
68
+ });
69
+ it("uses direction fallback for invalid positions in the same textblock", ()=>{
70
+ const doc = testBuilders.doc(testBuilders.paragraph("A", testBuilders.deletion({
71
+ id: 1
72
+ }, "<expected>B<invalid>"), "C<old>"));
73
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
74
+ const nextState = applyTextSelection(state, getTag(doc, "invalid"));
75
+ assertTextSelection(nextState, getTag(doc, "expected"));
76
+ });
77
+ it("keeps deletion anchor and non-anchor deletion positions continuous", ()=>{
78
+ const doc = testBuilders.doc(testBuilders.paragraph(testBuilders.deletion({
79
+ id: 1,
80
+ type: "anchor"
81
+ }, "<expected>" + ZWSP + "<invalid>"), testBuilders.deletion({
82
+ id: 1
83
+ }, "deleted"), "visible<old>"));
84
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
85
+ const nextState = applyTextSelection(state, getTag(doc, "invalid"));
86
+ assertTextSelection(nextState, getTag(doc, "expected"));
87
+ });
88
+ it("moves hidden-deletion-style boundary positions to visible content", ()=>{
89
+ const doc = testBuilders.doc(testBuilders.paragraph("before<old>"), testBuilders.paragraph(testBuilders.deletion({
90
+ id: 1
91
+ }, "<invalid>deleted"), "v<expected>isible"));
92
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
93
+ const nextState = applyTextSelection(state, getTag(doc, "invalid"));
94
+ assertTextSelection(nextState, getTag(doc, "expected"));
95
+ });
96
+ it("keeps paired split marker positions attached to textblock boundaries", ()=>{
97
+ const doc = testBuilders.doc(testBuilders.paragraph("before<old>"), testBuilders.paragraph("content<expected>", testBuilders.insertion({
98
+ id: 1
99
+ }, ZWSP + "<invalid>")));
100
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
101
+ const nextState = applyTextSelection(state, getTag(doc, "invalid"));
102
+ assertTextSelection(nextState, getTag(doc, "expected"));
103
+ });
104
+ it("moves positions between adjacent insertion ZWSPs to a valid neighbor", ()=>{
105
+ const doc = testBuilders.doc(testBuilders.paragraph("before<old>", testBuilders.insertion({
106
+ id: 1
107
+ }, "<expected>" + ZWSP + "<invalid>" + ZWSP), "<valid>after"));
108
+ const state = createState(doc, TextSelection.create(doc, getTag(doc, "old")));
109
+ const nextState = applyTextSelection(state, getTag(doc, "invalid"));
110
+ assertTextSelection(nextState, getTag(doc, "valid"));
111
+ });
112
+ });
@@ -0,0 +1,3 @@
1
+ import { type ResolvedPos } from "prosemirror-model";
2
+ export type InvalidSelectionPositionReason = "not-in-inline-content" | "between-deletion-anchor-and-non-anchor-deletion" | "between-two-non-anchor-deletions" | "between-node-boundary-and-non-anchor-deletion" | "between-non-anchor-deletion-and-node-boundary" | "between-non-anchor-deletion-and-anything" | "between-two-zwsp-insertions" | "between-zwsp-insertion-and-right-node-boundary" | "between-zwsp-insertion-and-left-node-boundary";
3
+ export declare function getInvalidSelectionPositionReason($pos: ResolvedPos): InvalidSelectionPositionReason | null;
@@ -0,0 +1,50 @@
1
+ import { ZWSP } from "../../constants.js";
2
+ import { getSuggestionMarks } from "../../utils.js";
3
+ export function getInvalidSelectionPositionReason($pos) {
4
+ // Text selection is only valid in nodes that allow inline content.
5
+ // https://github.com/ProseMirror/prosemirror-state/blob/1.4.4/src/selection.ts#L219
6
+ if (!$pos.parent.inlineContent) {
7
+ return "not-in-inline-content";
8
+ }
9
+ const { deletion, insertion } = getSuggestionMarks($pos.doc.type.schema);
10
+ const deletionBefore = deletion.isInSet($pos.nodeBefore?.marks ?? []);
11
+ const deletionAfter = deletion.isInSet($pos.nodeAfter?.marks ?? []);
12
+ const isAnchorBefore = deletionBefore && deletionBefore.attrs["type"] === "anchor";
13
+ const isAnchorAfter = deletionAfter && deletionAfter.attrs["type"] === "anchor";
14
+ if (isAnchorBefore && deletionAfter && !isAnchorAfter) {
15
+ return "between-deletion-anchor-and-non-anchor-deletion";
16
+ }
17
+ if (deletionBefore && deletionAfter && !isAnchorBefore && !isAnchorAfter) {
18
+ return "between-two-non-anchor-deletions";
19
+ }
20
+ if ($pos.nodeBefore == null && deletionAfter && !isAnchorAfter) {
21
+ return "between-node-boundary-and-non-anchor-deletion";
22
+ }
23
+ if (deletionBefore && $pos.nodeAfter == null && !isAnchorBefore) {
24
+ return "between-non-anchor-deletion-and-node-boundary";
25
+ }
26
+ if (deletionBefore && !isAnchorBefore && $pos.nodeAfter == null) {
27
+ return "between-non-anchor-deletion-and-node-boundary";
28
+ }
29
+ if (deletionBefore && !isAnchorBefore) {
30
+ return "between-non-anchor-deletion-and-anything";
31
+ }
32
+ const insertionBefore = insertion.isInSet($pos.nodeBefore?.marks ?? []);
33
+ const insertionAfter = insertion.isInSet($pos.nodeAfter?.marks ?? []);
34
+ const ZWSP_REGEXP = new RegExp(ZWSP, "g");
35
+ const isZWSPBefore = $pos.nodeBefore && $pos.nodeBefore.isText && $pos.nodeBefore.textContent.replace(ZWSP_REGEXP, "") === "";
36
+ const isZWSPAfter = $pos.nodeAfter && $pos.nodeAfter.isText && $pos.nodeAfter.textContent.replace(ZWSP_REGEXP, "") === "";
37
+ if (insertionBefore && insertionAfter && isZWSPBefore && isZWSPAfter) {
38
+ return "between-two-zwsp-insertions";
39
+ }
40
+ if (insertionBefore && isZWSPBefore && $pos.nodeAfter == null && // A position like this:
41
+ // <p><insertion>ZWSP</insertion>|</p>
42
+ // because it means this paragraph was just created and it's empty.
43
+ $pos.parent.textContent.replace(ZWSP_REGEXP, "") !== "") {
44
+ return "between-zwsp-insertion-and-right-node-boundary";
45
+ }
46
+ if (insertionAfter && isZWSPAfter && $pos.nodeBefore == null) {
47
+ return "between-zwsp-insertion-and-left-node-boundary";
48
+ }
49
+ return null;
50
+ }
@@ -1,24 +1,10 @@
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 JoinMarkAttrs {
6
- type: "join";
7
- data: {
8
- leftNode: {
9
- type: string;
10
- attrs: object;
11
- marks: object[];
12
- };
13
- rightNode: {
14
- type: string;
15
- attrs: object;
16
- marks: object[];
17
- };
18
- };
19
- }
20
- export declare function isJoinMarkAttrs(attrs: Attrs): attrs is JoinMarkAttrs;
21
- export declare function maybeRevertJoinMark(tr: Transform, from: number, to: number, node: Node, markType: MarkType): boolean;
5
+ export declare function maybeRevertJoinMark(tr: Transform, from: number, to: number, node: Node, markType: MarkType): false | {
6
+ restoredStructureSuggestionIds: Set<SuggestionId>;
7
+ };
22
8
  /**
23
9
  * Remove ZWSP text nodes marked as deletions (except for type=join) from the given range
24
10
  */
@@ -34,4 +20,3 @@ export declare function joinNodesAndMarkJoinPoints(trackedTransaction: Transacti
34
20
  */
35
21
  export declare function collapseZWSPNodes(trackedTransaction: Transaction, from: number, to: number): Transform;
36
22
  export declare function findJoinMark(pos: ResolvedPos): Mark | null;
37
- export {};
@@ -2,41 +2,104 @@ 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
- export function isJoinMarkAttrs(attrs) {
6
- if (attrs["type"] !== "join") return false;
7
- if (attrs["data"] == null) return false;
8
- const data = attrs["data"];
9
- if (data.leftNode == null || data.rightNode == null) return false;
10
- if (typeof data.leftNode.type !== "string" || typeof data.rightNode.type !== "string") return false;
11
- if (typeof data.leftNode.attrs !== "object" || typeof data.rightNode.attrs !== "object") return false;
12
- if (!Array.isArray(data.leftNode.marks) || !Array.isArray(data.rightNode.marks)) return false;
5
+ import { areEquivalentStructureMarks } from "../wrapUnwrap/areEquivalentStructureMarks.js";
6
+ import { guardStructureMarkAttrs } from "../wrapUnwrap/types.js";
7
+ import { MAX_BLOCK_JOIN_DEPTH, normalizeJoinNodesMetadata } from "./normalizeJoinNodesMetadata.js";
8
+ import { isJoinMark } from "./types.js";
9
+ function serializeJoinNode(node) {
10
+ return {
11
+ type: node.type.name,
12
+ attrs: node.attrs,
13
+ marks: node.marks.map((mark)=>mark.toJSON())
14
+ };
15
+ }
16
+ function marksFromJSON(schema, markData) {
17
+ return markData.map((markData)=>Mark.fromJSON(schema, markData));
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
+ }
37
+ function restoreNodeMarkup(tr, pos, node) {
38
+ const nodeType = tr.doc.type.schema.nodes[node.type];
39
+ if (!nodeType) return false;
40
+ const marks = mergeSerializedMarksWithCurrentStructureMarks(tr, pos, marksFromJSON(tr.doc.type.schema, node.marks));
41
+ tr.setNodeMarkup(pos, nodeType, node.attrs, marks);
13
42
  return true;
14
43
  }
44
+ // collect structure suggestion IDs that will be re-introduced into the document after the join is reverted
45
+ function getRestoredStructureSuggestionIds(joinNodes, schema) {
46
+ const { structure } = getSuggestionMarks(schema);
47
+ const restoredStructureSuggestionIds = new Set();
48
+ for (const node of [
49
+ ...joinNodes.leftNodes,
50
+ ...joinNodes.rightNodes
51
+ ]){
52
+ const marks = marksFromJSON(schema, node.marks);
53
+ for (const mark of marks){
54
+ if (mark.type !== structure) continue;
55
+ if (!guardStructureMarkAttrs(mark.attrs)) continue;
56
+ restoredStructureSuggestionIds.add(mark.attrs.id);
57
+ }
58
+ }
59
+ return restoredStructureSuggestionIds;
60
+ }
15
61
  export function maybeRevertJoinMark(tr, from, to, node, markType) {
16
62
  const mark = node.marks.find((mark)=>mark.type === markType);
17
- if (!mark || mark.attrs["type"] !== "join" || node.text !== ZWSP) return false;
18
- // this is a mark of type join
19
- // split the current node at this mark position
20
- // delete this mark together with its zwsp content
21
- // assign left and right node (after the split) properties from the mark's data
63
+ if (!mark || !isJoinMark(mark) || node.text !== ZWSP) return false;
64
+ const joinNodes = normalizeJoinNodesMetadata(mark.attrs);
65
+ if (!joinNodes) return false;
66
+ for (const node of [
67
+ ...joinNodes.leftNodes,
68
+ ...joinNodes.rightNodes
69
+ ]){
70
+ const nodeType = tr.doc.type.schema.nodes[node.type];
71
+ if (!nodeType) return false;
72
+ try {
73
+ marksFromJSON(tr.doc.type.schema, node.marks);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+ const restoredStructureSuggestionIds = getRestoredStructureSuggestionIds(joinNodes, tr.doc.type.schema);
79
+ // Reverting a join marker removes its ZWSP anchor, splits at that position,
80
+ // and restores markup because ProseMirror split creates nodes with defaults.
81
+ const joinDepth = joinNodes.leftNodes.length;
22
82
  tr.delete(from, to);
23
- tr.split(from);
24
- // insertionFrom is now at the end of the left node (but before the closing token)
25
- // after() will give us the position after the closing token (but before the next opening token) - exactly the split pos
26
- const $insertionFrom = tr.doc.resolve(from);
27
- const $splitPos = tr.doc.resolve($insertionFrom.after());
28
- const { attrs } = mark;
29
- if (!isJoinMarkAttrs(attrs) || !$splitPos.nodeBefore || !$splitPos.nodeAfter) return false;
30
- // restore left and right node type, attrs and marks, as they were before the join
31
- const { leftNode, rightNode } = attrs.data;
32
- const leftNodeType = tr.doc.type.schema.nodes[leftNode.type];
33
- const rightNodeType = tr.doc.type.schema.nodes[rightNode.type];
34
- const leftNodeMarks = leftNode.marks.map((markData)=>Mark.fromJSON(tr.doc.type.schema, markData));
35
- const rightNodeMarks = rightNode.marks.map((markData)=>Mark.fromJSON(tr.doc.type.schema, markData));
36
- if (!leftNodeType || !rightNodeType) return false;
37
- tr.setNodeMarkup($splitPos.pos - $splitPos.nodeBefore.nodeSize, leftNodeType, leftNode.attrs, leftNodeMarks);
38
- tr.setNodeMarkup($splitPos.pos, rightNodeType, rightNode.attrs, rightNodeMarks);
39
- return true;
83
+ tr.split(from, joinDepth);
84
+ const $splitFrom = tr.doc.resolve(from);
85
+ const baseDepth = $splitFrom.depth;
86
+ let rightPos = $splitFrom.after(baseDepth - joinDepth + 1);
87
+ // Metadata is stored child-first, but markup must be restored outer-first so
88
+ // positions inside the newly split structure remain addressable.
89
+ for(let index = joinDepth - 1; index >= 0; index -= 1){
90
+ const leftNode = joinNodes.leftNodes[index];
91
+ const rightNode = joinNodes.rightNodes[index];
92
+ if (!leftNode || !rightNode) return false;
93
+ const leftPos = $splitFrom.before(baseDepth - index);
94
+ if (!restoreNodeMarkup(tr, leftPos, leftNode) || !restoreNodeMarkup(tr, rightPos, rightNode)) {
95
+ return false;
96
+ }
97
+ // Each deeper right node starts one position inside the right node restored before it.
98
+ rightPos += 1;
99
+ }
100
+ return {
101
+ restoredStructureSuggestionIds
102
+ };
40
103
  }
41
104
  /**
42
105
  * Remove ZWSP text nodes marked as deletions (except for type=join) from the given range
@@ -72,45 +135,95 @@ export function maybeRevertJoinMark(tr, from, to, node, markType) {
72
135
  doc.nodesBetween(blockRange.start, blockRange.end, (node, pos)=>{
73
136
  if (node.isInline) return false;
74
137
  const endOfNode = pos + node.nodeSize;
75
- // make sure the node ends within the range
76
138
  if (endOfNode >= blockRange.$to.pos) return true;
77
- const $endOfNode = doc.resolve(endOfNode);
78
- // make sure we are between two nodes
79
- if (!$endOfNode.nodeBefore || !$endOfNode.nodeAfter) return false;
80
- // we cannot insert zwsp text nodes into non-textblock nodes
81
- if (!$endOfNode.nodeBefore.isTextblock || !$endOfNode.nodeAfter.isTextblock) return true;
82
- const mappedEndOfNode = transform.mapping.map(endOfNode);
83
- const $mappedEndOfNode = transform.doc.resolve(mappedEndOfNode);
84
- if (!canJoin(transform.doc, mappedEndOfNode) || !$mappedEndOfNode.nodeBefore || !$mappedEndOfNode.nodeAfter) {
139
+ // List-item joins can start between non-textblock nodes; expand inward to
140
+ // capture the visible textblock pair and its structural parent pair.
141
+ const joinCandidate = getJoinCandidateAtBoundary(doc, endOfNode, from, to, MAX_BLOCK_JOIN_DEPTH);
142
+ if (!joinCandidate) return true;
143
+ const mappedJoinPos = transform.mapping.map(joinCandidate.joinPos);
144
+ if (!canApplyJoinCandidate(transform.doc, mappedJoinPos, joinCandidate.leftNodes.length)) {
85
145
  return true;
86
146
  }
87
- const leftNode = {
88
- type: $endOfNode.nodeBefore.type.name,
89
- attrs: $endOfNode.nodeBefore.attrs,
90
- marks: $endOfNode.nodeBefore.marks.map((mark)=>mark.toJSON())
91
- };
92
- const rightNode = {
93
- type: $endOfNode.nodeAfter.type.name,
94
- attrs: $endOfNode.nodeAfter.attrs,
95
- marks: $endOfNode.nodeAfter.marks.map((mark)=>mark.toJSON())
96
- };
97
- transform.join(mappedEndOfNode);
147
+ const shouldSuppressJoinMark = [
148
+ ...joinCandidate.leftNodes,
149
+ ...joinCandidate.rightNodes
150
+ ].some((node)=>hasStructureAddMark(node));
151
+ transform.join(mappedJoinPos, joinCandidate.leftNodes.length);
152
+ // Joining provisional structure cancels its pending add instead of creating
153
+ // a second review artifact for the same user action.
154
+ if (shouldSuppressJoinMark) return false;
98
155
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
99
156
  const joinStep = transform.steps[transform.steps.length - 1];
100
- const joinPos = joinStep.getMap().map(mappedEndOfNode);
157
+ const joinPos = joinStep.getMap().map(mappedJoinPos);
101
158
  transform.insert(joinPos, transform.doc.type.schema.text(ZWSP));
102
159
  transform.addMark(joinPos, joinPos + 1, deletion.create({
103
160
  id: markId,
104
161
  type: "join",
105
162
  data: {
106
- leftNode,
107
- rightNode
163
+ leftNodes: joinCandidate.leftNodes.map(serializeJoinNode),
164
+ rightNodes: joinCandidate.rightNodes.map(serializeJoinNode)
108
165
  }
109
166
  }));
110
167
  return false;
111
168
  });
112
169
  return transform;
113
170
  }
171
+ function getJoinCandidateAtBoundary(doc, boundaryPos, from, to, maxJoinDepth) {
172
+ const $boundary = doc.resolve(boundaryPos);
173
+ const leftNode = $boundary.nodeBefore;
174
+ const rightNode = $boundary.nodeAfter;
175
+ // Multi-depth list joins can begin between structural nodes, not just textblocks.
176
+ if (!leftNode || !rightNode) return null;
177
+ if (leftNode.isInline || rightNode.isInline) return null;
178
+ if (!canJoin(doc, boundaryPos)) return null;
179
+ const pairs = [
180
+ {
181
+ leftNode,
182
+ rightNode
183
+ }
184
+ ];
185
+ for(let expandBy = 1; pairs.length < maxJoinDepth; expandBy += 1){
186
+ const left = boundaryPos - expandBy;
187
+ const right = boundaryPos + expandBy;
188
+ if (left < from || right > to) break;
189
+ const $left = doc.resolve(left);
190
+ const $right = doc.resolve(right);
191
+ // Once text is adjacent, there is no deeper block pair to capture.
192
+ if ($left.nodeBefore?.isText || $right.nodeAfter?.isText) break;
193
+ if ($left.nodeAfter === null && $left.nodeBefore && !$left.nodeBefore.isInline && $right.nodeBefore === null && $right.nodeAfter && !$right.nodeAfter.isInline) {
194
+ pairs.push({
195
+ leftNode: $left.nodeBefore,
196
+ rightNode: $right.nodeAfter
197
+ });
198
+ continue;
199
+ }
200
+ break;
201
+ }
202
+ // canJoin only checks the boundary; the temporary transform verifies depth.
203
+ if (!canApplyJoinCandidate(doc, boundaryPos, pairs.length)) return null;
204
+ return {
205
+ joinPos: boundaryPos,
206
+ leftNodes: pairs.map((pair)=>pair.leftNode).reverse(),
207
+ rightNodes: pairs.map((pair)=>pair.rightNode).reverse()
208
+ };
209
+ }
210
+ function canApplyJoinCandidate(doc, joinPos, depth) {
211
+ if (!canJoin(doc, joinPos)) return false;
212
+ try {
213
+ new Transform(doc).join(joinPos, depth);
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ function hasStructureAddMark(node) {
220
+ const { structure } = getSuggestionMarks(node.type.schema);
221
+ return node.marks.some((mark)=>{
222
+ if (mark.type !== structure) return false;
223
+ if (!guardStructureMarkAttrs(mark.attrs)) return false;
224
+ return mark.attrs.data.op.op === "add";
225
+ });
226
+ }
114
227
  /**
115
228
  * Find ZWSP nodes marked as insertions and deletions with the same mark id
116
229
  * Delete them from the given range
@@ -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,36 @@
1
+ import { type Mark, type Attrs, type Node } from "prosemirror-model";
2
+ import { type SuggestionId } from "../../generateId.js";
3
+ export interface SerializedJoinNode {
4
+ type: string;
5
+ attrs: object;
6
+ marks: {
7
+ attrs: Record<string, unknown>;
8
+ }[];
9
+ }
10
+ export interface JoinMarkAttrs {
11
+ id: SuggestionId;
12
+ type: "join";
13
+ data: {
14
+ leftNode?: SerializedJoinNode;
15
+ rightNode?: SerializedJoinNode;
16
+ leftNodes?: SerializedJoinNode[];
17
+ rightNodes?: SerializedJoinNode[];
18
+ };
19
+ }
20
+ export interface JoinPair {
21
+ leftNode: Node;
22
+ rightNode: Node;
23
+ }
24
+ export interface JoinCandidate {
25
+ joinPos: number;
26
+ leftNodes: Node[];
27
+ rightNodes: Node[];
28
+ }
29
+ export declare function isSerializedJoinNode(node: unknown): node is SerializedJoinNode;
30
+ export declare function isJoinMarkAttrs(attrs: Attrs): attrs is JoinMarkAttrs;
31
+ export declare function isJoinMarkObject(mark: unknown): mark is Omit<Mark, "attrs"> & {
32
+ attrs: JoinMarkAttrs;
33
+ };
34
+ export declare function isJoinMark(mark: Mark): mark is Omit<Mark, "attrs"> & {
35
+ attrs: JoinMarkAttrs;
36
+ };
@@ -0,0 +1,23 @@
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 (typeof attrs["id"] !== "string" && typeof attrs["id"] !== "number") {
9
+ return false;
10
+ }
11
+ if (attrs["type"] !== "join") return false;
12
+ if (attrs["data"] == null) return false;
13
+ return true;
14
+ }
15
+ export function isJoinMarkObject(mark) {
16
+ if (mark === null || typeof mark !== "object") return false;
17
+ if (!("attrs" in mark)) return false;
18
+ return isJoinMarkAttrs(mark.attrs);
19
+ }
20
+ export function isJoinMark(mark) {
21
+ const { deletion } = getSuggestionMarks(mark.type.schema);
22
+ return mark.type === deletion && isJoinMarkAttrs(mark.attrs);
23
+ }
@@ -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
+ }