visual-comments 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -2
  3. package/dist/core/aim.d.ts +21 -0
  4. package/dist/core/aim.js +21 -0
  5. package/dist/core/api.d.ts +5 -0
  6. package/dist/core/api.js +11 -0
  7. package/dist/core/metaball.d.ts +15 -0
  8. package/dist/core/metaball.js +42 -0
  9. package/dist/core/surfaces.d.ts +95 -0
  10. package/dist/core/surfaces.js +197 -0
  11. package/dist/core/target.d.ts +59 -0
  12. package/dist/core/target.js +382 -0
  13. package/dist/core/threads.svelte.d.ts +46 -0
  14. package/dist/core/threads.svelte.js +219 -0
  15. package/dist/core/time.d.ts +3 -0
  16. package/dist/core/time.js +25 -0
  17. package/dist/index.d.ts +5 -0
  18. package/dist/index.js +6 -0
  19. package/dist/server/index.d.ts +33 -0
  20. package/dist/server/index.js +67 -0
  21. package/dist/shared/limits.d.ts +9 -0
  22. package/dist/shared/limits.js +10 -0
  23. package/dist/shared/protocol.d.ts +2 -0
  24. package/dist/shared/protocol.js +3 -0
  25. package/dist/shared/types.d.ts +93 -0
  26. package/dist/shared/types.js +2 -0
  27. package/dist/ui/Account.svelte +68 -0
  28. package/dist/ui/Account.svelte.d.ts +8 -0
  29. package/dist/ui/App.svelte +68 -0
  30. package/dist/ui/App.svelte.d.ts +7 -0
  31. package/dist/ui/Avatar.svelte +42 -0
  32. package/dist/ui/Avatar.svelte.d.ts +12 -0
  33. package/dist/ui/Composer.svelte +267 -0
  34. package/dist/ui/Composer.svelte.d.ts +20 -0
  35. package/dist/ui/Icon.svelte +44 -0
  36. package/dist/ui/Icon.svelte.d.ts +29 -0
  37. package/dist/ui/Menu.svelte +65 -0
  38. package/dist/ui/Menu.svelte.d.ts +13 -0
  39. package/dist/ui/MenuItem.svelte +38 -0
  40. package/dist/ui/MenuItem.svelte.d.ts +12 -0
  41. package/dist/ui/Overlay.svelte +488 -0
  42. package/dist/ui/Overlay.svelte.d.ts +3 -0
  43. package/dist/ui/Panel.svelte +206 -0
  44. package/dist/ui/Panel.svelte.d.ts +10 -0
  45. package/dist/ui/Pin.svelte +136 -0
  46. package/dist/ui/Pin.svelte.d.ts +13 -0
  47. package/dist/ui/ThreadCard.svelte +177 -0
  48. package/dist/ui/ThreadCard.svelte.d.ts +13 -0
  49. package/dist/ui/Toolbar.svelte +35 -0
  50. package/dist/ui/Toolbar.svelte.d.ts +18 -0
  51. package/dist/ui/context.d.ts +3 -0
  52. package/dist/ui/context.js +8 -0
  53. package/dist/ui/generated/styles.css +2 -0
  54. package/dist/ui/generated/styles.d.ts +2 -0
  55. package/dist/ui/generated/styles.js +3 -0
  56. package/dist/ui/styles.css +92 -0
  57. package/dist/ui/text.d.ts +3 -0
  58. package/dist/ui/text.js +7 -0
  59. package/dist/visual-comments.svelte.d.ts +181 -0
  60. package/dist/visual-comments.svelte.js +568 -0
  61. package/dist-widget/visual-comments.js +51 -0
  62. package/package.json +66 -5
@@ -0,0 +1,382 @@
1
+ // Finds the element a comment pin is meant for, in a document: the host page,
2
+ // or a same-origin frame's own document. A frame's elements belong to another
3
+ // realm: test them by name, never with instanceof. Everything here is in the
4
+ // document's client pixels.
5
+ /** How far the pull reaches, in screen pixels. */
6
+ export const MAGNET = 14;
7
+ /** Elements covering more of the region than this are layout, not something to comment on. */
8
+ const LAYOUT_SHARE = 0.6;
9
+ /** Controls up to this size take in what they contain, as a button takes its icon and text. */
10
+ const COMPACT = { width: 480, height: 56 };
11
+ const NAMED = '[data-vc-target],[data-pf-comment]';
12
+ const CONTROL = 'a[href],button,input,select,textarea,label,summary,[contenteditable],[tabindex]:not([tabindex="-1"]),' +
13
+ '[role=button],[role=link],[role=tab],[role=menuitem],[role=checkbox],[role=switch],[role=option],' +
14
+ '[data-vc-interactive],[data-pf-link],[data-pf-interactive]';
15
+ const MEDIA = 'img,svg,canvas,video,picture,iframe';
16
+ const CONTENT = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dt,dd,blockquote,pre,figcaption,caption,legend';
17
+ const GROUP = 'section,article,aside,nav,header,footer,form,table,tr,fieldset,dialog,figure,ul,ol,' +
18
+ '[role=region],[role=dialog],[role=row],[role=grid],[role=table],[role=tablist],[role=listitem],' +
19
+ '[role=cell],[role=gridcell],[role=columnheader]';
20
+ const SVG = 'http://www.w3.org/2000/svg';
21
+ const TRANSPARENT = /^transparent$|,\s*0\)$|\/\s*0\)$/;
22
+ /** The elements at a point, top first, without skipped ones. */
23
+ export function elementsAt(doc, x, y, skip) {
24
+ return doc.elementsFromPoint(x, y).filter((element) => !skip(element));
25
+ }
26
+ /**
27
+ * The element nearest the point, or undefined for open space. Under the
28
+ * pointer, the innermost meaningful element wins. A smaller one within
29
+ * `radius` pulls the pointer onto it, as long as it sits inside that element.
30
+ */
31
+ export function targetAt(doc, x, y, radius, search) {
32
+ const scan = new Scan(doc, search);
33
+ const { region } = search;
34
+ const found = new Map();
35
+ for (const [dx, dy] of probes(radius)) {
36
+ const px = x + dx;
37
+ const py = y + dy;
38
+ if (px < region.x || py < region.y || px >= region.x + region.width || py >= region.y + region.height)
39
+ continue;
40
+ for (const hit of elementsAt(doc, px, py, search.skip)) {
41
+ const element = scan.target(hit);
42
+ if (element && !found.has(element))
43
+ found.set(element, scan.box(element));
44
+ }
45
+ }
46
+ let inner;
47
+ for (const [element, box] of found) {
48
+ if (distance(box, x, y) === 0 && (!inner || area(box) < area(inner.box)))
49
+ inner = { element, box };
50
+ }
51
+ let pull;
52
+ for (const [element, box] of found) {
53
+ const gap = distance(box, x, y);
54
+ if (gap === 0 || gap > radius)
55
+ continue;
56
+ if (inner && !(area(box) < area(inner.box) && inner.element.contains(element)))
57
+ continue;
58
+ if (!pull || gap < pull.gap || (gap === pull.gap && area(box) < area(pull.box)))
59
+ pull = { element, box, gap };
60
+ }
61
+ const chosen = pull ?? inner;
62
+ return chosen && scan.describe(chosen.element, chosen.box);
63
+ }
64
+ /** The meaningful element around this one, for when the pin meant more than it caught. */
65
+ export function widen(target, search) {
66
+ if (!target.element.ownerDocument.defaultView)
67
+ return undefined;
68
+ const scan = new Scan(target.element.ownerDocument, search);
69
+ for (let element = target.element.parentElement; element; element = element.parentElement) {
70
+ if (search.within && !search.within.contains(element))
71
+ break;
72
+ if (!scan.candidate(element))
73
+ continue;
74
+ const box = scan.box(element);
75
+ if (area(box) > area(target.box) * 1.02)
76
+ return scan.describe(element, box);
77
+ }
78
+ return undefined;
79
+ }
80
+ /** The element an anchor names in a document, if it is still there and still says the same. */
81
+ export function findAnchor(doc, anchor) {
82
+ let element = null;
83
+ try {
84
+ element = doc.querySelector(anchor.selector);
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ if (!element)
90
+ return undefined;
91
+ const said = normalize(anchor.text).replace(/…$/, '').slice(0, 40);
92
+ return !said || normalize(visibleText(element)).startsWith(said) ? element : undefined;
93
+ }
94
+ /**
95
+ * Whether the point of the element shows: inside the region, not clipped by a
96
+ * scrolling container, and not covered (a sticky header).
97
+ */
98
+ export function shows(element, x, y, search) {
99
+ const { region } = search;
100
+ if (x < region.x || y < region.y || x >= region.x + region.width || y >= region.y + region.height)
101
+ return false;
102
+ if (clipped(element, x, y))
103
+ return false;
104
+ const top = elementsAt(element.ownerDocument, x, y, search.skip)[0];
105
+ // The element itself may not take pointer events (a chart), so its container counts as showing it.
106
+ return Boolean(top && (top === element || element.contains(top) || top.contains(element)));
107
+ }
108
+ /** Scrolls the document's containers until the element is in the middle of them. */
109
+ export function reveal(element) {
110
+ const view = element.ownerDocument.defaultView;
111
+ if (!view)
112
+ return;
113
+ for (let node = element.parentElement; node; node = node.parentElement) {
114
+ const style = view.getComputedStyle(node);
115
+ const scrolls = /auto|scroll/.test(style.overflowX + style.overflowY);
116
+ if (!scrolls || node === element.ownerDocument.body)
117
+ continue;
118
+ const outer = node.getBoundingClientRect();
119
+ const r = element.getBoundingClientRect();
120
+ if (r.top < outer.top || r.bottom > outer.bottom)
121
+ node.scrollTop += r.top - outer.top - (outer.height - r.height) / 2;
122
+ if (r.left < outer.left || r.right > outer.right)
123
+ node.scrollLeft += r.left - outer.left - (outer.width - r.width) / 2;
124
+ }
125
+ const r = element.getBoundingClientRect();
126
+ if (r.top < 0 || r.bottom > view.innerHeight)
127
+ view.scrollBy(0, r.top - (view.innerHeight - r.height) / 2);
128
+ }
129
+ /** The scrolling container from this element up that can still scroll, to pass wheel turns to. */
130
+ export function scrollerFrom(start, dx, dy) {
131
+ const view = start?.ownerDocument.defaultView;
132
+ if (!view)
133
+ return undefined;
134
+ for (let node = start; node; node = node.parentElement) {
135
+ const style = view.getComputedStyle(node);
136
+ const root = node === node.ownerDocument.scrollingElement;
137
+ const canY = (root || /auto|scroll/.test(style.overflowY)) && node.scrollHeight > node.clientHeight
138
+ && (dy < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight - 1);
139
+ const canX = (root || /auto|scroll/.test(style.overflowX)) && node.scrollWidth > node.clientWidth
140
+ && (dx < 0 ? node.scrollLeft > 0 : node.scrollLeft + node.clientWidth < node.scrollWidth - 1);
141
+ if ((dy && canY) || (dx && canX))
142
+ return node;
143
+ }
144
+ return undefined;
145
+ }
146
+ /** Whether a container that clips its overflow hides this point of the element. */
147
+ function clipped(element, x, y) {
148
+ const view = element.ownerDocument.defaultView;
149
+ for (let node = element.parentElement; node && node !== element.ownerDocument.body; node = node.parentElement) {
150
+ const style = view.getComputedStyle(node);
151
+ if (style.overflowX === 'visible' && style.overflowY === 'visible')
152
+ continue;
153
+ const r = node.getBoundingClientRect();
154
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom)
155
+ return true;
156
+ }
157
+ return false;
158
+ }
159
+ /** What a thread stores about its target: a selector, its label and text. */
160
+ export function describeTarget(target) {
161
+ return {
162
+ selector: selectorOf(target.element),
163
+ label: target.label,
164
+ text: shorten(visibleText(target.element), 300)
165
+ };
166
+ }
167
+ /** One pass over a document, remembering what it has measured. */
168
+ class Scan {
169
+ view;
170
+ search;
171
+ ranks = new Map();
172
+ constructor(doc, search) {
173
+ this.view = doc.defaultView;
174
+ this.search = search;
175
+ }
176
+ /** The visible part of an element's box, within the region. */
177
+ box(element) {
178
+ const r = element.getBoundingClientRect();
179
+ const { region } = this.search;
180
+ const x = Math.max(r.left, region.x);
181
+ const y = Math.max(r.top, region.y);
182
+ return {
183
+ x,
184
+ y,
185
+ width: Math.max(Math.min(r.right, region.x + region.width) - x, 0),
186
+ height: Math.max(Math.min(r.bottom, region.y + region.height) - y, 0)
187
+ };
188
+ }
189
+ /**
190
+ * How much an element means on its own: 0 for wrappers and layout, then
191
+ * groups and boxes, content, controls and media, and elements the page
192
+ * names with `data-vc-target`.
193
+ */
194
+ rank(element) {
195
+ let rank = this.ranks.get(element);
196
+ if (rank === undefined) {
197
+ rank = this.measure(element);
198
+ this.ranks.set(element, rank);
199
+ }
200
+ return rank;
201
+ }
202
+ measure(element) {
203
+ const name = element.localName;
204
+ if (name === 'html' || name === 'body' || name === 'head')
205
+ return 0;
206
+ const { within, region, skip } = this.search;
207
+ if (within && (element === within || !within.contains(element)))
208
+ return 0;
209
+ if (skip(element))
210
+ return 0;
211
+ // Paths and groups inside an SVG belong to the SVG.
212
+ if (element.namespaceURI === SVG && (name !== 'svg' || element.parentElement?.closest('svg')))
213
+ return 0;
214
+ const r = element.getBoundingClientRect();
215
+ if (r.width < 2 || r.height < 2)
216
+ return 0;
217
+ const style = this.view.getComputedStyle(element);
218
+ if (style.visibility !== 'visible' || style.opacity === '0')
219
+ return 0;
220
+ if (element.matches(NAMED))
221
+ return 5;
222
+ if (r.width * r.height > region.width * region.height * LAYOUT_SHARE)
223
+ return 0;
224
+ if (element.matches(CONTROL) || element.matches(MEDIA))
225
+ return 4;
226
+ if (element.matches(CONTENT) || ownText(element))
227
+ return 3;
228
+ if (element.matches(GROUP) || boxed(style))
229
+ return 2;
230
+ return 0;
231
+ }
232
+ candidate(element) {
233
+ return this.rank(element) > 0;
234
+ }
235
+ /** The element a hit stands for: its nearest meaningful ancestor, taken in by a compact control. */
236
+ target(hit) {
237
+ let element = hit;
238
+ while (element && !this.candidate(element))
239
+ element = element.parentElement;
240
+ if (!element)
241
+ return undefined;
242
+ const control = element.parentElement?.closest(CONTROL);
243
+ if (control && this.candidate(control)) {
244
+ const r = control.getBoundingClientRect();
245
+ if (r.width <= COMPACT.width && r.height <= COMPACT.height)
246
+ element = control;
247
+ }
248
+ // A wrapper the same size as what it wraps is the same thing; keep the one that means more.
249
+ for (let parent = element.parentElement; parent && sameBox(parent, element); parent = parent.parentElement) {
250
+ if (this.rank(parent) < this.rank(element))
251
+ break;
252
+ element = parent;
253
+ }
254
+ return element;
255
+ }
256
+ describe(element, box) {
257
+ const radius = parseFloat(this.view.getComputedStyle(element).borderTopLeftRadius) || 0;
258
+ return { element, box, radius: Math.min(radius, box.width / 2, box.height / 2), label: labelOf(element) };
259
+ }
260
+ }
261
+ function labelOf(element) {
262
+ const name = element.getAttribute('data-vc-target') ||
263
+ element.getAttribute('data-pf-comment') ||
264
+ element.getAttribute('aria-label') ||
265
+ element.getAttribute('alt') ||
266
+ element.getAttribute('title') ||
267
+ element.getAttribute('placeholder') ||
268
+ visibleText(element) ||
269
+ // A label or wrapper around a field is named by the field.
270
+ element.querySelector('input,textarea,select')?.getAttribute('placeholder') ||
271
+ element.querySelector('input,textarea,select')?.getAttribute('aria-label');
272
+ const label = name ? `${kindOf(element)} “${shorten(name, 60)}”` : kindOf(element);
273
+ const column = columnOf(element);
274
+ return column ? `${label} in “${shorten(column, 40)}”` : label;
275
+ }
276
+ /** A table cell's column heading, so a bare number says what it counts. */
277
+ function columnOf(element) {
278
+ if (element.localName !== 'td')
279
+ return undefined;
280
+ const index = [...(element.parentElement?.children ?? [])].indexOf(element);
281
+ const head = element.closest('table')?.querySelector('thead tr')?.children[index];
282
+ return head ? visibleText(head) || undefined : undefined;
283
+ }
284
+ const ROLES = {
285
+ button: 'button', link: 'link', tab: 'tab', menuitem: 'menu item', checkbox: 'checkbox',
286
+ switch: 'switch', option: 'option', row: 'row', cell: 'cell', gridcell: 'cell',
287
+ columnheader: 'column header', listitem: 'list item', region: 'region', dialog: 'dialog',
288
+ tablist: 'tabs', grid: 'table', table: 'table', img: 'image'
289
+ };
290
+ const TAGS = {
291
+ a: 'link', button: 'button', select: 'menu', textarea: 'field', label: 'label', summary: 'button',
292
+ img: 'image', picture: 'image', svg: 'graphic', canvas: 'graphic', video: 'video', iframe: 'embed',
293
+ h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
294
+ p: 'text', li: 'list item', td: 'cell', th: 'column header', tr: 'row', table: 'table',
295
+ ul: 'list', ol: 'list', nav: 'navigation', header: 'header', footer: 'footer', aside: 'sidebar',
296
+ section: 'section', article: 'article', form: 'form', dialog: 'dialog', figure: 'figure',
297
+ blockquote: 'quote', pre: 'code', caption: 'caption', figcaption: 'caption', legend: 'legend'
298
+ };
299
+ /** Tags that say more than a role a page gives them, e.g. a table row that is also a link. */
300
+ const STRUCTURE = new Set(['tr', 'td', 'th', 'li']);
301
+ function kindOf(element) {
302
+ if (STRUCTURE.has(element.localName))
303
+ return TAGS[element.localName];
304
+ const role = ROLES[element.getAttribute('role') ?? ''];
305
+ if (role)
306
+ return role;
307
+ if (element.localName === 'input') {
308
+ const type = element.getAttribute('type') ?? 'text';
309
+ return type === 'checkbox' || type === 'radio' ? type : type === 'button' || type === 'submit' ? 'button' : 'field';
310
+ }
311
+ return TAGS[element.localName] ?? (ownText(element) ? 'text' : 'box');
312
+ }
313
+ /**
314
+ * A short path to the element: from the nearest ancestor with an id, or one
315
+ * the page names with `data-vc-target` or `data-testid`, by tag and position.
316
+ */
317
+ function selectorOf(element) {
318
+ const parts = [];
319
+ for (let node = element; node && node.localName !== 'html'; node = node.parentElement) {
320
+ if (node.id) {
321
+ parts.unshift(`#${CSS.escape(node.id)}`);
322
+ break;
323
+ }
324
+ const named = ['data-vc-target', 'data-testid'].find((name) => node.hasAttribute(name));
325
+ if (named) {
326
+ const selector = `[${named}="${CSS.escape(node.getAttribute(named))}"]`;
327
+ if (node.ownerDocument.querySelectorAll(selector).length === 1) {
328
+ parts.unshift(selector);
329
+ break;
330
+ }
331
+ }
332
+ let part = node.localName;
333
+ const twins = node.parentElement
334
+ ? [...node.parentElement.children].filter((child) => child.localName === node.localName)
335
+ : [];
336
+ if (twins.length > 1)
337
+ part += `:nth-of-type(${twins.indexOf(node) + 1})`;
338
+ parts.unshift(part);
339
+ }
340
+ return parts.join(' > ');
341
+ }
342
+ function visibleText(element) {
343
+ const text = element.innerText ?? element.textContent ?? '';
344
+ return text.replace(/\s+/g, ' ').trim();
345
+ }
346
+ const normalize = (text) => text.replace(/\s+/g, ' ').trim().toLowerCase();
347
+ function ownText(element) {
348
+ for (const node of element.childNodes)
349
+ if (node.nodeType === 3 && node.textContent?.trim())
350
+ return true;
351
+ return false;
352
+ }
353
+ function boxed(style) {
354
+ if (!TRANSPARENT.test(style.backgroundColor) || style.backgroundImage !== 'none' || style.boxShadow !== 'none')
355
+ return true;
356
+ return ['Top', 'Right', 'Bottom', 'Left'].some((side) => parseFloat(style[`border${side}Width`]) > 0 && !TRANSPARENT.test(style[`border${side}Color`]));
357
+ }
358
+ function sameBox(a, b) {
359
+ const r = a.getBoundingClientRect();
360
+ const s = b.getBoundingClientRect();
361
+ return Math.abs(r.left - s.left) < 1 && Math.abs(r.top - s.top) < 1
362
+ && Math.abs(r.right - s.right) < 1 && Math.abs(r.bottom - s.bottom) < 1;
363
+ }
364
+ /** The pointer, then rings around it, so nearby elements are found as well as the one under it. */
365
+ function probes(radius) {
366
+ const points = [[0, 0]];
367
+ for (const [reach, count] of [[radius / 2, 4], [radius, 8]]) {
368
+ for (let i = 0; i < count; i++) {
369
+ const angle = (i / count) * Math.PI * 2 + (count === 4 ? Math.PI / 4 : 0);
370
+ points.push([Math.cos(angle) * reach, Math.sin(angle) * reach]);
371
+ }
372
+ }
373
+ return points;
374
+ }
375
+ function distance(box, x, y) {
376
+ const dx = Math.max(box.x - x, 0, x - (box.x + box.width));
377
+ const dy = Math.max(box.y - y, 0, y - (box.y + box.height));
378
+ return Math.hypot(dx, dy);
379
+ }
380
+ const area = (box) => box.width * box.height;
381
+ export const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
382
+ export const shorten = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
@@ -0,0 +1,46 @@
1
+ import type { Api } from './api.js';
2
+ import type { CommentImage, CommentThread, NewThreadInput, Viewer } from '../shared/types.js';
3
+ /**
4
+ * The space's threads, kept current with the server's event stream. Actions
5
+ * show their result at once and resync with the server when they fail.
6
+ */
7
+ export declare class Threads {
8
+ viewer: Viewer | null;
9
+ threads: CommentThread[];
10
+ spaceName: string;
11
+ loading: boolean;
12
+ /** A failed action, shown until dismissed. */
13
+ error: string | null;
14
+ /** Ticks every 30 seconds so relative times stay current. */
15
+ now: number;
16
+ private api;
17
+ private refreshing?;
18
+ private queued;
19
+ private listening?;
20
+ private nextPending;
21
+ constructor(api: Api);
22
+ get unreadCount(): number;
23
+ start(): () => void;
24
+ /** Coalesces overlapping refreshes into at most one more request. */
25
+ refresh(): Promise<void>;
26
+ private stopListening;
27
+ /** Follows the event stream; after a dropped connection, catches up on anything missed. */
28
+ private listen;
29
+ private request;
30
+ private apply;
31
+ /** Runs an action, showing its error and resyncing with the server if it fails. */
32
+ private run;
33
+ private update;
34
+ imageUrl(image: CommentImage): string;
35
+ /** Uploads an image for a comment being written. Send its id with the comment. */
36
+ upload(file: Blob, width: number, height: number): Promise<CommentImage>;
37
+ create(input: Omit<NewThreadInput, 'images'>, images?: CommentImage[]): Promise<CommentThread>;
38
+ /** Shows the reply at once; throws if it could not be sent. */
39
+ reply(threadId: number, body: string, images?: CommentImage[]): Promise<void>;
40
+ edit(commentId: number, body: string, images: CommentImage[]): Promise<void>;
41
+ removeComment(commentId: number): Promise<void>;
42
+ removeThread(threadId: number): Promise<void>;
43
+ resolve(threadId: number, resolved: boolean): Promise<void>;
44
+ markRead(threadId: number, read: boolean): Promise<void>;
45
+ markAllRead(): Promise<void>;
46
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The space's threads, kept current with the server's event stream. Actions
3
+ * show their result at once and resync with the server when they fail.
4
+ */
5
+ export class Threads {
6
+ viewer = $state(null);
7
+ threads = $state([]);
8
+ spaceName = $state('');
9
+ loading = $state(true);
10
+ /** A failed action, shown until dismissed. */
11
+ error = $state(null);
12
+ /** Ticks every 30 seconds so relative times stay current. */
13
+ now = $state(Date.now());
14
+ api;
15
+ refreshing;
16
+ queued = false;
17
+ listening;
18
+ nextPending = -1;
19
+ constructor(api) {
20
+ this.api = api;
21
+ }
22
+ get unreadCount() {
23
+ return this.threads.filter((thread) => thread.unread && !thread.resolvedAt).length;
24
+ }
25
+ start() {
26
+ void this.refresh();
27
+ const onfocus = () => { void this.refresh(); };
28
+ window.addEventListener('focus', onfocus);
29
+ const timer = setInterval(() => { this.now = Date.now(); }, 30_000);
30
+ return () => {
31
+ window.removeEventListener('focus', onfocus);
32
+ clearInterval(timer);
33
+ this.stopListening();
34
+ };
35
+ }
36
+ /** Coalesces overlapping refreshes into at most one more request. */
37
+ refresh() {
38
+ if (this.refreshing) {
39
+ this.queued = true;
40
+ return this.refreshing;
41
+ }
42
+ this.refreshing = (async () => {
43
+ try {
44
+ const response = await this.api.fetch('');
45
+ if (!response.ok)
46
+ throw new Error();
47
+ const snapshot = (await response.json());
48
+ this.viewer = snapshot.viewer;
49
+ this.spaceName = snapshot.space.name;
50
+ // Keep replies that are still on their way to the server.
51
+ const pending = new Map(this.threads.map((t) => [t.id, t.comments.filter((c) => c.pending)]));
52
+ this.threads = snapshot.threads.map((thread) => {
53
+ const waiting = pending.get(thread.id);
54
+ return waiting?.length ? { ...thread, comments: [...thread.comments, ...waiting] } : thread;
55
+ });
56
+ if (this.viewer)
57
+ this.listen();
58
+ else
59
+ this.stopListening();
60
+ }
61
+ catch {
62
+ // Keep what is shown; the next event or focus retries.
63
+ }
64
+ finally {
65
+ this.loading = false;
66
+ this.refreshing = undefined;
67
+ if (this.queued) {
68
+ this.queued = false;
69
+ void this.refresh();
70
+ }
71
+ }
72
+ })();
73
+ return this.refreshing;
74
+ }
75
+ stopListening() {
76
+ this.listening?.abort();
77
+ this.listening = undefined;
78
+ }
79
+ /** Follows the event stream; after a dropped connection, catches up on anything missed. */
80
+ listen() {
81
+ if (this.listening)
82
+ return;
83
+ const controller = new AbortController();
84
+ this.listening = controller;
85
+ void (async () => {
86
+ let connected = false;
87
+ while (!controller.signal.aborted && this.viewer) {
88
+ try {
89
+ const response = await this.api.fetch('/events', { signal: controller.signal });
90
+ if (!response.ok || !response.body)
91
+ break;
92
+ if (connected)
93
+ void this.refresh();
94
+ connected = true;
95
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
96
+ let buffer = '';
97
+ for (;;) {
98
+ const { value, done } = await reader.read();
99
+ if (done)
100
+ break;
101
+ buffer += value;
102
+ let end;
103
+ while ((end = buffer.indexOf('\n\n')) !== -1) {
104
+ if (/^event: change$/m.test(buffer.slice(0, end)))
105
+ void this.refresh();
106
+ buffer = buffer.slice(end + 2);
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ // Dropped; try again shortly.
112
+ }
113
+ if (controller.signal.aborted)
114
+ break;
115
+ await new Promise((done) => setTimeout(done, 3000));
116
+ }
117
+ if (this.listening === controller)
118
+ this.listening = undefined;
119
+ })();
120
+ }
121
+ async request(method, path, body) {
122
+ const response = await this.api.fetch(path, {
123
+ method,
124
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
125
+ body: body === undefined ? undefined : JSON.stringify(body)
126
+ });
127
+ const result = await response.json().catch(() => ({}));
128
+ if (!response.ok) {
129
+ throw new Error(result.error ?? 'Something went wrong. Try again.');
130
+ }
131
+ return result;
132
+ }
133
+ apply(result) {
134
+ if (result.thread) {
135
+ const thread = result.thread;
136
+ const index = this.threads.findIndex((t) => t.id === thread.id);
137
+ if (index === -1)
138
+ this.threads.push(thread);
139
+ else
140
+ this.threads[index] = thread;
141
+ }
142
+ if (result.deleted !== undefined)
143
+ this.threads = this.threads.filter((t) => t.id !== result.deleted);
144
+ }
145
+ /** Runs an action, showing its error and resyncing with the server if it fails. */
146
+ async run(action, rethrow = false) {
147
+ try {
148
+ this.apply(await action());
149
+ }
150
+ catch (error) {
151
+ this.error = error.message;
152
+ void this.refresh();
153
+ if (rethrow)
154
+ throw error;
155
+ }
156
+ }
157
+ update(id, change) {
158
+ const index = this.threads.findIndex((t) => t.id === id);
159
+ if (index !== -1)
160
+ this.threads[index] = change(this.threads[index]);
161
+ }
162
+ imageUrl(image) {
163
+ return `${this.api.endpoint}/images/${image.id}/${image.key}`;
164
+ }
165
+ /** Uploads an image for a comment being written. Send its id with the comment. */
166
+ async upload(file, width, height) {
167
+ const response = await this.api.fetch(`/images?width=${width}&height=${height}`, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': file.type },
170
+ body: file
171
+ });
172
+ const result = await response.json().catch(() => ({}));
173
+ if (!response.ok) {
174
+ throw new Error(result.error ?? (response.status === 413 ? 'This image is too large.' : 'The image could not be uploaded.'));
175
+ }
176
+ return result.image;
177
+ }
178
+ async create(input, images = []) {
179
+ const { thread } = await this.request('POST', '/threads', { ...input, images: images.map((i) => i.id) });
180
+ this.apply({ thread });
181
+ return thread;
182
+ }
183
+ /** Shows the reply at once; throws if it could not be sent. */
184
+ async reply(threadId, body, images = []) {
185
+ const pending = {
186
+ id: this.nextPending--, author: this.viewer, body, images, createdAt: Date.now(), editedAt: null, pending: true
187
+ };
188
+ this.update(threadId, (t) => ({ ...t, resolvedAt: null, resolvedBy: null, comments: [...t.comments, pending] }));
189
+ try {
190
+ await this.run(() => this.request('POST', `/threads/${threadId}/comments`, { body, images: images.map((i) => i.id) }), true);
191
+ }
192
+ finally {
193
+ this.update(threadId, (t) => ({ ...t, comments: t.comments.filter((c) => c.id !== pending.id) }));
194
+ }
195
+ }
196
+ async edit(commentId, body, images) {
197
+ await this.run(() => this.request('PATCH', `/comments/${commentId}`, { body, images: images.map((i) => i.id) }), true);
198
+ }
199
+ async removeComment(commentId) {
200
+ await this.run(() => this.request('DELETE', `/comments/${commentId}`));
201
+ }
202
+ async removeThread(threadId) {
203
+ this.threads = this.threads.filter((t) => t.id !== threadId);
204
+ await this.run(() => this.request('DELETE', `/threads/${threadId}`));
205
+ }
206
+ async resolve(threadId, resolved) {
207
+ this.update(threadId, (t) => ({
208
+ ...t, resolvedAt: resolved ? Date.now() : null, resolvedBy: resolved ? this.viewer : null
209
+ }));
210
+ await this.run(() => this.request('PATCH', `/threads/${threadId}`, { resolved }));
211
+ }
212
+ async markRead(threadId, read) {
213
+ this.update(threadId, (t) => ({ ...t, unread: !read && t.comments.some((c) => c.author.sub !== this.viewer?.sub) }));
214
+ await this.run(() => this.request('POST', `/threads/${threadId}/read`, { read }));
215
+ }
216
+ async markAllRead() {
217
+ await Promise.all(this.threads.filter((t) => t.unread).map((t) => this.markRead(t.id, true)));
218
+ }
219
+ }
@@ -0,0 +1,3 @@
1
+ /** "just now", "5 minutes ago", "yesterday", then a date. */
2
+ export declare function ago(time: number, now: number): string;
3
+ export declare function fullDate(time: number): string;
@@ -0,0 +1,25 @@
1
+ const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
2
+ const UNITS = [
3
+ ['minute', 60_000],
4
+ ['hour', 3_600_000],
5
+ ['day', 86_400_000]
6
+ ];
7
+ /** "just now", "5 minutes ago", "yesterday", then a date. */
8
+ export function ago(time, now) {
9
+ const elapsed = Math.max(0, now - time);
10
+ if (elapsed < 60_000)
11
+ return 'just now';
12
+ if (elapsed < 7 * 86_400_000) {
13
+ const [unit, size] = UNITS.findLast(([, size]) => elapsed >= size);
14
+ return relative.format(-Math.floor(elapsed / size), unit);
15
+ }
16
+ const date = new Date(time);
17
+ return date.toLocaleDateString(undefined, {
18
+ day: 'numeric',
19
+ month: 'short',
20
+ year: date.getFullYear() === new Date(now).getFullYear() ? undefined : 'numeric'
21
+ });
22
+ }
23
+ export function fullDate(time) {
24
+ return new Date(time).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
25
+ }
@@ -0,0 +1,5 @@
1
+ export { VisualComments, pageOf, type VisualCommentsOptions, type SurfaceInfo, type CommentFilters } from './visual-comments.svelte.js';
2
+ export type * from './shared/types.js';
3
+ import { VisualComments, type VisualCommentsOptions } from './visual-comments.svelte.js';
4
+ /** Adds comments to the page. Call once, in the browser. */
5
+ export declare function createVisualComments(options: VisualCommentsOptions, doc?: Document): VisualComments;