@startsimpli/ui 0.4.142 → 0.4.144
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/blog/MarkdownRenderer.tsx +23 -5
- package/src/components/track-changes/ChangesRail.tsx +687 -0
- package/src/components/track-changes/TrackedField.tsx +293 -0
- package/src/components/track-changes/TrackedReadView.tsx +273 -0
- 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__/rails.test.tsx +604 -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/final-text.ts +54 -0
- package/src/components/track-changes/http-client.ts +109 -0
- package/src/components/track-changes/index.ts +90 -0
- package/src/components/track-changes/normalize.ts +393 -0
- package/src/components/track-changes/read-marks.ts +262 -0
- package/src/components/track-changes/types.ts +451 -0
- package/src/components/track-changes/useTrackChanges.ts +407 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TrackedTextEditor — the track-changes editor surface (bd startsim-sjuai;
|
|
5
|
+
* design startsim-xyn25 section d).
|
|
6
|
+
*
|
|
7
|
+
* CodeMirror 6 over the STORED STRING: markdown source for a blog, plain text
|
|
8
|
+
* for LinkedIn. Quinn chose this over a Word-like rich editor because a rich
|
|
9
|
+
* editor saves its own serialization, and on the 183 live drafts that rewrote
|
|
10
|
+
* 4 blogs and every LinkedIn post, which blame would then credit to whoever
|
|
11
|
+
* saved. This editor never normalizes: what the host passes in is what it
|
|
12
|
+
* hands back, byte for byte.
|
|
13
|
+
*
|
|
14
|
+
* TWO MODES.
|
|
15
|
+
* - EDITING: typing changes the field. `onChange(text)` reports the whole
|
|
16
|
+
* string; the host saves it (useConditionalSave) and passes it back.
|
|
17
|
+
* - SUGGESTING: typing changes a LOCAL PROPOSED COPY, shown as insert/delete
|
|
18
|
+
* marks against the stored text. `onChange` is NEVER called, so nothing can
|
|
19
|
+
* PATCH the field. The whole proposed text is posted with the base version
|
|
20
|
+
* (`onPropose`) after a pause (5 s), on blur, and on leaving the mode; the
|
|
21
|
+
* server hunks it and supersedes this author's own open set. A reviewer who
|
|
22
|
+
* may not edit (`canEdit={false}`) is held in this mode: view-only reviewers
|
|
23
|
+
* may suggest (Quinn, 2026-10-07).
|
|
24
|
+
*
|
|
25
|
+
* PRESENTATIONAL. It fetches nothing. Annotations come in as props, normally
|
|
26
|
+
* from {@link useTrackChanges}, which owns the injected client; the changes
|
|
27
|
+
* rail (startsim-whwxd.3) reads that same hook result and drives this editor
|
|
28
|
+
* through the ref handle (`focusMark`, `nextChange`, `selectionAnchor`) and the
|
|
29
|
+
* `show`/`onActiveChange` props. The rail, show/hide, final/original views and
|
|
30
|
+
* the Read-view overlay are deliberately not here.
|
|
31
|
+
*
|
|
32
|
+
* ACCESSIBLE. Every mark carries its sentence as a `title`; F8 / Shift+F8 move
|
|
33
|
+
* between suggestions and comments, Alt+F8 / Shift+Alt+F8 between authorship
|
|
34
|
+
* spans; the mark at the cursor is announced in a polite live region. Tab
|
|
35
|
+
* leaves the editor (no tab trapping).
|
|
36
|
+
*
|
|
37
|
+
* LAYOUT on the component's own container, never the viewport (bd
|
|
38
|
+
* startsim-jkkn7.12): its common home is a narrow review column.
|
|
39
|
+
*
|
|
40
|
+
* CodeMirror is an OPTIONAL peer dependency of @startsimpli/ui, imported only
|
|
41
|
+
* from the `@startsimpli/ui/track-changes` subpath. Mount it with a dynamic
|
|
42
|
+
* import so pages that never edit do not load it.
|
|
43
|
+
*/
|
|
44
|
+
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
45
|
+
import { markdownLanguage } from '@codemirror/lang-markdown';
|
|
46
|
+
import { LanguageSupport, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
47
|
+
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
|
48
|
+
import { EditorView, keymap } from '@codemirror/view';
|
|
49
|
+
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
DEFAULT_KEYS,
|
|
53
|
+
activeMarkOf,
|
|
54
|
+
enterSuggesting,
|
|
55
|
+
focusMark,
|
|
56
|
+
internalChange,
|
|
57
|
+
isAuthorship,
|
|
58
|
+
isChangeOrComment,
|
|
59
|
+
isSuggesting,
|
|
60
|
+
leaveSuggesting,
|
|
61
|
+
marksOf,
|
|
62
|
+
moveToMark,
|
|
63
|
+
ownSuggestions,
|
|
64
|
+
rebaseOnto,
|
|
65
|
+
selectionAnchorOf,
|
|
66
|
+
setAnnotations,
|
|
67
|
+
trackChanges,
|
|
68
|
+
trackingOf,
|
|
69
|
+
type MarkVisibility,
|
|
70
|
+
type TrackKeys,
|
|
71
|
+
type TrackMark,
|
|
72
|
+
} from './extension';
|
|
73
|
+
import type { BlameSpan, CommentThread, SelectionAnchor, Suggestion } from './types';
|
|
74
|
+
|
|
75
|
+
export type TrackMode = 'editing' | 'suggesting';
|
|
76
|
+
|
|
77
|
+
export interface TrackedTextEditorProps {
|
|
78
|
+
/** The stored string at `version`. */
|
|
79
|
+
value: string;
|
|
80
|
+
/** The record version `value` was read at. Suggestions are posted against
|
|
81
|
+
* it; `null` disables suggesting. */
|
|
82
|
+
version: number | null;
|
|
83
|
+
/** Accessible name of the text area ("Blog (markdown)"). */
|
|
84
|
+
label: string;
|
|
85
|
+
/** `markdown` highlights markdown syntax; `plain` is plain text. The stored
|
|
86
|
+
* string is identical either way. */
|
|
87
|
+
language?: 'markdown' | 'plain';
|
|
88
|
+
|
|
89
|
+
/** Controlled mode. */
|
|
90
|
+
mode?: TrackMode;
|
|
91
|
+
defaultMode?: TrackMode;
|
|
92
|
+
onModeChange?: (mode: TrackMode) => void;
|
|
93
|
+
/** False: this reviewer may suggest but not edit. Default true. */
|
|
94
|
+
canEdit?: boolean;
|
|
95
|
+
/** Neither edit nor suggest; marks stay navigable. */
|
|
96
|
+
readOnly?: boolean;
|
|
97
|
+
|
|
98
|
+
/** Editing mode only: the whole string after each edit. */
|
|
99
|
+
onChange?: (value: string) => void;
|
|
100
|
+
/** Suggesting mode: post the whole proposed text against `baseVersion`.
|
|
101
|
+
* Reject to keep the proposal and show it as not sent. */
|
|
102
|
+
onPropose?: (proposed: string, baseVersion: number) => Promise<unknown> | void;
|
|
103
|
+
/** Pause before a proposal is posted. Default 5000 ms (design section d). */
|
|
104
|
+
proposeDelayMs?: number;
|
|
105
|
+
|
|
106
|
+
/** Annotations, normalized (see useTrackChanges), in `value` coordinates. */
|
|
107
|
+
blame?: BlameSpan[];
|
|
108
|
+
suggestions?: Suggestion[];
|
|
109
|
+
threads?: CommentThread[];
|
|
110
|
+
/** The signed-in reviewer's `sub`. Their own open suggestions are loaded into
|
|
111
|
+
* the proposal when suggesting starts, so that the next post (which
|
|
112
|
+
* supersedes their open set) carries them forward. REQUIRED: pass `null`
|
|
113
|
+
* only when the reviewer has no sub, accepting that limitation. */
|
|
114
|
+
currentActorSub: string | null;
|
|
115
|
+
|
|
116
|
+
/** Show/hide per kind (whwxd.3's toggle). Authors are the `showAuthors`
|
|
117
|
+
* prop, because this editor owns that toggle. */
|
|
118
|
+
show?: Omit<MarkVisibility, 'blame'>;
|
|
119
|
+
showAuthors?: boolean;
|
|
120
|
+
defaultShowAuthors?: boolean;
|
|
121
|
+
onShowAuthorsChange?: (show: boolean) => void;
|
|
122
|
+
|
|
123
|
+
/** Sentences to show under the editor (from useTrackChanges `notices`). */
|
|
124
|
+
notices?: string[];
|
|
125
|
+
/** The mark at the cursor changed (keyboard, click or `focusMark`). */
|
|
126
|
+
onActiveChange?: (mark: TrackMark | null) => void;
|
|
127
|
+
/** The selection as a stored-string anchor, or null (for "comment on
|
|
128
|
+
* selection" in the rail). */
|
|
129
|
+
onSelectionChange?: (anchor: SelectionAnchor | null) => void;
|
|
130
|
+
|
|
131
|
+
keys?: TrackKeys;
|
|
132
|
+
/** Render the mode and authors toolbar. Default true. */
|
|
133
|
+
toolbar?: boolean;
|
|
134
|
+
className?: string;
|
|
135
|
+
editorClassName?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TrackedTextEditorHandle {
|
|
139
|
+
readonly view: EditorView | null;
|
|
140
|
+
focus: () => void;
|
|
141
|
+
nextChange: () => boolean;
|
|
142
|
+
prevChange: () => boolean;
|
|
143
|
+
/** Select and announce a mark by id (suggestion, thread or blame span id). */
|
|
144
|
+
focusMark: (id: string) => boolean;
|
|
145
|
+
selectionAnchor: () => SelectionAnchor | null;
|
|
146
|
+
/** Post the pending proposal now (no-op outside suggesting mode or when
|
|
147
|
+
* nothing changed since the last post). */
|
|
148
|
+
flush: () => Promise<void>;
|
|
149
|
+
proposedText: () => string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type ProposalState =
|
|
153
|
+
| { state: 'idle' }
|
|
154
|
+
| { state: 'pending' }
|
|
155
|
+
| { state: 'sending' }
|
|
156
|
+
| { state: 'sent' }
|
|
157
|
+
| { state: 'error'; message: string };
|
|
158
|
+
|
|
159
|
+
const EMITTED_MEMORY = 50;
|
|
160
|
+
|
|
161
|
+
function keyLabel(k: string) {
|
|
162
|
+
return k.replace(/-/g, '+');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const baseEditorTheme = EditorView.theme({
|
|
166
|
+
'&': { fontSize: 'inherit', backgroundColor: 'transparent' },
|
|
167
|
+
'&.cm-focused': { outline: 'none' },
|
|
168
|
+
// The host's font, not CodeMirror's monospace default: this is prose.
|
|
169
|
+
'.cm-scroller': { fontFamily: 'inherit', lineHeight: 'inherit' },
|
|
170
|
+
'.cm-content': { fontFamily: 'inherit', padding: '0.75rem', minHeight: '8rem', caretColor: 'currentColor' },
|
|
171
|
+
'.cm-line': { padding: '0' },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedTextEditorProps>(
|
|
175
|
+
function TrackedTextEditor(props, ref) {
|
|
176
|
+
const {
|
|
177
|
+
value,
|
|
178
|
+
version,
|
|
179
|
+
label,
|
|
180
|
+
language = 'markdown',
|
|
181
|
+
mode: modeProp,
|
|
182
|
+
defaultMode,
|
|
183
|
+
onModeChange,
|
|
184
|
+
canEdit = true,
|
|
185
|
+
readOnly = false,
|
|
186
|
+
onPropose,
|
|
187
|
+
blame,
|
|
188
|
+
suggestions,
|
|
189
|
+
threads,
|
|
190
|
+
currentActorSub,
|
|
191
|
+
show,
|
|
192
|
+
showAuthors: showAuthorsProp,
|
|
193
|
+
defaultShowAuthors = false,
|
|
194
|
+
onShowAuthorsChange,
|
|
195
|
+
notices,
|
|
196
|
+
keys,
|
|
197
|
+
toolbar = true,
|
|
198
|
+
className,
|
|
199
|
+
editorClassName,
|
|
200
|
+
} = props;
|
|
201
|
+
|
|
202
|
+
const propsRef = useRef(props);
|
|
203
|
+
propsRef.current = props;
|
|
204
|
+
|
|
205
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
206
|
+
const viewRef = useRef<EditorView | null>(null);
|
|
207
|
+
const compartments = useRef({
|
|
208
|
+
language: new Compartment(),
|
|
209
|
+
readOnly: new Compartment(),
|
|
210
|
+
history: new Compartment(),
|
|
211
|
+
attrs: new Compartment(),
|
|
212
|
+
}).current;
|
|
213
|
+
const emittedRef = useRef<string[]>([]);
|
|
214
|
+
const lastPostedRef = useRef<string>('');
|
|
215
|
+
const touchedRef = useRef(false);
|
|
216
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
217
|
+
const activeRef = useRef<TrackMark | null>(null);
|
|
218
|
+
|
|
219
|
+
const helpId = useId();
|
|
220
|
+
const [innerMode, setInnerMode] = useState<TrackMode>(defaultMode ?? (canEdit ? 'editing' : 'suggesting'));
|
|
221
|
+
const mode: TrackMode = !canEdit ? 'suggesting' : (modeProp ?? innerMode);
|
|
222
|
+
const [innerShowAuthors, setInnerShowAuthors] = useState(defaultShowAuthors);
|
|
223
|
+
const showAuthors = showAuthorsProp ?? innerShowAuthors;
|
|
224
|
+
const [active, setActive] = useState<TrackMark | null>(null);
|
|
225
|
+
const [unsaved, setUnsaved] = useState(false);
|
|
226
|
+
const [localCount, setLocalCount] = useState(0);
|
|
227
|
+
const [proposal, setProposal] = useState<ProposalState>({ state: 'idle' });
|
|
228
|
+
const [unplacedOwn, setUnplacedOwn] = useState(0);
|
|
229
|
+
|
|
230
|
+
const canSuggest = version != null && !!onPropose && !readOnly;
|
|
231
|
+
const editable = !readOnly && (mode === 'editing' ? canEdit : canSuggest);
|
|
232
|
+
|
|
233
|
+
/* ------------------------------ proposals ----------------------------- */
|
|
234
|
+
|
|
235
|
+
const flush = useCallback(async () => {
|
|
236
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
237
|
+
timerRef.current = null;
|
|
238
|
+
const view = viewRef.current;
|
|
239
|
+
const p = propsRef.current;
|
|
240
|
+
if (!view || !isSuggesting(view.state) || p.version == null || !p.onPropose) return;
|
|
241
|
+
const text = view.state.doc.toString();
|
|
242
|
+
if (text === lastPostedRef.current) {
|
|
243
|
+
setProposal((s) => (s.state === 'pending' ? { state: 'idle' } : s));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const previous = lastPostedRef.current;
|
|
247
|
+
lastPostedRef.current = text;
|
|
248
|
+
setProposal({ state: 'sending' });
|
|
249
|
+
try {
|
|
250
|
+
await p.onPropose(text, p.version);
|
|
251
|
+
setProposal({ state: 'sent' });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
lastPostedRef.current = previous;
|
|
254
|
+
const message = err instanceof Error && err.message ? err.message : 'the server did not say why';
|
|
255
|
+
setProposal({ state: 'error', message });
|
|
256
|
+
}
|
|
257
|
+
}, []);
|
|
258
|
+
|
|
259
|
+
const schedule = useCallback(() => {
|
|
260
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
261
|
+
const delay = propsRef.current.proposeDelayMs ?? 5000;
|
|
262
|
+
timerRef.current = setTimeout(() => void flush(), delay);
|
|
263
|
+
setProposal({ state: 'pending' });
|
|
264
|
+
}, [flush]);
|
|
265
|
+
|
|
266
|
+
/** Enter suggesting mode. What counts as "already posted" is set BEFORE
|
|
267
|
+
* the switch is dispatched, so nothing that runs during the dispatch can
|
|
268
|
+
* post the starting text as if it were a proposal. */
|
|
269
|
+
const enter = useCallback((view: EditorView) => {
|
|
270
|
+
const specs = enterSuggesting(view.state);
|
|
271
|
+
let next = view.state;
|
|
272
|
+
for (const spec of specs) next = next.update(spec).state;
|
|
273
|
+
lastPostedRef.current = next.doc.toString();
|
|
274
|
+
for (const spec of specs) view.dispatch(spec);
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
/* -------------------------------- mount ------------------------------- */
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
const host = hostRef.current;
|
|
281
|
+
if (!host) return;
|
|
282
|
+
const p = propsRef.current;
|
|
283
|
+
const listener = EditorView.updateListener.of((u) => {
|
|
284
|
+
const cur = propsRef.current;
|
|
285
|
+
const suggestingNow = isSuggesting(u.state);
|
|
286
|
+
const byUser = u.transactions.some((tr) => tr.docChanged && !tr.annotation(internalChange));
|
|
287
|
+
if (byUser) {
|
|
288
|
+
if (suggestingNow) {
|
|
289
|
+
touchedRef.current = true;
|
|
290
|
+
schedule();
|
|
291
|
+
} else {
|
|
292
|
+
const text = u.state.doc.toString();
|
|
293
|
+
const emitted = emittedRef.current;
|
|
294
|
+
emitted.push(text);
|
|
295
|
+
if (emitted.length > EMITTED_MEMORY) emitted.shift();
|
|
296
|
+
cur.onChange?.(text);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (u.docChanged || u.transactions.some((tr) => tr.effects.length)) {
|
|
300
|
+
setUnsaved(!suggestingNow && !trackingOf(u.state).changes.empty);
|
|
301
|
+
setLocalCount(suggestingNow ? marksOf(u.state).filter((m) => m.kind === 'local').length : 0);
|
|
302
|
+
setUnplacedOwn(suggestingNow ? ownSuggestions(u.state).overlapping.length : 0);
|
|
303
|
+
}
|
|
304
|
+
const nextActive = activeMarkOf(u.state);
|
|
305
|
+
const prev = activeRef.current;
|
|
306
|
+
if (nextActive?.id !== prev?.id || nextActive?.description !== prev?.description) {
|
|
307
|
+
activeRef.current = nextActive;
|
|
308
|
+
setActive(nextActive);
|
|
309
|
+
cur.onActiveChange?.(nextActive);
|
|
310
|
+
}
|
|
311
|
+
if (u.selectionSet) cur.onSelectionChange?.(selectionAnchorOf(u.state));
|
|
312
|
+
});
|
|
313
|
+
// Post on blur from the DOM event itself, NOT from `update.focusChanged`:
|
|
314
|
+
// CodeMirror reports a focus change with the NEXT update, and when the
|
|
315
|
+
// reviewer clicks "Suggesting" that next update is the mode switch, so a
|
|
316
|
+
// focusChanged check would see suggesting mode and post the untouched
|
|
317
|
+
// text (caught in the browser harness). The event fires at mousedown,
|
|
318
|
+
// while the mode is still the one the reviewer was typing in.
|
|
319
|
+
const blurPost = EditorView.domEventHandlers({
|
|
320
|
+
blur: (_e, v) => {
|
|
321
|
+
if (isSuggesting(v.state)) void flush();
|
|
322
|
+
return false;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const k = { ...DEFAULT_KEYS, ...p.keys };
|
|
327
|
+
const view = new EditorView({
|
|
328
|
+
parent: host,
|
|
329
|
+
state: EditorState.create({
|
|
330
|
+
doc: p.value,
|
|
331
|
+
extensions: [
|
|
332
|
+
trackChanges({ keys: k }),
|
|
333
|
+
compartments.history.of(history()),
|
|
334
|
+
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
335
|
+
EditorView.lineWrapping,
|
|
336
|
+
compartments.language.of([]),
|
|
337
|
+
compartments.readOnly.of(EditorState.readOnly.of(true)),
|
|
338
|
+
compartments.attrs.of([]),
|
|
339
|
+
baseEditorTheme,
|
|
340
|
+
listener,
|
|
341
|
+
blurPost,
|
|
342
|
+
],
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
viewRef.current = view;
|
|
346
|
+
return () => {
|
|
347
|
+
void flush();
|
|
348
|
+
view.destroy();
|
|
349
|
+
viewRef.current = null;
|
|
350
|
+
};
|
|
351
|
+
// Mount once; every prop below is applied by its own effect.
|
|
352
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
353
|
+
}, []);
|
|
354
|
+
|
|
355
|
+
/* ------------------------ props → editor state ------------------------ */
|
|
356
|
+
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
const view = viewRef.current;
|
|
359
|
+
if (!view) return;
|
|
360
|
+
// `markdownLanguage` (GFM) on its own, NOT `markdown()`: the latter also
|
|
361
|
+
// loads the HTML, CSS and JavaScript grammars and autocomplete to colour
|
|
362
|
+
// embedded code, which roughly doubles the editor's weight (measured
|
|
363
|
+
// ~190 KB minified) for prose that has none.
|
|
364
|
+
const ext: Extension =
|
|
365
|
+
language === 'markdown'
|
|
366
|
+
? [new LanguageSupport(markdownLanguage), syntaxHighlighting(defaultHighlightStyle, { fallback: true })]
|
|
367
|
+
: [];
|
|
368
|
+
view.dispatch({ effects: compartments.language.reconfigure(ext) });
|
|
369
|
+
}, [language, compartments]);
|
|
370
|
+
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
viewRef.current?.dispatch({
|
|
373
|
+
effects: compartments.attrs.reconfigure(
|
|
374
|
+
EditorView.contentAttributes.of({ 'aria-label': label, 'aria-describedby': helpId, 'aria-multiline': 'true' }),
|
|
375
|
+
),
|
|
376
|
+
});
|
|
377
|
+
}, [label, helpId, compartments]);
|
|
378
|
+
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
viewRef.current?.dispatch({ effects: compartments.readOnly.reconfigure(EditorState.readOnly.of(!editable)) });
|
|
381
|
+
}, [editable, compartments]);
|
|
382
|
+
|
|
383
|
+
// The stored value moved: a save echo, or an external change (accept,
|
|
384
|
+
// another reviewer, restore). Declared before the annotations effect so a
|
|
385
|
+
// render that changes both rebases first and then paints the new offsets.
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
const view = viewRef.current;
|
|
388
|
+
if (!view) return;
|
|
389
|
+
const before = view.state.doc.toString();
|
|
390
|
+
const spec = rebaseOnto(view.state, value, { ownEcho: emittedRef.current.includes(value) });
|
|
391
|
+
if (!spec) return;
|
|
392
|
+
view.dispatch(spec);
|
|
393
|
+
if (isSuggesting(view.state) && lastPostedRef.current === before) {
|
|
394
|
+
lastPostedRef.current = view.state.doc.toString();
|
|
395
|
+
}
|
|
396
|
+
}, [value]);
|
|
397
|
+
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
const view = viewRef.current;
|
|
400
|
+
if (!view) return;
|
|
401
|
+
view.dispatch({
|
|
402
|
+
effects: setAnnotations.of({
|
|
403
|
+
blame: blame ?? [],
|
|
404
|
+
suggestions: suggestions ?? [],
|
|
405
|
+
threads: threads ?? [],
|
|
406
|
+
currentActorSub,
|
|
407
|
+
show: { suggestions: show?.suggestions ?? true, comments: show?.comments ?? true, blame: showAuthors },
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
// The reviewer's own suggestions often arrive AFTER suggesting started
|
|
411
|
+
// (the reads are async). Until they type, reload the proposal from them,
|
|
412
|
+
// or the first post would supersede and drop them.
|
|
413
|
+
if (isSuggesting(view.state) && !touchedRef.current) {
|
|
414
|
+
view.dispatch(leaveSuggesting(view.state));
|
|
415
|
+
enter(view);
|
|
416
|
+
}
|
|
417
|
+
}, [blame, suggestions, threads, currentActorSub, show?.suggestions, show?.comments, showAuthors, enter]);
|
|
418
|
+
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const view = viewRef.current;
|
|
421
|
+
if (!view) return;
|
|
422
|
+
const want = mode === 'suggesting' && canSuggest;
|
|
423
|
+
const have = isSuggesting(view.state);
|
|
424
|
+
if (want === have) return;
|
|
425
|
+
if (want) {
|
|
426
|
+
if (!trackingOf(view.state).changes.empty) return; // unsaved typing; the switch is disabled
|
|
427
|
+
enter(view);
|
|
428
|
+
touchedRef.current = false;
|
|
429
|
+
setProposal({ state: 'idle' });
|
|
430
|
+
} else {
|
|
431
|
+
void flush(); // reads the proposal synchronously, before it is discarded
|
|
432
|
+
view.dispatch(leaveSuggesting(view.state));
|
|
433
|
+
}
|
|
434
|
+
// A fresh undo history per mode: undoing in suggesting mode must never
|
|
435
|
+
// reach back into edits that were already saved to the field.
|
|
436
|
+
view.dispatch({ effects: compartments.history.reconfigure([]) });
|
|
437
|
+
view.dispatch({ effects: compartments.history.reconfigure(history()) });
|
|
438
|
+
}, [mode, canSuggest, flush, enter, compartments]);
|
|
439
|
+
|
|
440
|
+
/* ------------------------------- handle ------------------------------- */
|
|
441
|
+
|
|
442
|
+
useImperativeHandle(
|
|
443
|
+
ref,
|
|
444
|
+
() => ({
|
|
445
|
+
get view() {
|
|
446
|
+
return viewRef.current;
|
|
447
|
+
},
|
|
448
|
+
focus: () => viewRef.current?.focus(),
|
|
449
|
+
nextChange: () => {
|
|
450
|
+
const v = viewRef.current;
|
|
451
|
+
const spec = v && moveToMark(v.state, 1, isChangeOrComment);
|
|
452
|
+
if (!v || !spec) return false;
|
|
453
|
+
v.dispatch(spec);
|
|
454
|
+
return true;
|
|
455
|
+
},
|
|
456
|
+
prevChange: () => {
|
|
457
|
+
const v = viewRef.current;
|
|
458
|
+
const spec = v && moveToMark(v.state, -1, isChangeOrComment);
|
|
459
|
+
if (!v || !spec) return false;
|
|
460
|
+
v.dispatch(spec);
|
|
461
|
+
return true;
|
|
462
|
+
},
|
|
463
|
+
focusMark: (id: string) => {
|
|
464
|
+
const v = viewRef.current;
|
|
465
|
+
const spec = v && focusMark(v.state, id);
|
|
466
|
+
if (!v || !spec) return false;
|
|
467
|
+
v.dispatch(spec);
|
|
468
|
+
v.focus();
|
|
469
|
+
return true;
|
|
470
|
+
},
|
|
471
|
+
selectionAnchor: () => (viewRef.current ? selectionAnchorOf(viewRef.current.state) : null),
|
|
472
|
+
flush,
|
|
473
|
+
proposedText: () => viewRef.current?.state.doc.toString() ?? '',
|
|
474
|
+
}),
|
|
475
|
+
[flush],
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
/* ------------------------------- render ------------------------------- */
|
|
479
|
+
|
|
480
|
+
const setMode = (m: TrackMode) => {
|
|
481
|
+
if (m === mode) return;
|
|
482
|
+
if (modeProp === undefined) setInnerMode(m);
|
|
483
|
+
onModeChange?.(m);
|
|
484
|
+
};
|
|
485
|
+
const toggleAuthors = () => {
|
|
486
|
+
const next = !showAuthors;
|
|
487
|
+
if (showAuthorsProp === undefined) setInnerShowAuthors(next);
|
|
488
|
+
onShowAuthorsChange?.(next);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const k = { ...DEFAULT_KEYS, ...keys };
|
|
492
|
+
const segment = (on: boolean) =>
|
|
493
|
+
[
|
|
494
|
+
'rounded px-2.5 py-1 text-sm font-medium transition-colors',
|
|
495
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
496
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
497
|
+
on ? 'bg-secondary text-secondary-foreground' : 'text-muted-foreground hover:text-foreground',
|
|
498
|
+
].join(' ');
|
|
499
|
+
|
|
500
|
+
const suggestDisabledWhy =
|
|
501
|
+
version == null
|
|
502
|
+
? 'This record has no version yet, so suggestions cannot be anchored.'
|
|
503
|
+
: !onPropose
|
|
504
|
+
? 'Suggestions are not available here.'
|
|
505
|
+
: unsaved && mode === 'editing'
|
|
506
|
+
? 'Your edits are still saving. Suggesting starts from the saved text.'
|
|
507
|
+
: undefined;
|
|
508
|
+
|
|
509
|
+
let proposalLine: string | null = null;
|
|
510
|
+
if (mode === 'suggesting' && canSuggest) {
|
|
511
|
+
const n = `${localCount} ${localCount === 1 ? 'change' : 'changes'}`;
|
|
512
|
+
proposalLine =
|
|
513
|
+
proposal.state === 'error'
|
|
514
|
+
? `Suggestion not sent: ${proposal.message}. It will be retried.`
|
|
515
|
+
: proposal.state === 'sending'
|
|
516
|
+
? `${n}, sending…`
|
|
517
|
+
: proposal.state === 'pending'
|
|
518
|
+
? `${n}, not sent yet`
|
|
519
|
+
: proposal.state === 'sent'
|
|
520
|
+
? `${n}, sent`
|
|
521
|
+
: n;
|
|
522
|
+
} else if (mode === 'suggesting' && !canSuggest && !readOnly) {
|
|
523
|
+
proposalLine = 'You can read this field but not suggest changes to it.';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<div data-testid="tracked-text-editor" className={['@container flex flex-col gap-2', className].filter(Boolean).join(' ')}>
|
|
528
|
+
{toolbar && (
|
|
529
|
+
<div className="flex flex-col gap-2 @md:flex-row @md:items-center @md:justify-between">
|
|
530
|
+
<div role="group" aria-label="Mode" className="inline-flex w-fit gap-0.5 rounded-md border border-border p-0.5">
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
className={segment(mode === 'editing')}
|
|
534
|
+
aria-pressed={mode === 'editing'}
|
|
535
|
+
disabled={!canEdit || readOnly}
|
|
536
|
+
title={!canEdit ? 'You can suggest changes to this field but not edit it.' : undefined}
|
|
537
|
+
onClick={() => setMode('editing')}
|
|
538
|
+
>
|
|
539
|
+
Editing
|
|
540
|
+
</button>
|
|
541
|
+
<button
|
|
542
|
+
type="button"
|
|
543
|
+
className={segment(mode === 'suggesting')}
|
|
544
|
+
aria-pressed={mode === 'suggesting'}
|
|
545
|
+
disabled={mode !== 'suggesting' && !!suggestDisabledWhy}
|
|
546
|
+
title={suggestDisabledWhy}
|
|
547
|
+
onClick={() => setMode('suggesting')}
|
|
548
|
+
>
|
|
549
|
+
Suggesting
|
|
550
|
+
</button>
|
|
551
|
+
</div>
|
|
552
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
553
|
+
{proposalLine && <span data-testid="proposal-state">{proposalLine}</span>}
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
className={segment(showAuthors)}
|
|
557
|
+
aria-pressed={showAuthors}
|
|
558
|
+
onClick={toggleAuthors}
|
|
559
|
+
>
|
|
560
|
+
Show authors
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
<div
|
|
566
|
+
ref={hostRef}
|
|
567
|
+
className={
|
|
568
|
+
editorClassName ??
|
|
569
|
+
'rounded-md border border-input bg-background text-sm leading-relaxed focus-within:ring-2 focus-within:ring-ring'
|
|
570
|
+
}
|
|
571
|
+
/>
|
|
572
|
+
<p role="status" aria-live="polite" className="min-h-[1rem] text-xs text-muted-foreground">
|
|
573
|
+
{active?.description ?? ''}
|
|
574
|
+
</p>
|
|
575
|
+
<p id={helpId} className="sr-only">
|
|
576
|
+
{`${keyLabel(k.nextChange)} and ${keyLabel(k.prevChange)} move to the next and previous suggestion or comment. `}
|
|
577
|
+
{`${keyLabel(k.nextAuthor)} and ${keyLabel(k.prevAuthor)} move between passages by author when authors are shown. `}
|
|
578
|
+
{'Tab leaves the editor.'}
|
|
579
|
+
</p>
|
|
580
|
+
{mode === 'suggesting' && unplacedOwn > 0 && (
|
|
581
|
+
<p className="text-xs text-foreground">
|
|
582
|
+
{unplacedOwn === 1
|
|
583
|
+
? '1 of your earlier suggestions overlaps another of yours and could not be placed; your next change will replace it.'
|
|
584
|
+
: `${unplacedOwn} of your earlier suggestions overlap another of yours and could not be placed; your next change will replace them.`}
|
|
585
|
+
</p>
|
|
586
|
+
)}
|
|
587
|
+
{notices?.map((n) => (
|
|
588
|
+
<p key={n} className="text-xs text-muted-foreground">
|
|
589
|
+
{n}
|
|
590
|
+
</p>
|
|
591
|
+
))}
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
},
|
|
595
|
+
);
|