@startsimpli/ui 0.4.141 → 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 +38 -12
- package/src/components/activity/ActivityFeedPanel.tsx +96 -29
- package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
- package/src/components/activity/types.ts +10 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
- package/src/components/collection/InlineReviewActions.tsx +3 -2
- package/src/components/collection/ReviewDrawer.tsx +35 -7
- package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
- package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
- package/src/components/collection/data.ts +16 -1
- package/src/components/collection/index.ts +2 -1
- package/src/components/collection/scoped-client.ts +4 -1
- package/src/components/collection/types.ts +19 -1
- package/src/components/history/FieldAttribution.tsx +308 -0
- package/src/components/history/RecordActivityPanel.tsx +344 -0
- package/src/components/history/RecordHistoryPanel.tsx +331 -15
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +69 -3
- package/src/components/history/StaleSaveDialog.tsx +28 -7
- package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
- package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
- package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
- package/src/components/history/__tests__/restore.test.ts +209 -0
- package/src/components/history/index.ts +72 -1
- package/src/components/history/restore.ts +295 -0
- package/src/components/history/revision-feed.ts +105 -0
- package/src/components/history/revision-history.ts +22 -0
- package/src/components/history/types.ts +153 -1
- 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,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CodeMirror 6 half of the track-changes editor (bd startsim-sjuai;
|
|
3
|
+
* design startsim-xyn25 section d). Headless: everything here is state, so it
|
|
4
|
+
* is tested on `EditorState` with no DOM.
|
|
5
|
+
*
|
|
6
|
+
* ── THE MODEL ───────────────────────────────────────────────────────────────
|
|
7
|
+
* The editor holds two things about the stored string:
|
|
8
|
+
*
|
|
9
|
+
* BASE the field's value at the version the host loaded. Every server
|
|
10
|
+
* offset (blame, suggestions, comments) is an offset into BASE.
|
|
11
|
+
* CHANGES one ChangeSet, BASE → the document on screen, composed from every
|
|
12
|
+
* local transaction.
|
|
13
|
+
*
|
|
14
|
+
* In EDITING mode CHANGES is the reviewer's unsaved typing; the host saves the
|
|
15
|
+
* document and echoes it back as the new BASE. In SUGGESTING mode CHANGES IS
|
|
16
|
+
* THE PROPOSAL: the document is the proposed copy, BASE is never written, and
|
|
17
|
+
* each change renders as an insert or a struck deletion. The proposal is the
|
|
18
|
+
* whole document text, which is exactly what the design posts
|
|
19
|
+
* (`{field, base_version, proposed}`).
|
|
20
|
+
*
|
|
21
|
+
* Every decoration is DERIVED from (BASE annotations, CHANGES, mode) on each
|
|
22
|
+
* update rather than mapped incrementally, so there is one source of truth for
|
|
23
|
+
* where a mark sits and nothing can drift. A span's text typed locally is cut
|
|
24
|
+
* out of it: a reviewer's unsaved words are never credited to whoever wrote
|
|
25
|
+
* the sentence around them.
|
|
26
|
+
*
|
|
27
|
+
* ── THE ROUND-TRIP RULE ─────────────────────────────────────────────────────
|
|
28
|
+
* By default CodeMirror splits lines on CRLF and CR and joins them with LF, so
|
|
29
|
+
* loading a CRLF value and saving it would rewrite every line ending, and
|
|
30
|
+
* blame would credit the saver with all of it: the exact hazard the design
|
|
31
|
+
* measured against rich editors. `lineSeparator: '\n'` makes LF the only
|
|
32
|
+
* separator, so a CR stays a character of its line and the string survives
|
|
33
|
+
* byte for byte.
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
Annotation,
|
|
37
|
+
ChangeSet,
|
|
38
|
+
EditorSelection,
|
|
39
|
+
EditorState,
|
|
40
|
+
Facet,
|
|
41
|
+
Prec,
|
|
42
|
+
StateEffect,
|
|
43
|
+
StateField,
|
|
44
|
+
Text,
|
|
45
|
+
Transaction,
|
|
46
|
+
type Extension,
|
|
47
|
+
type TransactionSpec,
|
|
48
|
+
} from '@codemirror/state';
|
|
49
|
+
import { Decoration, EditorView, WidgetType, keymap, type Command } from '@codemirror/view';
|
|
50
|
+
|
|
51
|
+
import type { ActorKind } from '../ActorIdentity';
|
|
52
|
+
import { describeBlame, describeLocal, describeSuggestion, describeThread } from './describe';
|
|
53
|
+
import { anchorFor } from './normalize';
|
|
54
|
+
import type { BlameSpan, CommentThread, SelectionAnchor, Suggestion } from './types';
|
|
55
|
+
import { wordChanges } from './word-diff';
|
|
56
|
+
|
|
57
|
+
export const LINE_SEPARATOR = '\n';
|
|
58
|
+
|
|
59
|
+
export function textOf(value: string): Text {
|
|
60
|
+
return Text.of(value.split(LINE_SEPARATOR));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function changesOf(from: string, to: string): ChangeSet {
|
|
64
|
+
return ChangeSet.of(wordChanges(from, to), from.length, LINE_SEPARATOR);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* -------------------------------------------------------------------------- */
|
|
68
|
+
/* State */
|
|
69
|
+
/* -------------------------------------------------------------------------- */
|
|
70
|
+
|
|
71
|
+
export interface Tracking {
|
|
72
|
+
base: Text;
|
|
73
|
+
changes: ChangeSet;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Replace BASE and CHANGES outright (load, rebase, mode switch). */
|
|
77
|
+
export const resetTracking = StateEffect.define<Tracking>();
|
|
78
|
+
|
|
79
|
+
/** Marks a transaction the editor made itself, not the reviewer: it is never
|
|
80
|
+
* reported to the host as an edit and never starts a proposal post. */
|
|
81
|
+
export const internalChange = Annotation.define<boolean>();
|
|
82
|
+
|
|
83
|
+
const trackingField = StateField.define<Tracking>({
|
|
84
|
+
create: (s) => ({ base: s.doc, changes: ChangeSet.empty(s.doc.length) }),
|
|
85
|
+
update(value, tr) {
|
|
86
|
+
for (const e of tr.effects) if (e.is(resetTracking)) return e.value;
|
|
87
|
+
if (!tr.docChanged) return value;
|
|
88
|
+
return { base: value.base, changes: value.changes.compose(tr.changes) };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const setSuggesting = StateEffect.define<boolean>();
|
|
93
|
+
|
|
94
|
+
const suggestingField = StateField.define<boolean>({
|
|
95
|
+
create: () => false,
|
|
96
|
+
update(value, tr) {
|
|
97
|
+
for (const e of tr.effects) if (e.is(setSuggesting)) return e.value;
|
|
98
|
+
return value;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** Which kinds of mark are drawn. whwxd.3's show/hide toggle drives this. */
|
|
103
|
+
export interface MarkVisibility {
|
|
104
|
+
blame?: boolean;
|
|
105
|
+
suggestions?: boolean;
|
|
106
|
+
comments?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Server annotations in BASE coordinates, already normalized to UTF-16. */
|
|
110
|
+
export interface AnnotationsInput {
|
|
111
|
+
blame: BlameSpan[];
|
|
112
|
+
suggestions: Suggestion[];
|
|
113
|
+
threads: CommentThread[];
|
|
114
|
+
/** Whose open suggestions are this reviewer's own. In suggesting mode they
|
|
115
|
+
* are loaded into the proposal so that the next post (which supersedes the
|
|
116
|
+
* author's own open set) carries them forward instead of dropping them. */
|
|
117
|
+
currentActorSub: string | null;
|
|
118
|
+
show?: MarkVisibility;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const setAnnotations = StateEffect.define<AnnotationsInput>();
|
|
122
|
+
|
|
123
|
+
/** BASE moved from one value to another; carry the annotations across it
|
|
124
|
+
* until the host reloads them for the new version. */
|
|
125
|
+
const mapBase = StateEffect.define<ChangeSet>();
|
|
126
|
+
|
|
127
|
+
const EMPTY_ANNOTATIONS: AnnotationsInput = { blame: [], suggestions: [], threads: [], currentActorSub: null };
|
|
128
|
+
|
|
129
|
+
function mapRange<T extends { from: number; to: number }>(r: T, c: ChangeSet): T | null {
|
|
130
|
+
const from = c.mapPos(r.from, 1);
|
|
131
|
+
const to = Math.max(from, c.mapPos(r.to, -1));
|
|
132
|
+
if (r.to > r.from && to <= from) return null;
|
|
133
|
+
return { ...r, from, to };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const annotationsField = StateField.define<AnnotationsInput>({
|
|
137
|
+
create: () => EMPTY_ANNOTATIONS,
|
|
138
|
+
update(value, tr) {
|
|
139
|
+
let next = value;
|
|
140
|
+
for (const e of tr.effects) {
|
|
141
|
+
if (e.is(setAnnotations)) next = e.value;
|
|
142
|
+
else if (e.is(mapBase)) {
|
|
143
|
+
const c = e.value;
|
|
144
|
+
next = {
|
|
145
|
+
...next,
|
|
146
|
+
blame: next.blame.flatMap((s) => mapRange(s, c) ?? []),
|
|
147
|
+
suggestions: next.suggestions.flatMap((s) =>
|
|
148
|
+
s.anchorState === 'orphaned' ? [s] : (mapRange(s, c) ?? []),
|
|
149
|
+
),
|
|
150
|
+
threads: next.threads.map((t) => {
|
|
151
|
+
if (t.from == null || t.to == null) return t;
|
|
152
|
+
const m = mapRange({ from: t.from, to: t.to }, c);
|
|
153
|
+
return m ? { ...t, ...m } : { ...t, from: null, to: null, anchorState: 'orphaned' as const };
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/** The mark the reviewer last moved to (by keyboard or by the rail). */
|
|
163
|
+
export const setExplicitActive = StateEffect.define<string | null>();
|
|
164
|
+
|
|
165
|
+
const explicitActiveField = StateField.define<string | null>({
|
|
166
|
+
create: () => null,
|
|
167
|
+
update(value, tr) {
|
|
168
|
+
for (const e of tr.effects) if (e.is(setExplicitActive)) return e.value;
|
|
169
|
+
return value;
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/* -------------------------------------------------------------------------- */
|
|
174
|
+
/* Marks */
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
|
|
177
|
+
export type MarkKind = 'blame' | 'suggestion' | 'comment' | 'local';
|
|
178
|
+
|
|
179
|
+
/** One thing drawn over the text, in DOCUMENT coordinates. `from === to` for
|
|
180
|
+
* a pure insert suggestion or a local deletion (there is no text to cover). */
|
|
181
|
+
export interface TrackMark {
|
|
182
|
+
id: string;
|
|
183
|
+
kind: MarkKind;
|
|
184
|
+
from: number;
|
|
185
|
+
to: number;
|
|
186
|
+
/** The accessible sentence, also the tooltip. */
|
|
187
|
+
description: string;
|
|
188
|
+
/** `self` for the reviewer's own local proposal. */
|
|
189
|
+
actorKind: ActorKind | 'self';
|
|
190
|
+
/** Stable per author, for the tint. */
|
|
191
|
+
authorKey: string;
|
|
192
|
+
/** Suggestion / local: text that would be inserted. */
|
|
193
|
+
inserted?: string;
|
|
194
|
+
/** Local deletion: the base text no longer in the document. */
|
|
195
|
+
deleted?: string;
|
|
196
|
+
source: BlameSpan | Suggestion | CommentThread | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const KIND_ORDER: Record<MarkKind, number> = { suggestion: 0, local: 0, comment: 1, blame: 2 };
|
|
200
|
+
|
|
201
|
+
function byPosition(a: TrackMark, b: TrackMark) {
|
|
202
|
+
return a.from - b.from || KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.to - b.to;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Cut `[from, to)` around locally inserted ranges. */
|
|
206
|
+
function subtract(from: number, to: number, holes: Array<[number, number]>): Array<[number, number]> {
|
|
207
|
+
const out: Array<[number, number]> = [];
|
|
208
|
+
let start = from;
|
|
209
|
+
for (const [hf, ht] of holes) {
|
|
210
|
+
if (ht <= start || hf >= to) continue;
|
|
211
|
+
if (hf > start) out.push([start, hf]);
|
|
212
|
+
start = Math.max(start, ht);
|
|
213
|
+
}
|
|
214
|
+
if (start < to) out.push([start, to]);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function computeMarks(state: EditorState): TrackMark[] {
|
|
219
|
+
const { base, changes } = state.field(trackingField);
|
|
220
|
+
const suggesting = state.field(suggestingField);
|
|
221
|
+
const ann = state.field(annotationsField);
|
|
222
|
+
const show = { blame: true, suggestions: true, comments: true, ...ann.show };
|
|
223
|
+
const marks: TrackMark[] = [];
|
|
224
|
+
|
|
225
|
+
const inserted: Array<[number, number]> = [];
|
|
226
|
+
changes.iterChanges((fromA, toA, fromB, toB, text) => {
|
|
227
|
+
if (toB > fromB) inserted.push([fromB, toB]);
|
|
228
|
+
if (!suggesting) return;
|
|
229
|
+
const deleted = base.sliceString(fromA, toA, LINE_SEPARATOR);
|
|
230
|
+
const ins = text.sliceString(0, text.length, LINE_SEPARATOR);
|
|
231
|
+
marks.push({
|
|
232
|
+
id: `local:${fromB}`,
|
|
233
|
+
kind: 'local',
|
|
234
|
+
from: fromB,
|
|
235
|
+
to: toB,
|
|
236
|
+
description: describeLocal(deleted, ins),
|
|
237
|
+
actorKind: 'self',
|
|
238
|
+
authorKey: 'self',
|
|
239
|
+
inserted: ins,
|
|
240
|
+
deleted,
|
|
241
|
+
source: null,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (show.blame) {
|
|
246
|
+
for (const s of ann.blame) {
|
|
247
|
+
const m = mapRange(s, changes);
|
|
248
|
+
if (!m) continue;
|
|
249
|
+
const pieces = subtract(m.from, m.to, inserted);
|
|
250
|
+
pieces.forEach(([from, to], i) => {
|
|
251
|
+
marks.push({
|
|
252
|
+
id: pieces.length > 1 ? `${s.id}#${i}` : s.id,
|
|
253
|
+
kind: 'blame',
|
|
254
|
+
from,
|
|
255
|
+
to,
|
|
256
|
+
description: describeBlame(s),
|
|
257
|
+
actorKind: s.author ? s.author.kind : 'unknown',
|
|
258
|
+
authorKey: s.author ? s.author.sub || s.author.label : '',
|
|
259
|
+
source: s,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (show.suggestions) {
|
|
266
|
+
for (const s of ann.suggestions) {
|
|
267
|
+
if (s.status !== 'open' || s.anchorState === 'orphaned') continue;
|
|
268
|
+
// Suggesting mode: the reviewer's own open suggestions ARE the local
|
|
269
|
+
// proposal (see enterSuggesting); drawing them twice would double them.
|
|
270
|
+
if (suggesting && ann.currentActorSub && s.author.sub === ann.currentActorSub) continue;
|
|
271
|
+
const m = mapRange(s, changes);
|
|
272
|
+
if (!m) continue;
|
|
273
|
+
marks.push({
|
|
274
|
+
id: s.id,
|
|
275
|
+
kind: 'suggestion',
|
|
276
|
+
from: m.from,
|
|
277
|
+
to: m.to,
|
|
278
|
+
description: describeSuggestion(s),
|
|
279
|
+
actorKind: s.author.kind,
|
|
280
|
+
authorKey: s.author.sub || s.author.label,
|
|
281
|
+
inserted: s.replacement,
|
|
282
|
+
source: s,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (show.comments) {
|
|
288
|
+
for (const t of ann.threads) {
|
|
289
|
+
if (t.resolved || t.anchorState === 'orphaned' || t.from == null || t.to == null) continue;
|
|
290
|
+
const m = mapRange({ from: t.from, to: t.to }, changes);
|
|
291
|
+
if (!m) continue;
|
|
292
|
+
const root = t.comments[0]?.author;
|
|
293
|
+
marks.push({
|
|
294
|
+
id: t.id,
|
|
295
|
+
kind: 'comment',
|
|
296
|
+
from: m.from,
|
|
297
|
+
to: m.to,
|
|
298
|
+
description: describeThread(t),
|
|
299
|
+
actorKind: root?.kind ?? 'unknown',
|
|
300
|
+
authorKey: root ? root.sub || root.label : '',
|
|
301
|
+
source: t,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return marks.sort(byPosition);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const marksFacet = Facet.define<TrackMark[], TrackMark[]>({ combine: (v) => v[0] ?? [] });
|
|
310
|
+
|
|
311
|
+
function covers(m: TrackMark, head: number, docLength: number): boolean {
|
|
312
|
+
if (m.kind === 'blame') return m.from <= head && (head < m.to || (head === m.to && head === docLength));
|
|
313
|
+
return m.from <= head && head <= m.to;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function computeActive(state: EditorState): TrackMark | null {
|
|
317
|
+
const marks = state.facet(marksFacet);
|
|
318
|
+
const head = state.selection.main.head;
|
|
319
|
+
const explicit = state.field(explicitActiveField);
|
|
320
|
+
if (explicit) {
|
|
321
|
+
const m = marks.find((x) => x.id === explicit);
|
|
322
|
+
if (m && covers(m, head, state.doc.length)) return m;
|
|
323
|
+
}
|
|
324
|
+
let best: TrackMark | null = null;
|
|
325
|
+
for (const m of marks) {
|
|
326
|
+
if (!covers(m, head, state.doc.length)) continue;
|
|
327
|
+
if (
|
|
328
|
+
!best ||
|
|
329
|
+
KIND_ORDER[m.kind] < KIND_ORDER[best.kind] ||
|
|
330
|
+
(KIND_ORDER[m.kind] === KIND_ORDER[best.kind] && m.to - m.from < best.to - best.from)
|
|
331
|
+
) {
|
|
332
|
+
best = m;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return best;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const activeFacet = Facet.define<TrackMark | null, TrackMark | null>({ combine: (v) => v[0] ?? null });
|
|
339
|
+
|
|
340
|
+
/* -------------------------------------------------------------------------- */
|
|
341
|
+
/* Read access */
|
|
342
|
+
/* -------------------------------------------------------------------------- */
|
|
343
|
+
|
|
344
|
+
export const marksOf = (s: EditorState) => s.facet(marksFacet);
|
|
345
|
+
export const activeMarkOf = (s: EditorState) => s.facet(activeFacet);
|
|
346
|
+
export const trackingOf = (s: EditorState) => s.field(trackingField);
|
|
347
|
+
export const isSuggesting = (s: EditorState) => s.field(suggestingField);
|
|
348
|
+
export const annotationsOf = (s: EditorState) => s.field(annotationsField);
|
|
349
|
+
/** The suggesting-mode proposal: the whole document text. */
|
|
350
|
+
export const proposedText = (s: EditorState) => s.doc.toString();
|
|
351
|
+
|
|
352
|
+
/* -------------------------------------------------------------------------- */
|
|
353
|
+
/* Transitions (pure: each returns a spec for the caller to dispatch) */
|
|
354
|
+
/* -------------------------------------------------------------------------- */
|
|
355
|
+
|
|
356
|
+
const INTERNAL = [internalChange.of(true), Transaction.addToHistory.of(false)];
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The stored value is now `value`. Three cases:
|
|
360
|
+
*
|
|
361
|
+
* - it equals BASE: nothing to do (returns null);
|
|
362
|
+
* - `ownEcho` (editing mode): the host saved text this editor produced, so it
|
|
363
|
+
* becomes BASE and whatever was typed since stays as unsaved CHANGES. The
|
|
364
|
+
* document is NOT touched, which is what stops a lagging echo from
|
|
365
|
+
* re-inserting text the reviewer already has;
|
|
366
|
+
* - otherwise it is EXTERNAL (an accept, another reviewer, a restore): the
|
|
367
|
+
* difference is applied as a change, rebased over local CHANGES, so local
|
|
368
|
+
* typing or a proposal survives and every mark maps instead of collapsing.
|
|
369
|
+
*/
|
|
370
|
+
export function rebaseOnto(
|
|
371
|
+
state: EditorState,
|
|
372
|
+
value: string,
|
|
373
|
+
opts: { ownEcho?: boolean } = {},
|
|
374
|
+
): TransactionSpec | null {
|
|
375
|
+
const { base, changes } = state.field(trackingField);
|
|
376
|
+
const baseStr = base.sliceString(0, base.length, LINE_SEPARATOR);
|
|
377
|
+
if (value === baseStr) return null;
|
|
378
|
+
const moved = changesOf(baseStr, value);
|
|
379
|
+
const doc = state.doc.toString();
|
|
380
|
+
const suggesting = state.field(suggestingField);
|
|
381
|
+
|
|
382
|
+
if (!suggesting && (opts.ownEcho || value === doc)) {
|
|
383
|
+
return {
|
|
384
|
+
effects: [resetTracking.of({ base: textOf(value), changes: changesOf(value, doc) }), mapBase.of(moved)],
|
|
385
|
+
annotations: INTERNAL,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const docChange = moved.map(changes);
|
|
390
|
+
const local = changes.map(moved, true);
|
|
391
|
+
return {
|
|
392
|
+
changes: docChange,
|
|
393
|
+
effects: [resetTracking.of({ base: textOf(value), changes: local }), mapBase.of(moved)],
|
|
394
|
+
annotations: INTERNAL,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Enter suggesting mode. The document must equal BASE (no unsaved typing),
|
|
400
|
+
* which the component guarantees by disabling the switch until it does.
|
|
401
|
+
* The reviewer's own open suggestions are loaded into the proposal.
|
|
402
|
+
*/
|
|
403
|
+
/**
|
|
404
|
+
* The reviewer's own open suggestions, split three ways:
|
|
405
|
+
* - `placed`: carried into the proposal;
|
|
406
|
+
* - `orphaned`: cannot be placed, but the server KEEPS them open on the next
|
|
407
|
+
* full-text post (`kept_orphaned`, start-simpli-api #508), so nothing is lost;
|
|
408
|
+
* - `overlapping`: live, but overlapping an earlier one of the reviewer's own,
|
|
409
|
+
* so the proposal cannot carry both and the next post SUPERSEDES them. The
|
|
410
|
+
* component warns about these rather than dropping them silently.
|
|
411
|
+
*/
|
|
412
|
+
export function ownSuggestions(state: EditorState): {
|
|
413
|
+
placed: Suggestion[];
|
|
414
|
+
orphaned: Suggestion[];
|
|
415
|
+
overlapping: Suggestion[];
|
|
416
|
+
} {
|
|
417
|
+
const ann = state.field(annotationsField);
|
|
418
|
+
const placed: Suggestion[] = [];
|
|
419
|
+
const orphaned: Suggestion[] = [];
|
|
420
|
+
const overlapping: Suggestion[] = [];
|
|
421
|
+
if (!ann.currentActorSub) return { placed, orphaned, overlapping };
|
|
422
|
+
const base = state.field(trackingField).base;
|
|
423
|
+
const own = ann.suggestions
|
|
424
|
+
.filter((s) => s.status === 'open' && s.author.sub === ann.currentActorSub)
|
|
425
|
+
.sort((a, b) => a.from - b.from);
|
|
426
|
+
let end = -1;
|
|
427
|
+
for (const s of own) {
|
|
428
|
+
if (s.anchorState === 'orphaned' || s.to > base.length) orphaned.push(s);
|
|
429
|
+
else if (s.from < end) overlapping.push(s);
|
|
430
|
+
else {
|
|
431
|
+
placed.push(s);
|
|
432
|
+
end = s.to;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { placed, orphaned, overlapping };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function enterSuggesting(state: EditorState): TransactionSpec[] {
|
|
439
|
+
const { placed: own } = ownSuggestions(state);
|
|
440
|
+
const specs: TransactionSpec[] = [
|
|
441
|
+
{
|
|
442
|
+
effects: [setSuggesting.of(true), resetTracking.of({ base: state.doc, changes: ChangeSet.empty(state.doc.length) })],
|
|
443
|
+
annotations: INTERNAL,
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
const picked = own.map((s) => ({ from: s.from, to: s.to, insert: s.replacement }));
|
|
447
|
+
if (picked.length) specs.push({ changes: picked, annotations: INTERNAL });
|
|
448
|
+
return specs;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Leave suggesting mode: the document returns to BASE, untouched. */
|
|
452
|
+
export function leaveSuggesting(state: EditorState): TransactionSpec {
|
|
453
|
+
const { base, changes } = state.field(trackingField);
|
|
454
|
+
return {
|
|
455
|
+
changes: changes.invert(base),
|
|
456
|
+
effects: [setSuggesting.of(false), resetTracking.of({ base, changes: ChangeSet.empty(base.length) })],
|
|
457
|
+
annotations: INTERNAL,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Which marks the change navigation visits. Authorship spans have their own
|
|
462
|
+
* keys, because stepping through every span of a blog to reach the next
|
|
463
|
+
* suggestion is not navigation. */
|
|
464
|
+
export const isChangeOrComment = (m: TrackMark) => m.kind !== 'blame';
|
|
465
|
+
export const isAuthorship = (m: TrackMark) => m.kind === 'blame';
|
|
466
|
+
|
|
467
|
+
function selectMark(m: TrackMark): TransactionSpec {
|
|
468
|
+
return {
|
|
469
|
+
selection: m.to > m.from ? EditorSelection.range(m.from, m.to) : EditorSelection.cursor(m.from),
|
|
470
|
+
effects: [setExplicitActive.of(m.id), EditorView.scrollIntoView(m.from, { y: 'center' })],
|
|
471
|
+
userEvent: 'select.track-changes',
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Move to the next (`dir` 1) or previous (-1) mark, wrapping. */
|
|
476
|
+
export function moveToMark(
|
|
477
|
+
state: EditorState,
|
|
478
|
+
dir: 1 | -1,
|
|
479
|
+
filter: (m: TrackMark) => boolean = isChangeOrComment,
|
|
480
|
+
): TransactionSpec | null {
|
|
481
|
+
const list = state.facet(marksFacet).filter(filter);
|
|
482
|
+
if (!list.length) return null;
|
|
483
|
+
const active = state.facet(activeFacet);
|
|
484
|
+
const at = active ? list.findIndex((m) => m.id === active.id) : -1;
|
|
485
|
+
let i: number;
|
|
486
|
+
if (at >= 0) i = (at + dir + list.length) % list.length;
|
|
487
|
+
else {
|
|
488
|
+
const head = state.selection.main.head;
|
|
489
|
+
if (dir > 0) {
|
|
490
|
+
i = list.findIndex((m) => m.from >= head);
|
|
491
|
+
if (i < 0) i = 0;
|
|
492
|
+
} else {
|
|
493
|
+
i = -1;
|
|
494
|
+
for (let k = list.length - 1; k >= 0; k--) {
|
|
495
|
+
if (list[k].from < head) {
|
|
496
|
+
i = k;
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (i < 0) i = list.length - 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return selectMark(list[i]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Select a mark by id (the rail's "show me this one"). */
|
|
507
|
+
export function focusMark(state: EditorState, id: string): TransactionSpec | null {
|
|
508
|
+
const m = state.facet(marksFacet).find((x) => x.id === id);
|
|
509
|
+
return m ? selectMark(m) : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const run =
|
|
513
|
+
(dir: 1 | -1, filter: (m: TrackMark) => boolean): Command =>
|
|
514
|
+
(view) => {
|
|
515
|
+
const spec = moveToMark(view.state, dir, filter);
|
|
516
|
+
if (!spec) return false;
|
|
517
|
+
view.dispatch(spec);
|
|
518
|
+
return true;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
export interface TrackKeys {
|
|
522
|
+
nextChange?: string;
|
|
523
|
+
prevChange?: string;
|
|
524
|
+
nextAuthor?: string;
|
|
525
|
+
prevAuthor?: string;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** F8 is "next problem" in most editors and is free in CodeMirror's default
|
|
529
|
+
* keymap (Alt-ArrowUp/Down are move-line, so they are not used). */
|
|
530
|
+
export const DEFAULT_KEYS: Required<TrackKeys> = {
|
|
531
|
+
nextChange: 'F8',
|
|
532
|
+
prevChange: 'Shift-F8',
|
|
533
|
+
nextAuthor: 'Alt-F8',
|
|
534
|
+
prevAuthor: 'Shift-Alt-F8',
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/* -------------------------------------------------------------------------- */
|
|
538
|
+
/* Rendering */
|
|
539
|
+
/* -------------------------------------------------------------------------- */
|
|
540
|
+
|
|
541
|
+
const HUES = 8;
|
|
542
|
+
|
|
543
|
+
function hueOf(key: string): number {
|
|
544
|
+
let h = 0;
|
|
545
|
+
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
|
|
546
|
+
return h % HUES;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function attrs(m: TrackMark): Record<string, string> {
|
|
550
|
+
return {
|
|
551
|
+
'data-tc-id': m.id,
|
|
552
|
+
'data-tc-kind': m.kind,
|
|
553
|
+
'data-actor-kind': m.actorKind,
|
|
554
|
+
title: m.description,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
class TextWidget extends WidgetType {
|
|
559
|
+
constructor(
|
|
560
|
+
readonly tag: 'ins' | 'del',
|
|
561
|
+
readonly text: string,
|
|
562
|
+
readonly cls: string,
|
|
563
|
+
readonly m: TrackMark,
|
|
564
|
+
) {
|
|
565
|
+
super();
|
|
566
|
+
}
|
|
567
|
+
eq(other: TextWidget) {
|
|
568
|
+
return other.tag === this.tag && other.text === this.text && other.cls === this.cls && other.m.id === this.m.id;
|
|
569
|
+
}
|
|
570
|
+
toDOM() {
|
|
571
|
+
const el = document.createElement(this.tag);
|
|
572
|
+
el.className = this.cls;
|
|
573
|
+
el.textContent = this.text;
|
|
574
|
+
for (const [k, v] of Object.entries(attrs(this.m))) el.setAttribute(k, v);
|
|
575
|
+
return el;
|
|
576
|
+
}
|
|
577
|
+
ignoreEvent() {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function decorationsFor(marks: TrackMark[], active: TrackMark | null, explicit: string | null) {
|
|
583
|
+
const out = [];
|
|
584
|
+
for (const m of marks) {
|
|
585
|
+
// An authorship span covers whole paragraphs and the cursor is always in
|
|
586
|
+
// one, so it is outlined only when navigated to, never merely by typing.
|
|
587
|
+
const on = active?.id === m.id && (m.kind !== 'blame' || explicit === m.id) ? ' cm-tc-active' : '';
|
|
588
|
+
const a = attrs(m);
|
|
589
|
+
if (m.kind === 'blame') {
|
|
590
|
+
const tint = m.actorKind === 'unknown' ? 'cm-tc-unknown' : `cm-tc-a${hueOf(m.authorKey)}`;
|
|
591
|
+
const machine = m.actorKind === 'machine' ? ' cm-tc-machine' : '';
|
|
592
|
+
out.push(Decoration.mark({ class: `cm-tc-blame ${tint}${machine}${on}`, attributes: a }).range(m.from, m.to));
|
|
593
|
+
} else if (m.kind === 'comment') {
|
|
594
|
+
out.push(Decoration.mark({ tagName: 'mark', class: `cm-tc-comment${on}`, attributes: a }).range(m.from, m.to));
|
|
595
|
+
} else if (m.kind === 'suggestion') {
|
|
596
|
+
if (m.to > m.from) {
|
|
597
|
+
out.push(Decoration.mark({ tagName: 'del', class: `cm-tc-sugg-del${on}`, attributes: a }).range(m.from, m.to));
|
|
598
|
+
}
|
|
599
|
+
if (m.inserted) {
|
|
600
|
+
out.push(
|
|
601
|
+
Decoration.widget({ widget: new TextWidget('ins', m.inserted, `cm-tc-sugg-ins${on}`, m), side: 1 }).range(m.to),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
if (m.deleted) {
|
|
606
|
+
out.push(
|
|
607
|
+
Decoration.widget({ widget: new TextWidget('del', m.deleted, `cm-tc-local-del${on}`, m), side: -1 }).range(m.from),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (m.to > m.from) {
|
|
611
|
+
out.push(Decoration.mark({ tagName: 'ins', class: `cm-tc-local-ins${on}`, attributes: a }).range(m.from, m.to));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return Decoration.set(out, true);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Colours come from the theme contract with fallbacks, NOT Tailwind classes:
|
|
620
|
+
* decorations are created at runtime, so a standalone app's Tailwind purge
|
|
621
|
+
* never sees these class names. Author tints are eight hues, overridable per
|
|
622
|
+
* tenant with `--tc-author-0` … `--tc-author-7` (HSL channels, like the
|
|
623
|
+
* contract). Deletions use the destructive token at low strength: it is a
|
|
624
|
+
* proposal, not an error, so it must not read as an incident. A machine span
|
|
625
|
+
* is dashed (shape, not colour, carries the meaning), and an unknown span is
|
|
626
|
+
* a quiet dotted underline: calm, because it is the common case today.
|
|
627
|
+
*/
|
|
628
|
+
// No greens, reds, oranges or yellows: green and red mean "inserted" and
|
|
629
|
+
// "deleted" here and yellow means "commented", so an author tint near any of
|
|
630
|
+
// them would say something false (an orange author read as a deletion in the
|
|
631
|
+
// browser harness).
|
|
632
|
+
const AUTHOR_DEFAULTS = [
|
|
633
|
+
'217 91% 60%',
|
|
634
|
+
'271 81% 56%',
|
|
635
|
+
'330 81% 60%',
|
|
636
|
+
'189 94% 43%',
|
|
637
|
+
'245 58% 51%',
|
|
638
|
+
'292 84% 61%',
|
|
639
|
+
'200 60% 40%',
|
|
640
|
+
'230 25% 45%',
|
|
641
|
+
];
|
|
642
|
+
|
|
643
|
+
const theme = EditorView.baseTheme({
|
|
644
|
+
'.cm-tc-blame': { textDecorationLine: 'underline', textDecorationThickness: '2px', textUnderlineOffset: '3px' },
|
|
645
|
+
...Object.fromEntries(
|
|
646
|
+
AUTHOR_DEFAULTS.map((hsl, i) => [
|
|
647
|
+
`.cm-tc-a${i}`,
|
|
648
|
+
{
|
|
649
|
+
textDecorationColor: `hsl(var(--tc-author-${i}, ${hsl}))`,
|
|
650
|
+
backgroundColor: `hsl(var(--tc-author-${i}, ${hsl}) / 0.08)`,
|
|
651
|
+
},
|
|
652
|
+
]),
|
|
653
|
+
),
|
|
654
|
+
'.cm-tc-machine': { textDecorationStyle: 'dashed' },
|
|
655
|
+
'.cm-tc-unknown': {
|
|
656
|
+
textDecorationStyle: 'dotted',
|
|
657
|
+
textDecorationThickness: '1px',
|
|
658
|
+
textDecorationColor: 'hsl(var(--muted-foreground, 215 16% 47%) / 0.6)',
|
|
659
|
+
},
|
|
660
|
+
'.cm-tc-comment': {
|
|
661
|
+
backgroundColor: 'hsl(var(--tc-comment, 48 96% 53%) / 0.25)',
|
|
662
|
+
color: 'inherit',
|
|
663
|
+
borderBottom: '2px solid hsl(var(--tc-comment, 48 96% 53%) / 0.8)',
|
|
664
|
+
},
|
|
665
|
+
'.cm-tc-sugg-del, .cm-tc-local-del': {
|
|
666
|
+
textDecorationLine: 'line-through',
|
|
667
|
+
color: 'hsl(var(--destructive, 0 72% 51%))',
|
|
668
|
+
backgroundColor: 'hsl(var(--destructive, 0 72% 51%) / 0.08)',
|
|
669
|
+
},
|
|
670
|
+
'.cm-tc-sugg-ins, .cm-tc-local-ins': {
|
|
671
|
+
textDecorationLine: 'underline',
|
|
672
|
+
textDecorationColor: 'hsl(var(--success, 142 71% 36%))',
|
|
673
|
+
backgroundColor: 'hsl(var(--success, 142 71% 36%) / 0.12)',
|
|
674
|
+
},
|
|
675
|
+
'.cm-tc-active': { outline: '2px solid hsl(var(--ring, 217 91% 60%))', outlineOffset: '1px', borderRadius: '2px' },
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
export interface TrackChangesConfig {
|
|
679
|
+
keys?: TrackKeys;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Everything the track-changes editor adds to a CodeMirror state. */
|
|
683
|
+
export function trackChanges(config: TrackChangesConfig = {}): Extension {
|
|
684
|
+
const keys = { ...DEFAULT_KEYS, ...config.keys };
|
|
685
|
+
return [
|
|
686
|
+
EditorState.lineSeparator.of(LINE_SEPARATOR),
|
|
687
|
+
trackingField,
|
|
688
|
+
suggestingField,
|
|
689
|
+
annotationsField,
|
|
690
|
+
explicitActiveField,
|
|
691
|
+
marksFacet.compute([trackingField, suggestingField, annotationsField], computeMarks),
|
|
692
|
+
activeFacet.compute(['selection', marksFacet, explicitActiveField], computeActive),
|
|
693
|
+
EditorView.decorations.compute([marksFacet, activeFacet, explicitActiveField], (s) =>
|
|
694
|
+
decorationsFor(s.facet(marksFacet), s.facet(activeFacet), s.field(explicitActiveField)),
|
|
695
|
+
),
|
|
696
|
+
Prec.high(
|
|
697
|
+
keymap.of([
|
|
698
|
+
{ key: keys.nextChange, run: run(1, isChangeOrComment) },
|
|
699
|
+
{ key: keys.prevChange, run: run(-1, isChangeOrComment) },
|
|
700
|
+
{ key: keys.nextAuthor, run: run(1, isAuthorship) },
|
|
701
|
+
{ key: keys.prevAuthor, run: run(-1, isAuthorship) },
|
|
702
|
+
]),
|
|
703
|
+
),
|
|
704
|
+
theme,
|
|
705
|
+
];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The selection as an anchor in BASE (stored-string) coordinates, which is
|
|
710
|
+
* what a comment is anchored to on the server. Unsaved typing or a local
|
|
711
|
+
* proposal before the selection does not shift it. `null` when nothing is
|
|
712
|
+
* selected or the selection is only locally typed text (it has no stored
|
|
713
|
+
* characters to anchor to).
|
|
714
|
+
*/
|
|
715
|
+
export function selectionAnchorOf(state: EditorState): SelectionAnchor | null {
|
|
716
|
+
const sel = state.selection.main;
|
|
717
|
+
if (sel.empty) return null;
|
|
718
|
+
const { base, changes } = state.field(trackingField);
|
|
719
|
+
const inv = changes.invertedDesc;
|
|
720
|
+
const from = inv.mapPos(sel.from, 1);
|
|
721
|
+
const to = inv.mapPos(sel.to, -1);
|
|
722
|
+
if (to <= from) return null;
|
|
723
|
+
return anchorFor(base.sliceString(0, base.length, LINE_SEPARATOR), from, to);
|
|
724
|
+
}
|