@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.
Files changed (39) hide show
  1. package/package.json +38 -12
  2. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  3. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  4. package/src/components/activity/types.ts +10 -5
  5. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  6. package/src/components/collection/InlineReviewActions.tsx +3 -2
  7. package/src/components/collection/ReviewDrawer.tsx +35 -7
  8. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  9. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  10. package/src/components/collection/data.ts +16 -1
  11. package/src/components/collection/index.ts +2 -1
  12. package/src/components/collection/scoped-client.ts +4 -1
  13. package/src/components/collection/types.ts +19 -1
  14. package/src/components/history/FieldAttribution.tsx +308 -0
  15. package/src/components/history/RecordActivityPanel.tsx +344 -0
  16. package/src/components/history/RecordHistoryPanel.tsx +331 -15
  17. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  18. package/src/components/history/RevisionFields.tsx +69 -3
  19. package/src/components/history/StaleSaveDialog.tsx +28 -7
  20. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  21. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  22. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  23. package/src/components/history/__tests__/restore.test.ts +209 -0
  24. package/src/components/history/index.ts +72 -1
  25. package/src/components/history/restore.ts +295 -0
  26. package/src/components/history/revision-feed.ts +105 -0
  27. package/src/components/history/revision-history.ts +22 -0
  28. package/src/components/history/types.ts +153 -1
  29. package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
  30. package/src/components/track-changes/__tests__/extension.test.ts +350 -0
  31. package/src/components/track-changes/__tests__/model.test.ts +461 -0
  32. package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
  33. package/src/components/track-changes/describe.ts +76 -0
  34. package/src/components/track-changes/extension.ts +724 -0
  35. package/src/components/track-changes/index.ts +74 -0
  36. package/src/components/track-changes/normalize.ts +364 -0
  37. package/src/components/track-changes/types.ts +373 -0
  38. package/src/components/track-changes/useTrackChanges.ts +214 -0
  39. package/src/components/track-changes/word-diff.ts +97 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @startsimpli/ui/track-changes — the track-changes editor surface
3
+ * (bd startsim-sjuai; design startsim-xyn25; epic startsim-whwxd).
4
+ *
5
+ * A SEPARATE SUBPATH ON PURPOSE, and never re-exported from the root barrel:
6
+ * it imports CodeMirror, which is an OPTIONAL peer dependency. An app that
7
+ * never mounts the editor must not need CodeMirror installed, and one that
8
+ * does should load it with a dynamic import on the page that edits.
9
+ *
10
+ * Install in the consuming app:
11
+ * @codemirror/state @codemirror/view @codemirror/commands
12
+ * @codemirror/language @codemirror/lang-markdown
13
+ *
14
+ * Wiring, in the host:
15
+ *
16
+ * ```tsx
17
+ * const tc = useTrackChanges({ client, field: 'blog', value, version });
18
+ * <TrackedTextEditor
19
+ * label="Blog (markdown)" value={value} version={version}
20
+ * currentActorSub={me.sub} canEdit={canEdit}
21
+ * blame={tc.blame} suggestions={tc.suggestions} threads={tc.threads}
22
+ * notices={tc.notices} onPropose={tc.propose}
23
+ * onChange={save} // editing mode only; never called while suggesting
24
+ * />
25
+ * ```
26
+ */
27
+ export { TrackedTextEditor } from './TrackedTextEditor';
28
+ export type { TrackMode, TrackedTextEditorHandle, TrackedTextEditorProps } from './TrackedTextEditor';
29
+
30
+ export { useTrackChanges } from './useTrackChanges';
31
+ export type { SourceState, SourceStatus, TrackChanges, UseTrackChangesOptions } from './useTrackChanges';
32
+
33
+ export {
34
+ DEFAULT_KEYS,
35
+ LINE_SEPARATOR,
36
+ activeMarkOf,
37
+ computeMarks,
38
+ enterSuggesting,
39
+ focusMark,
40
+ isAuthorship,
41
+ isChangeOrComment,
42
+ isSuggesting,
43
+ leaveSuggesting,
44
+ marksOf,
45
+ moveToMark,
46
+ ownSuggestions,
47
+ proposedText,
48
+ rebaseOnto,
49
+ selectionAnchorOf,
50
+ setAnnotations,
51
+ trackChanges,
52
+ trackingOf,
53
+ } from './extension';
54
+ export type { AnnotationsInput, MarkKind, MarkVisibility, TrackKeys, TrackMark, Tracking } from './extension';
55
+
56
+ export {
57
+ ANCHOR_CONTEXT,
58
+ anchorFor,
59
+ blameNotice,
60
+ BLAME_REASON_TEXT,
61
+ codePointToUtf16,
62
+ normalizeActor,
63
+ normalizeBlame,
64
+ normalizeSuggestions,
65
+ normalizeThreads,
66
+ toServerOffsets,
67
+ utf16ToCodePoint,
68
+ } from './normalize';
69
+
70
+ export { describeBlame, describeSuggestion, describeThread } from './describe';
71
+ export { applyTextChanges, tokenize, wordChanges } from './word-diff';
72
+ export type { TextChange } from './word-diff';
73
+
74
+ export type * from './types';
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Wire → editor shapes for the track-changes surface (bd startsim-sjuai).
3
+ *
4
+ * Three jobs, each the kind of thing that fails silently if done per caller:
5
+ *
6
+ * 1. READ BOTH SPELLINGS. The shared API client camelCases every response key,
7
+ * so `actor_kind` may arrive as `actorKind` (see history/types.ts).
8
+ * 2. CONVERT OFFSETS. The server counts code points, the editor counts UTF-16
9
+ * code units (see types.ts). Converted here, once, against the text the
10
+ * offsets describe.
11
+ * 3. NEVER PAINT A GUESS. An anchor whose quote does not match the text at its
12
+ * converted offsets is reported `orphaned`, and a span outside the text is
13
+ * clipped or dropped. Painting a comment on the wrong words is worse than
14
+ * listing it as orphaned, which is what the design asks for anyway.
15
+ */
16
+ import type { ActorKind, ActorKindSource } from '../ActorIdentity';
17
+ import type {
18
+ AnchorState,
19
+ AnchorWire,
20
+ BlameReason,
21
+ BlameResponseWire,
22
+ BlameSpan,
23
+ BlameSpanWire,
24
+ BlameVia,
25
+ CommentPageWire,
26
+ CommentThread,
27
+ CommentThreadWire,
28
+ OffsetUnit,
29
+ SelectionAnchor,
30
+ Suggestion,
31
+ SuggestionPageWire,
32
+ SuggestionStatus,
33
+ SuggestionWire,
34
+ PositionWire,
35
+ TrackActor,
36
+ WireActor,
37
+ WireAuthor,
38
+ } from './types';
39
+
40
+ /* -------------------------------------------------------------------------- */
41
+ /* Offsets */
42
+ /* -------------------------------------------------------------------------- */
43
+
44
+ const ASTRAL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
45
+
46
+ /** UTF-16 offset of the `cp`-th code point of `text`. Clamped to the text. */
47
+ export function codePointToUtf16(text: string, cp: number): number {
48
+ if (!(cp > 0)) return 0;
49
+ if (!ASTRAL.test(text)) return Math.min(cp, text.length);
50
+ let units = 0;
51
+ let points = 0;
52
+ while (units < text.length && points < cp) {
53
+ const c = text.charCodeAt(units);
54
+ units += c >= 0xd800 && c <= 0xdbff && units + 1 < text.length ? 2 : 1;
55
+ points++;
56
+ }
57
+ return units;
58
+ }
59
+
60
+ /** Code-point offset of UTF-16 offset `u` in `text`. */
61
+ export function utf16ToCodePoint(text: string, u: number): number {
62
+ if (!(u > 0)) return 0;
63
+ const end = Math.min(u, text.length);
64
+ if (!ASTRAL.test(text)) return end;
65
+ return Array.from(text.slice(0, end)).length;
66
+ }
67
+
68
+ /** An editor range in the server's unit, for posting an anchor. */
69
+ export function toServerOffsets(
70
+ text: string,
71
+ range: { from: number; to: number },
72
+ unit: OffsetUnit = 'code_point',
73
+ ): { from: number; to: number } {
74
+ if (unit === 'utf16') return { from: range.from, to: range.to };
75
+ return { from: utf16ToCodePoint(text, range.from), to: utf16ToCodePoint(text, range.to) };
76
+ }
77
+
78
+ /** The design's anchor (section c): the range, its text, and 32 characters of
79
+ * context each side for the server's quote fallback. UTF-16 offsets. */
80
+ export const ANCHOR_CONTEXT = 32;
81
+
82
+ export function anchorFor(text: string, a: number, b: number): SelectionAnchor {
83
+ const from = Math.max(0, Math.min(a, b, text.length));
84
+ const to = Math.min(text.length, Math.max(a, b, 0));
85
+ return {
86
+ from,
87
+ to,
88
+ quote: text.slice(from, to),
89
+ prefix: text.slice(Math.max(0, from - ANCHOR_CONTEXT), from),
90
+ suffix: text.slice(to, to + ANCHOR_CONTEXT),
91
+ };
92
+ }
93
+
94
+ /* -------------------------------------------------------------------------- */
95
+ /* Actors */
96
+ /* -------------------------------------------------------------------------- */
97
+
98
+ const str = (v: unknown): string => (typeof v === 'string' ? v : '');
99
+
100
+ function kindOf(v: unknown): ActorKind {
101
+ return v === 'person' || v === 'machine' ? v : 'unknown';
102
+ }
103
+
104
+ function kindSourceOf(v: unknown): ActorKindSource | undefined {
105
+ return v === 'claim' || v === 'inferred' ? v : undefined;
106
+ }
107
+
108
+ function isNested(w: object): w is WireAuthor {
109
+ return 'sub' in w || 'label' in w || 'kind' in w;
110
+ }
111
+
112
+ /** Reads flat actor columns (`actor_label`), a nested author object
113
+ * (`{sub, label, kind, kind_source}`), or a bare label. */
114
+ export function normalizeActor(w: WireActor | WireAuthor | string | null | undefined): TrackActor {
115
+ if (typeof w === 'string') return { sub: '', label: w, kind: 'unknown' };
116
+ if (w && isNested(w)) {
117
+ return {
118
+ sub: str(w.sub),
119
+ label: str(w.label).trim(),
120
+ kind: kindOf(w.kind),
121
+ kindSource: kindSourceOf(w.kind_source ?? w.kindSource),
122
+ };
123
+ }
124
+ w = w as WireActor | null | undefined;
125
+ return {
126
+ sub: str(w?.actor_sub ?? w?.actorSub),
127
+ label: str(w?.actor_label ?? w?.actorLabel).trim(),
128
+ kind: kindOf(w?.actor_kind ?? w?.actorKind),
129
+ kindSource: kindSourceOf(w?.actor_kind_source ?? w?.actorKindSource),
130
+ };
131
+ }
132
+
133
+ /* -------------------------------------------------------------------------- */
134
+ /* Ranges */
135
+ /* -------------------------------------------------------------------------- */
136
+
137
+ function unitOf(w: { offset_unit?: OffsetUnit | null; offsetUnit?: OffsetUnit | null } | null | undefined): OffsetUnit {
138
+ return (w?.offset_unit ?? w?.offsetUnit) === 'utf16' ? 'utf16' : 'code_point';
139
+ }
140
+
141
+ function convert(text: string, n: number, unit: OffsetUnit): number {
142
+ if (unit === 'utf16') return Math.max(0, Math.min(Math.trunc(n), text.length));
143
+ return codePointToUtf16(text, Math.trunc(n));
144
+ }
145
+
146
+ function isNum(v: unknown): v is number {
147
+ return typeof v === 'number' && Number.isFinite(v);
148
+ }
149
+
150
+ const ANCHOR_STATES: AnchorState[] = ['mapped', 'reanchored', 'orphaned'];
151
+
152
+ function anchorStateOf(v: unknown): AnchorState {
153
+ return ANCHOR_STATES.includes(v as AnchorState) ? (v as AnchorState) : 'mapped';
154
+ }
155
+
156
+ /** Resolve an anchor to UTF-16 offsets in `text`, or report it orphaned. */
157
+ function resolveAnchor(
158
+ text: string,
159
+ anchor: AnchorWire | null | undefined,
160
+ declared: AnchorState,
161
+ unit: OffsetUnit,
162
+ ): { from: number | null; to: number | null; quote: string; state: AnchorState } {
163
+ const quote = str(anchor?.quote);
164
+ if (!anchor || declared === 'orphaned' || !isNum(anchor.from) || !isNum(anchor.to)) {
165
+ return { from: null, to: null, quote, state: anchor ? 'orphaned' : declared };
166
+ }
167
+ const from = convert(text, anchor.from, unit);
168
+ const to = convert(text, anchor.to, unit);
169
+ // An anchor is never clamped: past the end means it describes other text.
170
+ const max = unit === 'utf16' ? text.length : utf16ToCodePoint(text, text.length);
171
+ if (anchor.from < 0 || anchor.to < anchor.from || anchor.to > max) {
172
+ return { from: null, to: null, quote, state: 'orphaned' };
173
+ }
174
+ // A quote that does not match is a disagreement about the text, so the
175
+ // offsets mean nothing. `quote` absent (null) is not checked: a server that
176
+ // withheld it under the field gate still placed the range.
177
+ if (anchor.quote != null && text.slice(from, to) !== quote) {
178
+ return { from: null, to: null, quote, state: 'orphaned' };
179
+ }
180
+ // A pure insert has no quote, so the check above passes for ANY offset. Its
181
+ // neighbours are checked instead: the nearest INSERT_CONTEXT characters of
182
+ // prefix and suffix. Only when BOTH sides disagree is it orphaned, because
183
+ // an edit a few words away legitimately changes one side's wider context
184
+ // while the server still (correctly) reports the insert point as mapped.
185
+ if (from === to && !contextFits(text, from, anchor.prefix, anchor.suffix)) {
186
+ return { from: null, to: null, quote, state: 'orphaned' };
187
+ }
188
+ return { from, to, quote: anchor.quote != null ? quote : text.slice(from, to), state: declared };
189
+ }
190
+
191
+ const INSERT_CONTEXT = 8;
192
+
193
+ function contextFits(text: string, at: number, prefix?: string | null, suffix?: string | null): boolean {
194
+ const before = prefix ? prefix.slice(-INSERT_CONTEXT) : '';
195
+ const after = suffix ? suffix.slice(0, INSERT_CONTEXT) : '';
196
+ if (!before && !after) return true; // nothing to check against (start of an empty field)
197
+ const beforeOk = !!before && text.slice(Math.max(0, at - before.length), at) === before;
198
+ const afterOk = !!after && text.slice(at, at + after.length) === after;
199
+ return beforeOk || afterOk;
200
+ }
201
+
202
+ /** The anchor to place and its declared state: `position` (the contract)
203
+ * first, then the older spellings. A position's quote is the anchor's. */
204
+ function placement(
205
+ w: {
206
+ position?: PositionWire | null;
207
+ mapped_anchor?: AnchorWire | null;
208
+ mappedAnchor?: AnchorWire | null;
209
+ current?: AnchorWire | null;
210
+ anchor?: AnchorWire | null;
211
+ anchor_status?: string | null;
212
+ anchorStatus?: string | null;
213
+ },
214
+ ): { anchor: AnchorWire | null | undefined; state: AnchorState } {
215
+ const pos = w.position;
216
+ // An explicit null: the server placed nothing (a suggestion that is no
217
+ // longer open, or a record/field comment). Not placed, and not orphaned.
218
+ if (pos === null) return { anchor: null, state: 'mapped' };
219
+ if (pos) {
220
+ const state = anchorStateOf(pos.state);
221
+ if (!isNum(pos.from) || !isNum(pos.to)) {
222
+ return { anchor: { from: NaN, to: NaN, quote: w.anchor?.quote ?? null }, state: 'orphaned' };
223
+ }
224
+ return {
225
+ anchor: { from: pos.from, to: pos.to, quote: w.anchor?.quote ?? null, prefix: w.anchor?.prefix, suffix: w.anchor?.suffix },
226
+ state,
227
+ };
228
+ }
229
+ return {
230
+ anchor: w.mapped_anchor ?? w.mappedAnchor ?? w.current ?? w.anchor,
231
+ state: anchorStateOf(w.anchor_status ?? w.anchorStatus),
232
+ };
233
+ }
234
+
235
+ function results<T>(w: { results?: T[] | null } | T[] | null | undefined): T[] {
236
+ if (Array.isArray(w)) return w;
237
+ return w?.results ?? [];
238
+ }
239
+
240
+ /* -------------------------------------------------------------------------- */
241
+ /* Blame */
242
+ /* -------------------------------------------------------------------------- */
243
+
244
+ const VIAS: BlameVia[] = ['edit', 'accepted_suggestion', 'restore', 'unknown'];
245
+
246
+ /** What each reason code means, for when the server sends no `detail`. */
247
+ export const BLAME_REASON_TEXT: Record<BlameReason, string> = {
248
+ before_history: 'written before the history began',
249
+ truncated: 'a revision was too large to store in full',
250
+ values_omitted: 'a revision was too large to store its values',
251
+ chain_break: 'the history has a gap here',
252
+ unrecorded_change: 'changed without a recorded revision',
253
+ };
254
+
255
+ function reasonOf(v: unknown): BlameReason | null {
256
+ return typeof v === 'string' && v in BLAME_REASON_TEXT ? (v as BlameReason) : null;
257
+ }
258
+
259
+ export function normalizeBlame(w: BlameResponseWire | null | undefined, text: string): BlameSpan[] {
260
+ const unit = unitOf(w);
261
+ const out: BlameSpan[] = [];
262
+ for (const s of w?.spans ?? []) {
263
+ if (!isNum(s.from) || !isNum(s.to)) continue;
264
+ const from = convert(text, s.from, unit);
265
+ const to = convert(text, s.to, unit);
266
+ if (to <= from) continue;
267
+ const via = VIAS.includes(s.via as BlameVia) ? (s.via as BlameVia) : null;
268
+ const rawReason = str(s.reason).trim();
269
+ const actor = normalizeActor(s);
270
+ // Unknown when the server says so (via or a reason), or when there is no
271
+ // one to credit. Never an author invented from a partial row.
272
+ const unknown = via === 'unknown' || !!rawReason || !(actor.label || actor.sub);
273
+ const reason = reasonOf(rawReason);
274
+ const said =
275
+ str(s.detail).trim() ||
276
+ (reason ? BLAME_REASON_TEXT[reason] : rawReason) ||
277
+ 'author not recorded';
278
+ const acceptedRaw = s.accepted_by ?? s.acceptedBy;
279
+ const restored = s.restored_from ?? s.restoredFrom;
280
+ out.push({
281
+ id: `blame:${from}`,
282
+ from,
283
+ to,
284
+ author: unknown ? null : actor,
285
+ unknownReason: unknown ? said : null,
286
+ reason: unknown ? reason : null,
287
+ version: isNum(s.version) ? s.version : null,
288
+ at: s.at ?? null,
289
+ via: unknown ? 'unknown' : via,
290
+ acceptedBy: !unknown && acceptedRaw ? normalizeActor(acceptedRaw) : null,
291
+ restoredFrom: isNum(restored) ? restored : null,
292
+ });
293
+ }
294
+ return out.sort((a, b) => a.from - b.from);
295
+ }
296
+
297
+ /** A sentence when the field's authorship is not recorded at all (history
298
+ * off on the type: `recorded: false`, `spans: null`), else null. */
299
+ export function blameNotice(w: BlameResponseWire | null | undefined): string | null {
300
+ if (!w || (w.recorded !== false && w.spans !== null)) return null;
301
+ const why = str(w.detail).trim();
302
+ return `Who wrote each passage is not recorded${why ? `: ${why}` : '.'}`;
303
+ }
304
+
305
+ /* -------------------------------------------------------------------------- */
306
+ /* Suggestions */
307
+ /* -------------------------------------------------------------------------- */
308
+
309
+ const STATUSES: SuggestionStatus[] = ['open', 'accepted', 'rejected', 'withdrawn', 'superseded'];
310
+
311
+ export function normalizeSuggestions(
312
+ w: SuggestionPageWire | SuggestionWire[] | null | undefined,
313
+ text: string,
314
+ ): Suggestion[] {
315
+ const unit = Array.isArray(w) ? 'code_point' : unitOf(w);
316
+ return results(w).map((s) => {
317
+ const { anchor, state } = placement(s);
318
+ const r = resolveAnchor(text, anchor, state, unit);
319
+ const status = STATUSES.includes(s.status as SuggestionStatus) ? (s.status as SuggestionStatus) : 'open';
320
+ return {
321
+ id: String(s.id),
322
+ setId: (s.set_id ?? s.setId ?? null) as string | null,
323
+ from: r.from ?? 0,
324
+ to: r.to ?? 0,
325
+ quote: r.quote,
326
+ replacement: str(s.replacement),
327
+ status,
328
+ anchorState: r.state,
329
+ author: normalizeActor(s.author ?? s),
330
+ createdAt: s.created_at ?? s.createdAt ?? null,
331
+ };
332
+ });
333
+ }
334
+
335
+ /* -------------------------------------------------------------------------- */
336
+ /* Comments */
337
+ /* -------------------------------------------------------------------------- */
338
+
339
+ export function normalizeThreads(
340
+ w: CommentPageWire | CommentThreadWire[] | null | undefined,
341
+ text: string,
342
+ ): CommentThread[] {
343
+ const unit = Array.isArray(w) ? 'code_point' : unitOf(w);
344
+ return results(w).map((t) => {
345
+ const { anchor, state } = placement(t);
346
+ const r = resolveAnchor(text, anchor, state, unit);
347
+ return {
348
+ id: String(t.thread_id ?? t.threadId ?? t.id ?? ''),
349
+ from: r.from,
350
+ to: r.to,
351
+ quote: r.quote,
352
+ anchorState: r.state,
353
+ resolved: t.status === 'resolved' || Boolean(t.resolved),
354
+ redacted: Boolean(t.redacted),
355
+ comments: (t.comments ?? []).map((c) => ({
356
+ id: String(c.id),
357
+ body: c.deleted ? null : (c.body ?? null),
358
+ deleted: Boolean(c.deleted),
359
+ author: normalizeActor(c.author ?? c),
360
+ createdAt: c.created_at ?? c.createdAt ?? null,
361
+ })),
362
+ };
363
+ });
364
+ }