@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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * @startsimpli/ui/track-changes — the shapes the track-changes editor surface
3
+ * reads and the one call it writes (bd startsim-sjuai; design startsim-xyn25).
4
+ *
5
+ * THE FIELD STAYS A PLAIN STRING. Suggestions and comments are separate server
6
+ * rows anchored to character offsets of that string at a known version, and
7
+ * blame is derived server-side from the revision chain. The editor renders all
8
+ * three as DECORATIONS over the stored string and never writes a mark into it.
9
+ *
10
+ * STRUCTURAL ON PURPOSE, like `history/types.ts`: packages/ui imports no API
11
+ * client. The consuming app injects its own authed client
12
+ * ({@link TrackChangesClient}). Both spellings of every key are declared
13
+ * because the shared API client camelCases responses recursively; the
14
+ * normalizers in `normalize.ts` read either.
15
+ *
16
+ * ── OFFSET UNITS ────────────────────────────────────────────────────────────
17
+ * The server is Python, so its string offsets count CODE POINTS. The editor is
18
+ * JavaScript, so its offsets count UTF-16 CODE UNITS. They differ by one for
19
+ * every character outside the Basic Multilingual Plane before the offset, and
20
+ * LinkedIn posts are full of emoji. `offset_unit` on a response says which unit
21
+ * it uses; absent means `code_point`. The normalizers convert at the boundary,
22
+ * so everything past them is UTF-16.
23
+ *
24
+ * ── STATUS OF THESE SHAPES ──────────────────────────────────────────────────
25
+ * All three follow the FINAL contracts: blame start-simpli-api #507
26
+ * (startsim-gulrr), suggestions and comments #508 (startsim-txfrl,
27
+ * startsim-a76zp). Every response declares `offset_unit: "utf16"` and the
28
+ * `version` its positions were mapped to; suggestions and comments carry a
29
+ * nested `author` and a `position {state, from, to, version}` (null when not
30
+ * placed); blame spans carry flat actor columns, and an unknown span carries
31
+ * `via: "unknown"`, a `reason` code and a `detail` sentence. Older spellings
32
+ * the normalizer still reads are kept only so a camelising client works.
33
+ */
34
+ import type { ActorKind, ActorKindSource } from '../ActorIdentity';
35
+
36
+ export type { ActorKind, ActorKindSource };
37
+
38
+ /** Which unit a response's offsets count. */
39
+ export type OffsetUnit = 'code_point' | 'utf16';
40
+
41
+ /** The actor columns every annotation and blame span carries, both spellings. */
42
+ export interface WireActor {
43
+ actor_sub?: string | null;
44
+ actorSub?: string | null;
45
+ actor_label?: string | null;
46
+ actorLabel?: string | null;
47
+ actor_kind?: ActorKind | string | null;
48
+ actorKind?: ActorKind | string | null;
49
+ actor_kind_source?: ActorKindSource | string | null;
50
+ actorKindSource?: ActorKindSource | string | null;
51
+ }
52
+
53
+ /** The nested actor object the suggestion and comment routes answer with
54
+ * (start-simpli-api RED branch feat/tc-suggestions-comments): `author`,
55
+ * `resolution.by`. */
56
+ export interface WireAuthor {
57
+ sub?: string | null;
58
+ label?: string | null;
59
+ kind?: ActorKind | string | null;
60
+ kind_source?: ActorKindSource | string | null;
61
+ kindSource?: ActorKindSource | string | null;
62
+ }
63
+
64
+ /** Where the server mapped an anchor to on read: current-version offsets and
65
+ * the mapping's outcome. `from`/`to` are `null` when orphaned. */
66
+ export interface PositionWire {
67
+ from?: number | null;
68
+ to?: number | null;
69
+ state?: AnchorState | string | null;
70
+ /** The record version the position was mapped to. */
71
+ version?: number | null;
72
+ }
73
+
74
+ /** An actor after normalization. `label` may be `''` (a service token can carry
75
+ * no email); `kind` is three-state and `unknown` is calm, not a defect. */
76
+ export interface TrackActor {
77
+ sub: string;
78
+ label: string;
79
+ kind: ActorKind;
80
+ kindSource?: ActorKindSource;
81
+ }
82
+
83
+ /* -------------------------------------------------------------------------- */
84
+ /* BLAME GET /api/v1/entities/<id>/blame/?field=<f> (startsim-gulrr) */
85
+ /* -------------------------------------------------------------------------- */
86
+
87
+ /** How a span's text got there. `restore` credits the restorer; an accepted
88
+ * suggestion credits its AUTHOR, with `accepted_by` the acceptor; `unknown`
89
+ * spans carry a `reason` and no actor at all. */
90
+ export type BlameVia = 'edit' | 'accepted_suggestion' | 'restore' | 'unknown';
91
+
92
+ /** Why nobody can be credited with a span. */
93
+ export type BlameReason = 'before_history' | 'truncated' | 'values_omitted' | 'chain_break' | 'unrecorded_change';
94
+
95
+ /**
96
+ * One span of GET /api/v1/entities/<id>/blame/?field=<f> (start-simpli-api
97
+ * #507, final contract in startsim-gulrr). Spans are contiguous, non-empty,
98
+ * in order and cover the whole text. Author columns are FLAT on a known span;
99
+ * an unknown span has none of them.
100
+ */
101
+ export interface BlameSpanWire extends WireActor {
102
+ from: number;
103
+ to: number;
104
+ via?: BlameVia | string | null;
105
+ /** `null` on an unknown span that no revision touched. */
106
+ version?: number | null;
107
+ at?: string | null;
108
+ /** accepted_suggestion only: the ACCEPTOR, with actor_* keys. */
109
+ accepted_by?: WireActor | null;
110
+ acceptedBy?: WireActor | null;
111
+ suggestion_id?: string | null;
112
+ set_id?: string | null;
113
+ /** restore only. */
114
+ restored_from?: number | null;
115
+ restoredFrom?: number | null;
116
+ /** unknown only. */
117
+ reason?: BlameReason | string | null;
118
+ /** unknown only: a readable sentence. */
119
+ detail?: string | null;
120
+ }
121
+
122
+ export interface BlameResponseWire {
123
+ entity_id?: string;
124
+ field?: string;
125
+ /** The record's current version; the spans describe its text. */
126
+ version?: number | null;
127
+ /** Always "utf16" on this route. */
128
+ offset_unit?: OffsetUnit | null;
129
+ offsetUnit?: OffsetUnit | null;
130
+ /** UTF-16 length of the current text. */
131
+ length?: number | null;
132
+ /** False when the type's history is off: then `spans` is null and
133
+ * `detail` says why. */
134
+ recorded?: boolean | null;
135
+ history?: { enabled?: boolean; declared_by?: string } | null;
136
+ trail_starts?: { version?: number; at?: string; from_creation?: boolean } | null;
137
+ spans?: BlameSpanWire[] | null;
138
+ detail?: string | null;
139
+ }
140
+
141
+ /* -------------------------------------------------------------------------- */
142
+ /* SUGGESTIONS GET/POST /api/v1/entities/<id>/suggestions/ (startsim-txfrl) */
143
+ /* -------------------------------------------------------------------------- */
144
+
145
+ export type SuggestionStatus = 'open' | 'accepted' | 'rejected' | 'withdrawn' | 'superseded';
146
+
147
+ /** Where an anchor landed after the server mapped it to the current version
148
+ * (design section c). An orphaned anchor is NEVER painted at a guessed place. */
149
+ export type AnchorState = 'mapped' | 'reanchored' | 'orphaned';
150
+
151
+ export interface AnchorWire {
152
+ from: number;
153
+ to: number;
154
+ quote?: string | null;
155
+ prefix?: string | null;
156
+ suffix?: string | null;
157
+ }
158
+
159
+ export interface SuggestionWire extends WireActor {
160
+ id: string;
161
+ /** The author, as the RED contract nests it. Flat actor columns are read
162
+ * as a fallback. */
163
+ author?: WireAuthor | null;
164
+ /** The anchor mapped to the CURRENT version, with its state. Preferred
165
+ * over every other spelling below. */
166
+ position?: PositionWire | null;
167
+ set_id?: string | null;
168
+ setId?: string | null;
169
+ field?: string | null;
170
+ base_version?: number | null;
171
+ baseVersion?: number | null;
172
+ /** The anchor as PROPOSED, against `base_version`. */
173
+ anchor?: AnchorWire | null;
174
+ /** ASSUMED: the anchor mapped to the CURRENT version on read. Also read:
175
+ * `current`, `mappedAnchor`. When absent, `anchor` is taken to already be
176
+ * in current-version offsets. */
177
+ mapped_anchor?: AnchorWire | null;
178
+ mappedAnchor?: AnchorWire | null;
179
+ current?: AnchorWire | null;
180
+ replacement?: string | null;
181
+ /** The fold of the suggestion's events. Absent means `open`. */
182
+ status?: SuggestionStatus | string | null;
183
+ /** ASSUMED name. Also read: `anchorStatus`, `anchor_state`, `mapping`. */
184
+ anchor_status?: AnchorState | string | null;
185
+ anchorStatus?: AnchorState | string | null;
186
+ created_at?: string | null;
187
+ createdAt?: string | null;
188
+ /** How a closed suggestion was closed: who, when, and (accepted) the
189
+ * revision it produced. Null while open. */
190
+ resolution?: ResolutionWire | null;
191
+ }
192
+
193
+ export interface ResolutionWire {
194
+ action?: string | null;
195
+ by?: WireAuthor | null;
196
+ at?: string | null;
197
+ reason?: string | null;
198
+ revision?: number | null;
199
+ }
200
+
201
+ export interface SuggestionPageWire {
202
+ version?: number | null;
203
+ next?: string | null;
204
+ next_cursor?: string | null;
205
+ nextCursor?: string | null;
206
+ results?: SuggestionWire[] | null;
207
+ offset_unit?: OffsetUnit | null;
208
+ offsetUnit?: OffsetUnit | null;
209
+ }
210
+
211
+ /** The suggesting-mode post (design section d): the WHOLE proposed text; the
212
+ * server hunks it with the shared word diff and supersedes this author's own
213
+ * still-open set. */
214
+ export interface ProposeRequest {
215
+ field: string;
216
+ base_version: number;
217
+ proposed: string;
218
+ }
219
+
220
+ /* -------------------------------------------------------------------------- */
221
+ /* COMMENTS GET /api/v1/entities/<id>/comments/?field=<f> (startsim-a76zp) */
222
+ /* -------------------------------------------------------------------------- */
223
+
224
+ export interface CommentWire extends WireActor {
225
+ id: string;
226
+ thread_id?: string | null;
227
+ author?: WireAuthor | null;
228
+ edited_at?: string | null;
229
+ editedAt?: string | null;
230
+ /** Mentioned subs. Shown in-app only; nothing is emailed (Quinn). */
231
+ mentions?: string[] | null;
232
+ body?: string | null;
233
+ created_at?: string | null;
234
+ createdAt?: string | null;
235
+ /** Tombstoned: the fact of the comment stays, the body is gone. */
236
+ deleted?: boolean | null;
237
+ }
238
+
239
+ export interface CommentThreadWire extends WireActor {
240
+ /** Also read: `id`. */
241
+ thread_id?: string | null;
242
+ threadId?: string | null;
243
+ id?: string | null;
244
+ field?: string | null;
245
+ anchor?: AnchorWire | null;
246
+ mapped_anchor?: AnchorWire | null;
247
+ mappedAnchor?: AnchorWire | null;
248
+ current?: AnchorWire | null;
249
+ anchor_status?: AnchorState | string | null;
250
+ anchorStatus?: AnchorState | string | null;
251
+ position?: PositionWire | null;
252
+ author?: WireAuthor | null;
253
+ /** `open` | `resolved`. `resolved: true` also read. */
254
+ status?: string | null;
255
+ resolution?: ResolutionWire | null;
256
+ base_version?: number | null;
257
+ created_at?: string | null;
258
+ resolved?: boolean | null;
259
+ comments?: CommentWire[] | null;
260
+ /** The body withheld by the per-field read gate. */
261
+ redacted?: boolean | null;
262
+ }
263
+
264
+ export interface CommentPageWire {
265
+ version?: number | null;
266
+ next?: string | null;
267
+ next_cursor?: string | null;
268
+ nextCursor?: string | null;
269
+ results?: CommentThreadWire[] | null;
270
+ offset_unit?: OffsetUnit | null;
271
+ offsetUnit?: OffsetUnit | null;
272
+ }
273
+
274
+ /* -------------------------------------------------------------------------- */
275
+ /* THE INJECTED CLIENT */
276
+ /* -------------------------------------------------------------------------- */
277
+
278
+ /**
279
+ * What the editor surface needs from the server, and nothing it does not.
280
+ * The consuming app binds the record id:
281
+ *
282
+ * ```ts
283
+ * const client: TrackChangesClient = {
284
+ * blame: (q) => api.entities.blame(id, q),
285
+ * listSuggestions: (q) => api.entities.suggestions(id, q),
286
+ * listComments: (q) => api.entities.comments(id, q),
287
+ * propose: (body) => api.entities.propose(id, body),
288
+ * };
289
+ * ```
290
+ *
291
+ * Every read is optional so a host can mount the editor before a route ships.
292
+ * Accept, reject, reply and resolve are the rail's (startsim-whwxd.3), which
293
+ * extends this interface rather than declaring a second one.
294
+ *
295
+ * A 404 from any read means the caller may not read the record or the field.
296
+ * The editor says so; it never treats it as "no annotations".
297
+ */
298
+ export interface TrackChangesClient {
299
+ blame?: (query: { field: string }) => Promise<BlameResponseWire>;
300
+ /** Keyset-paginated: the hook passes `cursor` (the previous page's
301
+ * `next_cursor`) until there is none, because a proposal supersedes ALL of
302
+ * the author's open suggestions and the editor must know every one. */
303
+ listSuggestions?: (query: { field: string; cursor?: string }) => Promise<SuggestionPageWire | SuggestionWire[]>;
304
+ listComments?: (query: { field: string; cursor?: string }) => Promise<CommentPageWire | CommentThreadWire[]>;
305
+ propose?: (body: ProposeRequest) => Promise<unknown>;
306
+ }
307
+
308
+ /* -------------------------------------------------------------------------- */
309
+ /* NORMALIZED SHAPES (UTF-16 offsets into the stored string) */
310
+ /* -------------------------------------------------------------------------- */
311
+
312
+ export interface BlameSpan {
313
+ /** Stable within one response: `blame:<from>`. */
314
+ id: string;
315
+ from: number;
316
+ to: number;
317
+ /** `null` for an unknown span. */
318
+ author: TrackActor | null;
319
+ /** Set when nobody can be credited: the sentence a reader is shown. */
320
+ unknownReason: string | null;
321
+ /** The machine-readable reason code of an unknown span. */
322
+ reason: BlameReason | null;
323
+ version: number | null;
324
+ at: string | null;
325
+ via: BlameVia | null;
326
+ acceptedBy: TrackActor | null;
327
+ restoredFrom: number | null;
328
+ }
329
+
330
+ export interface Suggestion {
331
+ id: string;
332
+ setId: string | null;
333
+ from: number;
334
+ to: number;
335
+ /** The text the suggestion replaces (empty for a pure insert). */
336
+ quote: string;
337
+ /** The text it proposes (empty for a pure delete). */
338
+ replacement: string;
339
+ status: SuggestionStatus;
340
+ anchorState: AnchorState;
341
+ author: TrackActor;
342
+ createdAt: string | null;
343
+ }
344
+
345
+ export interface TrackComment {
346
+ id: string;
347
+ body: string | null;
348
+ deleted: boolean;
349
+ author: TrackActor;
350
+ createdAt: string | null;
351
+ }
352
+
353
+ export interface CommentThread {
354
+ id: string;
355
+ /** `null` for a whole-record or whole-field comment. */
356
+ from: number | null;
357
+ to: number | null;
358
+ quote: string;
359
+ anchorState: AnchorState;
360
+ resolved: boolean;
361
+ redacted: boolean;
362
+ comments: TrackComment[];
363
+ }
364
+
365
+ /** What a selection becomes when a host anchors a comment to it. UTF-16
366
+ * offsets; convert with `toServerOffsets` before posting. */
367
+ export interface SelectionAnchor {
368
+ from: number;
369
+ to: number;
370
+ quote: string;
371
+ prefix: string;
372
+ suffix: string;
373
+ }
@@ -0,0 +1,214 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useTrackChanges — the ONE owner of the injected client for a tracked field
5
+ * (bd startsim-sjuai).
6
+ *
7
+ * It reads blame, suggestions and comments for one field at one version,
8
+ * normalizes them against the stored text (UTF-16 offsets, both spellings),
9
+ * and posts suggesting-mode proposals. The editor is presentational over its
10
+ * result, and the changes rail (startsim-whwxd.3) reads the SAME result, so
11
+ * the rail and the editor can never show two different sets of suggestions or
12
+ * fetch them twice.
13
+ *
14
+ * HONEST STATES. A read that fails is not an empty list: it lands in `notices`
15
+ * as a sentence the host shows. A 404 means the reader may not see this
16
+ * record or field (the routes are nested under the record's gate). A response
17
+ * for a version other than the one loaded is held back rather than painted,
18
+ * because its offsets describe different text.
19
+ */
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
21
+
22
+ import { blameNotice, normalizeBlame, normalizeSuggestions, normalizeThreads } from './normalize';
23
+ import type {
24
+ BlameResponseWire,
25
+ BlameSpan,
26
+ CommentPageWire,
27
+ CommentThread,
28
+ CommentThreadWire,
29
+ Suggestion,
30
+ SuggestionPageWire,
31
+ SuggestionWire,
32
+ TrackChangesClient,
33
+ } from './types';
34
+
35
+ export type SourceState = 'off' | 'loading' | 'ready' | 'error';
36
+
37
+ export interface SourceStatus {
38
+ state: SourceState;
39
+ /** A sentence for the reader when `state` is `error`. */
40
+ message?: string;
41
+ }
42
+
43
+ export interface UseTrackChangesOptions {
44
+ client: TrackChangesClient;
45
+ /** The record's field, as declared (`blog`, `linkedin`). */
46
+ field: string;
47
+ /** The stored value at `version`: the text every offset refers to. */
48
+ value: string;
49
+ /** The record version `value` was read at; `null` before the record has
50
+ * one (then nothing is read and nothing can be proposed). */
51
+ version: number | null;
52
+ /** Off: read nothing (e.g. while the record is loading). Default on. */
53
+ enabled?: boolean;
54
+ }
55
+
56
+ export interface TrackChanges {
57
+ blame: BlameSpan[];
58
+ suggestions: Suggestion[];
59
+ threads: CommentThread[];
60
+ status: { blame: SourceStatus; suggestions: SourceStatus; comments: SourceStatus };
61
+ /** One sentence per source that could not be read. Never empty-by-failure. */
62
+ notices: string[];
63
+ /** Re-read all three (after an accept, a reply, a resolve). */
64
+ reload: () => void;
65
+ /** Post the whole proposed text against `version`. Rejects on failure, so
66
+ * the editor keeps the proposal and says it was not sent. */
67
+ propose: (proposed: string, baseVersion: number) => Promise<void>;
68
+ }
69
+
70
+ interface Loaded<T> {
71
+ status: SourceStatus;
72
+ data: T | null;
73
+ }
74
+
75
+ const OFF: Loaded<never> = { status: { state: 'off' }, data: null };
76
+
77
+ function statusCode(err: unknown): number | undefined {
78
+ const e = err as { status?: unknown; response?: { status?: unknown } } | null;
79
+ const s = e?.status ?? e?.response?.status;
80
+ return typeof s === 'number' ? s : undefined;
81
+ }
82
+
83
+ function detail(err: unknown): string {
84
+ const e = err as { body?: { detail?: unknown }; data?: { detail?: unknown }; message?: unknown } | null;
85
+ const d = e?.body?.detail ?? e?.data?.detail ?? e?.message;
86
+ return typeof d === 'string' && d.trim() ? d.trim() : 'the server did not say why';
87
+ }
88
+
89
+ function failure(what: string, err: unknown): SourceStatus {
90
+ // 404: the record is unreadable. 403: the field is withheld from this
91
+ // reader (the contract refuses narrowing to a withheld field).
92
+ const code = statusCode(err);
93
+ if (code === 404 || code === 403) {
94
+ return { state: 'error', message: `You cannot see ${what} for this field.` };
95
+ }
96
+ return { state: 'error', message: `Could not load ${what}: ${detail(err)}.` };
97
+ }
98
+
99
+ function versionOf(w: unknown): number | null {
100
+ if (!w || Array.isArray(w) || typeof w !== 'object') return null;
101
+ const v = (w as { version?: unknown }).version;
102
+ return typeof v === 'number' ? v : null;
103
+ }
104
+
105
+ /** A response is used only if it describes the loaded version (or says
106
+ * nothing about versions at all). */
107
+ function matches(w: unknown, version: number | null): boolean {
108
+ const v = versionOf(w);
109
+ return v == null || v === version;
110
+ }
111
+
112
+ type Paged<T> = { results?: T[] | null; next_cursor?: string | null; nextCursor?: string | null } | T[];
113
+
114
+ /** Read every page, following `next_cursor`, into one page. Stops if the
115
+ * server hands back a cursor it already gave (a loop, not more data). */
116
+ function allPages<T, P extends Paged<T>>(
117
+ fn: (q: { field: string; cursor?: string }) => Promise<P>,
118
+ ): (q: { field: string }) => Promise<P> {
119
+ return async ({ field }) => {
120
+ const first = await fn({ field });
121
+ if (Array.isArray(first)) return first;
122
+ const all: T[] = [...(first.results ?? [])];
123
+ const seen = new Set<string>();
124
+ let cursor = first.next_cursor ?? first.nextCursor ?? null;
125
+ while (cursor && !seen.has(cursor)) {
126
+ seen.add(cursor);
127
+ const page = await fn({ field, cursor });
128
+ if (Array.isArray(page)) {
129
+ all.push(...(page as T[]));
130
+ break;
131
+ }
132
+ all.push(...(page.results ?? []));
133
+ cursor = page.next_cursor ?? page.nextCursor ?? null;
134
+ }
135
+ return { ...first, results: all, next_cursor: null, nextCursor: null };
136
+ };
137
+ }
138
+
139
+ export function useTrackChanges({
140
+ client,
141
+ field,
142
+ value,
143
+ version,
144
+ enabled = true,
145
+ }: UseTrackChangesOptions): TrackChanges {
146
+ const [blame, setBlame] = useState<Loaded<BlameResponseWire>>(OFF);
147
+ const [sugg, setSugg] = useState<Loaded<SuggestionPageWire | SuggestionWire[]>>(OFF);
148
+ const [comm, setComm] = useState<Loaded<CommentPageWire | CommentThreadWire[]>>(OFF);
149
+ const [tick, setTick] = useState(0);
150
+ const clientRef = useRef(client);
151
+ clientRef.current = client;
152
+
153
+ useEffect(() => {
154
+ if (!enabled || version == null) {
155
+ setBlame(OFF);
156
+ setSugg(OFF);
157
+ setComm(OFF);
158
+ return;
159
+ }
160
+ let live = true;
161
+ const c = clientRef.current;
162
+ function load<T>(
163
+ fn: ((q: { field: string }) => Promise<T>) | undefined,
164
+ set: (l: Loaded<T>) => void,
165
+ what: string,
166
+ ) {
167
+ if (!fn) {
168
+ set(OFF);
169
+ return;
170
+ }
171
+ set({ status: { state: 'loading' }, data: null });
172
+ fn({ field }).then(
173
+ (data) => live && set({ status: { state: 'ready' }, data }),
174
+ (err) => live && set({ status: failure(what, err), data: null }),
175
+ );
176
+ }
177
+ load(c.blame, setBlame, 'who wrote each passage');
178
+ load(c.listSuggestions && allPages(c.listSuggestions), setSugg, 'suggestions');
179
+ load(c.listComments && allPages(c.listComments), setComm, 'comments');
180
+ return () => {
181
+ live = false;
182
+ };
183
+ }, [enabled, field, version, tick]);
184
+
185
+ const reload = useCallback(() => setTick((t) => t + 1), []);
186
+
187
+ const propose = useCallback(
188
+ async (proposed: string, baseVersion: number) => {
189
+ const fn = clientRef.current.propose;
190
+ if (!fn) throw new Error('This host cannot post suggestions.');
191
+ await fn({ field, base_version: baseVersion, proposed });
192
+ setTick((t) => t + 1);
193
+ },
194
+ [field],
195
+ );
196
+
197
+ const normalized = useMemo(
198
+ () => ({
199
+ blame: blame.data && matches(blame.data, version) ? normalizeBlame(blame.data, value) : [],
200
+ suggestions: sugg.data && matches(sugg.data, version) ? normalizeSuggestions(sugg.data, value) : [],
201
+ threads: comm.data && matches(comm.data, version) ? normalizeThreads(comm.data, value) : [],
202
+ }),
203
+ [blame.data, sugg.data, comm.data, value, version],
204
+ );
205
+
206
+ const status = { blame: blame.status, suggestions: sugg.status, comments: comm.status };
207
+ const notices = [status.blame, status.suggestions, status.comments].flatMap((s) =>
208
+ s.state === 'error' && s.message ? [s.message] : [],
209
+ );
210
+ const unrecorded = blame.data ? blameNotice(blame.data) : null;
211
+ if (unrecorded) notices.unshift(unrecorded);
212
+
213
+ return { ...normalized, status, notices, reload, propose };
214
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The smallest word-sized change from one value of a field to another, as
3
+ * editor change specs (bd startsim-sjuai).
4
+ *
5
+ * WHAT THIS IS FOR, AND WHAT IT IS NOT. The editor uses it in exactly one
6
+ * place: when the stored value changes underneath it (an accept, another
7
+ * reviewer's save), it applies the difference as a CHANGE rather than
8
+ * replacing the document, so every decoration and the cursor map through it
9
+ * instead of collapsing to the start. It is NOT the diff that decides span
10
+ * boundaries for blame, anchors or suggestion hunks: that is the shared
11
+ * server module (startsim-whwxd.1), and the editor always paints the server's
12
+ * offsets. A disagreement here can only make a mapped mark land a few
13
+ * characters differently until the next read, never paint wrong authorship.
14
+ *
15
+ * REUSES `diffLines` from history/text-diff (rule #1): that LCS takes any
16
+ * `string[]`, so handing it word tokens instead of lines makes it word-level.
17
+ * The text the live drafts carry (design startsim-xyn25 section 0) is one
18
+ * ~500-character line per paragraph, which is why a line diff is not enough.
19
+ */
20
+ import { diffLines } from '../history/text-diff';
21
+
22
+ export interface TextChange {
23
+ from: number;
24
+ to: number;
25
+ insert: string;
26
+ }
27
+
28
+ /** Han, Hiragana and Katakana have no spaces between words, so each character
29
+ * is its own token; otherwise a whole CJK sentence would be one "word". */
30
+ const TOKEN =
31
+ /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]|[\p{L}\p{N}\p{M}_]+|\s+|[^\s\p{L}\p{N}\p{M}_]/gu;
32
+
33
+ export function tokenize(text: string): string[] {
34
+ return text.match(TOKEN) ?? [];
35
+ }
36
+
37
+ /** Above this many DP cells (tokens x tokens) the middle is replaced whole.
38
+ * A 4 KB blog is ~1,300 tokens, so a whole-text rewrite is ~1.7M cells. */
39
+ const MAX_CELLS = 4_000_000;
40
+
41
+ const isHigh = (c: number) => c >= 0xd800 && c <= 0xdbff;
42
+ const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff;
43
+
44
+ /** Changes in `a`'s coordinates, sorted and non-overlapping, that turn `a`
45
+ * into `b`. Empty when they are equal. */
46
+ export function wordChanges(a: string, b: string): TextChange[] {
47
+ if (a === b) return [];
48
+ const max = Math.min(a.length, b.length);
49
+ let p = 0;
50
+ while (p < max && a.charCodeAt(p) === b.charCodeAt(p)) p++;
51
+ // Never split a surrogate pair.
52
+ if (p > 0 && isHigh(a.charCodeAt(p - 1))) p--;
53
+ let s = 0;
54
+ while (s < max - p && a.charCodeAt(a.length - 1 - s) === b.charCodeAt(b.length - 1 - s)) s++;
55
+ if (s > 0 && isLow(a.charCodeAt(a.length - s))) s--;
56
+
57
+ const midA = a.slice(p, a.length - s);
58
+ const midB = b.slice(p, b.length - s);
59
+ const tA = tokenize(midA);
60
+ const tB = tokenize(midB);
61
+ // tokenize covers every character (the last class matches anything that is
62
+ // not whitespace, a letter, a number or a mark), so tokens re-join exactly.
63
+ if (tA.join('') !== midA || tB.join('') !== midB || tA.length * tB.length > MAX_CELLS) {
64
+ return [{ from: p, to: a.length - s, insert: midB }];
65
+ }
66
+
67
+ const out: TextChange[] = [];
68
+ let pos = p;
69
+ let pending: TextChange | null = null;
70
+ for (const op of diffLines(tA, tB)) {
71
+ if (op.type === 'eq') {
72
+ if (pending) out.push(pending);
73
+ pending = null;
74
+ pos += op.line.length;
75
+ } else if (op.type === 'del') {
76
+ pending = pending ?? { from: pos, to: pos, insert: '' };
77
+ pending.to += op.line.length;
78
+ pos += op.line.length;
79
+ } else {
80
+ pending = pending ?? { from: pos, to: pos, insert: '' };
81
+ pending.insert += op.line;
82
+ }
83
+ }
84
+ if (pending) out.push(pending);
85
+ return out;
86
+ }
87
+
88
+ /** Apply sorted, non-overlapping changes expressed in `text`'s coordinates. */
89
+ export function applyTextChanges(text: string, changes: TextChange[]): string {
90
+ let out = '';
91
+ let last = 0;
92
+ for (const c of changes) {
93
+ out += text.slice(last, c.from) + c.insert;
94
+ last = c.to;
95
+ }
96
+ return out + text.slice(last);
97
+ }