@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,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
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -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,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 {};
|
package/dist/utils.d.ts
ADDED
|
@@ -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
|
+
}
|