@startsimpli/ui 0.4.142 → 0.4.144

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,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
+ }