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.
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/core/aim.d.ts +21 -0
- package/dist/core/aim.js +21 -0
- package/dist/core/api.d.ts +5 -0
- package/dist/core/api.js +11 -0
- package/dist/core/metaball.d.ts +15 -0
- package/dist/core/metaball.js +42 -0
- package/dist/core/surfaces.d.ts +95 -0
- package/dist/core/surfaces.js +197 -0
- package/dist/core/target.d.ts +59 -0
- package/dist/core/target.js +382 -0
- package/dist/core/threads.svelte.d.ts +46 -0
- package/dist/core/threads.svelte.js +219 -0
- package/dist/core/time.d.ts +3 -0
- package/dist/core/time.js +25 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/server/index.d.ts +33 -0
- package/dist/server/index.js +67 -0
- package/dist/shared/limits.d.ts +9 -0
- package/dist/shared/limits.js +10 -0
- package/dist/shared/protocol.d.ts +2 -0
- package/dist/shared/protocol.js +3 -0
- package/dist/shared/types.d.ts +93 -0
- package/dist/shared/types.js +2 -0
- package/dist/ui/Account.svelte +68 -0
- package/dist/ui/Account.svelte.d.ts +8 -0
- package/dist/ui/App.svelte +68 -0
- package/dist/ui/App.svelte.d.ts +7 -0
- package/dist/ui/Avatar.svelte +42 -0
- package/dist/ui/Avatar.svelte.d.ts +12 -0
- package/dist/ui/Composer.svelte +267 -0
- package/dist/ui/Composer.svelte.d.ts +20 -0
- package/dist/ui/Icon.svelte +44 -0
- package/dist/ui/Icon.svelte.d.ts +29 -0
- package/dist/ui/Menu.svelte +65 -0
- package/dist/ui/Menu.svelte.d.ts +13 -0
- package/dist/ui/MenuItem.svelte +38 -0
- package/dist/ui/MenuItem.svelte.d.ts +12 -0
- package/dist/ui/Overlay.svelte +488 -0
- package/dist/ui/Overlay.svelte.d.ts +3 -0
- package/dist/ui/Panel.svelte +206 -0
- package/dist/ui/Panel.svelte.d.ts +10 -0
- package/dist/ui/Pin.svelte +136 -0
- package/dist/ui/Pin.svelte.d.ts +13 -0
- package/dist/ui/ThreadCard.svelte +177 -0
- package/dist/ui/ThreadCard.svelte.d.ts +13 -0
- package/dist/ui/Toolbar.svelte +35 -0
- package/dist/ui/Toolbar.svelte.d.ts +18 -0
- package/dist/ui/context.d.ts +3 -0
- package/dist/ui/context.js +8 -0
- package/dist/ui/generated/styles.css +2 -0
- package/dist/ui/generated/styles.d.ts +2 -0
- package/dist/ui/generated/styles.js +3 -0
- package/dist/ui/styles.css +92 -0
- package/dist/ui/text.d.ts +3 -0
- package/dist/ui/text.js +7 -0
- package/dist/visual-comments.svelte.d.ts +181 -0
- package/dist/visual-comments.svelte.js +568 -0
- package/dist-widget/visual-comments.js +51 -0
- 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,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>
|