@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,35 @@
1
+ import { type Node } from "prosemirror-model";
2
+ import { type EditorState, type Transaction } from "prosemirror-state";
3
+ import { type ReplaceStep, type Step } from "prosemirror-transform";
4
+ import { type SuggestionId } from "./generateId.js";
5
+ /**
6
+ * Transform a replace step into its equivalent tracked steps.
7
+ *
8
+ * Any deletions of slices that are _not_ within existing
9
+ * insertion marks will be replaced with addMark steps that add
10
+ * deletion marks to those ranges.
11
+ *
12
+ * Any deletions of slices that _are_ within existing insertion
13
+ * marks will actually be deleted.
14
+ *
15
+ * Any slices that are to be inserted will also be marked with
16
+ * insertion marks.
17
+ *
18
+ * If a deletion begins at the very end of a textblock, a zero-width
19
+ * space will be inserted at the end of that texblock and given
20
+ * a deletion mark.
21
+ *
22
+ * Similarly, if a deletion ends at the very beginning fo a textblock,
23
+ * a zero-width space will be inserted at the beginning of that
24
+ * textblock and given a deletion mark.
25
+ *
26
+ * If an insertion slice is open on either end, and there is no content
27
+ * adjacent to the open end(s), zero-width spaces
28
+ * will be added at the open end(s) and given insertion marks.
29
+ *
30
+ * After all of the above have been evaluated, if the resulting
31
+ * insertion or deletion marks abut or join existing marks, they
32
+ * will be joined and given the same ids. Any no-longer-necessary
33
+ * zero-width spaces will be removed.
34
+ */
35
+ export declare function suggestReplaceStep(trackedTransaction: Transaction, state: EditorState, doc: Node, step: ReplaceStep, prevSteps: Step[], suggestionId: SuggestionId): boolean;
@@ -0,0 +1,247 @@
1
+ import { TextSelection } from "prosemirror-state";
2
+ import { findSuggestionMarkEnd } from "./findSuggestionMarkEnd.js";
3
+ import { rebasePos } from "./rebasePos.js";
4
+ import { getSuggestionMarks } from "./utils.js";
5
+ import { joinBlocks } from "./features/joinBlocks/index.js";
6
+ /**
7
+ * Transform a replace step into its equivalent tracked steps.
8
+ *
9
+ * Any deletions of slices that are _not_ within existing
10
+ * insertion marks will be replaced with addMark steps that add
11
+ * deletion marks to those ranges.
12
+ *
13
+ * Any deletions of slices that _are_ within existing insertion
14
+ * marks will actually be deleted.
15
+ *
16
+ * Any slices that are to be inserted will also be marked with
17
+ * insertion marks.
18
+ *
19
+ * If a deletion begins at the very end of a textblock, a zero-width
20
+ * space will be inserted at the end of that texblock and given
21
+ * a deletion mark.
22
+ *
23
+ * Similarly, if a deletion ends at the very beginning fo a textblock,
24
+ * a zero-width space will be inserted at the beginning of that
25
+ * textblock and given a deletion mark.
26
+ *
27
+ * If an insertion slice is open on either end, and there is no content
28
+ * adjacent to the open end(s), zero-width spaces
29
+ * will be added at the open end(s) and given insertion marks.
30
+ *
31
+ * After all of the above have been evaluated, if the resulting
32
+ * insertion or deletion marks abut or join existing marks, they
33
+ * will be joined and given the same ids. Any no-longer-necessary
34
+ * zero-width spaces will be removed.
35
+ */ export function suggestReplaceStep(trackedTransaction, state, doc, step, prevSteps, suggestionId) {
36
+ const { deletion, insertion } = getSuggestionMarks(state.schema);
37
+ // Check for insertion and deletion marks directly
38
+ // adjacent to this step's boundaries. If they exist,
39
+ // we'll use their ids, rather than producing a new one
40
+ const nodeBefore = doc.resolve(step.from).nodeBefore;
41
+ const markBefore = nodeBefore?.marks.find((mark)=>mark.type === deletion || mark.type === insertion) ?? null;
42
+ const nodeAfter = doc.resolve(step.to).nodeAfter;
43
+ const markAfter = nodeAfter?.marks.find((mark)=>mark.type === deletion || mark.type === insertion) ?? null;
44
+ let markId = markBefore?.attrs["id"] ?? markAfter?.attrs["id"] ?? suggestionId;
45
+ // Rebase this step's boundaries onto the newest doc
46
+ let stepFrom = rebasePos(step.from, prevSteps, trackedTransaction.steps);
47
+ let stepTo = rebasePos(step.to, prevSteps, trackedTransaction.steps);
48
+ if (state.selection.empty && stepFrom !== stepTo) {
49
+ trackedTransaction.setSelection(TextSelection.near(trackedTransaction.doc.resolve(stepFrom)));
50
+ }
51
+ // Process block joins and delete insertion-marked content
52
+ // Only do this when actually deleting (stepFrom !== stepTo)
53
+ // Don't do this when inserting (step.slice.content.size > 0 with stepFrom === stepTo)
54
+ let didBlockJoin = false;
55
+ if (stepFrom !== stepTo) {
56
+ didBlockJoin = joinBlocks(trackedTransaction, stepFrom, stepTo, insertion);
57
+ }
58
+ // Update the step boundaries, since we may have just changed
59
+ // the document
60
+ stepFrom = rebasePos(step.from, prevSteps, trackedTransaction.steps);
61
+ stepTo = rebasePos(step.to, prevSteps, trackedTransaction.steps);
62
+ // Re-resolve nodeAfter and markAfter if we did a block join
63
+ // The original values are stale after joinBlocks modifies the document
64
+ let nodeAfterResolved = nodeAfter;
65
+ let markAfterResolved = markAfter;
66
+ if (didBlockJoin) {
67
+ const $stepTo = trackedTransaction.doc.resolve(stepTo);
68
+ nodeAfterResolved = $stepTo.nodeAfter;
69
+ markAfterResolved = nodeAfterResolved?.marks.find((mark)=>mark.type === deletion || mark.type === insertion) ?? null;
70
+ // When inserting new content after a block join, use the suggestionId parameter
71
+ // instead of reusing adjacent mark IDs. The suggestionId represents a new operation.
72
+ const sliceHasNewContent = step.slice.openStart === 0 && step.slice.openEnd === 0;
73
+ if (sliceHasNewContent && step.slice.content.size) {
74
+ markId = suggestionId;
75
+ }
76
+ }
77
+ // If there's a deletion, we need to check for and handle
78
+ // the case where it crosses a block boundary, so that we
79
+ // can leave zero-width spaces as markers if there's no other
80
+ // content to anchor the deletion to.
81
+ if (stepFrom !== stepTo) {
82
+ let $stepFrom = trackedTransaction.doc.resolve(stepFrom);
83
+ let $stepTo = trackedTransaction.doc.resolve(stepTo);
84
+ // When there are no characters to mark with deletions before
85
+ // the end of a block, we add zero-width, non-printable
86
+ // characters as markers to indicate that a deletion exists
87
+ // and crosses a block boundary. This allows us to render the
88
+ // deleted boundary with a widget, as well as properly handle
89
+ // future, adjacent deletions and insertions.
90
+ if (!$stepFrom.nodeAfter && !deletion.isInSet($stepFrom.nodeBefore?.marks ?? [])) {
91
+ trackedTransaction.insertText("\u200B", stepFrom);
92
+ stepTo++;
93
+ $stepTo = trackedTransaction.doc.resolve(stepTo);
94
+ }
95
+ if (!$stepTo.nodeBefore && !deletion.isInSet($stepTo.nodeAfter?.marks ?? [])) {
96
+ trackedTransaction.insertText("\u200B", stepTo);
97
+ stepTo++;
98
+ $stepTo = trackedTransaction.doc.resolve(stepTo);
99
+ }
100
+ // When we produce a deletion mark that directly abuts
101
+ // an existing mark with a zero-width space, we delete
102
+ // that space. We'll join the marks later, and can use
103
+ // the joined marks to find deletions across the block
104
+ // boundary
105
+ if ($stepFrom.nodeBefore?.text?.endsWith("\u200B") && !$stepTo.nodeAfter?.text?.startsWith("\u200B")) {
106
+ trackedTransaction.delete(stepFrom - 1, stepFrom);
107
+ stepFrom--;
108
+ stepTo--;
109
+ $stepFrom = trackedTransaction.doc.resolve(stepFrom);
110
+ $stepTo = trackedTransaction.doc.resolve(stepTo);
111
+ }
112
+ if ($stepTo.nodeAfter?.text?.startsWith("\u200B") && !$stepFrom.nodeBefore?.text?.endsWith("\u200B")) {
113
+ trackedTransaction.delete(stepTo, stepTo + 1);
114
+ }
115
+ // If the user is deleting exactly a zero-width space,
116
+ // delete the space and also shift the range back by one,
117
+ // so that they actually mark the character before the
118
+ // zero-width space as deleted. The user doesn't know
119
+ // the zero-width space is there, so deleting it would
120
+ // appear to do nothing
121
+ if ($stepFrom.nodeBefore && stepTo - stepFrom === 1 && trackedTransaction.doc.textBetween(stepFrom, stepTo) === "\u200B") {
122
+ trackedTransaction.delete(stepFrom, stepTo);
123
+ stepFrom--;
124
+ stepTo--;
125
+ $stepFrom = trackedTransaction.doc.resolve(stepFrom);
126
+ $stepTo = trackedTransaction.doc.resolve(stepTo);
127
+ trackedTransaction.setSelection(TextSelection.near($stepFrom));
128
+ }
129
+ }
130
+ // TODO: Even if the range doesn't map to a block
131
+ // range, check whether it contains any whole
132
+ // blocks, so that we can use node marks on those.
133
+ //
134
+ // If the deleted range maps precisely to a block
135
+ // range. If they do, add node marks to the nodes
136
+ // in the range, rather than using inline marks
137
+ // on the content.
138
+ const blockRange = trackedTransaction.doc.resolve(stepFrom).blockRange(trackedTransaction.doc.resolve(stepTo));
139
+ if (!blockRange || blockRange.start !== stepFrom || blockRange.end !== stepTo) {
140
+ trackedTransaction.addMark(stepFrom, stepTo, deletion.create({
141
+ id: markId
142
+ }));
143
+ } else {
144
+ trackedTransaction.doc.nodesBetween(blockRange.start, blockRange.end, (_, pos)=>{
145
+ if (pos < blockRange.start) return true;
146
+ trackedTransaction.addNodeMark(pos, deletion.create({
147
+ id: markId
148
+ }));
149
+ return false;
150
+ });
151
+ }
152
+ // TODO: This could break if there's already a deletion-insertion-deletion-insertion combination
153
+ // This is the code that creates those combinations, doing this twice in a row could break it
154
+ // Detect when a new mark directly abuts an existing mark with
155
+ // a different id and merge them
156
+ // Use re-resolved values if we did a block join
157
+ if (nodeAfterResolved && markAfterResolved && markAfterResolved.attrs["id"] !== markId) {
158
+ const $nodeAfterStart = trackedTransaction.doc.resolve(stepTo);
159
+ const nodeAfterEnd = $nodeAfterStart.pos + nodeAfterResolved.nodeSize;
160
+ trackedTransaction.removeMark(stepTo, nodeAfterEnd, markAfterResolved.type);
161
+ trackedTransaction.addMark(stepTo, nodeAfterEnd, markAfterResolved.type.create({
162
+ id: markId
163
+ }));
164
+ if (markAfterResolved.type === deletion) {
165
+ const insertionNode = trackedTransaction.doc.resolve(nodeAfterEnd).nodeAfter;
166
+ if (insertionNode && insertion.isInSet(insertionNode.marks)) {
167
+ const insertionNodeEnd = nodeAfterEnd + insertionNode.nodeSize;
168
+ trackedTransaction.removeMark(nodeAfterEnd, insertionNodeEnd, insertion);
169
+ trackedTransaction.addMark(nodeAfterEnd, insertionNodeEnd, insertion.create({
170
+ id: markId
171
+ }));
172
+ }
173
+ }
174
+ }
175
+ // Handle insertions
176
+ // When didBlockJoin is true, only process insertions if the slice contains
177
+ // actual new content (closed slice) rather than just structural info for the join (open slice).
178
+ // Open slices have openStart > 0 or openEnd > 0 and represent block structure.
179
+ // Closed slices have openStart = 0 and openEnd = 0 and contain new user content.
180
+ // TODO: Done with AI, not 100% sure about the argument but it works. Kind of.
181
+ // The replaced content is not equivalent to what would happen without suggestions, just with insertions
182
+ // but it's workable. Only issues are with deleting between different depths ( for ex. between list and root level paragraph )
183
+ const sliceHasNewContent = step.slice.openStart === 0 && step.slice.openEnd === 0;
184
+ const shouldProcessInsertion = step.slice.content.size && (!didBlockJoin || sliceHasNewContent);
185
+ if (shouldProcessInsertion) {
186
+ const $to = trackedTransaction.doc.resolve(stepTo);
187
+ // Don't allow inserting content within an existing deletion
188
+ // mark. Instead, shift the proposed insertion to the end
189
+ // of the deletion.
190
+ const insertFrom = findSuggestionMarkEnd($to, deletion);
191
+ // We execute the insertion normally, on top of all of the existing
192
+ // tracked changes.
193
+ trackedTransaction.replace(insertFrom, insertFrom, step.slice);
194
+ const insertStep = // We just created this step, so it we can assert that it exists
195
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
196
+ trackedTransaction.steps[trackedTransaction.steps.length - 1];
197
+ let insertedTo = insertStep.getMap().map(insertFrom);
198
+ // Then, we iterate through the newly inserted content and mark it
199
+ // as inserted.
200
+ trackedTransaction.doc.nodesBetween(insertFrom, insertedTo, (node, pos)=>{
201
+ const $pos = trackedTransaction.doc.resolve(pos);
202
+ // If any of this node's ancestors are already marked as insertions,
203
+ // we can skip it
204
+ for(let d = $pos.depth; d >= 0; d--){
205
+ if (insertion.isInSet($pos.node(d).marks)) return;
206
+ }
207
+ // When an insertion constitutes only part of a node,
208
+ // use inline marks to mark only the inserted portion
209
+ const shouldAddInlineMarks = pos < insertFrom || pos + node.nodeSize > insertedTo || node.isInline;
210
+ if (shouldAddInlineMarks) {
211
+ trackedTransaction.addMark(Math.max(pos, insertFrom), Math.min(pos + node.nodeSize, insertedTo), insertion.create({
212
+ id: markId
213
+ }));
214
+ return;
215
+ }
216
+ // Use a node mark when an entire node was newly inserted.
217
+ trackedTransaction.addNodeMark(pos, insertion.create({
218
+ id: markId
219
+ }));
220
+ });
221
+ const $insertFrom = trackedTransaction.doc.resolve(insertFrom);
222
+ let $insertedTo = trackedTransaction.doc.resolve(insertedTo);
223
+ // Like with deletions, identify when we've inserted a
224
+ // node boundary and add zero-width spaces as anchors on
225
+ // either side.
226
+ if (!$insertFrom.nodeAfter) {
227
+ trackedTransaction.insertText("\u200B", insertFrom);
228
+ trackedTransaction.addMark(insertFrom, insertFrom + 1, insertion.create({
229
+ id: markId
230
+ }));
231
+ insertedTo++;
232
+ $insertedTo = trackedTransaction.doc.resolve(insertedTo);
233
+ }
234
+ if (!$insertedTo.nodeBefore) {
235
+ trackedTransaction.insertText("\u200B", insertedTo);
236
+ trackedTransaction.addMark(insertedTo, insertedTo + 1, insertion.create({
237
+ id: markId
238
+ }));
239
+ insertedTo++;
240
+ $insertedTo = trackedTransaction.doc.resolve(insertedTo);
241
+ }
242
+ if (insertFrom !== $to.pos) {
243
+ trackedTransaction.setSelection(TextSelection.near(trackedTransaction.doc.resolve(insertFrom + step.slice.size)));
244
+ }
245
+ }
246
+ return markId === suggestionId;
247
+ }
@@ -0,0 +1,9 @@
1
+ import { type MarkSpec } from "prosemirror-model";
2
+ export declare const deletion: MarkSpec;
3
+ export declare const insertion: MarkSpec;
4
+ export declare const modification: MarkSpec;
5
+ /**
6
+ * Add the deletion, insertion, and modification marks to
7
+ * the provided MarkSpec map.
8
+ */
9
+ export declare function addSuggestionMarks<Marks extends string>(marks: Record<Marks, MarkSpec>): Record<Marks | "deletion" | "insertion" | "modification", MarkSpec>;
package/dist/schema.js ADDED
@@ -0,0 +1,139 @@
1
+ import { suggestionIdValidate } from "./generateId.js";
2
+ export const deletion = {
3
+ inclusive: false,
4
+ excludes: "insertion modification deletion",
5
+ attrs: {
6
+ id: {
7
+ validate: suggestionIdValidate
8
+ }
9
+ },
10
+ toDOM (mark, inline) {
11
+ return [
12
+ "del",
13
+ {
14
+ "data-id": JSON.stringify(mark.attrs["id"]),
15
+ "data-inline": String(inline),
16
+ ...!inline && {
17
+ style: "display: block"
18
+ }
19
+ },
20
+ 0
21
+ ];
22
+ },
23
+ parseDOM: [
24
+ {
25
+ tag: "del",
26
+ getAttrs (node) {
27
+ if (!node.dataset["id"]) return false;
28
+ return {
29
+ id: JSON.parse(node.dataset["id"])
30
+ };
31
+ }
32
+ }
33
+ ]
34
+ };
35
+ export const insertion = {
36
+ inclusive: false,
37
+ excludes: "deletion modification insertion",
38
+ attrs: {
39
+ id: {
40
+ validate: suggestionIdValidate
41
+ }
42
+ },
43
+ toDOM (mark, inline) {
44
+ return [
45
+ "ins",
46
+ {
47
+ "data-id": JSON.stringify(mark.attrs["id"]),
48
+ "data-inline": String(inline),
49
+ ...!inline && {
50
+ style: "display: block"
51
+ }
52
+ },
53
+ 0
54
+ ];
55
+ },
56
+ parseDOM: [
57
+ {
58
+ tag: "ins",
59
+ getAttrs (node) {
60
+ if (!node.dataset["id"]) return false;
61
+ return {
62
+ id: JSON.parse(node.dataset["id"])
63
+ };
64
+ }
65
+ }
66
+ ]
67
+ };
68
+ export const modification = {
69
+ inclusive: false,
70
+ excludes: "deletion insertion",
71
+ attrs: {
72
+ id: {
73
+ validate: suggestionIdValidate
74
+ },
75
+ type: {
76
+ validate: "string"
77
+ },
78
+ attrName: {
79
+ default: null,
80
+ validate: "string|null"
81
+ },
82
+ previousValue: {
83
+ default: null
84
+ },
85
+ newValue: {
86
+ default: null
87
+ }
88
+ },
89
+ toDOM (mark, inline) {
90
+ return [
91
+ inline ? "span" : "div",
92
+ {
93
+ "data-type": "modification",
94
+ "data-id": JSON.stringify(mark.attrs["id"]),
95
+ "data-mod-type": mark.attrs["type"],
96
+ "data-mod-prev-val": JSON.stringify(mark.attrs["previousValue"]),
97
+ // TODO: Try to serialize marks with toJSON?
98
+ "data-mod-new-val": JSON.stringify(mark.attrs["newValue"])
99
+ },
100
+ 0
101
+ ];
102
+ },
103
+ parseDOM: [
104
+ {
105
+ tag: "span[data-type='modification']",
106
+ getAttrs (node) {
107
+ if (!node.dataset["id"]) return false;
108
+ return {
109
+ id: JSON.parse(node.dataset["id"]),
110
+ type: node.dataset["modType"],
111
+ previousValue: node.dataset["modPrevVal"],
112
+ newValue: node.dataset["modNewVal"]
113
+ };
114
+ }
115
+ },
116
+ {
117
+ tag: "div[data-type='modification']",
118
+ getAttrs (node) {
119
+ if (!node.dataset["id"]) return false;
120
+ return {
121
+ id: JSON.parse(node.dataset["id"]),
122
+ type: node.dataset["modType"],
123
+ previousValue: node.dataset["modPrevVal"]
124
+ };
125
+ }
126
+ }
127
+ ]
128
+ };
129
+ /**
130
+ * Add the deletion, insertion, and modification marks to
131
+ * the provided MarkSpec map.
132
+ */ export function addSuggestionMarks(marks) {
133
+ return {
134
+ ...marks,
135
+ deletion,
136
+ insertion,
137
+ modification
138
+ };
139
+ }
@@ -0,0 +1,2 @@
1
+ import { type MarkSpec } from "prosemirror-model";
2
+ export declare const difficulty: MarkSpec;
@@ -0,0 +1,11 @@
1
+ import { Schema, type Node } from "prosemirror-model";
2
+ import { type MarkBuilder, type NodeBuilder } from "prosemirror-test-builder";
3
+ declare const schema: Schema<"blockquote" | "text" | "doc" | "paragraph" | "image" | "orderedList" | "bulletList" | "listItem" | "horizontal_rule" | "heading" | "code_block" | "hard_break", "insertion" | "deletion" | "modification" | "code" | "em" | "link" | "strong" | "difficulty">;
4
+ export declare const testBuilders: { [NodeTypeName in keyof (typeof schema)["nodes"]]: NodeBuilder; } & { [MarkTypeName in keyof (typeof schema)["marks"]]: MarkBuilder; } & {
5
+ schema: typeof schema;
6
+ };
7
+ export type TaggedNode = Node & {
8
+ flat: Node;
9
+ tag: Record<string, number>;
10
+ };
11
+ export {};
@@ -0,0 +1,11 @@
1
+ import { type MarkType, type Schema } from "prosemirror-model";
2
+ export interface SuggestionMarks {
3
+ insertion: MarkType;
4
+ deletion: MarkType;
5
+ modification: MarkType;
6
+ }
7
+ /**
8
+ * Get the suggestion mark types from a schema, with proper error handling.
9
+ * Throws an error if any of the required marks are not found.
10
+ */
11
+ export declare function getSuggestionMarks(schema: Schema): SuggestionMarks;
package/dist/utils.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Get the suggestion mark types from a schema, with proper error handling.
3
+ * Throws an error if any of the required marks are not found.
4
+ */ export function getSuggestionMarks(schema) {
5
+ const { insertion, deletion, modification } = schema.marks;
6
+ if (!insertion) {
7
+ throw new Error("Failed to find insertion mark in schema. Did you forget to add it?");
8
+ }
9
+ if (!deletion) {
10
+ throw new Error("Failed to find deletion mark in schema. Did you forget to add it?");
11
+ }
12
+ if (!modification) {
13
+ throw new Error("Failed to find modification mark in schema. Did you forget to add it?");
14
+ }
15
+ return {
16
+ insertion,
17
+ deletion,
18
+ modification
19
+ };
20
+ }
@@ -0,0 +1,27 @@
1
+ import { type Schema, type Node } from "prosemirror-model";
2
+ import { type EditorState, type Transaction } from "prosemirror-state";
3
+ import { type EditorView } from "prosemirror-view";
4
+ import { type SuggestionId } from "./generateId.js";
5
+ /**
6
+ * Given a standard transaction from ProseMirror, produce
7
+ * a new transaction that tracks the changes from the original,
8
+ * rather than applying them.
9
+ *
10
+ * For each type of step, we implement custom behavior to prevent
11
+ * deletions from being removed from the document, instead adding
12
+ * deletion marks, and ensuring that all insertions have insertion
13
+ * marks.
14
+ */
15
+ export declare function transformToSuggestionTransaction(originalTransaction: Transaction, state: EditorState, generateId?: (schema: Schema, doc?: Node) => SuggestionId): Transaction;
16
+ /**
17
+ * A `dispatchTransaction` decorator. Wrap your existing `dispatchTransaction`
18
+ * function with `withSuggestChanges`, or pass no arguments to use the default
19
+ * implementation (`view.setState(view.state.apply(tr))`).
20
+ *
21
+ * The result is a `dispatchTransaction` function that will intercept
22
+ * and modify incoming transactions when suggest changes is enabled.
23
+ * These modified transactions will suggest changes instead of directly
24
+ * applying them, e.g. by marking a range with the deletion mark rather
25
+ * than removing it from the document.
26
+ */
27
+ export declare function withSuggestChanges(dispatchTransaction?: EditorView["dispatch"], generateId?: (schema: Schema, doc?: Node) => SuggestionId): EditorView["dispatch"];
@@ -0,0 +1,114 @@
1
+ import { AddMarkStep, AddNodeMarkStep, AttrStep, RemoveMarkStep, RemoveNodeMarkStep, ReplaceAroundStep, ReplaceStep } from "prosemirror-transform";
2
+ import { trackAddMarkStep } from "./addMarkStep.js";
3
+ import { trackAddNodeMarkStep } from "./addNodeMarkStep.js";
4
+ import { trackAttrStep } from "./attrStep.js";
5
+ import { suggestRemoveMarkStep } from "./removeMarkStep.js";
6
+ import { suggestRemoveNodeMarkStep } from "./removeNodeMarkStep.js";
7
+ import { suggestReplaceAroundStep } from "./replaceAroundStep.js";
8
+ import { suggestReplaceStep } from "./replaceStep.js";
9
+ import { isSuggestChangesEnabled, suggestChangesKey } from "./plugin.js";
10
+ import { generateNextNumberId } from "./generateId.js";
11
+ import { getSuggestionMarks } from "./utils.js";
12
+ function getStepHandler(step) {
13
+ if (step instanceof ReplaceStep) {
14
+ return suggestReplaceStep;
15
+ }
16
+ if (step instanceof ReplaceAroundStep) {
17
+ return suggestReplaceAroundStep;
18
+ }
19
+ if (step instanceof AddMarkStep) {
20
+ return trackAddMarkStep;
21
+ }
22
+ if (step instanceof RemoveMarkStep) {
23
+ return suggestRemoveMarkStep;
24
+ }
25
+ if (step instanceof AddNodeMarkStep) {
26
+ return trackAddNodeMarkStep;
27
+ }
28
+ if (step instanceof RemoveNodeMarkStep) {
29
+ return suggestRemoveNodeMarkStep;
30
+ }
31
+ if (step instanceof AttrStep) {
32
+ return trackAttrStep;
33
+ }
34
+ // Default handler — simply rebase the step onto the
35
+ // tracked transaction and apply it.
36
+ return (trackedTransaction, _state, _doc, step, prevSteps)=>{
37
+ const reset = prevSteps.slice().reverse().reduce((acc, step)=>acc?.map(step.getMap().invert()) ?? null, step);
38
+ const rebased = trackedTransaction.steps.reduce((acc, step)=>acc?.map(step.getMap()) ?? null, reset);
39
+ if (rebased) {
40
+ trackedTransaction.step(rebased);
41
+ }
42
+ return false;
43
+ };
44
+ }
45
+ /**
46
+ * Given a standard transaction from ProseMirror, produce
47
+ * a new transaction that tracks the changes from the original,
48
+ * rather than applying them.
49
+ *
50
+ * For each type of step, we implement custom behavior to prevent
51
+ * deletions from being removed from the document, instead adding
52
+ * deletion marks, and ensuring that all insertions have insertion
53
+ * marks.
54
+ */ export function transformToSuggestionTransaction(originalTransaction, state, generateId) {
55
+ getSuggestionMarks(state.schema);
56
+ let suggestionId = generateId ? generateId(state.schema, originalTransaction.docs[0]) : generateNextNumberId(state.schema, originalTransaction.docs[0]);
57
+ // Create a new transaction from scratch. The original transaction
58
+ // is going to be dropped in favor of this one.
59
+ const trackedTransaction = state.tr;
60
+ for(let i = 0; i < originalTransaction.steps.length; i++){
61
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
62
+ const step = originalTransaction.steps[i];
63
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
64
+ const doc = originalTransaction.docs[i];
65
+ const stepTracker = getStepHandler(step);
66
+ if (stepTracker(trackedTransaction, state, doc, step, originalTransaction.steps.slice(0, i), suggestionId) && i < originalTransaction.steps.length - 1) {
67
+ // If the suggestionId was used by one of the step handlers,
68
+ // increment it so that it's not reused.
69
+ if (generateId) {
70
+ suggestionId = generateId(state.schema, trackedTransaction.doc);
71
+ } else if (typeof suggestionId === "number") {
72
+ suggestionId = suggestionId + 1;
73
+ }
74
+ }
75
+ continue;
76
+ }
77
+ if (originalTransaction.selectionSet && !trackedTransaction.selectionSet) {
78
+ // Map the original selection backwards through the original transaction,
79
+ // and then forwards through the new one.
80
+ const originalBaseDoc = originalTransaction.docs[0];
81
+ const base = originalBaseDoc ? originalTransaction.selection.map(originalBaseDoc, originalTransaction.mapping.invert()) : originalTransaction.selection;
82
+ trackedTransaction.setSelection(base.map(trackedTransaction.doc, trackedTransaction.mapping));
83
+ }
84
+ if (originalTransaction.scrolledIntoView) {
85
+ trackedTransaction.scrollIntoView();
86
+ }
87
+ if (originalTransaction.storedMarksSet) {
88
+ trackedTransaction.setStoredMarks(originalTransaction.storedMarks);
89
+ }
90
+ // @ts-expect-error Preserve original transaction meta exactly as-is
91
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
92
+ trackedTransaction.meta = originalTransaction.meta;
93
+ return trackedTransaction;
94
+ }
95
+ /**
96
+ * A `dispatchTransaction` decorator. Wrap your existing `dispatchTransaction`
97
+ * function with `withSuggestChanges`, or pass no arguments to use the default
98
+ * implementation (`view.setState(view.state.apply(tr))`).
99
+ *
100
+ * The result is a `dispatchTransaction` function that will intercept
101
+ * and modify incoming transactions when suggest changes is enabled.
102
+ * These modified transactions will suggest changes instead of directly
103
+ * applying them, e.g. by marking a range with the deletion mark rather
104
+ * than removing it from the document.
105
+ */ export function withSuggestChanges(dispatchTransaction, generateId) {
106
+ const dispatch = dispatchTransaction ?? function(tr) {
107
+ this.updateState(this.state.apply(tr));
108
+ };
109
+ return function dispatchTransaction(tr) {
110
+ const ySyncMeta = tr.getMeta("y-sync$") ?? {};
111
+ const transaction = isSuggestChangesEnabled(this.state) && !tr.getMeta("history$") && !tr.getMeta("collab$") && !ySyncMeta.isUndoRedoOperation && !ySyncMeta.isChangeOrigin && !("skip" in (tr.getMeta(suggestChangesKey) ?? {})) ? transformToSuggestionTransaction(tr, this.state, generateId) : tr;
112
+ dispatch.call(this, transaction);
113
+ };
114
+ }