@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +425 -0
  3. package/dist/addMarkStep.d.ts +12 -0
  4. package/dist/addMarkStep.js +17 -0
  5. package/dist/addNodeMarkStep.d.ts +11 -0
  6. package/dist/addNodeMarkStep.js +36 -0
  7. package/dist/attrStep.d.ts +11 -0
  8. package/dist/attrStep.js +33 -0
  9. package/dist/commands.d.ts +64 -0
  10. package/dist/commands.js +314 -0
  11. package/dist/decorations.d.ts +3 -0
  12. package/dist/decorations.js +73 -0
  13. package/dist/features/joinBlocks/__tests__/blockJoin.playwright.test.d.ts +14 -0
  14. package/dist/features/joinBlocks/__tests__/crossBlockReplace.test.d.ts +1 -0
  15. package/dist/features/joinBlocks/__tests__/getZWSPPairsInRange.test.d.ts +1 -0
  16. package/dist/features/joinBlocks/__tests__/multiStepBlockJoin.test.d.ts +1 -0
  17. package/dist/features/joinBlocks/__tests__/nestedBlockJoin.test.d.ts +1 -0
  18. package/dist/features/joinBlocks/__tests__/paragraphBackspace.test.d.ts +1 -0
  19. package/dist/features/joinBlocks/__tests__/playwrightHelpers.d.ts +143 -0
  20. package/dist/features/joinBlocks/__tests__/testHelpers.d.ts +106 -0
  21. package/dist/features/joinBlocks/index.d.ts +3 -0
  22. package/dist/features/joinBlocks/index.js +64 -0
  23. package/dist/features/joinBlocks/types.d.ts +11 -0
  24. package/dist/features/joinBlocks/types.js +1 -0
  25. package/dist/features/joinBlocks/utils/boundary.d.ts +10 -0
  26. package/dist/features/joinBlocks/utils/boundary.js +147 -0
  27. package/dist/features/joinBlocks/utils/getZWSPPairsInRange.d.ts +10 -0
  28. package/dist/features/joinBlocks/utils/getZWSPPairsInRange.js +57 -0
  29. package/dist/findSuggestionMarkEnd.d.ts +2 -0
  30. package/dist/findSuggestionMarkEnd.js +38 -0
  31. package/dist/generateId.d.ts +5 -0
  32. package/dist/generateId.js +24 -0
  33. package/dist/index.d.ts +4 -0
  34. package/dist/index.js +4 -0
  35. package/dist/plugin.d.ts +8 -0
  36. package/dist/plugin.js +38 -0
  37. package/dist/rebasePos.d.ts +9 -0
  38. package/dist/rebasePos.js +10 -0
  39. package/dist/removeMarkStep.d.ts +12 -0
  40. package/dist/removeMarkStep.js +17 -0
  41. package/dist/removeNodeMarkStep.d.ts +11 -0
  42. package/dist/removeNodeMarkStep.js +30 -0
  43. package/dist/replaceAroundStep.d.ts +12 -0
  44. package/dist/replaceAroundStep.js +96 -0
  45. package/dist/replaceStep.d.ts +35 -0
  46. package/dist/replaceStep.js +247 -0
  47. package/dist/schema.d.ts +9 -0
  48. package/dist/schema.js +139 -0
  49. package/dist/testing/difficultyMark.d.ts +2 -0
  50. package/dist/testing/testBuilders.d.ts +11 -0
  51. package/dist/utils.d.ts +11 -0
  52. package/dist/utils.js +20 -0
  53. package/dist/withSuggestChanges.d.ts +27 -0
  54. package/dist/withSuggestChanges.js +114 -0
  55. 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,2 @@
1
+ import { type MarkType, type ResolvedPos } from "prosemirror-model";
2
+ export declare function findSuggestionMarkEnd($pos: ResolvedPos, markType: MarkType): number;
@@ -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
+ }
@@ -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";
@@ -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
+ }