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,488 @@
1
+ <svelte:options css="injected" />
2
+
3
+ <!--
4
+ Pins, the new-comment field and the open thread, drawn over the page in
5
+ client pixels. Each surface says where its pixels are on screen, so pins
6
+ follow it however it is zoomed, panned or scrolled. Cards open on whichever
7
+ side of a pin has room inside the surface's stage. It also outlines the
8
+ element the pointer is aiming at, the new comment's element and the open
9
+ thread's.
10
+ -->
11
+ <script lang="ts">
12
+ import { untrack } from 'svelte';
13
+ import { useComments } from './context.js';
14
+ import Composer from './Composer.svelte';
15
+ import Pin from './Pin.svelte';
16
+ import ThreadCard from './ThreadCard.svelte';
17
+ import Icon from './Icon.svelte';
18
+ import { describeTarget, MAGNET, widen, type Box } from '../core/target.js';
19
+ import { docBoxToClient, docBoxToSurface, intersect, spotOf, toClient, type Rect, type Surface } from '../core/surfaces.js';
20
+ import { neck } from '../core/metaball.js';
21
+ import type { CommentImage, CommentThread } from '../shared/types.js';
22
+
23
+ const vc = useComments();
24
+ const comments = vc.store;
25
+ const PIN = 32;
26
+ const GAP = 10;
27
+ const EDGE = 8;
28
+ const CARD_WIDTH = 320;
29
+ const COMPOSER_WIDTH = 296;
30
+
31
+ let composer = $state<Composer>();
32
+ let cardHeight = $state(0);
33
+ let shake = $state(false);
34
+
35
+ /** Brings the unsent comment back to attention instead of discarding it. */
36
+ function focusDraft() {
37
+ composer?.focus();
38
+ shake = false;
39
+ requestAnimationFrame(() => (shake = true));
40
+ }
41
+ $effect(() => {
42
+ vc.onFocusDraft(focusDraft);
43
+ return () => vc.onFocusDraft(undefined);
44
+ });
45
+
46
+ /** Where cards on a surface can go: its stage, clear of the list. */
47
+ function stageOf(surface: Surface): Rect {
48
+ const panel = vc.active ? vc.panelRect ?? floatingPanel() : undefined;
49
+ const stage = intersect(surface.stage, { left: 0, top: 0, right: innerWidth, bottom: innerHeight });
50
+ return panel && panel.left < stage.right && panel.left > stage.left ? { ...stage, right: panel.left } : stage;
51
+ }
52
+
53
+ /** The list's place when the host gives it none: along the right edge. */
54
+ function floatingPanel(): Rect {
55
+ return { left: innerWidth - 12 - 320, top: 12, right: innerWidth - 12, bottom: innerHeight - 12 };
56
+ }
57
+
58
+ /** A thread's pin where it is now, or undefined while it is not on screen. */
59
+ function spot(thread: CommentThread) {
60
+ void vc.layout;
61
+ const surface = vc.surface(thread.surface);
62
+ if (!surface) return undefined;
63
+ return { surface, ...spotOf(surface, thread) };
64
+ }
65
+
66
+ // Opening a thread scrolls its element into view inside its document, once found.
67
+ $effect(() => {
68
+ void vc.layout;
69
+ const id = vc.revealing;
70
+ if (id === undefined) return;
71
+ untrack(() => {
72
+ const thread = comments.threads.find((t) => t.id === id);
73
+ if (!thread?.anchor) return void (vc.revealing = undefined);
74
+ const where = spot(thread);
75
+ if (!where?.surface.doc) return;
76
+ if (where.element && where.hidden) vc.revealElement(where.element);
77
+ else vc.revealing = undefined;
78
+ });
79
+ });
80
+
81
+ const pins = $derived(vc.pinned.flatMap((thread) => {
82
+ const where = spot(thread);
83
+ return where && !where.hidden ? [{ thread, left: where.x, top: where.y }] : [];
84
+ }));
85
+
86
+ /** Beside the pin, on the side with room, kept inside the stage. */
87
+ function beside(at: { left: number; top: number }, width: number, height: number, bounds: Rect) {
88
+ let left = at.left + PIN + GAP;
89
+ if (left + width > bounds.right - EDGE) left = at.left - GAP - width;
90
+ left = Math.max(bounds.left + EDGE, Math.min(left, bounds.right - EDGE - width));
91
+ const top = Math.max(bounds.top + EDGE, Math.min(at.top - PIN, bounds.bottom - EDGE - height));
92
+ return { left, top };
93
+ }
94
+
95
+ const open = $derived.by(() => {
96
+ const thread = vc.open;
97
+ const where = thread && spot(thread);
98
+ if (!thread || !where || where.hidden) return undefined;
99
+ const bounds = stageOf(where.surface);
100
+ const at = { left: where.x, top: where.y };
101
+ return { thread, where, card: beside(at, CARD_WIDTH, cardHeight, bounds), maxHeight: bounds.bottom - bounds.top - EDGE * 2 };
102
+ });
103
+
104
+ /** A client box with a little room around it, and its corner radius on screen. */
105
+ function outline(rect: Rect, radius = 0) {
106
+ const pad = 3;
107
+ return {
108
+ left: rect.left - pad,
109
+ top: rect.top - pad,
110
+ width: rect.right - rect.left + pad * 2,
111
+ height: rect.bottom - rect.top + pad * 2,
112
+ radius: radius + pad
113
+ };
114
+ }
115
+
116
+ const aimSurface = $derived(vc.aim && vc.surface(vc.aim.surface));
117
+ const aiming = $derived.by(() => {
118
+ const aim = vc.aim;
119
+ if (!aim?.target || !aimSurface) return undefined;
120
+ const shape = outline(docBoxToClient(aimSurface, aim.target.box), aim.target.radius * aimSurface.docScale);
121
+ // Like a pointer resting on a button, the outline leans a little toward the pointer.
122
+ const box = aim.targetBox!;
123
+ const lean = (offset: number, size: number) => Math.max(-1, Math.min(1, offset / (size / 2 || 1))) * 3;
124
+ return {
125
+ ...shape,
126
+ dx: lean(aim.point.x - (box.x + box.width / 2), box.width),
127
+ dy: lean(aim.point.y - (box.y + box.height / 2), box.height)
128
+ };
129
+ });
130
+
131
+ /**
132
+ * Over an element the cursor becomes a faint dot, joined to the outline
133
+ * while the pointer is outside it.
134
+ */
135
+ const DOT = 4;
136
+ const dot = $derived.by(() => {
137
+ const cursor = vc.cursor;
138
+ if (!cursor || !aiming) return undefined;
139
+ const box = {
140
+ left: aiming.left + aiming.dx,
141
+ top: aiming.top + aiming.dy,
142
+ width: aiming.width,
143
+ height: aiming.height,
144
+ radius: aiming.radius
145
+ };
146
+ return { ...cursor, neck: neck(cursor.x, cursor.y, DOT, box, MAGNET + 8) };
147
+ });
148
+
149
+ const draftSurface = $derived(vc.draft && vc.surface(vc.draft.surface));
150
+ /** The new comment's element where it is now, following any scroll since the click, in surface pixels. */
151
+ const draftBox = $derived.by((): Box | undefined => {
152
+ void vc.layout;
153
+ const target = vc.draft?.target;
154
+ if (!target || !draftSurface) return undefined;
155
+ if (!target.element.isConnected) return vc.draft!.targetBox;
156
+ const r = target.element.getBoundingClientRect();
157
+ return docBoxToSurface(draftSurface, { x: r.left, y: r.top, width: r.width, height: r.height });
158
+ });
159
+ const drafted = $derived.by(() => {
160
+ const target = vc.draft?.target;
161
+ if (!target || !draftBox || !draftSurface) return undefined;
162
+ const a = toClient(draftSurface, draftBox.x, draftBox.y);
163
+ const s = draftSurface.scale;
164
+ return outline({ left: a.x, top: a.y, right: a.x + draftBox.width * s, bottom: a.y + draftBox.height * s }, target.radius * draftSurface.docScale);
165
+ });
166
+ const wider = $derived(vc.draft?.target && draftSurface && widen(vc.draft.target, draftSurface.search));
167
+ const anchored = $derived(open?.thread.anchor && open.where.box && outline(open.where.box));
168
+ /** The new comment's element, or else the open thread's. */
169
+ const marked = $derived(drafted || (vc.draft ? undefined : anchored));
170
+
171
+ /** Moves the comment to another element, keeping the pin at the same place on screen. */
172
+ function retarget(target: NonNullable<typeof vc.draft>['target']) {
173
+ const draft = vc.draft;
174
+ if (!draft || !draftSurface || !target) return;
175
+ draft.target = target;
176
+ draft.targetBox = docBoxToSurface(draftSurface, target.box);
177
+ composer?.focus();
178
+ }
179
+
180
+ function widenDraft() {
181
+ if (!vc.draft?.target || !wider) return;
182
+ vc.draft.inner = [vc.draft.target, ...(vc.draft.inner ?? [])];
183
+ retarget(wider);
184
+ }
185
+
186
+ function narrowDraft() {
187
+ const [inner, ...rest] = vc.draft?.inner ?? [];
188
+ if (!vc.draft || !inner) return;
189
+ vc.draft.inner = rest;
190
+ retarget(inner);
191
+ }
192
+
193
+ /** Leaves the element and pins the comment where the pointer was. */
194
+ function detachDraft() {
195
+ const draft = vc.draft;
196
+ if (!draft) return;
197
+ draft.x = draft.point.x;
198
+ draft.y = draft.point.y;
199
+ draft.target = undefined;
200
+ draft.targetBox = undefined;
201
+ draft.inner = undefined;
202
+ composer?.focus();
203
+ }
204
+
205
+ function onDraftKeydown(event: KeyboardEvent) {
206
+ if (!event.altKey || event.metaKey || event.ctrlKey) return;
207
+ if (event.key === 'ArrowUp') widenDraft();
208
+ else if (event.key === 'ArrowDown') narrowDraft();
209
+ else return;
210
+ event.preventDefault();
211
+ }
212
+
213
+ const pending = $derived.by(() => {
214
+ const draft = vc.draft;
215
+ if (!draft || !draftSurface) return undefined;
216
+ const box = draft.targetBox && draftBox;
217
+ const x = box ? box.x + draft.x - draft.targetBox!.x : draft.x;
218
+ const y = box ? box.y + draft.y - draft.targetBox!.y : draft.y;
219
+ const c = toClient(draftSurface, x, y);
220
+ const at = { left: c.x, top: c.y };
221
+ return { at, card: beside({ left: at.left, top: at.top + 4 }, COMPOSER_WIDTH, 40, stageOf(draftSurface)) };
222
+ });
223
+
224
+ async function submitDraft(body: string, images: CommentImage[]) {
225
+ const draft = vc.draft;
226
+ const surface = draftSurface;
227
+ if (!draft || !surface) return;
228
+ try {
229
+ const anchor = draft.target && draft.targetBox
230
+ ? { ...describeTarget(draft.target), ...draft.targetBox }
231
+ : null;
232
+ const thread = await comments.create({
233
+ page: vc.page,
234
+ url: location.href,
235
+ surface: surface.id,
236
+ title: surface.title || document.title,
237
+ x: draft.x,
238
+ y: draft.y,
239
+ anchor,
240
+ context: contextFor(surface),
241
+ body
242
+ }, images);
243
+ vc.draft = undefined;
244
+ vc.openThread(thread.id);
245
+ } catch (error) {
246
+ comments.error = (error as Error).message;
247
+ throw error;
248
+ }
249
+ }
250
+
251
+ function contextFor(surface: Surface): Record<string, string> {
252
+ const extra = vc.options.context?.({ id: surface.id, title: surface.title, element: surface.element }) ?? {};
253
+ const entries = Object.entries({ viewport: `${innerWidth}×${innerHeight}`, ...surface.context, ...extra })
254
+ .filter(([, value]) => value !== undefined && value !== '')
255
+ .map(([key, value]) => [key, String(value)]);
256
+ return Object.fromEntries(entries);
257
+ }
258
+ </script>
259
+
260
+ {#if vc.cursor}
261
+ <!-- The .vc-comment-cursor image, drawn. Its hotspot is the bottom-left point. -->
262
+ <svg
263
+ class={['vc-cursor', aiming && 'vc-cursor-hidden']}
264
+ style:left="{vc.cursor.x - 1}px"
265
+ style:top="{vc.cursor.y - 27}px"
266
+ width="28"
267
+ height="28"
268
+ viewBox="0 0 28 28"
269
+ aria-hidden="true"
270
+ >
271
+ <path d="M14 1.5a12.5 12.5 0 1 1 0 25H1.5V14A12.5 12.5 0 0 1 14 1.5z" fill="white" stroke="#1c1917" stroke-width="1.5" />
272
+ <path d="M14 9v10M9 14h10" stroke="#1c1917" stroke-width="1.5" stroke-linecap="round" />
273
+ </svg>
274
+ {/if}
275
+ {#if dot}
276
+ <svg class="vc-dot" aria-hidden="true">
277
+ <g>
278
+ {#if dot.neck}<path d={dot.neck} />{/if}
279
+ <circle cx={dot.x} cy={dot.y} r={DOT} />
280
+ </g>
281
+ </svg>
282
+ {/if}
283
+ {#if aiming}
284
+ <span
285
+ class="vc-target vc-target-aim absolute"
286
+ style:left="{aiming.left}px"
287
+ style:top="{aiming.top}px"
288
+ style:width="{aiming.width}px"
289
+ style:height="{aiming.height}px"
290
+ style:border-radius="{aiming.radius}px"
291
+ style:translate="{aiming.dx}px {aiming.dy}px"
292
+ aria-hidden="true"
293
+ ></span>
294
+ {/if}
295
+ {#if marked}
296
+ <span
297
+ class={['vc-target absolute', !drafted && 'vc-target-quiet']}
298
+ style:left="{marked.left}px"
299
+ style:top="{marked.top}px"
300
+ style:width="{marked.width}px"
301
+ style:height="{marked.height}px"
302
+ style:border-radius="{marked.radius}px"
303
+ aria-hidden="true"
304
+ ></span>
305
+ {/if}
306
+
307
+ {#each pins as pin (pin.thread.id)}
308
+ <Pin
309
+ thread={pin.thread}
310
+ left={pin.left}
311
+ top={pin.top}
312
+ now={comments.now}
313
+ open={pin.thread.id === vc.openId}
314
+ onopen={() => {
315
+ vc.draft = undefined;
316
+ vc.openThread(pin.thread.id === vc.openId ? undefined : pin.thread.id);
317
+ }}
318
+ />
319
+ {/each}
320
+
321
+ {#if pending && vc.draft}
322
+ <span
323
+ class="vc-draft-pin absolute z-20 block size-8 bg-vc-comment shadow-[0_1px_4px_rgb(0_0_0/0.3)]"
324
+ style:left="{pending.at.left}px"
325
+ style:top="{pending.at.top}px"
326
+ aria-hidden="true"
327
+ ></span>
328
+ <!-- Alt+Up and Alt+Down from the field widen or narrow the comment's element. -->
329
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
330
+ <div
331
+ data-vc-ui
332
+ class={['pointer-events-auto absolute z-40 cursor-default', shake && 'vc-shake']}
333
+ style:left="{pending.card.left}px"
334
+ style:top="{pending.card.top}px"
335
+ role="group"
336
+ aria-label="New comment"
337
+ onanimationend={() => (shake = false)}
338
+ onkeydown={onDraftKeydown}
339
+ >
340
+ {#if vc.viewer}
341
+ <Composer
342
+ bind:this={composer}
343
+ variant="new"
344
+ placeholder="Add a comment"
345
+ initial={vc.draft.text}
346
+ autofocus
347
+ oninput={(text) => { if (vc.draft) vc.draft.text = text; }}
348
+ onimages={(count) => { if (vc.draft) vc.draft.images = count; }}
349
+ onsubmit={submitDraft}
350
+ oncancel={() => (vc.draft = undefined)}
351
+ />
352
+ {#if vc.draft.target}
353
+ <div class="mt-1.5 flex w-fit max-w-[296px] items-center rounded-full bg-white py-0.5 pr-0.5 pl-3 text-xs text-vc-muted shadow-[0_1px_6px_rgb(0_0_0/0.16)]">
354
+ <span class="min-w-0 truncate pr-1" title={vc.draft.target.label}>On {vc.draft.target.label}</span>
355
+ {#if vc.draft.inner?.length}
356
+ <button type="button" class="vc-target-tool" aria-label="Select the element inside" title="Inside (⌥↓)" onclick={narrowDraft}>
357
+ <Icon name="chevron-down" size={14} />
358
+ </button>
359
+ {/if}
360
+ {#if wider}
361
+ <button type="button" class="vc-target-tool" aria-label="Select the element around it" title="Around it (⌥↑)" onclick={widenDraft}>
362
+ <Icon name="chevron-up" size={14} />
363
+ </button>
364
+ {/if}
365
+ <button type="button" class="vc-target-tool" aria-label="Pin to the point instead" title="Pin to the point instead" onclick={detachDraft}>
366
+ <Icon name="close" size={14} />
367
+ </button>
368
+ </div>
369
+ {/if}
370
+ {:else}
371
+ <div class="flex w-[296px] items-center gap-3 rounded-[18px] bg-white py-2 pr-2 pl-4 text-[13px] text-vc-ink shadow-[0_2px_14px_rgb(0_0_0/0.18)]">
372
+ <p class="flex-1">Sign in to comment.</p>
373
+ <button type="button" class="h-7 rounded-full bg-vc-comment px-3 font-medium text-white hover:brightness-110"
374
+ onclick={() => vc.signIn()}>Sign in</button>
375
+ </div>
376
+ {/if}
377
+ </div>
378
+ {/if}
379
+
380
+ {#if open}
381
+ <div
382
+ class="absolute z-40"
383
+ style:left="{open.card.left}px"
384
+ style:top="{open.card.top}px"
385
+ bind:offsetHeight={cardHeight}
386
+ >
387
+ <ThreadCard
388
+ thread={open.thread}
389
+ title={vc.titleOf(open.thread)}
390
+ link={vc.linkOf(open.thread)}
391
+ maxHeight={Math.max(200, open.maxHeight)}
392
+ onclose={() => vc.openThread(undefined)}
393
+ />
394
+ </div>
395
+ {/if}
396
+
397
+ <style>
398
+ .vc-cursor {
399
+ position: fixed;
400
+ z-index: 50;
401
+ transform-origin: 1px 27px;
402
+ transition: opacity 140ms ease, scale 140ms ease;
403
+ }
404
+ .vc-cursor-hidden {
405
+ opacity: 0;
406
+ scale: 0.6;
407
+ }
408
+ .vc-dot {
409
+ position: fixed;
410
+ inset: 0;
411
+ z-index: 50;
412
+ width: 100%;
413
+ height: 100%;
414
+ overflow: visible;
415
+ animation: vc-dot-in 140ms ease;
416
+ }
417
+ /* One group, so where the neck overlaps the dot it is not darker. */
418
+ .vc-dot g {
419
+ fill: var(--color-vc-comment);
420
+ opacity: 0.5;
421
+ }
422
+ @keyframes vc-dot-in {
423
+ from {
424
+ opacity: 0;
425
+ }
426
+ }
427
+ .vc-target {
428
+ border: 2px solid var(--color-vc-comment);
429
+ background: color-mix(in srgb, var(--color-vc-comment) 8%, transparent);
430
+ }
431
+ .vc-target-aim {
432
+ transition-property: left, top, width, height, border-radius, translate;
433
+ transition-duration: 110ms, 110ms, 110ms, 110ms, 110ms, 60ms;
434
+ transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
435
+ animation: vc-target-in 110ms ease-out;
436
+ }
437
+ @keyframes vc-target-in {
438
+ from {
439
+ opacity: 0;
440
+ scale: 1.06;
441
+ }
442
+ }
443
+ .vc-target-quiet {
444
+ border: 1.5px dashed var(--color-vc-comment);
445
+ background: none;
446
+ }
447
+ .vc-target-tool {
448
+ display: grid;
449
+ place-items: center;
450
+ width: 22px;
451
+ height: 22px;
452
+ border-radius: 999px;
453
+ color: var(--color-vc-muted);
454
+ }
455
+ .vc-target-tool:hover {
456
+ background: var(--color-vc-wash);
457
+ color: var(--color-vc-ink);
458
+ }
459
+ .vc-draft-pin {
460
+ border-radius: 16px 16px 16px 0;
461
+ transform: translateY(-100%);
462
+ animation: vc-drop 180ms cubic-bezier(0.2, 0.9, 0.3, 1.3);
463
+ transform-origin: bottom left;
464
+ }
465
+ @keyframes vc-drop {
466
+ from {
467
+ transform: translateY(-100%) scale(0.4);
468
+ opacity: 0;
469
+ }
470
+ }
471
+ .vc-shake {
472
+ animation: vc-shake 320ms ease;
473
+ }
474
+ @keyframes vc-shake {
475
+ 20%, 60% { translate: -6px 0; }
476
+ 40%, 80% { translate: 6px 0; }
477
+ }
478
+ @media (prefers-reduced-motion: reduce) {
479
+ .vc-draft-pin,
480
+ .vc-shake,
481
+ .vc-target-aim,
482
+ .vc-cursor,
483
+ .vc-dot {
484
+ animation: none;
485
+ transition: none;
486
+ }
487
+ }
488
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const Overlay: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type Overlay = ReturnType<typeof Overlay>;
3
+ export default Overlay;
@@ -0,0 +1,206 @@
1
+ <!-- The threads of this page, or of every page. Choosing one shows it where it was placed. -->
2
+ <script lang="ts">
3
+ import { fly } from 'svelte/transition';
4
+ import { useComments } from './context.js';
5
+ import Avatar from './Avatar.svelte';
6
+ import Icon from './Icon.svelte';
7
+ import Menu from './Menu.svelte';
8
+ import MenuItem from './MenuItem.svelte';
9
+ import { commentText } from './text.js';
10
+ import { ago, fullDate } from '../core/time.js';
11
+ import type { CommentThread } from '../shared/types.js';
12
+
13
+ let { onselect, onclose, class: className = '', style = '' }: {
14
+ onselect: (thread: CommentThread) => void;
15
+ onclose: () => void;
16
+ class?: string;
17
+ style?: string;
18
+ } = $props();
19
+
20
+ const vc = useComments();
21
+ const comments = vc.store;
22
+ const filters = $derived(vc.filters);
23
+ const viewer = $derived(vc.viewer);
24
+
25
+ const lastActivity = (thread: CommentThread) => thread.comments.at(-1)?.createdAt ?? thread.createdAt;
26
+ /** Threads on other pages, which the list shows only when asked. */
27
+ const elsewhere = $derived(comments.threads.filter((t) => t.page !== vc.page).length);
28
+ const here = $derived(comments.threads.filter((t) => filters.allPages || t.page === vc.page));
29
+ const resolvedCount = $derived(here.filter((t) => t.resolvedAt).length);
30
+
31
+ const visible = $derived.by(() => {
32
+ const query = filters.query.trim().toLocaleLowerCase();
33
+ const matches = (thread: CommentThread) =>
34
+ !query ||
35
+ `#${thread.id}` === query ||
36
+ vc.titleOf(thread).toLocaleLowerCase().includes(query) ||
37
+ thread.comments.some((c) =>
38
+ c.body.toLocaleLowerCase().includes(query) || c.author.name.toLocaleLowerCase().includes(query));
39
+ const list = here.filter((thread) =>
40
+ (filters.showResolved || !thread.resolvedAt) &&
41
+ (!filters.onlyMine || thread.comments.some((c) => c.author.sub === viewer?.sub)) &&
42
+ matches(thread));
43
+ // By page, then by the surface's place on it.
44
+ const order = (thread: CommentThread) => vc.orderOf(thread);
45
+ return filters.sort === 'place'
46
+ ? list.sort((a, b) => a.page.localeCompare(b.page) || order(a) - order(b) || a.id - b.id)
47
+ : list.sort((a, b) => lastActivity(b) - lastActivity(a));
48
+ });
49
+
50
+ function copyLink(thread: CommentThread) {
51
+ void navigator.clipboard?.writeText(vc.linkOf(thread));
52
+ }
53
+ </script>
54
+
55
+ <aside
56
+ data-vc-ui
57
+ aria-label="Comments"
58
+ class={['pointer-events-auto flex min-h-0 flex-col overflow-hidden rounded border border-vc-line bg-white text-[13px] text-vc-ink shadow-[0_1px_3px_rgb(0_0_0/0.12)]', className]}
59
+ {style}
60
+ transition:fly={{ x: 16, duration: 160 }}
61
+ >
62
+ <header class="flex h-11 shrink-0 items-center gap-0.5 pr-1.5 pl-3.5">
63
+ <h2 class="min-w-0 flex-1 truncate font-semibold" title={comments.spaceName || undefined}>Comments</h2>
64
+ <Menu label="Filter and sort" align="end" class="vc-tool-light size-7 rounded-full">
65
+ {#snippet trigger()}<Icon name="filter" />{/snippet}
66
+ {#snippet children(close)}
67
+ <MenuItem checked={filters.sort === 'date'} onselect={() => { close(); filters.sort = 'date'; }}>Sort by date</MenuItem>
68
+ <MenuItem checked={filters.sort === 'place'} onselect={() => { close(); filters.sort = 'place'; }}>Sort by place</MenuItem>
69
+ <div class="my-1 border-t border-vc-line"></div>
70
+ {#if elsewhere || filters.allPages}
71
+ <MenuItem checked={filters.allPages} onselect={() => { close(); filters.allPages = !filters.allPages; }}>
72
+ Show every page
73
+ </MenuItem>
74
+ {/if}
75
+ <MenuItem checked={filters.showResolved} onselect={() => { close(); filters.showResolved = !filters.showResolved; }}>
76
+ Show resolved comments
77
+ </MenuItem>
78
+ {#if viewer}
79
+ <MenuItem checked={filters.onlyMine} onselect={() => { close(); filters.onlyMine = !filters.onlyMine; }}>
80
+ Only your threads
81
+ </MenuItem>
82
+ <div class="my-1 border-t border-vc-line"></div>
83
+ <MenuItem checked={false} onselect={() => { close(); void comments.markAllRead(); }}>Mark all as read</MenuItem>
84
+ {/if}
85
+ {/snippet}
86
+ </Menu>
87
+ <button type="button" class="vc-tool-light size-7 rounded-full" title="Close comments (C)" aria-label="Close comments" onclick={onclose}>
88
+ <Icon name="close" />
89
+ </button>
90
+ </header>
91
+
92
+ <div class="shrink-0 border-b border-vc-line px-2.5 pb-2.5">
93
+ <label class="flex h-8 items-center gap-2 rounded-[6px] bg-vc-surface px-2.5 text-vc-muted focus-within:ring-1 focus-within:ring-vc-comment">
94
+ <Icon name="search" size={15} />
95
+ <input
96
+ type="search"
97
+ placeholder="Search"
98
+ aria-label="Search comments"
99
+ bind:value={filters.query}
100
+ onkeydown={(event) => { if (event.key === 'Escape') { filters.query = ''; (event.target as HTMLElement).blur(); } }}
101
+ class="min-w-0 flex-1 bg-transparent text-vc-ink outline-none placeholder:text-vc-muted/80"
102
+ />
103
+ </label>
104
+ </div>
105
+
106
+ {#if !viewer && !comments.loading}
107
+ <div class="shrink-0 border-b border-vc-line px-3.5 py-3">
108
+ <p>Sign in to see comments and add your own.</p>
109
+ <button type="button" class="mt-2 h-7 rounded-full bg-vc-comment px-3 font-medium text-white hover:brightness-110"
110
+ onclick={() => vc.signIn()}>Sign in</button>
111
+ </div>
112
+ {/if}
113
+
114
+ <ol class="min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain p-2">
115
+ {#each visible as thread (thread.id)}
116
+ {@const root = thread.comments[0]}
117
+ {@const replies = thread.comments.length - 1}
118
+ {@const selected = thread.id === vc.openId}
119
+ <li class={['group relative rounded-[8px] px-3 py-2.5', selected ? 'bg-vc-comment-soft' : 'hover:bg-vc-surface']}>
120
+ <button
121
+ type="button"
122
+ class="absolute inset-0 rounded-[8px] focus-visible:outline-2 focus-visible:outline-vc-comment"
123
+ aria-label="Show comment #{thread.id} on {vc.titleOf(thread)}"
124
+ aria-current={selected || undefined}
125
+ onclick={() => onselect(thread)}
126
+ ></button>
127
+ <div class={['pointer-events-none relative', thread.resolvedAt && 'opacity-55']}>
128
+ <div class="flex h-7 items-center">
129
+ {#if thread.unread}
130
+ <span class="absolute top-3 -left-2 size-1.5 rounded-full bg-vc-comment" title="Unread"></span>
131
+ {/if}
132
+ <Avatar user={root.author} size={28} />
133
+ </div>
134
+ <p class="mt-1.5 truncate text-vc-muted">#{thread.id} · {vc.titleOf(thread)}</p>
135
+ <p class="truncate">
136
+ <span class="font-medium">{root.author.name}</span>
137
+ <time class="ml-1 text-vc-muted" title={fullDate(root.createdAt)}>{ago(root.createdAt, comments.now)}</time>
138
+ </p>
139
+ <p class={['line-clamp-3 break-words whitespace-pre-line', thread.unread && 'font-medium']}>{commentText(root)}</p>
140
+ {#if replies > 0}
141
+ <p class="mt-1 text-xs text-vc-muted">
142
+ {replies} {replies === 1 ? 'reply' : 'replies'}
143
+ </p>
144
+ {/if}
145
+ </div>
146
+ {#if viewer}
147
+ <div class="absolute top-2.5 right-2 flex items-center">
148
+ <div class={['transition-opacity', !selected && 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100']}>
149
+ <Menu label="Thread options" align="end" class="vc-tool-light size-7 rounded-full">
150
+ {#snippet trigger()}<Icon name="more" />{/snippet}
151
+ {#snippet children(close)}
152
+ <MenuItem onselect={() => { close(); void comments.markRead(thread.id, !thread.unread); }}>
153
+ {thread.unread ? 'Mark as read' : 'Mark as unread'}
154
+ </MenuItem>
155
+ <MenuItem onselect={() => { close(); copyLink(thread); }}>Copy link</MenuItem>
156
+ {#if root.author.sub === viewer.sub}
157
+ <MenuItem onselect={() => {
158
+ close();
159
+ if (confirm('Delete this thread and all its replies?')) void comments.removeThread(thread.id);
160
+ }}>Delete thread…</MenuItem>
161
+ {/if}
162
+ {/snippet}
163
+ </Menu>
164
+ </div>
165
+ <button
166
+ type="button"
167
+ class={['vc-tool-light size-7 rounded-full', thread.resolvedAt && 'text-vc-comment']}
168
+ aria-pressed={Boolean(thread.resolvedAt)}
169
+ title={thread.resolvedAt ? 'Reopen' : 'Resolve'}
170
+ aria-label={thread.resolvedAt ? `Reopen #${thread.id}` : `Resolve #${thread.id}`}
171
+ onclick={() => comments.resolve(thread.id, !thread.resolvedAt)}
172
+ >
173
+ <Icon name="resolve" size={18} />
174
+ </button>
175
+ </div>
176
+ {/if}
177
+ </li>
178
+ {:else}
179
+ {#if viewer && !comments.loading}
180
+ <li class="px-3 py-6 text-center text-vc-muted">
181
+ {#if filters.query.trim()}
182
+ No comments match “{filters.query.trim()}”.
183
+ {:else if filters.onlyMine}
184
+ You have not started or replied to any threads.
185
+ {:else if resolvedCount && !filters.showResolved}
186
+ Every thread is resolved.
187
+ <button type="button" class="text-vc-comment hover:underline" onclick={() => (filters.showResolved = true)}>
188
+ Show resolved
189
+ </button>
190
+ {:else}
191
+ No comments yet. Click {vc.surfaces.some((s) => s.element) ? 'a frame' : 'anywhere on the page'} to add one.
192
+ {/if}
193
+ </li>
194
+ {/if}
195
+ {/each}
196
+ </ol>
197
+
198
+ {#if comments.error}
199
+ <div role="alert" class="m-2 mt-0 flex shrink-0 items-start gap-2 rounded-[8px] bg-vc-ink px-3 py-2 text-white">
200
+ <p class="flex-1">{comments.error}</p>
201
+ <button type="button" class="text-white/70 hover:text-white" aria-label="Dismiss" onclick={() => (comments.error = null)}>
202
+ <Icon name="close" size={14} />
203
+ </button>
204
+ </div>
205
+ {/if}
206
+ </aside>