@magic-marker/prosemirror-suggest-changes 0.1.8
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/LICENSE +21 -0
- package/README.md +425 -0
- package/dist/addMarkStep.d.ts +12 -0
- package/dist/addMarkStep.js +17 -0
- package/dist/addNodeMarkStep.d.ts +11 -0
- package/dist/addNodeMarkStep.js +36 -0
- package/dist/attrStep.d.ts +11 -0
- package/dist/attrStep.js +33 -0
- package/dist/commands.d.ts +64 -0
- package/dist/commands.js +314 -0
- package/dist/decorations.d.ts +3 -0
- package/dist/decorations.js +73 -0
- package/dist/features/joinBlocks/__tests__/blockJoin.playwright.test.d.ts +14 -0
- package/dist/features/joinBlocks/__tests__/crossBlockReplace.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/getZWSPPairsInRange.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/multiStepBlockJoin.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/nestedBlockJoin.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/paragraphBackspace.test.d.ts +1 -0
- package/dist/features/joinBlocks/__tests__/playwrightHelpers.d.ts +143 -0
- package/dist/features/joinBlocks/__tests__/testHelpers.d.ts +106 -0
- package/dist/features/joinBlocks/index.d.ts +3 -0
- package/dist/features/joinBlocks/index.js +64 -0
- package/dist/features/joinBlocks/types.d.ts +11 -0
- package/dist/features/joinBlocks/types.js +1 -0
- package/dist/features/joinBlocks/utils/boundary.d.ts +10 -0
- package/dist/features/joinBlocks/utils/boundary.js +147 -0
- package/dist/features/joinBlocks/utils/getZWSPPairsInRange.d.ts +10 -0
- package/dist/features/joinBlocks/utils/getZWSPPairsInRange.js +57 -0
- package/dist/findSuggestionMarkEnd.d.ts +2 -0
- package/dist/findSuggestionMarkEnd.js +38 -0
- package/dist/generateId.d.ts +5 -0
- package/dist/generateId.js +24 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.js +38 -0
- package/dist/rebasePos.d.ts +9 -0
- package/dist/rebasePos.js +10 -0
- package/dist/removeMarkStep.d.ts +12 -0
- package/dist/removeMarkStep.js +17 -0
- package/dist/removeNodeMarkStep.d.ts +11 -0
- package/dist/removeNodeMarkStep.js +30 -0
- package/dist/replaceAroundStep.d.ts +12 -0
- package/dist/replaceAroundStep.js +96 -0
- package/dist/replaceStep.d.ts +35 -0
- package/dist/replaceStep.js +247 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +139 -0
- package/dist/testing/difficultyMark.d.ts +2 -0
- package/dist/testing/testBuilders.d.ts +11 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +20 -0
- package/dist/withSuggestChanges.d.ts +27 -0
- package/dist/withSuggestChanges.js +114 -0
- package/package.json +83 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getZWSPPairsInRange } from "./utils/getZWSPPairsInRange.js";
|
|
2
|
+
import { canJoin, Mapping } from "prosemirror-transform";
|
|
3
|
+
const calculateDepthAndPos = (from, to)=>{
|
|
4
|
+
return {
|
|
5
|
+
pos: (from + to) / 2,
|
|
6
|
+
depth: (to - from) / 2
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export const joinBlocks = (trackedTransaction, stepFrom, stepTo, insertionMarkType)=>{
|
|
10
|
+
const doc = trackedTransaction.doc;
|
|
11
|
+
// Record the step index before we start deleting
|
|
12
|
+
const startStep = trackedTransaction.steps.length;
|
|
13
|
+
// step 1 find pairs
|
|
14
|
+
const { pairs, ZWSPAtToBoundary, ZWSPAtFromBoundary } = getZWSPPairsInRange(doc, stepFrom, stepTo, insertionMarkType);
|
|
15
|
+
// Step 2: Find all insertion-marked content in the deletion range
|
|
16
|
+
// These ranges will be actually deleted (reverting the insertion)
|
|
17
|
+
// Remove ZWSPs on the boundaries if they are in the list pairs
|
|
18
|
+
const insertedRanges = [];
|
|
19
|
+
if (ZWSPAtFromBoundary) {
|
|
20
|
+
const from = ZWSPAtFromBoundary.pos;
|
|
21
|
+
insertedRanges.push({
|
|
22
|
+
from,
|
|
23
|
+
to: from + 1
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
doc.nodesBetween(stepFrom, stepTo, (node, pos)=>{
|
|
27
|
+
if (insertionMarkType.isInSet(node.marks)) {
|
|
28
|
+
insertedRanges.push({
|
|
29
|
+
from: Math.max(pos, stepFrom),
|
|
30
|
+
to: Math.min(pos + node.nodeSize, stepTo)
|
|
31
|
+
});
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
if (ZWSPAtToBoundary) {
|
|
37
|
+
const from = ZWSPAtToBoundary.pos;
|
|
38
|
+
insertedRanges.push({
|
|
39
|
+
from,
|
|
40
|
+
to: from + 1
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
pairs.reverse();
|
|
44
|
+
insertedRanges.reverse();
|
|
45
|
+
// step 3: join blocks at multiple depths as needed
|
|
46
|
+
for (const p of pairs){
|
|
47
|
+
const fromPos = p.left?.pos;
|
|
48
|
+
const toPos = p.right?.pos;
|
|
49
|
+
if (!fromPos || !toPos) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { depth, pos } = calculateDepthAndPos(fromPos + 1, toPos);
|
|
53
|
+
if (canJoin(trackedTransaction.doc, pos)) {
|
|
54
|
+
trackedTransaction.join(pos, depth);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// step 4: remove inserted ranges
|
|
58
|
+
const joinSteps = trackedTransaction.steps.slice(startStep);
|
|
59
|
+
const joinMapping = new Mapping(joinSteps.map((s)=>s.getMap()));
|
|
60
|
+
for (const range of insertedRanges){
|
|
61
|
+
trackedTransaction.delete(joinMapping.map(range.from), joinMapping.map(range.to));
|
|
62
|
+
}
|
|
63
|
+
return !!pairs.length;
|
|
64
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Node } from "prosemirror-model";
|
|
2
|
+
import type { SuggestionId } from "../../generateId.js";
|
|
3
|
+
export interface CharResult {
|
|
4
|
+
char: string;
|
|
5
|
+
node: Node;
|
|
6
|
+
pos: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ZWSPWithPos extends CharResult {
|
|
9
|
+
id: SuggestionId;
|
|
10
|
+
}
|
|
11
|
+
export declare const ZWSP = "\u200B";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ZWSP = "\u200B";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MarkType, Node } from "prosemirror-model";
|
|
2
|
+
import type { CharResult, ZWSPWithPos } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Finds the previous character and its containing text node.
|
|
5
|
+
* Handles being inside a node, at a node boundary, or across block boundaries.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getPreviousCharAndNode(doc: Node, pos: number): CharResult | null;
|
|
8
|
+
export declare function getNextCharAndNode(doc: Node, pos: number): CharResult | null;
|
|
9
|
+
export declare const getNextZWSP: (doc: Node, pos: number, insertionMarkType: MarkType) => ZWSPWithPos | undefined;
|
|
10
|
+
export declare const getPreviousZWSP: (doc: Node, pos: number, insertionMarkType: MarkType) => ZWSPWithPos | undefined;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ZWSP } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Finds the previous character and its containing text node.
|
|
4
|
+
* Handles being inside a node, at a node boundary, or across block boundaries.
|
|
5
|
+
*/ export function getPreviousCharAndNode(doc, pos) {
|
|
6
|
+
const $pos = doc.resolve(pos);
|
|
7
|
+
// SCENARIO A: We are inside a text node
|
|
8
|
+
// (nodeBefore is null because we aren't at a boundary, but textOffset > 0)
|
|
9
|
+
if (!$pos.nodeBefore && $pos.parent.isTextblock && $pos.textOffset > 0) {
|
|
10
|
+
const innerNode = $pos.parent.child($pos.index());
|
|
11
|
+
if (innerNode.isText && innerNode.text) {
|
|
12
|
+
return {
|
|
13
|
+
char: innerNode.text.charAt($pos.textOffset - 1),
|
|
14
|
+
node: innerNode,
|
|
15
|
+
pos: pos - 1
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// SCENARIO B: We are at a boundary, and the node immediately before is text
|
|
20
|
+
// Example: <strong>Bold</strong>|Plain
|
|
21
|
+
if ($pos.nodeBefore?.isText && $pos.nodeBefore.text) {
|
|
22
|
+
return {
|
|
23
|
+
char: $pos.nodeBefore.text.slice(-1),
|
|
24
|
+
node: $pos.nodeBefore,
|
|
25
|
+
pos: pos - 1
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// SCENARIO C: We are at the start of a block or after a non-text node (Image, etc.)
|
|
29
|
+
// We need to traverse backwards up the tree.
|
|
30
|
+
return findLastTextNodeBackwards(doc, pos);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Helper: Recursively scans backwards from a position to find the nearest text node.
|
|
34
|
+
*/ function findLastTextNodeBackwards(doc, startPos) {
|
|
35
|
+
let pos = startPos;
|
|
36
|
+
while(pos > 0){
|
|
37
|
+
const $pos = doc.resolve(pos);
|
|
38
|
+
const { nodeBefore } = $pos;
|
|
39
|
+
// 1. Found a text node
|
|
40
|
+
if (nodeBefore?.isText && nodeBefore.text) {
|
|
41
|
+
return {
|
|
42
|
+
char: nodeBefore.text.slice(-1),
|
|
43
|
+
node: nodeBefore,
|
|
44
|
+
pos: pos - 1
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// 2. Start of a block -> Move out to parent
|
|
48
|
+
if (!nodeBefore) {
|
|
49
|
+
if ($pos.depth === 0) break; // Top of doc
|
|
50
|
+
pos = $pos.before();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// 3. Previous node is an Element (Paragraph, etc) -> Enter it
|
|
54
|
+
if (!nodeBefore.isLeaf) {
|
|
55
|
+
pos -= 1; // Enter the block from the end
|
|
56
|
+
} else {
|
|
57
|
+
pos -= nodeBefore.nodeSize;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
export function getNextCharAndNode(doc, pos) {
|
|
63
|
+
const $pos = doc.resolve(pos);
|
|
64
|
+
const parent = $pos.parent;
|
|
65
|
+
// SCENARIO A: We are inside a text node (Inline)
|
|
66
|
+
// We check if there is text "after" our current offset within the current child node.
|
|
67
|
+
if (parent.isTextblock && !$pos.nodeAfter) {
|
|
68
|
+
const index = $pos.index();
|
|
69
|
+
const child = parent.maybeChild(index); // Get child at current index
|
|
70
|
+
// Check if we are inside a text node and NOT at the very end of it
|
|
71
|
+
if (child?.text && $pos.textOffset < child.nodeSize) {
|
|
72
|
+
return {
|
|
73
|
+
char: child.text.charAt($pos.textOffset),
|
|
74
|
+
node: child,
|
|
75
|
+
pos: pos
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// SCENARIO B: We are at a boundary, and the node immediately after is text
|
|
80
|
+
// Example: <p>End|<strong>Bold</strong></p>
|
|
81
|
+
if ($pos.nodeAfter?.text) {
|
|
82
|
+
return {
|
|
83
|
+
char: $pos.nodeAfter.text.charAt(0),
|
|
84
|
+
node: $pos.nodeAfter,
|
|
85
|
+
pos: pos
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// SCENARIO C: Recursive search forward
|
|
89
|
+
return findNextTextNodeForwards(doc, pos);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Helper: Recursively scans forwards to find the nearest text node.
|
|
93
|
+
*/ function findNextTextNodeForwards(doc, startPos) {
|
|
94
|
+
let pos = startPos;
|
|
95
|
+
const docSize = doc.content.size;
|
|
96
|
+
while(pos < docSize){
|
|
97
|
+
const $pos = doc.resolve(pos);
|
|
98
|
+
const { nodeAfter } = $pos;
|
|
99
|
+
// 1. Found a text node
|
|
100
|
+
if (nodeAfter?.text) {
|
|
101
|
+
return {
|
|
102
|
+
char: nodeAfter.text.charAt(0),
|
|
103
|
+
node: nodeAfter,
|
|
104
|
+
pos: pos
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// 2. End of a block (nodeAfter is null) -> Move out to parent
|
|
108
|
+
// Example: <p>Text|</p> -> Move to after </p>
|
|
109
|
+
if (!nodeAfter) {
|
|
110
|
+
if ($pos.depth === 0) break; // End of doc
|
|
111
|
+
pos = $pos.after();
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// 3. Next node is an Element (Paragraph, Blockquote) -> Enter it
|
|
115
|
+
if (!nodeAfter.isLeaf) {
|
|
116
|
+
pos += 1; // Enter the block from the start
|
|
117
|
+
} else {
|
|
118
|
+
pos += nodeAfter.nodeSize;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
// TODO: Unify with next
|
|
124
|
+
export const getNextZWSP = (doc, pos, insertionMarkType)=>{
|
|
125
|
+
// check if we have an insertion mark on the node
|
|
126
|
+
const nextCharInfo = getNextCharAndNode(doc, pos);
|
|
127
|
+
if (nextCharInfo?.char !== ZWSP) return;
|
|
128
|
+
const insertionMark = insertionMarkType.isInSet(nextCharInfo.node.marks);
|
|
129
|
+
const id = insertionMark?.attrs["id"];
|
|
130
|
+
if (!id) return;
|
|
131
|
+
return {
|
|
132
|
+
...nextCharInfo,
|
|
133
|
+
id
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
export const getPreviousZWSP = (doc, pos, insertionMarkType)=>{
|
|
137
|
+
// check if we have an insertion mark on the node
|
|
138
|
+
const nextCharInfo = getPreviousCharAndNode(doc, pos);
|
|
139
|
+
if (nextCharInfo?.char !== ZWSP) return;
|
|
140
|
+
const insertionMark = insertionMarkType.isInSet(nextCharInfo.node.marks);
|
|
141
|
+
const id = insertionMark?.attrs["id"];
|
|
142
|
+
if (!id) return;
|
|
143
|
+
return {
|
|
144
|
+
...nextCharInfo,
|
|
145
|
+
id
|
|
146
|
+
};
|
|
147
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type MarkType, type Node } from "prosemirror-model";
|
|
2
|
+
import type { ZWSPWithPos } from "../types.js";
|
|
3
|
+
export declare const getZWSPPairsInRange: (doc: Node, from: number, to: number, insertionMarkType: MarkType) => {
|
|
4
|
+
pairs: {
|
|
5
|
+
left?: ZWSPWithPos;
|
|
6
|
+
right?: ZWSPWithPos;
|
|
7
|
+
}[];
|
|
8
|
+
ZWSPAtFromBoundary: ZWSPWithPos | undefined;
|
|
9
|
+
ZWSPAtToBoundary: ZWSPWithPos | undefined;
|
|
10
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ZWSP } from "../types.js";
|
|
2
|
+
import { getNextZWSP, getPreviousZWSP } from "../utils/boundary.js";
|
|
3
|
+
export const getZWSPPairsInRange = (doc, from, to, insertionMarkType)=>{
|
|
4
|
+
const previousZWSP = getPreviousZWSP(doc, from, insertionMarkType);
|
|
5
|
+
const nextZWSP = getNextZWSP(doc, to, insertionMarkType);
|
|
6
|
+
const pairs = [];
|
|
7
|
+
let currentEndingZWSP = previousZWSP;
|
|
8
|
+
doc.nodesBetween(from, to, (node, pos, parent, index)=>{
|
|
9
|
+
// two cases: we have a left in currentPair and we're at 0 index
|
|
10
|
+
const insertionMarkId = insertionMarkType.isInSet(node.marks)?.attrs["id"];
|
|
11
|
+
if (currentEndingZWSP && index === 0 && node.text?.[0] === ZWSP && insertionMarkId && currentEndingZWSP.id === insertionMarkId && // Prevent self-pairing: don't pair a ZWSP with itself when both
|
|
12
|
+
// the previousZWSP and the current node's ZWSP are at the same position
|
|
13
|
+
currentEndingZWSP.pos !== pos) {
|
|
14
|
+
// maybe it's pos + 1
|
|
15
|
+
pairs.push({
|
|
16
|
+
left: currentEndingZWSP,
|
|
17
|
+
right: {
|
|
18
|
+
pos,
|
|
19
|
+
node,
|
|
20
|
+
char: ZWSP,
|
|
21
|
+
id: insertionMarkId
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
// don't return yet, we'll have to check if this text node contains
|
|
25
|
+
}
|
|
26
|
+
if (node.isText) {
|
|
27
|
+
// WE HAVE TO remove ending zwsp anyway. Either we found a pair, on the beginning of the next block
|
|
28
|
+
// or we did not, but then we don't have a matching pair on a block boundary.
|
|
29
|
+
currentEndingZWSP = undefined;
|
|
30
|
+
}
|
|
31
|
+
const lastTextInParent = node.isText && parent?.childCount === index + 1;
|
|
32
|
+
const lastCharInNode = node.text?.[node.text.length - 1];
|
|
33
|
+
if (node.isText && lastTextInParent && lastCharInNode === ZWSP && insertionMarkId) {
|
|
34
|
+
// unsure about nodesize -1
|
|
35
|
+
currentEndingZWSP = {
|
|
36
|
+
pos: pos + node.nodeSize - 1,
|
|
37
|
+
node,
|
|
38
|
+
char: ZWSP,
|
|
39
|
+
id: insertionMarkId
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
if (currentEndingZWSP?.id && currentEndingZWSP.id === nextZWSP?.id) {
|
|
45
|
+
pairs.push({
|
|
46
|
+
left: currentEndingZWSP,
|
|
47
|
+
right: nextZWSP
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const usedFromBoundaryZWSP = pairs[0]?.left === previousZWSP;
|
|
51
|
+
const usedToBoundaryZWSP = pairs[pairs.length - 1]?.right === nextZWSP;
|
|
52
|
+
return {
|
|
53
|
+
pairs,
|
|
54
|
+
ZWSPAtFromBoundary: usedFromBoundaryZWSP ? previousZWSP : undefined,
|
|
55
|
+
ZWSPAtToBoundary: usedToBoundaryZWSP ? nextZWSP : undefined
|
|
56
|
+
};
|
|
57
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function findSuggestionMarkEnd($pos, markType) {
|
|
2
|
+
const initialDeletionMark = ($pos.nodeAfter ?? $pos.nodeBefore)?.marks.find((mark)=>mark.type === markType);
|
|
3
|
+
if (!initialDeletionMark) {
|
|
4
|
+
return $pos.pos;
|
|
5
|
+
}
|
|
6
|
+
let afterPos = $pos.pos + ($pos.nodeAfter?.nodeSize ?? 0);
|
|
7
|
+
// We always return from this while loop
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
9
|
+
while(true){
|
|
10
|
+
const $afterPos = $pos.doc.resolve(afterPos);
|
|
11
|
+
if ($afterPos.depth < 1 || $afterPos.nodeAfter && !markType.isInSet($afterPos.marks())) {
|
|
12
|
+
return $afterPos.pos;
|
|
13
|
+
}
|
|
14
|
+
// We're at the end of a node. We need to check
|
|
15
|
+
// whether there's a matching deletion at the beginning
|
|
16
|
+
// of the next node
|
|
17
|
+
let afterParentPos = $afterPos.after();
|
|
18
|
+
let $afterParentPos = $pos.doc.resolve(afterParentPos);
|
|
19
|
+
let nextParent = $afterParentPos.nodeAfter;
|
|
20
|
+
while($afterParentPos.depth > 0 && !nextParent){
|
|
21
|
+
afterParentPos = $afterPos.after($afterParentPos.depth);
|
|
22
|
+
$afterParentPos = $pos.doc.resolve(afterParentPos);
|
|
23
|
+
nextParent = $afterParentPos.nodeAfter;
|
|
24
|
+
}
|
|
25
|
+
let cousinStartPos = afterParentPos + 1;
|
|
26
|
+
let cousin = nextParent?.firstChild;
|
|
27
|
+
while(cousin && !cousin.isLeaf && !markType.isInSet(cousin.marks)){
|
|
28
|
+
cousin = cousin.firstChild;
|
|
29
|
+
cousinStartPos++;
|
|
30
|
+
}
|
|
31
|
+
const deletionMark = cousin?.marks.find((mark)=>mark.type === markType);
|
|
32
|
+
if (!cousin || !deletionMark || deletionMark.attrs["id"] !== initialDeletionMark.attrs["id"]) {
|
|
33
|
+
return $afterPos.pos;
|
|
34
|
+
}
|
|
35
|
+
const $cousinStartPos = $pos.doc.resolve(cousinStartPos);
|
|
36
|
+
afterPos = $cousinStartPos.pos + ($cousinStartPos.nodeAfter?.nodeSize ?? 0);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Node, type Schema } from "prosemirror-model";
|
|
2
|
+
export type SuggestionId = string | number;
|
|
3
|
+
export declare const suggestionIdValidate = "number|string";
|
|
4
|
+
export declare function parseSuggestionId(id: string): SuggestionId;
|
|
5
|
+
export declare function generateNextNumberId(schema: Schema, doc?: Node): number;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getSuggestionMarks } from "./utils.js";
|
|
2
|
+
export const suggestionIdValidate = "number|string";
|
|
3
|
+
export function parseSuggestionId(id) {
|
|
4
|
+
const parsed = parseInt(id, 10);
|
|
5
|
+
if (isNaN(parsed)) {
|
|
6
|
+
return id;
|
|
7
|
+
}
|
|
8
|
+
return parsed;
|
|
9
|
+
}
|
|
10
|
+
export function generateNextNumberId(schema, doc) {
|
|
11
|
+
const { deletion, insertion, modification } = getSuggestionMarks(schema);
|
|
12
|
+
// Find the highest change id in the document so far,
|
|
13
|
+
// and use that as the starting point for new changes
|
|
14
|
+
let suggestionId = 0;
|
|
15
|
+
doc?.descendants((node)=>{
|
|
16
|
+
const mark = node.marks.find((mark)=>mark.type === insertion || mark.type === deletion || mark.type === modification);
|
|
17
|
+
if (mark) {
|
|
18
|
+
suggestionId = Math.max(suggestionId, mark.attrs["id"]);
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
});
|
|
23
|
+
return suggestionId + 1;
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { addSuggestionMarks, insertion, deletion, modification, } from "./schema.js";
|
|
2
|
+
export { selectSuggestion, revertSuggestion, revertSuggestions, applySuggestion, applySuggestions, enableSuggestChanges, disableSuggestChanges, toggleSuggestChanges, } from "./commands.js";
|
|
3
|
+
export { suggestChanges, suggestChangesKey, isSuggestChangesEnabled, } from "./plugin.js";
|
|
4
|
+
export { withSuggestChanges, transformToSuggestionTransaction, } from "./withSuggestChanges.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { addSuggestionMarks, insertion, deletion, modification } from "./schema.js";
|
|
2
|
+
export { selectSuggestion, revertSuggestion, revertSuggestions, applySuggestion, applySuggestions, enableSuggestChanges, disableSuggestChanges, toggleSuggestChanges } from "./commands.js";
|
|
3
|
+
export { suggestChanges, suggestChangesKey, isSuggestChangesEnabled } from "./plugin.js";
|
|
4
|
+
export { withSuggestChanges, transformToSuggestionTransaction } from "./withSuggestChanges.js";
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type EditorState, Plugin, PluginKey } from "prosemirror-state";
|
|
2
|
+
export declare const suggestChangesKey: PluginKey<{
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
}>;
|
|
5
|
+
export declare function suggestChanges(): Plugin<{
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function isSuggestChangesEnabled(state: EditorState): boolean;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
|
|
2
|
+
import { getSuggestionDecorations } from "./decorations.js";
|
|
3
|
+
export const suggestChangesKey = new PluginKey("@handlewithcare/prosemirror-suggest-changes");
|
|
4
|
+
export function suggestChanges() {
|
|
5
|
+
return new Plugin({
|
|
6
|
+
key: suggestChangesKey,
|
|
7
|
+
state: {
|
|
8
|
+
init () {
|
|
9
|
+
return {
|
|
10
|
+
enabled: false
|
|
11
|
+
};
|
|
12
|
+
},
|
|
13
|
+
apply (tr, value) {
|
|
14
|
+
const meta = tr.getMeta(suggestChangesKey);
|
|
15
|
+
if (meta && "enabled" in meta) return meta;
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
props: {
|
|
20
|
+
decorations: getSuggestionDecorations,
|
|
21
|
+
// Add a custom keydown handler that skips over any zero-width
|
|
22
|
+
// spaces that we've inserted so that users aren't aware of them
|
|
23
|
+
handleKeyDown (view, event) {
|
|
24
|
+
if (event.key === "ArrowRight" && view.state.selection instanceof TextSelection && view.state.selection.empty && view.state.selection.$cursor?.nodeAfter?.text?.startsWith("\u200B")) {
|
|
25
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, view.state.selection.$cursor.pos + 1)));
|
|
26
|
+
}
|
|
27
|
+
if (event.key === "ArrowLeft" && view.state.selection instanceof TextSelection && view.state.selection.empty && view.state.selection.$cursor?.nodeBefore?.text?.endsWith("\u200B")) {
|
|
28
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, view.state.selection.$cursor.pos - 1)));
|
|
29
|
+
}
|
|
30
|
+
// Never block any other handlers from running after
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function isSuggestChangesEnabled(state) {
|
|
37
|
+
return !!suggestChangesKey.getState(state)?.enabled;
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Step } from "prosemirror-transform";
|
|
2
|
+
/**
|
|
3
|
+
* Rebase a position onto a new lineage of steps
|
|
4
|
+
*
|
|
5
|
+
* @param pos The position to rebase
|
|
6
|
+
* @param back The old steps to undo, in the order they were originally applied
|
|
7
|
+
* @param forth The new steps to map through
|
|
8
|
+
*/
|
|
9
|
+
export declare function rebasePos(pos: number, back: Step[], forth: Step[]): number;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebase a position onto a new lineage of steps
|
|
3
|
+
*
|
|
4
|
+
* @param pos The position to rebase
|
|
5
|
+
* @param back The old steps to undo, in the order they were originally applied
|
|
6
|
+
* @param forth The new steps to map through
|
|
7
|
+
*/ export function rebasePos(pos, back, forth) {
|
|
8
|
+
const reset = back.slice().reverse().reduce((acc, step)=>step.getMap().invert().map(acc), pos);
|
|
9
|
+
return forth.reduce((acc, step)=>step.getMap().map(acc), reset);
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Node } from "prosemirror-model";
|
|
2
|
+
import { type EditorState, type Transaction } from "prosemirror-state";
|
|
3
|
+
import { type RemoveMarkStep, type Step } from "prosemirror-transform";
|
|
4
|
+
import { type SuggestionId } from "./generateId.js";
|
|
5
|
+
/**
|
|
6
|
+
* Transform a remove mark step into its equivalent tracked steps.
|
|
7
|
+
*
|
|
8
|
+
* Add mark steps are treated as replace steps in this model. An
|
|
9
|
+
* equivalent replace step will be generated, and then processed via
|
|
10
|
+
* trackReplaceStep().
|
|
11
|
+
*/
|
|
12
|
+
export declare function suggestRemoveMarkStep(trackedTransaction: Transaction, state: EditorState, doc: Node, step: RemoveMarkStep, prevSteps: Step[], suggestionId: SuggestionId): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { replaceStep } from "prosemirror-transform";
|
|
2
|
+
import { applySuggestionsToRange } from "./commands.js";
|
|
3
|
+
import { suggestReplaceStep } from "./replaceStep.js";
|
|
4
|
+
/**
|
|
5
|
+
* Transform a remove mark step into its equivalent tracked steps.
|
|
6
|
+
*
|
|
7
|
+
* Add mark steps are treated as replace steps in this model. An
|
|
8
|
+
* equivalent replace step will be generated, and then processed via
|
|
9
|
+
* trackReplaceStep().
|
|
10
|
+
*/ export function suggestRemoveMarkStep(trackedTransaction, state, doc, step, prevSteps, suggestionId) {
|
|
11
|
+
const applied = step.apply(doc).doc;
|
|
12
|
+
if (!applied) return false;
|
|
13
|
+
const slice = applySuggestionsToRange(applied, step.from, step.to);
|
|
14
|
+
const replace = replaceStep(doc, step.from, step.to, slice);
|
|
15
|
+
if (!replace) return false;
|
|
16
|
+
return suggestReplaceStep(trackedTransaction, state, doc, replace, prevSteps, suggestionId);
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Node } from "prosemirror-model";
|
|
2
|
+
import { type EditorState, type Transaction } from "prosemirror-state";
|
|
3
|
+
import { type RemoveNodeMarkStep, type Step } from "prosemirror-transform";
|
|
4
|
+
import { type SuggestionId } from "./generateId.js";
|
|
5
|
+
/**
|
|
6
|
+
* Transform a remove node mark step into its equivalent tracked steps.
|
|
7
|
+
*
|
|
8
|
+
* Remove node mark steps are processed normally, and then a modification
|
|
9
|
+
* mark is added to the node as well, to track the change.
|
|
10
|
+
*/
|
|
11
|
+
export declare function suggestRemoveNodeMarkStep(trackedTransaction: Transaction, state: EditorState, _doc: Node, step: RemoveNodeMarkStep, prevSteps: Step[], suggestionId: SuggestionId): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { rebasePos } from "./rebasePos.js";
|
|
2
|
+
import { getSuggestionMarks } from "./utils.js";
|
|
3
|
+
/**
|
|
4
|
+
* Transform a remove node mark step into its equivalent tracked steps.
|
|
5
|
+
*
|
|
6
|
+
* Remove node mark steps are processed normally, and then a modification
|
|
7
|
+
* mark is added to the node as well, to track the change.
|
|
8
|
+
*/ export function suggestRemoveNodeMarkStep(trackedTransaction, state, _doc, step, prevSteps, suggestionId) {
|
|
9
|
+
const { modification } = getSuggestionMarks(state.schema);
|
|
10
|
+
const rebasedPos = rebasePos(step.pos, prevSteps, trackedTransaction.steps);
|
|
11
|
+
const $pos = trackedTransaction.doc.resolve(rebasedPos);
|
|
12
|
+
const node = $pos.nodeAfter;
|
|
13
|
+
let marks = node?.marks ?? [];
|
|
14
|
+
const existingMod = marks.find((mark)=>mark.type === modification && mark.attrs["type"] === "mark" && mark.attrs["newValue"] && step.mark.eq(state.schema.markFromJSON(mark.attrs["newValue"])));
|
|
15
|
+
if (existingMod) {
|
|
16
|
+
trackedTransaction.removeNodeMark(rebasedPos, existingMod);
|
|
17
|
+
trackedTransaction.removeNodeMark(rebasedPos, state.schema.markFromJSON(existingMod.attrs["newValue"]));
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
marks = step.mark.removeFromSet(marks);
|
|
21
|
+
marks = modification.create({
|
|
22
|
+
id: suggestionId,
|
|
23
|
+
type: "mark",
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
25
|
+
previousValue: step.mark.toJSON(),
|
|
26
|
+
newValue: null
|
|
27
|
+
}).addToSet(marks);
|
|
28
|
+
trackedTransaction.setNodeMarkup(rebasedPos, null, null, marks);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Node } from "prosemirror-model";
|
|
2
|
+
import { type EditorState, type Transaction } from "prosemirror-state";
|
|
3
|
+
import { type ReplaceAroundStep, type Step } from "prosemirror-transform";
|
|
4
|
+
import { type SuggestionId } from "./generateId.js";
|
|
5
|
+
/**
|
|
6
|
+
* Transform a replace around step into its equivalent tracked steps.
|
|
7
|
+
*
|
|
8
|
+
* Replace around steps are treated as replace steps in this model. An
|
|
9
|
+
* equivalent replace step will be generated, and then processed via
|
|
10
|
+
* trackReplaceStep().
|
|
11
|
+
*/
|
|
12
|
+
export declare function suggestReplaceAroundStep(trackedTransaction: Transaction, state: EditorState, doc: Node, step: ReplaceAroundStep, prevSteps: Step[], suggestionId: SuggestionId): boolean;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AddNodeMarkStep, AttrStep, RemoveNodeMarkStep, replaceStep } from "prosemirror-transform";
|
|
2
|
+
import { trackAddNodeMarkStep } from "./addNodeMarkStep.js";
|
|
3
|
+
import { trackAttrStep } from "./attrStep.js";
|
|
4
|
+
import { rebasePos } from "./rebasePos.js";
|
|
5
|
+
import { suggestRemoveNodeMarkStep } from "./removeNodeMarkStep.js";
|
|
6
|
+
import { suggestReplaceStep } from "./replaceStep.js";
|
|
7
|
+
import { getSuggestionMarks } from "./utils.js";
|
|
8
|
+
import { applySuggestionsToRange } from "./commands.js";
|
|
9
|
+
/**
|
|
10
|
+
* This detects and handles changes from `setNodeMarkup` so that these are tracked as a modification
|
|
11
|
+
* instead of a deletion + insertion
|
|
12
|
+
* (https://github.com/handlewithcarecollective/prosemirror-suggest-changes/issues/7)
|
|
13
|
+
*/ function suggestSetNodeMarkup(trackedTransaction, state, doc, step, prevSteps, suggestionId) {
|
|
14
|
+
if (step.insert === 1 && step.slice.size === 2 && step.gapTo === step.to - 1 && step.gapFrom === step.from + 1 && step.structure) {
|
|
15
|
+
const { modification } = getSuggestionMarks(state.schema);
|
|
16
|
+
const newNode = step.slice.content.firstChild;
|
|
17
|
+
let from = rebasePos(step.from, prevSteps, trackedTransaction.steps);
|
|
18
|
+
let oldNode = trackedTransaction.doc.resolve(from).nodeAfter;
|
|
19
|
+
if (!newNode || !oldNode) {
|
|
20
|
+
throw new Error("Failed to apply modifications to node: unexpected ReplaceAroundStep as oldNode / newNode is null");
|
|
21
|
+
}
|
|
22
|
+
const addedMarks = newNode.marks.filter((m)=>!oldNode?.marks.some((m2)=>m2.eq(m)));
|
|
23
|
+
addedMarks.forEach((mark)=>{
|
|
24
|
+
if (mark.type === modification) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
trackAddNodeMarkStep(trackedTransaction, state, doc, new AddNodeMarkStep(step.from, mark), prevSteps, suggestionId);
|
|
28
|
+
});
|
|
29
|
+
from = rebasePos(step.from, prevSteps, trackedTransaction.steps);
|
|
30
|
+
oldNode = trackedTransaction.doc.resolve(step.from).nodeAfter;
|
|
31
|
+
if (!oldNode) {
|
|
32
|
+
throw new Error("Failed to apply modifications to node: unexpected ReplaceAroundStep as oldNode is null");
|
|
33
|
+
}
|
|
34
|
+
const removedMarks = oldNode.marks.filter((m)=>!newNode.marks.some((m2)=>m2.eq(m)));
|
|
35
|
+
removedMarks.forEach((mark)=>{
|
|
36
|
+
if (mark.type === modification) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
suggestRemoveNodeMarkStep(trackedTransaction, state, doc, new RemoveNodeMarkStep(step.from, mark), prevSteps, suggestionId);
|
|
40
|
+
});
|
|
41
|
+
if (newNode.type.name !== oldNode.type.name) {
|
|
42
|
+
// Code below is similar to trackAttrStep()
|
|
43
|
+
const rebasedPos = rebasePos(step.from, prevSteps, trackedTransaction.steps);
|
|
44
|
+
const $pos = trackedTransaction.doc.resolve(rebasedPos);
|
|
45
|
+
const node = $pos.nodeAfter;
|
|
46
|
+
if (!node) {
|
|
47
|
+
throw new Error("Failed to apply modifications to node: no node found");
|
|
48
|
+
}
|
|
49
|
+
let marks = node.marks;
|
|
50
|
+
const existingMod = marks.find((mark)=>mark.type === modification && mark.attrs["type"] === "nodeType");
|
|
51
|
+
if (existingMod) {
|
|
52
|
+
marks = existingMod.removeFromSet(marks);
|
|
53
|
+
}
|
|
54
|
+
marks = modification.create({
|
|
55
|
+
id: suggestionId,
|
|
56
|
+
type: "nodeType",
|
|
57
|
+
previousValue: node.type.name,
|
|
58
|
+
newValue: newNode.type.name
|
|
59
|
+
}).addToSet(marks);
|
|
60
|
+
trackedTransaction.setNodeMarkup(rebasedPos, newNode.type, null, marks);
|
|
61
|
+
}
|
|
62
|
+
const attrNames = new Set([
|
|
63
|
+
...Object.keys(newNode.attrs),
|
|
64
|
+
...Object.keys(oldNode.attrs)
|
|
65
|
+
]);
|
|
66
|
+
for (const attr of attrNames){
|
|
67
|
+
if (newNode.attrs[attr] !== oldNode.attrs[attr]) {
|
|
68
|
+
// delegate to trackAttrStep to handle the attribute change
|
|
69
|
+
trackAttrStep(trackedTransaction, state, doc, new AttrStep(step.from, attr, newNode.attrs[attr]), prevSteps, suggestionId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Transform a replace around step into its equivalent tracked steps.
|
|
78
|
+
*
|
|
79
|
+
* Replace around steps are treated as replace steps in this model. An
|
|
80
|
+
* equivalent replace step will be generated, and then processed via
|
|
81
|
+
* trackReplaceStep().
|
|
82
|
+
*/ export function suggestReplaceAroundStep(trackedTransaction, state, doc, step, prevSteps, suggestionId) {
|
|
83
|
+
const handled = suggestSetNodeMarkup(trackedTransaction, state, doc, step, prevSteps, suggestionId);
|
|
84
|
+
if (handled) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const applied = step.apply(doc).doc;
|
|
88
|
+
if (!applied) return false;
|
|
89
|
+
const from = step.getMap().map(step.from, -1);
|
|
90
|
+
const to = step.getMap().map(step.to, 1);
|
|
91
|
+
const blockRange = applied.resolve(from).blockRange(applied.resolve(to));
|
|
92
|
+
if (!blockRange) return false;
|
|
93
|
+
const replace = replaceStep(doc, step.getMap().invert().map(blockRange.start), step.getMap().invert().map(blockRange.end), applySuggestionsToRange(applied, blockRange.start, blockRange.end));
|
|
94
|
+
if (!replace) return false;
|
|
95
|
+
return suggestReplaceStep(trackedTransaction, state, doc, replace, prevSteps, suggestionId);
|
|
96
|
+
}
|