@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.
- package/dist/__tests__/playwrightHelpers.d.ts +2 -2
- package/dist/__tests__/playwrightPage.d.ts +53 -2
- package/dist/commands.js +222 -43
- package/dist/{ensureSelectionPlugin.js → features/ensureValidSelection/ensureSelectionPlugin.js} +44 -77
- package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.d.ts +1 -0
- package/dist/features/ensureValidSelection/ensureSelectionPlugin.test.js +112 -0
- package/dist/features/ensureValidSelection/selectionPosition.d.ts +3 -0
- package/dist/features/ensureValidSelection/selectionPosition.js +50 -0
- package/dist/features/joinOnDelete/__tests__/joinOnDeleteInLists.playwright.test.d.ts +1 -0
- package/dist/features/joinOnDelete/__tests__/joinOnDeleteInListsTipTapStyle.playwright.test.d.ts +1 -0
- package/dist/features/joinOnDelete/__tests__/listWithJoinsAndStructureMarks.playwright.test.d.ts +1 -0
- package/dist/features/joinOnDelete/index.d.ts +4 -19
- package/dist/features/joinOnDelete/index.js +166 -53
- package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.d.ts +6 -0
- package/dist/features/joinOnDelete/normalizeJoinNodesMetadata.js +24 -0
- package/dist/features/joinOnDelete/types.d.ts +36 -0
- package/dist/features/joinOnDelete/types.js +23 -0
- package/dist/features/transactionShaping/detectSpecialTransactionShape.d.ts +3 -0
- package/dist/features/transactionShaping/detectSpecialTransactionShape.js +4 -0
- package/dist/features/transactionShaping/index.d.ts +3 -0
- package/dist/features/transactionShaping/index.js +11 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.d.ts +3 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.js +48 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.d.ts +1 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/detectTipTapParagraphIntoListJoin.test.js +188 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.d.ts +3 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/handleTipTapParagraphIntoListJoin.js +69 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.d.ts +2 -0
- package/dist/features/transactionShaping/tipTapParagraphIntoListJoin/index.js +2 -0
- package/dist/features/transactionShaping/types.d.ts +20 -0
- package/dist/features/transactionShaping/types.js +1 -0
- package/dist/features/wrapUnwrap/__tests__/blockquoteStructure.playwright.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/buildMaterializedPaths.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/listStructure.playwright.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/listStructureTextEdits.playwright.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/__tests__/splitDetection.test.d.ts +1 -0
- package/dist/features/wrapUnwrap/addIdAttr.d.ts +2 -0
- package/dist/features/wrapUnwrap/addIdAttr.js +60 -0
- package/dist/features/wrapUnwrap/apply/applyStructureSuggestions.d.ts +10 -0
- package/dist/features/wrapUnwrap/apply/applyStructureSuggestions.js +41 -0
- package/dist/features/wrapUnwrap/apply/index.d.ts +5 -0
- package/dist/features/wrapUnwrap/apply/index.js +21 -0
- package/dist/features/wrapUnwrap/areEquivalentStructureMarks.d.ts +2 -0
- package/dist/features/wrapUnwrap/areEquivalentStructureMarks.js +17 -0
- package/dist/features/wrapUnwrap/buildMaterializedPaths.d.ts +3 -0
- package/dist/features/wrapUnwrap/buildMaterializedPaths.js +82 -0
- package/dist/features/wrapUnwrap/constants.d.ts +3 -0
- package/dist/features/wrapUnwrap/constants.js +3 -0
- package/dist/features/wrapUnwrap/generateUniqueNodeId.d.ts +1 -0
- package/dist/features/wrapUnwrap/generateUniqueNodeId.js +6 -0
- package/dist/features/wrapUnwrap/getNodeId.d.ts +2 -0
- package/dist/features/wrapUnwrap/getNodeId.js +5 -0
- package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.d.ts +3 -0
- package/dist/features/wrapUnwrap/revert/deleteNodeUpwards.js +37 -0
- package/dist/features/wrapUnwrap/revert/index.d.ts +5 -0
- package/dist/features/wrapUnwrap/revert/index.js +19 -0
- package/dist/features/wrapUnwrap/revert/revertAddOp.d.ts +4 -0
- package/dist/features/wrapUnwrap/revert/revertAddOp.js +4 -0
- package/dist/features/wrapUnwrap/revert/revertMoveOp.d.ts +16 -0
- package/dist/features/wrapUnwrap/revert/revertMoveOp.js +122 -0
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.d.ts +10 -0
- package/dist/features/wrapUnwrap/revert/revertStructureSuggestions.js +236 -0
- package/dist/features/wrapUnwrap/sameParentChain.d.ts +2 -0
- package/dist/features/wrapUnwrap/sameParentChain.js +4 -0
- package/dist/features/wrapUnwrap/structureChangesPlugin.d.ts +17 -0
- package/dist/features/wrapUnwrap/structureChangesPlugin.js +299 -0
- package/dist/features/wrapUnwrap/types.d.ts +54 -0
- package/dist/features/wrapUnwrap/types.js +23 -0
- package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.d.ts +17 -0
- package/dist/features/wrapUnwrap/uniqueNodeIdsPlugin.js +106 -0
- package/dist/generateId.js +31 -4
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -3
- package/dist/listInputRules.d.ts +2 -0
- package/dist/listInputRules.js +14 -0
- package/dist/rebaseStep.d.ts +9 -0
- package/dist/rebaseStep.js +11 -0
- package/dist/replaceStep.d.ts +1 -1
- package/dist/replaceStep.js +1 -0
- package/dist/schema.d.ts +2 -1
- package/dist/schema.js +37 -1
- package/dist/testing/e2eTestSchema.d.ts +2 -0
- package/dist/testing/testBuilders.d.ts +1 -2
- package/dist/transformToSuggestionTransaction.d.ts +22 -0
- package/dist/transformToSuggestionTransaction.js +101 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +6 -2
- package/dist/withSuggestChanges.d.ts +11 -14
- package/dist/withSuggestChanges.js +64 -94
- package/dist/wrappingInputRule.d.ts +4 -0
- package/dist/wrappingInputRule.js +28 -0
- package/package.json +1 -1
- package/src/features/joinOnDelete/README.md +0 -8
- /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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/features/joinOnDelete/__tests__/joinOnDeleteInListsTipTapStyle.playwright.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/features/joinOnDelete/__tests__/listWithJoinsAndStructureMarks.playwright.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
import { Mark, type Node, type
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
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,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,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
|
+
}
|