@startsimpli/ui 0.4.143 → 0.4.145
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 +1 -1
- package/src/components/blog/MarkdownRenderer.tsx +23 -5
- package/src/components/track-changes/ChangesRail.tsx +697 -0
- package/src/components/track-changes/TrackedField.tsx +293 -0
- package/src/components/track-changes/TrackedReadView.tsx +273 -0
- package/src/components/track-changes/__tests__/rails.test.tsx +616 -0
- package/src/components/track-changes/final-text.ts +54 -0
- package/src/components/track-changes/http-client.ts +109 -0
- package/src/components/track-changes/index.ts +18 -2
- package/src/components/track-changes/normalize.ts +29 -0
- package/src/components/track-changes/read-marks.ts +262 -0
- package/src/components/track-changes/types.ts +79 -1
- package/src/components/track-changes/useTrackChanges.ts +196 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FINAL and ORIGINAL views (bd startsim-whwxd.3; design startsim-xyn25
|
|
3
|
+
* section d).
|
|
4
|
+
*
|
|
5
|
+
* Read literally, the design's two views are the same text: the stored string
|
|
6
|
+
* never has a suggestion applied, so "the current text" and "the base with no
|
|
7
|
+
* suggestion applied" are one string. They follow Word's meaning instead:
|
|
8
|
+
*
|
|
9
|
+
* ORIGINAL the stored text, with no marks.
|
|
10
|
+
* FINAL the stored text with every OPEN, PLACED suggestion applied,
|
|
11
|
+
* with no marks: what the record would read if all were accepted.
|
|
12
|
+
*
|
|
13
|
+
* Two suggestions that overlap cannot both be applied, so the later one (in
|
|
14
|
+
* document order) is skipped and NAMED, never merged by guesswork. The server
|
|
15
|
+
* is the one that applies a real accept; this is a preview only.
|
|
16
|
+
*/
|
|
17
|
+
import type { Suggestion } from './types';
|
|
18
|
+
|
|
19
|
+
export interface FinalText {
|
|
20
|
+
text: string;
|
|
21
|
+
/** Applied, in document order. */
|
|
22
|
+
applied: string[];
|
|
23
|
+
/** Open and placed, but overlapping one already applied. */
|
|
24
|
+
skipped: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function placedOpen(suggestions: Suggestion[]): Suggestion[] {
|
|
28
|
+
return suggestions
|
|
29
|
+
.filter((s) => s.status === 'open' && s.anchorState !== 'orphaned')
|
|
30
|
+
.slice()
|
|
31
|
+
.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applyOpenSuggestions(text: string, suggestions: Suggestion[]): FinalText {
|
|
35
|
+
const applied: string[] = [];
|
|
36
|
+
const skipped: string[] = [];
|
|
37
|
+
let out = '';
|
|
38
|
+
let last = 0;
|
|
39
|
+
let lastInsertAt = -1;
|
|
40
|
+
for (const s of placedOpen(suggestions)) {
|
|
41
|
+
const overlaps = s.from < last || (s.from === s.to && s.from === lastInsertAt);
|
|
42
|
+
// The quote must still be what the text holds there; normalize already
|
|
43
|
+
// orphans a mismatch, so this is the belt to its braces.
|
|
44
|
+
if (overlaps || s.to > text.length || text.slice(s.from, s.to) !== s.quote) {
|
|
45
|
+
skipped.push(s.id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out += text.slice(last, s.from) + s.replacement;
|
|
49
|
+
last = s.to;
|
|
50
|
+
lastInsertAt = s.from === s.to ? s.from : -1;
|
|
51
|
+
applied.push(s.id);
|
|
52
|
+
}
|
|
53
|
+
return { text: out + text.slice(last), applied, skipped };
|
|
54
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A {@link TrackChangesClient} over plain `fetch`, for any foundry app whose
|
|
3
|
+
* tenant API serves the track-changes routes (start-simpli-api #507/#508;
|
|
4
|
+
* bd startsim-whwxd.3).
|
|
5
|
+
*
|
|
6
|
+
* PLAIN FETCH ON PURPOSE. The shared API client camelCases response keys
|
|
7
|
+
* recursively, and these responses carry tenant-declared field names
|
|
8
|
+
* (`field: "judge_verdict"`) that must arrive as declared. The normalizers
|
|
9
|
+
* read either spelling of their own keys, so raw snake_case is what they want.
|
|
10
|
+
*
|
|
11
|
+
* The host supplies the auth header and the URL shape (an app whose proxy
|
|
12
|
+
* appends a trailing slash passes paths without one, and vice versa).
|
|
13
|
+
*
|
|
14
|
+
* A non-2xx answer throws {@link TrackChangesHttpError} carrying `status` and
|
|
15
|
+
* the parsed `body`, which is what the hook reads to say WHY (a 404 is "you
|
|
16
|
+
* may not read this", never "there is nothing here").
|
|
17
|
+
*/
|
|
18
|
+
import type {
|
|
19
|
+
AcceptRequest,
|
|
20
|
+
AcceptResponseWire,
|
|
21
|
+
BlameResponseWire,
|
|
22
|
+
CommentPageWire,
|
|
23
|
+
CommentRequest,
|
|
24
|
+
CommentThreadWire,
|
|
25
|
+
CommentWire,
|
|
26
|
+
ProposeRequest,
|
|
27
|
+
RosterPageWire,
|
|
28
|
+
SuggestionPageWire,
|
|
29
|
+
TrackChangesClient,
|
|
30
|
+
} from './types';
|
|
31
|
+
|
|
32
|
+
export class TrackChangesHttpError extends Error {
|
|
33
|
+
readonly status: number;
|
|
34
|
+
readonly body: Record<string, unknown>;
|
|
35
|
+
constructor(status: number, body: Record<string, unknown>) {
|
|
36
|
+
const d = typeof body.detail === 'string' ? body.detail : `the server answered ${status}`;
|
|
37
|
+
super(d);
|
|
38
|
+
this.name = 'TrackChangesHttpError';
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.body = body;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HttpTrackChangesOptions {
|
|
45
|
+
/** Request headers, normally `{ authorization: 'Bearer …' }`. */
|
|
46
|
+
headers: () => Promise<Record<string, string>> | Record<string, string>;
|
|
47
|
+
/** Turn an API path (no leading host, no trailing slash, e.g.
|
|
48
|
+
* `/api/v1/entities/<id>/blame`) into the URL to fetch. Default: the path
|
|
49
|
+
* with a trailing slash, which is what Django routes. */
|
|
50
|
+
url?: (path: string) => string;
|
|
51
|
+
fetch?: typeof fetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function parse(res: Response): Promise<Record<string, unknown>> {
|
|
55
|
+
try {
|
|
56
|
+
const j = await res.json();
|
|
57
|
+
return j && typeof j === 'object' ? (j as Record<string, unknown>) : {};
|
|
58
|
+
} catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createHttpTrackChangesClient(
|
|
64
|
+
recordId: string | number,
|
|
65
|
+
{ headers, url = (p) => `${p}/`, fetch: f }: HttpTrackChangesOptions,
|
|
66
|
+
): TrackChangesClient {
|
|
67
|
+
const doFetch = f ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
|
|
68
|
+
const base = `/api/v1/entities/${recordId}`;
|
|
69
|
+
|
|
70
|
+
async function call<T>(path: string, init?: { method?: string; body?: unknown; query?: Record<string, string | undefined> }): Promise<T> {
|
|
71
|
+
const q = new URLSearchParams();
|
|
72
|
+
for (const [k, v] of Object.entries(init?.query ?? {})) if (v != null && v !== '') q.set(k, v);
|
|
73
|
+
const qs = q.toString();
|
|
74
|
+
const h = { ...(await headers()) };
|
|
75
|
+
if (init?.body !== undefined) h['content-type'] = 'application/json';
|
|
76
|
+
const res = await doFetch(`${url(path)}${qs ? `?${qs}` : ''}`, {
|
|
77
|
+
method: init?.method ?? 'GET',
|
|
78
|
+
headers: h,
|
|
79
|
+
...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
80
|
+
});
|
|
81
|
+
const body = await parse(res);
|
|
82
|
+
if (!res.ok) throw new TrackChangesHttpError(res.status, body);
|
|
83
|
+
return body as T;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
blame: ({ field }) => call<BlameResponseWire>(`${base}/blame`, { query: { field } }),
|
|
88
|
+
listSuggestions: ({ field, cursor }) =>
|
|
89
|
+
call<SuggestionPageWire>(`${base}/suggestions`, { query: { field, cursor } }),
|
|
90
|
+
listComments: ({ field, cursor, status }) =>
|
|
91
|
+
call<CommentPageWire>(`${base}/comments`, { query: { field, cursor, status } }),
|
|
92
|
+
propose: (body: ProposeRequest) => call(`${base}/suggestions`, { method: 'POST', body }),
|
|
93
|
+
accept: ({ ids, expectedVersion }: AcceptRequest) =>
|
|
94
|
+
call<AcceptResponseWire>(`${base}/suggestions/accept`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
// ONE spelling of the precondition: the server refuses if either
|
|
97
|
+
// disagrees, so sending If-Match as well can only add a way to fail.
|
|
98
|
+
body: { ids, expected_version: expectedVersion },
|
|
99
|
+
}),
|
|
100
|
+
reject: ({ ids, reason }) =>
|
|
101
|
+
call(`${base}/suggestions/reject`, { method: 'POST', body: reason ? { ids, reason } : { ids } }),
|
|
102
|
+
withdraw: (id) => call(`${base}/suggestions/${id}/withdraw`, { method: 'POST', body: {} }),
|
|
103
|
+
comment: (req: CommentRequest) => call<CommentThreadWire>(`${base}/comments`, { method: 'POST', body: req }),
|
|
104
|
+
reply: (threadId, req) => call<CommentWire>(`${base}/comments/${threadId}/replies`, { method: 'POST', body: req }),
|
|
105
|
+
resolveThread: (threadId) => call(`${base}/comments/${threadId}/resolve`, { method: 'POST', body: {} }),
|
|
106
|
+
reopenThread: (threadId) => call(`${base}/comments/${threadId}/reopen`, { method: 'POST', body: {} }),
|
|
107
|
+
roster: ({ cursor }) => call<RosterPageWire>(`/api/v1/org/roster`, { query: { cursor } }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -27,8 +27,23 @@
|
|
|
27
27
|
export { TrackedTextEditor } from './TrackedTextEditor';
|
|
28
28
|
export type { TrackMode, TrackedTextEditorHandle, TrackedTextEditorProps } from './TrackedTextEditor';
|
|
29
29
|
|
|
30
|
-
export { useTrackChanges } from './useTrackChanges';
|
|
31
|
-
export type { SourceState, SourceStatus, TrackChanges, UseTrackChangesOptions } from './useTrackChanges';
|
|
30
|
+
export { useTrackChanges, TrackChangesActionError } from './useTrackChanges';
|
|
31
|
+
export type { SourceState, SourceStatus, TrackChanges, TrackChangesActions, UseTrackChangesOptions } from './useTrackChanges';
|
|
32
|
+
|
|
33
|
+
// The rail, the Read view, the views, and the composer over all of them
|
|
34
|
+
// (startsim-whwxd.3).
|
|
35
|
+
export { TrackedField } from './TrackedField';
|
|
36
|
+
export type { TrackedFieldHandle, TrackedFieldProps, TrackedFieldSurface, TrackedFieldView } from './TrackedField';
|
|
37
|
+
export { ChangesRail, mentionQuery, mentionsIn } from './ChangesRail';
|
|
38
|
+
export type { ChangesRailProps } from './ChangesRail';
|
|
39
|
+
export { TrackedReadView, READ_STYLE, authorTint, readMarksOf, renderHast } from './TrackedReadView';
|
|
40
|
+
export type { TrackedReadViewProps } from './TrackedReadView';
|
|
41
|
+
export { alignToSource, markTree, plainTree, rehypeTrackMarks, sourceOffsetOf } from './read-marks';
|
|
42
|
+
export type { ReadMark, ReadMarkKind } from './read-marks';
|
|
43
|
+
export { applyOpenSuggestions, placedOpen } from './final-text';
|
|
44
|
+
export type { FinalText } from './final-text';
|
|
45
|
+
export { createHttpTrackChangesClient, TrackChangesHttpError } from './http-client';
|
|
46
|
+
export type { HttpTrackChangesOptions } from './http-client';
|
|
32
47
|
|
|
33
48
|
export {
|
|
34
49
|
DEFAULT_KEYS,
|
|
@@ -61,6 +76,7 @@ export {
|
|
|
61
76
|
codePointToUtf16,
|
|
62
77
|
normalizeActor,
|
|
63
78
|
normalizeBlame,
|
|
79
|
+
normalizeRoster,
|
|
64
80
|
normalizeSuggestions,
|
|
65
81
|
normalizeThreads,
|
|
66
82
|
toServerOffsets,
|
|
@@ -32,6 +32,9 @@ import type {
|
|
|
32
32
|
SuggestionStatus,
|
|
33
33
|
SuggestionWire,
|
|
34
34
|
PositionWire,
|
|
35
|
+
RosterEntryWire,
|
|
36
|
+
RosterMember,
|
|
37
|
+
RosterPageWire,
|
|
35
38
|
TrackActor,
|
|
36
39
|
WireActor,
|
|
37
40
|
WireAuthor,
|
|
@@ -332,6 +335,24 @@ export function normalizeSuggestions(
|
|
|
332
335
|
});
|
|
333
336
|
}
|
|
334
337
|
|
|
338
|
+
/* -------------------------------------------------------------------------- */
|
|
339
|
+
/* Roster */
|
|
340
|
+
/* -------------------------------------------------------------------------- */
|
|
341
|
+
|
|
342
|
+
/** Colleagues by `sub`, deduped (central's roster pages can repeat a member:
|
|
343
|
+
* startsim-whwxd.7), blank subs dropped, sorted by label. */
|
|
344
|
+
export function normalizeRoster(pages: Array<RosterPageWire | RosterEntryWire[] | null | undefined>): RosterMember[] {
|
|
345
|
+
const bySub = new Map<string, RosterMember>();
|
|
346
|
+
for (const page of pages) {
|
|
347
|
+
for (const r of results(page)) {
|
|
348
|
+
const sub = str(r?.sub).trim();
|
|
349
|
+
if (!sub || bySub.has(sub)) continue;
|
|
350
|
+
bySub.set(sub, { sub, label: str(r?.label).trim() });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return [...bySub.values()].sort((a, b) => (a.label || a.sub).localeCompare(b.label || b.sub));
|
|
354
|
+
}
|
|
355
|
+
|
|
335
356
|
/* -------------------------------------------------------------------------- */
|
|
336
357
|
/* Comments */
|
|
337
358
|
/* -------------------------------------------------------------------------- */
|
|
@@ -344,8 +365,14 @@ export function normalizeThreads(
|
|
|
344
365
|
return results(w).map((t) => {
|
|
345
366
|
const { anchor, state } = placement(t);
|
|
346
367
|
const r = resolveAnchor(text, anchor, state, unit);
|
|
368
|
+
const res = t.resolution;
|
|
347
369
|
return {
|
|
348
370
|
id: String(t.thread_id ?? t.threadId ?? t.id ?? ''),
|
|
371
|
+
field: typeof t.field === 'string' && t.field ? t.field : null,
|
|
372
|
+
resolution:
|
|
373
|
+
res && typeof res === 'object' && res.action
|
|
374
|
+
? { action: String(res.action), by: normalizeActor(res.by), at: res.at ?? null }
|
|
375
|
+
: null,
|
|
349
376
|
from: r.from,
|
|
350
377
|
to: r.to,
|
|
351
378
|
quote: r.quote,
|
|
@@ -358,6 +385,8 @@ export function normalizeThreads(
|
|
|
358
385
|
deleted: Boolean(c.deleted),
|
|
359
386
|
author: normalizeActor(c.author ?? c),
|
|
360
387
|
createdAt: c.created_at ?? c.createdAt ?? null,
|
|
388
|
+
mentions: Array.isArray(c.mentions) ? c.mentions.filter((m): m is string => typeof m === 'string' && !!m) : [],
|
|
389
|
+
editedAt: c.edited_at ?? c.editedAt ?? null,
|
|
361
390
|
})),
|
|
362
391
|
};
|
|
363
392
|
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks in the rendered Read view (bd startsim-whwxd.3; design startsim-xyn25
|
|
3
|
+
* section d): a rehype transform that wraps text runs by their SOURCE position.
|
|
4
|
+
*
|
|
5
|
+
* The markdown parser keeps, on every text node, the offsets of the source it
|
|
6
|
+
* came from. Server offsets are offsets into that same source string, so a
|
|
7
|
+
* suggestion, comment or authorship span can be drawn over the rendered prose
|
|
8
|
+
* exactly where it sits in the markdown.
|
|
9
|
+
*
|
|
10
|
+
* NEVER PAINT A GUESS. A text node's value is not always its source slice:
|
|
11
|
+
* `\_` renders `_`, `&` renders `&`. When the value is a subsequence of
|
|
12
|
+
* the slice (escapes, entities that decode to a character of their own
|
|
13
|
+
* spelling) the characters are aligned one by one. When it is not (`'`
|
|
14
|
+
* renders a quote that appears nowhere in its source), the node is left
|
|
15
|
+
* alone, and anything inside it is reported UNPLACED so the host says so
|
|
16
|
+
* instead of drawing it a few characters off.
|
|
17
|
+
*
|
|
18
|
+
* Every run the transform writes carries `data-src-from`, the source offset
|
|
19
|
+
* of its first character, so a DOM selection maps back to source offsets for
|
|
20
|
+
* "comment on selection".
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/* Minimal hast shapes: this file is the only reader, and keeping them local
|
|
24
|
+
avoids a dependency on @types/hast for one transform. */
|
|
25
|
+
export interface HastPoint {
|
|
26
|
+
offset?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface HastText {
|
|
29
|
+
type: 'text';
|
|
30
|
+
value: string;
|
|
31
|
+
position?: { start?: HastPoint; end?: HastPoint };
|
|
32
|
+
}
|
|
33
|
+
export interface HastElement {
|
|
34
|
+
type: 'element';
|
|
35
|
+
tagName: string;
|
|
36
|
+
properties?: Record<string, unknown>;
|
|
37
|
+
children: HastNode[];
|
|
38
|
+
position?: { start?: HastPoint; end?: HastPoint };
|
|
39
|
+
}
|
|
40
|
+
export interface HastRoot {
|
|
41
|
+
type: 'root';
|
|
42
|
+
children: HastNode[];
|
|
43
|
+
}
|
|
44
|
+
export type HastNode = HastText | HastElement | HastRoot | { type: string; [k: string]: unknown };
|
|
45
|
+
|
|
46
|
+
export type ReadMarkKind = 'blame' | 'suggestion' | 'comment';
|
|
47
|
+
|
|
48
|
+
/** One thing to draw, in SOURCE offsets (UTF-16, as JavaScript strings are). */
|
|
49
|
+
export interface ReadMark {
|
|
50
|
+
id: string;
|
|
51
|
+
kind: ReadMarkKind;
|
|
52
|
+
from: number;
|
|
53
|
+
to: number;
|
|
54
|
+
/** The accessible sentence; becomes the title. */
|
|
55
|
+
title: string;
|
|
56
|
+
/** Suggestion: the text it would insert, drawn after the struck range. */
|
|
57
|
+
insert?: string;
|
|
58
|
+
/** Classes for the wrapper. */
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Character-level map from a text node's value to source offsets, or null
|
|
63
|
+
* when the value cannot be aligned to its source. */
|
|
64
|
+
export function alignToSource(value: string, source: string, start: number, end: number): number[] | null {
|
|
65
|
+
if (!(start >= 0) || !(end >= start) || end > source.length) return null;
|
|
66
|
+
const slice = source.slice(start, end);
|
|
67
|
+
const map: number[] = new Array(value.length);
|
|
68
|
+
if (slice === value) {
|
|
69
|
+
for (let i = 0; i < value.length; i++) map[i] = start + i;
|
|
70
|
+
return map;
|
|
71
|
+
}
|
|
72
|
+
let j = 0;
|
|
73
|
+
for (let i = 0; i < value.length; i++) {
|
|
74
|
+
while (j < slice.length && slice[j] !== value[i]) j++;
|
|
75
|
+
if (j >= slice.length) return null;
|
|
76
|
+
map[i] = start + j;
|
|
77
|
+
j++;
|
|
78
|
+
}
|
|
79
|
+
return map;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isText(n: HastNode): n is HastText {
|
|
83
|
+
return n.type === 'text' && typeof (n as HastText).value === 'string';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasChildren(n: HastNode): n is HastElement | HastRoot {
|
|
87
|
+
return Array.isArray((n as HastElement).children);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const TAG: Record<ReadMarkKind, string> = { suggestion: 'del', comment: 'mark', blame: 'span' };
|
|
91
|
+
const NEST: ReadMarkKind[] = ['blame', 'comment', 'suggestion'];
|
|
92
|
+
|
|
93
|
+
function element(tagName: string, properties: Record<string, unknown>, children: HastNode[]): HastElement {
|
|
94
|
+
return { type: 'element', tagName, properties, children };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wrap the text runs of one aligned text node. Runs break at every mark
|
|
99
|
+
* boundary and at every place the source skips characters (an escape), so
|
|
100
|
+
* each run is contiguous in both the value and the source.
|
|
101
|
+
*/
|
|
102
|
+
function wrapText(
|
|
103
|
+
node: HastText,
|
|
104
|
+
map: number[],
|
|
105
|
+
marks: ReadMark[],
|
|
106
|
+
placed: Set<string>,
|
|
107
|
+
insertsDone: Set<string>,
|
|
108
|
+
): HastNode[] {
|
|
109
|
+
const value = node.value;
|
|
110
|
+
if (!value.length) return [node];
|
|
111
|
+
const nodeFrom = map[0];
|
|
112
|
+
const nodeTo = map[value.length - 1] + 1;
|
|
113
|
+
const here = marks.filter((m) => m.from < nodeTo && m.to > nodeFrom);
|
|
114
|
+
|
|
115
|
+
const cuts = new Set<number>([0, value.length]);
|
|
116
|
+
for (let i = 1; i < value.length; i++) if (map[i] !== map[i - 1] + 1) cuts.add(i);
|
|
117
|
+
for (const m of here) {
|
|
118
|
+
for (const edge of [m.from, m.to]) {
|
|
119
|
+
for (let i = 1; i < value.length; i++) if (map[i] === edge) cuts.add(i);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Pure inserts and the insert half of a replacement are drawn at a point.
|
|
123
|
+
const points = marks.filter((m) => m.kind === 'suggestion' && m.insert && !insertsDone.has(m.id));
|
|
124
|
+
for (const p of points) {
|
|
125
|
+
for (let i = 1; i < value.length; i++) if (map[i] === p.to) cuts.add(i);
|
|
126
|
+
}
|
|
127
|
+
const bounds = [...cuts].sort((a, b) => a - b);
|
|
128
|
+
|
|
129
|
+
const out: HastNode[] = [];
|
|
130
|
+
const emitInsertsAt = (srcPos: number) => {
|
|
131
|
+
for (const p of points) {
|
|
132
|
+
if (insertsDone.has(p.id) || p.to !== srcPos) continue;
|
|
133
|
+
insertsDone.add(p.id);
|
|
134
|
+
placed.add(p.id);
|
|
135
|
+
out.push(
|
|
136
|
+
element(
|
|
137
|
+
'ins',
|
|
138
|
+
{ className: ['tc-read-ins'], dataTcId: p.id, title: p.title },
|
|
139
|
+
[{ type: 'text', value: p.insert ?? '' }],
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
for (let b = 0; b < bounds.length - 1; b++) {
|
|
146
|
+
const i = bounds[b];
|
|
147
|
+
const k = bounds[b + 1];
|
|
148
|
+
const srcFrom = map[i];
|
|
149
|
+
const srcTo = map[k - 1] + 1;
|
|
150
|
+
emitInsertsAt(srcFrom);
|
|
151
|
+
let piece: HastNode = element('span', { dataSrcFrom: String(srcFrom) }, [{ type: 'text', value: value.slice(i, k) }]);
|
|
152
|
+
const covering = here.filter((m) => m.from <= srcFrom && m.to >= srcTo && m.to > m.from);
|
|
153
|
+
for (const kind of NEST) {
|
|
154
|
+
for (const m of covering.filter((x) => x.kind === kind)) {
|
|
155
|
+
placed.add(m.id);
|
|
156
|
+
piece = element(
|
|
157
|
+
TAG[kind],
|
|
158
|
+
{ className: ['tc-read-' + kind, ...(m.className ? m.className.split(/\s+/) : [])], dataTcId: m.id, title: m.title },
|
|
159
|
+
[piece],
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
out.push(piece);
|
|
164
|
+
if (b === bounds.length - 2) emitInsertsAt(srcTo);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface MarkTreeResult {
|
|
170
|
+
/** Mark ids drawn at least partly. */
|
|
171
|
+
placed: Set<string>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Transform a hast tree in place. `source` is the string the tree was parsed
|
|
176
|
+
* from. A mark is placed when at least one of its characters (or, for a pure
|
|
177
|
+
* insert, its point) is drawn.
|
|
178
|
+
*/
|
|
179
|
+
export function markTree(tree: HastNode, source: string, marks: ReadMark[]): MarkTreeResult {
|
|
180
|
+
const placed = new Set<string>();
|
|
181
|
+
const insertsDone = new Set<string>();
|
|
182
|
+
const visit = (parent: HastElement | HastRoot) => {
|
|
183
|
+
// Never inside code: a fenced block is re-highlighted by its own renderer
|
|
184
|
+
// and an inline code span is literal. Marks there are reported unplaced.
|
|
185
|
+
if ((parent as HastElement).tagName === 'code' || (parent as HastElement).tagName === 'pre') return;
|
|
186
|
+
const next: HastNode[] = [];
|
|
187
|
+
for (const child of parent.children) {
|
|
188
|
+
if (isText(child)) {
|
|
189
|
+
const start = child.position?.start?.offset;
|
|
190
|
+
const end = child.position?.end?.offset;
|
|
191
|
+
const map =
|
|
192
|
+
typeof start === 'number' && typeof end === 'number' ? alignToSource(child.value, source, start, end) : null;
|
|
193
|
+
if (map) next.push(...wrapText(child, map, marks, placed, insertsDone));
|
|
194
|
+
else next.push(child);
|
|
195
|
+
} else {
|
|
196
|
+
if (hasChildren(child)) visit(child);
|
|
197
|
+
next.push(child);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
parent.children = next;
|
|
201
|
+
};
|
|
202
|
+
if (hasChildren(tree)) visit(tree);
|
|
203
|
+
return { placed };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The rehype plugin form of {@link markTree}, for MarkdownRenderer. */
|
|
207
|
+
export function rehypeTrackMarks(options: {
|
|
208
|
+
source: string;
|
|
209
|
+
marks: ReadMark[];
|
|
210
|
+
onPlaced?: (placed: Set<string>) => void;
|
|
211
|
+
}) {
|
|
212
|
+
return () => (tree: HastNode) => {
|
|
213
|
+
const { placed } = markTree(tree, options.source, options.marks);
|
|
214
|
+
options.onPlaced?.(placed);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A plain-text field as a one-node tree, so plain text takes the same path. */
|
|
219
|
+
export function plainTree(source: string): HastRoot {
|
|
220
|
+
return {
|
|
221
|
+
type: 'root',
|
|
222
|
+
children: [{ type: 'text', value: source, position: { start: { offset: 0 }, end: { offset: source.length } } }],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Source offset of a DOM point inside a marked tree, or null when the point
|
|
227
|
+
* is not inside a run that carries `data-src-from`. */
|
|
228
|
+
export function sourceOffsetOf(node: Node | null, offset: number): number | null {
|
|
229
|
+
if (!node) return null;
|
|
230
|
+
if (node.nodeType !== 3) {
|
|
231
|
+
// An element point: use the child it sits before, or after the last.
|
|
232
|
+
const el = node as Element;
|
|
233
|
+
const child = el.childNodes[offset] ?? null;
|
|
234
|
+
if (child) return sourceOffsetOf(firstText(child), 0);
|
|
235
|
+
const last = el.childNodes[el.childNodes.length - 1] ?? null;
|
|
236
|
+
const t = last ? lastText(last) : null;
|
|
237
|
+
return t ? sourceOffsetOf(t, t.textContent?.length ?? 0) : null;
|
|
238
|
+
}
|
|
239
|
+
const run = (node.parentElement as Element | null)?.closest('[data-src-from]');
|
|
240
|
+
if (!run || run.closest('ins')) return null;
|
|
241
|
+
const from = Number(run.getAttribute('data-src-from'));
|
|
242
|
+
return Number.isFinite(from) ? from + offset : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function firstText(n: Node): Node | null {
|
|
246
|
+
if (n.nodeType === 3) return n;
|
|
247
|
+
for (const c of Array.from(n.childNodes)) {
|
|
248
|
+
const t = firstText(c);
|
|
249
|
+
if (t) return t;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function lastText(n: Node): Node | null {
|
|
255
|
+
if (n.nodeType === 3) return n;
|
|
256
|
+
const kids = Array.from(n.childNodes);
|
|
257
|
+
for (let i = kids.length - 1; i >= 0; i--) {
|
|
258
|
+
const t = lastText(kids[i]);
|
|
259
|
+
if (t) return t;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
@@ -301,8 +301,79 @@ export interface TrackChangesClient {
|
|
|
301
301
|
* `next_cursor`) until there is none, because a proposal supersedes ALL of
|
|
302
302
|
* the author's open suggestions and the editor must know every one. */
|
|
303
303
|
listSuggestions?: (query: { field: string; cursor?: string }) => Promise<SuggestionPageWire | SuggestionWire[]>;
|
|
304
|
-
|
|
304
|
+
/** `status: 'all'` is what the rail asks for, so resolved threads stay
|
|
305
|
+
* readable (and reopenable) in its resolved list. */
|
|
306
|
+
listComments?: (query: { field: string; cursor?: string; status?: 'open' | 'resolved' | 'all' }) => Promise<CommentPageWire | CommentThreadWire[]>;
|
|
305
307
|
propose?: (body: ProposeRequest) => Promise<unknown>;
|
|
308
|
+
|
|
309
|
+
/* ---- the rail's writes (startsim-whwxd.3). Each is optional, like the reads:
|
|
310
|
+
* a host that has not bound one simply does not get that control. ---- */
|
|
311
|
+
|
|
312
|
+
/** POST suggestions/accept/ {ids, expected_version}. One revision per
|
|
313
|
+
* suggestion, document order, all or nothing. Resolves the RECORD (the
|
|
314
|
+
* record serializer's body) plus `accepted`; the host must adopt its
|
|
315
|
+
* `version`, or its own next save 412s against this accept. */
|
|
316
|
+
accept?: (req: AcceptRequest) => Promise<AcceptResponseWire>;
|
|
317
|
+
/** POST suggestions/reject/ {ids, reason?}. The record does not move. */
|
|
318
|
+
reject?: (req: { ids: string[]; reason?: string }) => Promise<unknown>;
|
|
319
|
+
/** POST suggestions/<id>/withdraw/. Author only. */
|
|
320
|
+
withdraw?: (id: string) => Promise<unknown>;
|
|
321
|
+
/** POST comments/ — a span, field or record comment. */
|
|
322
|
+
comment?: (req: CommentRequest) => Promise<CommentThreadWire>;
|
|
323
|
+
/** POST comments/<thread_id>/replies/. */
|
|
324
|
+
reply?: (threadId: string, req: { body: string; mentions?: string[] }) => Promise<CommentWire>;
|
|
325
|
+
/** POST comments/<thread_id>/resolve/ */
|
|
326
|
+
resolveThread?: (threadId: string) => Promise<unknown>;
|
|
327
|
+
/** POST comments/<thread_id>/reopen/ */
|
|
328
|
+
reopenThread?: (threadId: string) => Promise<unknown>;
|
|
329
|
+
/** GET /api/v1/org/roster/ — the colleagues a comment may @mention. In-app
|
|
330
|
+
* only: the server sends nothing to anyone mentioned (Quinn, 2026-10-07). */
|
|
331
|
+
roster?: (query: { cursor?: string }) => Promise<RosterPageWire>;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface AcceptRequest {
|
|
335
|
+
ids: string[];
|
|
336
|
+
/** The version the reviewer is looking at. REQUIRED by the route (428
|
|
337
|
+
* without it, 412 when stale). */
|
|
338
|
+
expectedVersion: number;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The accept answer: the record body, plus what was accepted. */
|
|
342
|
+
export interface AcceptResponseWire {
|
|
343
|
+
id?: string | number;
|
|
344
|
+
version?: number | null;
|
|
345
|
+
data?: Record<string, unknown>;
|
|
346
|
+
accepted?: { suggestions?: string[]; revisions?: number[] } | null;
|
|
347
|
+
[key: string]: unknown;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface CommentRequest {
|
|
351
|
+
body: string;
|
|
352
|
+
/** Omitted: a whole-record comment. */
|
|
353
|
+
field?: string;
|
|
354
|
+
/** A span comment: both offsets, UTF-16 at `base_version`. */
|
|
355
|
+
from?: number;
|
|
356
|
+
to?: number;
|
|
357
|
+
base_version?: number;
|
|
358
|
+
mentions?: string[];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface RosterEntryWire {
|
|
362
|
+
sub?: string | null;
|
|
363
|
+
label?: string | null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface RosterPageWire {
|
|
367
|
+
next?: string | null;
|
|
368
|
+
next_cursor?: string | null;
|
|
369
|
+
nextCursor?: string | null;
|
|
370
|
+
results?: RosterEntryWire[] | null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A colleague who can be @mentioned. */
|
|
374
|
+
export interface RosterMember {
|
|
375
|
+
sub: string;
|
|
376
|
+
label: string;
|
|
306
377
|
}
|
|
307
378
|
|
|
308
379
|
/* -------------------------------------------------------------------------- */
|
|
@@ -348,10 +419,17 @@ export interface TrackComment {
|
|
|
348
419
|
deleted: boolean;
|
|
349
420
|
author: TrackActor;
|
|
350
421
|
createdAt: string | null;
|
|
422
|
+
/** Mentioned subs; shown in-app only. */
|
|
423
|
+
mentions: string[];
|
|
424
|
+
editedAt: string | null;
|
|
351
425
|
}
|
|
352
426
|
|
|
353
427
|
export interface CommentThread {
|
|
354
428
|
id: string;
|
|
429
|
+
/** The field the thread is on; `null` for a whole-record comment. */
|
|
430
|
+
field: string | null;
|
|
431
|
+
/** Who resolved or reopened it last, and when. */
|
|
432
|
+
resolution: { action: string; by: TrackActor; at: string | null } | null;
|
|
355
433
|
/** `null` for a whole-record or whole-field comment. */
|
|
356
434
|
from: number | null;
|
|
357
435
|
to: number | null;
|