@startsimpli/ui 0.4.142 → 0.4.143
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/package.json +36 -10
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/index.ts +74 -0
- package/src/components/track-changes/normalize.ts +364 -0
- package/src/components/track-changes/types.ts +373 -0
- package/src/components/track-changes/useTrackChanges.ts +214 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CodeMirror state layer, tested headless on EditorState (bd startsim-sjuai).
|
|
3
|
+
*
|
|
4
|
+
* What must hold: the editor never rewrites the stored string (CRLF, tabs,
|
|
5
|
+
* emoji, a trailing newline survive byte for byte); decorations sit on the
|
|
6
|
+
* right characters after local edits and after the stored value moves under
|
|
7
|
+
* the editor; text typed locally is never credited to the span it was typed
|
|
8
|
+
* into; and in suggesting mode the document is the PROPOSED copy, with the
|
|
9
|
+
* base untouched and every local change visible as an insert or a delete.
|
|
10
|
+
*/
|
|
11
|
+
import { EditorSelection, EditorState } from '@codemirror/state'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
activeMarkOf,
|
|
15
|
+
enterSuggesting,
|
|
16
|
+
leaveSuggesting,
|
|
17
|
+
marksOf,
|
|
18
|
+
moveToMark,
|
|
19
|
+
proposedText,
|
|
20
|
+
rebaseOnto,
|
|
21
|
+
setAnnotations,
|
|
22
|
+
trackChanges,
|
|
23
|
+
trackingOf,
|
|
24
|
+
} from '../extension'
|
|
25
|
+
import type { BlameSpan, CommentThread, Suggestion, TrackActor } from '../types'
|
|
26
|
+
|
|
27
|
+
const ada: TrackActor = { sub: 'ada', label: 'ada@x.io', kind: 'person', kindSource: 'claim' }
|
|
28
|
+
const bo: TrackActor = { sub: 'bo', label: 'bo@x.io', kind: 'person', kindSource: 'claim' }
|
|
29
|
+
const bot: TrackActor = { sub: 'svc', label: 'svc:n8n', kind: 'machine', kindSource: 'claim' }
|
|
30
|
+
|
|
31
|
+
function span(from: number, to: number, author: TrackActor | null, reason?: string): BlameSpan {
|
|
32
|
+
return {
|
|
33
|
+
id: `blame:${from}`,
|
|
34
|
+
from,
|
|
35
|
+
to,
|
|
36
|
+
author,
|
|
37
|
+
unknownReason: author ? null : (reason ?? 'before history began'),
|
|
38
|
+
reason: author ? null : 'before_history',
|
|
39
|
+
version: 1,
|
|
40
|
+
at: null,
|
|
41
|
+
via: author ? 'edit' : 'unknown',
|
|
42
|
+
acceptedBy: null,
|
|
43
|
+
restoredFrom: null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function suggestion(id: string, text: string, quote: string, replacement: string, author: TrackActor, at?: number): Suggestion {
|
|
48
|
+
const from = at ?? text.indexOf(quote)
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
setId: 'set',
|
|
52
|
+
from,
|
|
53
|
+
to: from + quote.length,
|
|
54
|
+
quote,
|
|
55
|
+
replacement,
|
|
56
|
+
status: 'open',
|
|
57
|
+
anchorState: 'mapped',
|
|
58
|
+
author,
|
|
59
|
+
createdAt: null,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function make(doc: string) {
|
|
64
|
+
return EditorState.create({ doc, extensions: [trackChanges()] })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const type = (s: EditorState, from: number, to: number, insert: string) =>
|
|
68
|
+
s.update({ changes: { from, to, insert }, userEvent: 'input.type' }).state
|
|
69
|
+
|
|
70
|
+
const text = (s: EditorState, m: { from: number; to: number }) => s.sliceDoc(m.from, m.to)
|
|
71
|
+
|
|
72
|
+
describe('the stored string is never rewritten', () => {
|
|
73
|
+
it.each([
|
|
74
|
+
['CRLF', 'line one\r\nline two\r\n'],
|
|
75
|
+
['lone CR', 'a\rb'],
|
|
76
|
+
['tabs and trailing spaces', '\tindented \n two\t\n'],
|
|
77
|
+
['emoji and CJK', '🚀 launch 中文博客 ✅\n'],
|
|
78
|
+
['nbsp and zero-width', 'a bc'],
|
|
79
|
+
['no trailing newline', '# Title\n\nbody'],
|
|
80
|
+
])('%s survives load and an edit that is undone by hand', (_, value) => {
|
|
81
|
+
let s = make(value)
|
|
82
|
+
expect(s.doc.toString()).toBe(value)
|
|
83
|
+
s = type(s, s.doc.length, s.doc.length, 'X')
|
|
84
|
+
s = type(s, s.doc.length - 1, s.doc.length, '')
|
|
85
|
+
expect(s.doc.toString()).toBe(value)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('decorations follow edits (editing mode)', () => {
|
|
90
|
+
const value = 'Ada wrote this. Bo wrote that.'
|
|
91
|
+
const annotations = {
|
|
92
|
+
blame: [span(0, 15, ada), span(15, 30, bo)],
|
|
93
|
+
suggestions: [suggestion('s1', value, 'that', 'this', bo)],
|
|
94
|
+
threads: [] as CommentThread[],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loaded() {
|
|
98
|
+
return make(value).update({ effects: setAnnotations.of({ ...annotations, currentActorSub: 'ada' }) }).state
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('places blame and suggestion marks on their characters', () => {
|
|
102
|
+
const s = loaded()
|
|
103
|
+
const marks = marksOf(s)
|
|
104
|
+
const blame = marks.filter((m) => m.kind === 'blame')
|
|
105
|
+
expect(blame.map((m) => text(s, m))).toEqual(['Ada wrote this.', ' Bo wrote that.'])
|
|
106
|
+
const sug = marks.find((m) => m.kind === 'suggestion')!
|
|
107
|
+
expect(text(s, sug)).toBe('that')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('shifts every mark when text is inserted before them', () => {
|
|
111
|
+
const s = type(loaded(), 0, 0, 'Note: ')
|
|
112
|
+
const marks = marksOf(s)
|
|
113
|
+
expect(marks.filter((m) => m.kind === 'blame').map((m) => text(s, m))).toEqual(['Ada wrote this.', ' Bo wrote that.'])
|
|
114
|
+
expect(text(s, marks.find((m) => m.kind === 'suggestion')!)).toBe('that')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not credit text typed inside a span to that span’s author', () => {
|
|
118
|
+
const at = value.indexOf('this')
|
|
119
|
+
const s = type(loaded(), at, at, 'all of ')
|
|
120
|
+
const blame = marksOf(s).filter((m) => m.kind === 'blame')
|
|
121
|
+
expect(blame.map((m) => text(s, m))).toEqual(['Ada wrote ', 'this.', ' Bo wrote that.'])
|
|
122
|
+
expect(blame.every((m) => !text(s, m).includes('all of'))).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('shrinks a span when part of it is deleted and drops a suggestion whose text is gone', () => {
|
|
126
|
+
const at = value.indexOf(' that')
|
|
127
|
+
const s = type(loaded(), at, at + 5, '')
|
|
128
|
+
const marks = marksOf(s)
|
|
129
|
+
expect(marks.filter((m) => m.kind === 'blame').map((m) => text(s, m))).toEqual(['Ada wrote this.', ' Bo wrote.'])
|
|
130
|
+
expect(marks.find((m) => m.kind === 'suggestion')).toBeUndefined()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('moves marks when the stored value changes under the editor, keeping a local unsaved edit', () => {
|
|
134
|
+
let s = loaded()
|
|
135
|
+
s = type(s, s.doc.length, s.doc.length, ' Mine.') // unsaved local edit
|
|
136
|
+
const next = 'Intro. Ada wrote this. Bo wrote that.'
|
|
137
|
+
s = s.update(rebaseOnto(s, next)!).state
|
|
138
|
+
expect(s.doc.toString()).toBe('Intro. Ada wrote this. Bo wrote that. Mine.')
|
|
139
|
+
expect(trackingOf(s).base.toString()).toBe(next)
|
|
140
|
+
// New annotations arrive in the NEW value's coordinates.
|
|
141
|
+
s = s.update({
|
|
142
|
+
effects: setAnnotations.of({
|
|
143
|
+
blame: [span(0, 7, bot), span(7, 22, ada), span(22, 37, bo)],
|
|
144
|
+
suggestions: [],
|
|
145
|
+
threads: [],
|
|
146
|
+
currentActorSub: 'ada',
|
|
147
|
+
}),
|
|
148
|
+
}).state
|
|
149
|
+
expect(marksOf(s).filter((m) => m.kind === 'blame').map((m) => text(s, m))).toEqual([
|
|
150
|
+
'Intro. ',
|
|
151
|
+
'Ada wrote this.',
|
|
152
|
+
' Bo wrote that.',
|
|
153
|
+
])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('treats the host echoing the editor’s own text as a save, not an external change', () => {
|
|
157
|
+
let s = loaded()
|
|
158
|
+
s = type(s, s.doc.length, s.doc.length, ' Mine.')
|
|
159
|
+
const saved = s.doc.toString()
|
|
160
|
+
s = s.update(rebaseOnto(s, saved) ?? {}).state
|
|
161
|
+
expect(s.doc.toString()).toBe(saved)
|
|
162
|
+
expect(trackingOf(s).changes.empty).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('marks an unknown span with its reason, and a machine span as a machine', () => {
|
|
166
|
+
const s = make('old text new text').update({
|
|
167
|
+
effects: setAnnotations.of({
|
|
168
|
+
blame: [span(0, 9, null, 'before history began 2026-10-06'), span(9, 17, bot)],
|
|
169
|
+
suggestions: [],
|
|
170
|
+
threads: [],
|
|
171
|
+
currentActorSub: null,
|
|
172
|
+
}),
|
|
173
|
+
}).state
|
|
174
|
+
const [unknown, machine] = marksOf(s)
|
|
175
|
+
expect(unknown.description).toBe('Author unknown: before history began 2026-10-06')
|
|
176
|
+
expect(unknown.actorKind).toBe('unknown')
|
|
177
|
+
expect(machine.description).toMatch(/^Written by svc:n8n \(machine\)/)
|
|
178
|
+
expect(machine.actorKind).toBe('machine')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('hides a kind of mark when asked, so whwxd.3 can build show/hide over it', () => {
|
|
182
|
+
const s = loaded().update({
|
|
183
|
+
effects: setAnnotations.of({ ...annotations, currentActorSub: 'ada', show: { blame: false } }),
|
|
184
|
+
}).state
|
|
185
|
+
expect(marksOf(s).some((m) => m.kind === 'blame')).toBe(false)
|
|
186
|
+
expect(marksOf(s).some((m) => m.kind === 'suggestion')).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('suggesting mode', () => {
|
|
191
|
+
const value = 'The quick brown fox jumps.'
|
|
192
|
+
|
|
193
|
+
function suggesting(own: Suggestion[] = [], others: Suggestion[] = []) {
|
|
194
|
+
let s = make(value).update({
|
|
195
|
+
effects: setAnnotations.of({ blame: [], suggestions: [...own, ...others], threads: [], currentActorSub: 'ada' }),
|
|
196
|
+
}).state
|
|
197
|
+
for (const spec of enterSuggesting(s)) s = s.update(spec).state
|
|
198
|
+
return s
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it('keeps the base untouched and shows a typed insert as an insert', () => {
|
|
202
|
+
let s = suggesting()
|
|
203
|
+
const at = value.indexOf('fox')
|
|
204
|
+
s = type(s, at, at, 'red ')
|
|
205
|
+
expect(trackingOf(s).base.toString()).toBe(value)
|
|
206
|
+
expect(proposedText(s)).toBe('The quick brown red fox jumps.')
|
|
207
|
+
const local = marksOf(s).filter((m) => m.kind === 'local')
|
|
208
|
+
expect(local).toHaveLength(1)
|
|
209
|
+
expect(text(s, local[0])).toBe('red ')
|
|
210
|
+
expect(local[0].description).toBe('Your suggestion: insert “red”')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('shows a deletion as struck base text that is no longer in the document', () => {
|
|
214
|
+
let s = suggesting()
|
|
215
|
+
const at = value.indexOf('quick ')
|
|
216
|
+
s = type(s, at, at + 6, '')
|
|
217
|
+
const [del] = marksOf(s).filter((m) => m.kind === 'local')
|
|
218
|
+
expect(del.deleted).toBe('quick ')
|
|
219
|
+
expect(del.from).toBe(del.to)
|
|
220
|
+
expect(del.description).toBe('Your suggestion: delete “quick”')
|
|
221
|
+
expect(proposedText(s)).toBe('The brown fox jumps.')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('starts from the author’s own open suggestions, so a re-post does not drop them', () => {
|
|
225
|
+
const mine = suggestion('m1', value, 'quick', 'slow', ada)
|
|
226
|
+
const s = suggesting([mine])
|
|
227
|
+
expect(proposedText(s)).toBe('The slow brown fox jumps.')
|
|
228
|
+
// Shown as the author's local change, not twice.
|
|
229
|
+
expect(marksOf(s).filter((m) => m.kind === 'suggestion')).toHaveLength(0)
|
|
230
|
+
expect(marksOf(s).filter((m) => m.kind === 'local')).toHaveLength(1)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('maps another reviewer’s suggestion through the local proposal', () => {
|
|
234
|
+
const theirs = suggestion('t1', value, 'jumps', 'leaps', bo)
|
|
235
|
+
let s = suggesting([], [theirs])
|
|
236
|
+
s = type(s, 0, 0, 'Look: ')
|
|
237
|
+
const m = marksOf(s).find((x) => x.kind === 'suggestion')!
|
|
238
|
+
expect(text(s, m)).toBe('jumps')
|
|
239
|
+
expect(m.description).toBe('Suggestion by bo@x.io: replace “jumps” with “leaps”')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('keeps the proposal when the base moves underneath it', () => {
|
|
243
|
+
let s = suggesting()
|
|
244
|
+
const at = value.indexOf('fox')
|
|
245
|
+
s = type(s, at, at + 3, 'cat')
|
|
246
|
+
s = s.update(rebaseOnto(s, 'Today: The quick brown fox jumps.')!).state
|
|
247
|
+
expect(trackingOf(s).base.toString()).toBe('Today: The quick brown fox jumps.')
|
|
248
|
+
expect(proposedText(s)).toBe('Today: The quick brown cat jumps.')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('returns to the base text, untouched, when leaving', () => {
|
|
252
|
+
let s = suggesting()
|
|
253
|
+
s = type(s, 0, 3, 'A')
|
|
254
|
+
s = s.update(leaveSuggesting(s)).state
|
|
255
|
+
expect(s.doc.toString()).toBe(value)
|
|
256
|
+
expect(trackingOf(s).changes.empty).toBe(true)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('keyboard navigation between marks', () => {
|
|
261
|
+
const value = 'One two three four.'
|
|
262
|
+
|
|
263
|
+
it('moves to the next and previous change and reports it as active', () => {
|
|
264
|
+
let s = make(value).update({
|
|
265
|
+
effects: setAnnotations.of({
|
|
266
|
+
blame: [],
|
|
267
|
+
suggestions: [suggestion('s1', value, 'two', '2', bo), suggestion('s2', value, 'four', '4', bo)],
|
|
268
|
+
threads: [
|
|
269
|
+
{
|
|
270
|
+
id: 't1',
|
|
271
|
+
from: value.indexOf('three'),
|
|
272
|
+
to: value.indexOf('three') + 5,
|
|
273
|
+
quote: 'three',
|
|
274
|
+
anchorState: 'mapped',
|
|
275
|
+
resolved: false,
|
|
276
|
+
redacted: false,
|
|
277
|
+
comments: [{ id: 'c', body: 'really?', deleted: false, author: ada, createdAt: null }],
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
currentActorSub: null,
|
|
281
|
+
}),
|
|
282
|
+
}).state
|
|
283
|
+
const order: string[] = []
|
|
284
|
+
for (let i = 0; i < 3; i++) {
|
|
285
|
+
s = s.update(moveToMark(s, 1)!).state
|
|
286
|
+
order.push(activeMarkOf(s)!.id)
|
|
287
|
+
}
|
|
288
|
+
expect(order).toEqual(['s1', 't1', 's2'])
|
|
289
|
+
expect(text(s, s.selection.main)).toBe('four')
|
|
290
|
+
s = s.update(moveToMark(s, -1)!).state
|
|
291
|
+
expect(activeMarkOf(s)!.description).toBe('Comment by ada@x.io: “really?”')
|
|
292
|
+
// Wraps.
|
|
293
|
+
s = s.update(moveToMark(s, 1)!).state
|
|
294
|
+
s = s.update(moveToMark(s, 1)!).state
|
|
295
|
+
expect(activeMarkOf(s)!.id).toBe('s1')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('reports the mark under the cursor as active when the cursor is moved by hand', () => {
|
|
299
|
+
let s = make(value).update({
|
|
300
|
+
effects: setAnnotations.of({
|
|
301
|
+
blame: [span(0, 19, ada)],
|
|
302
|
+
suggestions: [suggestion('s1', value, 'two', '2', bo)],
|
|
303
|
+
threads: [],
|
|
304
|
+
currentActorSub: null,
|
|
305
|
+
}),
|
|
306
|
+
}).state
|
|
307
|
+
s = s.update({ selection: EditorSelection.cursor(value.indexOf('two') + 1) }).state
|
|
308
|
+
// The suggestion wins over the blame span it sits inside.
|
|
309
|
+
expect(activeMarkOf(s)!.id).toBe('s1')
|
|
310
|
+
s = s.update({ selection: EditorSelection.cursor(1) }).state
|
|
311
|
+
expect(activeMarkOf(s)!.kind).toBe('blame')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('returns null when there is nothing to move to', () => {
|
|
315
|
+
expect(moveToMark(make('plain'), 1)).toBeNull()
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('the invariant: CHANGES applied to BASE is always the document', () => {
|
|
320
|
+
it('holds across random local edits and external rebases, in both modes', () => {
|
|
321
|
+
let seed = 11
|
|
322
|
+
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff)
|
|
323
|
+
const pieces = ['word ', 'other ', '🚀', '中文', '\r\n', '\n', 'x', ', ']
|
|
324
|
+
const gen = () => Array.from({ length: 1 + Math.floor(rnd() * 4) }, () => pieces[Math.floor(rnd() * pieces.length)]).join('')
|
|
325
|
+
const randomEdit = (doc: string) => {
|
|
326
|
+
const a = Math.floor(rnd() * (doc.length + 1))
|
|
327
|
+
const b = Math.min(doc.length, a + Math.floor(rnd() * 6))
|
|
328
|
+
return { from: a, to: b, insert: rnd() < 0.3 ? '' : gen() }
|
|
329
|
+
}
|
|
330
|
+
for (let round = 0; round < 60; round++) {
|
|
331
|
+
let s = make('start ' + gen() + ' end')
|
|
332
|
+
if (round % 2) for (const spec of enterSuggesting(s)) s = s.update(spec).state
|
|
333
|
+
for (let step = 0; step < 15; step++) {
|
|
334
|
+
if (rnd() < 0.7) {
|
|
335
|
+
s = s.update({ changes: randomEdit(s.doc.toString()), userEvent: 'input.type' }).state
|
|
336
|
+
} else {
|
|
337
|
+
const base = trackingOf(s).base.toString()
|
|
338
|
+
const e = randomEdit(base)
|
|
339
|
+
const next = base.slice(0, e.from) + e.insert + base.slice(e.to)
|
|
340
|
+
const spec = rebaseOnto(s, next)
|
|
341
|
+
if (spec) s = s.update(spec).state
|
|
342
|
+
expect(trackingOf(s).base.toString()).toBe(next)
|
|
343
|
+
}
|
|
344
|
+
const { base, changes } = trackingOf(s)
|
|
345
|
+
expect(changes.apply(base).toString()).toBe(s.doc.toString())
|
|
346
|
+
marksOf(s) // never throws
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
})
|