@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.142",
3
+ "version": "0.4.143",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -33,7 +33,8 @@
33
33
  "./terminal/styles": "./src/components/terminal/xterm.css",
34
34
  "./session": "./src/components/session/index.ts",
35
35
  "./session-console": "./src/components/session/AgentSessionConsole.tsx",
36
- "./tailwind": "./tailwind.preset.js"
36
+ "./tailwind": "./tailwind.preset.js",
37
+ "./track-changes": "./src/components/track-changes/index.ts"
37
38
  },
38
39
  "files": [
39
40
  "src",
@@ -49,6 +50,11 @@
49
50
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
50
51
  "react": "^18.0.0 || ^19.0.0",
51
52
  "react-dom": "^18.0.0 || ^19.0.0",
53
+ "@codemirror/commands": "^6.0.0",
54
+ "@codemirror/lang-markdown": "^6.0.0",
55
+ "@codemirror/language": "^6.0.0",
56
+ "@codemirror/state": "^6.0.0",
57
+ "@codemirror/view": "^6.0.0",
52
58
  "@startsimpli/api": "^0.5.66",
53
59
  "@startsimpli/auth": "^0.4.38",
54
60
  "@startsimpli/i18n": "^0.1.3"
@@ -68,17 +74,25 @@
68
74
  },
69
75
  "@tanstack/react-query": {
70
76
  "optional": true
77
+ },
78
+ "@codemirror/commands": {
79
+ "optional": true
80
+ },
81
+ "@codemirror/lang-markdown": {
82
+ "optional": true
83
+ },
84
+ "@codemirror/language": {
85
+ "optional": true
86
+ },
87
+ "@codemirror/state": {
88
+ "optional": true
89
+ },
90
+ "@codemirror/view": {
91
+ "optional": true
71
92
  }
72
93
  },
73
94
  "dependencies": {
74
95
  "@hello-pangea/dnd": "^18.0.1",
75
- "@types/dagre": "^0.7.52",
76
- "@types/react-big-calendar": "^1.16.3",
77
- "@types/react-syntax-highlighter": "^15.5.13",
78
- "@xterm/addon-fit": "^0.10.0",
79
- "@xyflow/react": "^12.10.2",
80
- "dagre": "^0.8.5",
81
- "xterm": "^5.3.0",
82
96
  "@radix-ui/react-accordion": "^1.2.12",
83
97
  "@radix-ui/react-checkbox": "^1.3.3",
84
98
  "@radix-ui/react-collapsible": "^1.1.12",
@@ -93,8 +107,14 @@
93
107
  "@radix-ui/react-slot": "^1.2.4",
94
108
  "@radix-ui/react-tabs": "^1.1.13",
95
109
  "@radix-ui/react-tooltip": "^1.2.8",
110
+ "@types/dagre": "^0.7.52",
111
+ "@types/react-big-calendar": "^1.16.3",
112
+ "@types/react-syntax-highlighter": "^15.5.13",
113
+ "@xterm/addon-fit": "^0.10.0",
114
+ "@xyflow/react": "^12.10.2",
96
115
  "class-variance-authority": "^0.7.1",
97
116
  "clsx": "^2.1.1",
117
+ "dagre": "^0.8.5",
98
118
  "date-fns": "^3.6.0",
99
119
  "dompurify": "^3.4.1",
100
120
  "lucide-react": "^0.408.0",
@@ -106,9 +126,15 @@
106
126
  "remark-gfm": "^4.0.1",
107
127
  "tailwind-merge": "^2.6.1",
108
128
  "tailwindcss-animate": "^1.0.7",
109
- "xlsx": "^0.18.5"
129
+ "xlsx": "^0.18.5",
130
+ "xterm": "^5.3.0"
110
131
  },
111
132
  "devDependencies": {
133
+ "@codemirror/commands": "^6.11.1",
134
+ "@codemirror/lang-markdown": "^6.5.2",
135
+ "@codemirror/language": "^6.13.1",
136
+ "@codemirror/state": "^6.7.6",
137
+ "@codemirror/view": "^6.43.14",
112
138
  "@swc/core": "^1.15.30",
113
139
  "@swc/jest": "^0.2.39",
114
140
  "@tanstack/react-query": "^5.99.2",
@@ -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
+ );