@startsimpli/ui 0.4.141 → 0.4.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +38 -12
- package/src/components/activity/ActivityFeedPanel.tsx +96 -29
- package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
- package/src/components/activity/types.ts +10 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
- package/src/components/collection/InlineReviewActions.tsx +3 -2
- package/src/components/collection/ReviewDrawer.tsx +35 -7
- package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
- package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
- package/src/components/collection/data.ts +16 -1
- package/src/components/collection/index.ts +2 -1
- package/src/components/collection/scoped-client.ts +4 -1
- package/src/components/collection/types.ts +19 -1
- package/src/components/history/FieldAttribution.tsx +308 -0
- package/src/components/history/RecordActivityPanel.tsx +344 -0
- package/src/components/history/RecordHistoryPanel.tsx +331 -15
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +69 -3
- package/src/components/history/StaleSaveDialog.tsx +28 -7
- package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
- package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
- package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
- package/src/components/history/__tests__/restore.test.ts +209 -0
- package/src/components/history/index.ts +72 -1
- package/src/components/history/restore.ts +295 -0
- package/src/components/history/revision-feed.ts +105 -0
- package/src/components/history/revision-history.ts +22 -0
- package/src/components/history/types.ts +153 -1
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/index.ts +74 -0
- package/src/components/track-changes/normalize.ts +364 -0
- package/src/components/track-changes/types.ts +373 -0
- package/src/components/track-changes/useTrackChanges.ts +214 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,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
|
+
}
|