visual-comments 0.0.0-stage → 0.2.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 +77 -2
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +477 -0
- 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/presence.d.ts +13 -0
- package/dist/core/presence.js +25 -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 +80 -0
- package/dist/core/threads.svelte.js +336 -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/env.d.ts +78 -0
- package/dist/server/env.js +94 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.js +74 -0
- package/dist/server/variables.d.ts +28 -0
- package/dist/server/variables.js +73 -0
- package/dist/shared/limits.d.ts +9 -0
- package/dist/shared/limits.js +10 -0
- package/dist/shared/protocol.d.ts +6 -0
- package/dist/shared/protocol.js +7 -0
- package/dist/shared/types.d.ts +157 -0
- package/dist/shared/types.js +2 -0
- package/dist/sveltekit/CommentTools.svelte +98 -0
- package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
- package/dist/sveltekit/Comments.svelte +104 -0
- package/dist/sveltekit/Comments.svelte.d.ts +25 -0
- package/dist/sveltekit/Toolbar.svelte +64 -0
- package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
- package/dist/sveltekit/ToolbarButton.svelte +66 -0
- package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
- package/dist/sveltekit/context.d.ts +16 -0
- package/dist/sveltekit/context.js +9 -0
- package/dist/sveltekit/index.d.ts +6 -0
- package/dist/sveltekit/index.js +6 -0
- package/dist/ui/Account.svelte +106 -0
- package/dist/ui/Account.svelte.d.ts +8 -0
- package/dist/ui/App.svelte +77 -0
- package/dist/ui/App.svelte.d.ts +7 -0
- package/dist/ui/Avatar.svelte +64 -0
- package/dist/ui/Avatar.svelte.d.ts +11 -0
- package/dist/ui/Composer.svelte +267 -0
- package/dist/ui/Composer.svelte.d.ts +20 -0
- package/dist/ui/Connect.svelte +128 -0
- package/dist/ui/Connect.svelte.d.ts +6 -0
- package/dist/ui/Icon.svelte +44 -0
- package/dist/ui/Icon.svelte.d.ts +29 -0
- package/dist/ui/Menu.svelte +69 -0
- package/dist/ui/Menu.svelte.d.ts +15 -0
- package/dist/ui/MenuItem.svelte +38 -0
- package/dist/ui/MenuItem.svelte.d.ts +12 -0
- package/dist/ui/Overlay.svelte +502 -0
- package/dist/ui/Overlay.svelte.d.ts +3 -0
- package/dist/ui/Panel.svelte +220 -0
- package/dist/ui/Panel.svelte.d.ts +10 -0
- package/dist/ui/Pin.svelte +156 -0
- package/dist/ui/Pin.svelte.d.ts +15 -0
- package/dist/ui/Presence.svelte +43 -0
- package/dist/ui/Presence.svelte.d.ts +8 -0
- package/dist/ui/ThreadCard.svelte +201 -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 +113 -0
- package/dist/ui/text.d.ts +5 -0
- package/dist/ui/text.js +11 -0
- package/dist/visual-comments.svelte.d.ts +230 -0
- package/dist/visual-comments.svelte.js +693 -0
- package/dist/vite/index.d.ts +20 -0
- package/dist/vite/index.js +78 -0
- package/dist-widget/visual-comments.js +58 -0
- package/package.json +86 -3
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A tool of the comments' toolbar, for the app's own tools beside the
|
|
3
|
+
comment button: round, light on the toolbar's dark glass, and filled while
|
|
4
|
+
`pressed`. Give it an icon as its content and a `label`.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import type { Snippet } from 'svelte';
|
|
8
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
9
|
+
|
|
10
|
+
let { label, pressed, children, ...rest }: HTMLButtonAttributes & {
|
|
11
|
+
/** What the tool does, for screen readers and as its tooltip. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** For a tool that is on or off. */
|
|
14
|
+
pressed?: boolean;
|
|
15
|
+
children: Snippet;
|
|
16
|
+
} = $props();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<button type="button" class="vc-toolbar-button" aria-label={label} title={label} aria-pressed={pressed} {...rest}>
|
|
20
|
+
{@render children()}
|
|
21
|
+
</button>
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.vc-toolbar-button {
|
|
25
|
+
position: relative;
|
|
26
|
+
display: inline-flex;
|
|
27
|
+
height: 2rem;
|
|
28
|
+
min-width: 2rem;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
gap: 0.375rem;
|
|
32
|
+
border: 0;
|
|
33
|
+
border-radius: 9999px;
|
|
34
|
+
padding: 0 0.5rem;
|
|
35
|
+
background: transparent;
|
|
36
|
+
color: inherit;
|
|
37
|
+
font: inherit;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
outline: none;
|
|
40
|
+
}
|
|
41
|
+
.vc-toolbar-button:hover {
|
|
42
|
+
background: var(--vc-island-hover, rgb(255 255 255 / 0.14));
|
|
43
|
+
}
|
|
44
|
+
.vc-toolbar-button[aria-pressed='true'] {
|
|
45
|
+
background: var(--vc-island-ink, #fff);
|
|
46
|
+
color: var(--vc-ink, #292524);
|
|
47
|
+
}
|
|
48
|
+
.vc-toolbar-button:focus-visible {
|
|
49
|
+
box-shadow: 0 0 0 2px rgb(255 255 255 / 0.6);
|
|
50
|
+
}
|
|
51
|
+
.vc-toolbar-button:disabled {
|
|
52
|
+
opacity: 0.5;
|
|
53
|
+
cursor: default;
|
|
54
|
+
}
|
|
55
|
+
/* Something is unread: a dot, as on the comments' own toolbar. */
|
|
56
|
+
.vc-toolbar-button[data-vc-unread]::after {
|
|
57
|
+
content: '';
|
|
58
|
+
position: absolute;
|
|
59
|
+
top: 0.25rem;
|
|
60
|
+
right: 0.25rem;
|
|
61
|
+
width: 0.5rem;
|
|
62
|
+
height: 0.5rem;
|
|
63
|
+
border-radius: 9999px;
|
|
64
|
+
background: var(--vc-comment, #0d99ff);
|
|
65
|
+
}
|
|
66
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
3
|
+
type $$ComponentProps = HTMLButtonAttributes & {
|
|
4
|
+
/** What the tool does, for screen readers and as its tooltip. */
|
|
5
|
+
label: string;
|
|
6
|
+
/** For a tool that is on or off. */
|
|
7
|
+
pressed?: boolean;
|
|
8
|
+
children: Snippet;
|
|
9
|
+
};
|
|
10
|
+
declare const ToolbarButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type ToolbarButton = ReturnType<typeof ToolbarButton>;
|
|
12
|
+
export default ToolbarButton;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Viewer } from '../shared/types.js';
|
|
2
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
3
|
+
/** The comments of the app, as `<Comments>` provides them. Both fields are reactive. */
|
|
4
|
+
export interface AppComments {
|
|
5
|
+
/** Whether comments are on for this deployment: `<Comments config>` is set. */
|
|
6
|
+
readonly enabled: boolean;
|
|
7
|
+
/** The comments, once they have started in the browser; undefined before, during server rendering, and while off. */
|
|
8
|
+
readonly current: VisualComments | undefined;
|
|
9
|
+
/** Who is signed in as the page loaded (`comments.load` on the server); undefined when it did not say. */
|
|
10
|
+
readonly viewer: Viewer | null | undefined;
|
|
11
|
+
/** Where the sign-in routes are, e.g. `/auth`. */
|
|
12
|
+
readonly auth: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function setAppComments(comments: AppComments): void;
|
|
15
|
+
/** The comments of the app, inside `<Comments>`; undefined outside it. */
|
|
16
|
+
export declare function useComments(): AppComments | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
const KEY = Symbol('visual-comments/sveltekit');
|
|
3
|
+
export function setAppComments(comments) {
|
|
4
|
+
setContext(KEY, comments);
|
|
5
|
+
}
|
|
6
|
+
/** The comments of the app, inside `<Comments>`; undefined outside it. */
|
|
7
|
+
export function useComments() {
|
|
8
|
+
return getContext(KEY);
|
|
9
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as Comments, type CommentsProps } from './Comments.svelte';
|
|
2
|
+
export { default as Toolbar } from './Toolbar.svelte';
|
|
3
|
+
export { default as ToolbarButton } from './ToolbarButton.svelte';
|
|
4
|
+
export { useComments, type AppComments } from './context.js';
|
|
5
|
+
export type { CommentsClientConfig } from '../server/env.js';
|
|
6
|
+
export type { VisualComments, VisualCommentsOptions } from '../visual-comments.svelte.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `visual-comments/sveltekit`: comments in a SvelteKit app. The server side,
|
|
2
|
+
// set up from environment variables, is `commentsFromEnv` of `visual-comments/server`.
|
|
3
|
+
export { default as Comments } from './Comments.svelte';
|
|
4
|
+
export { default as Toolbar } from './Toolbar.svelte';
|
|
5
|
+
export { default as ToolbarButton } from './ToolbarButton.svelte';
|
|
6
|
+
export { useComments } from './context.js';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<svelte:options css="injected" />
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
The account button: the viewer's avatar with a menu to sign out and to see
|
|
5
|
+
the space's agents, or Sign in; beside it, who else is here. It sits in the
|
|
6
|
+
floating toolbar, or in a host's own toolbar where the host puts a
|
|
7
|
+
`data-vc-account` element; there it takes the host's text colour.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
11
|
+
import type { Agent } from '../shared/types.js';
|
|
12
|
+
import Avatar from './Avatar.svelte';
|
|
13
|
+
import { setComments } from './context.js';
|
|
14
|
+
import Icon from './Icon.svelte';
|
|
15
|
+
import Menu from './Menu.svelte';
|
|
16
|
+
import MenuItem from './MenuItem.svelte';
|
|
17
|
+
import Presence from './Presence.svelte';
|
|
18
|
+
|
|
19
|
+
let { vc, slot = false }: { vc: VisualComments; slot?: boolean } = $props();
|
|
20
|
+
// svelte-ignore state_referenced_locally
|
|
21
|
+
if (slot) setComments(vc);
|
|
22
|
+
|
|
23
|
+
const tool = $derived(slot ? 'vc-slot-tool' : 'vc-island-tool');
|
|
24
|
+
const signedIn = $derived(Boolean(vc.viewer));
|
|
25
|
+
|
|
26
|
+
// Listed ahead, so the menu opens at its full size.
|
|
27
|
+
$effect(() => {
|
|
28
|
+
if (signedIn) void vc.store.loadAgents();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function remove(agent: Agent) {
|
|
32
|
+
if (confirm(`Remove ${agent.name}? It will no longer see or write comments here.`)) void vc.store.removeAgent(agent.id);
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<span class={['vc-ui inline-flex items-center', slot && 'vc-slot']}>
|
|
37
|
+
{#if vc.viewer}
|
|
38
|
+
<Presence {vc} />
|
|
39
|
+
<Menu label={`Account: ${vc.viewer.name}`} align="end" class="{tool} pr-2 pl-0.5" onopen={() => vc.store.loadAgents()}>
|
|
40
|
+
{#snippet trigger()}
|
|
41
|
+
{#if vc.viewer}<Avatar user={vc.viewer} size={28} />{/if}
|
|
42
|
+
<Icon name="chevron-down" size={14} />
|
|
43
|
+
{/snippet}
|
|
44
|
+
{#snippet children(close)}
|
|
45
|
+
<div class="max-w-72 border-b border-vc-line px-3 py-2">
|
|
46
|
+
<p class="truncate font-medium">{vc.viewer?.name}</p>
|
|
47
|
+
{#if vc.viewer?.email}<p class="mt-0.5 truncate text-xs text-vc-muted">{vc.viewer.email}</p>{/if}
|
|
48
|
+
{#if vc.store.spaceName}<p class="mt-0.5 truncate text-xs text-vc-muted">Comments in {vc.store.spaceName}</p>{/if}
|
|
49
|
+
</div>
|
|
50
|
+
{#if vc.store.agents}
|
|
51
|
+
<div class="max-w-72 border-b border-vc-line py-1.5">
|
|
52
|
+
<p class="px-3 pb-1 text-xs text-vc-muted">Agents</p>
|
|
53
|
+
{#each vc.store.agents as agent (agent.id)}
|
|
54
|
+
<div class="flex items-center gap-2 px-3 py-1">
|
|
55
|
+
<Avatar user={{ sub: `agent:${agent.id}`, name: agent.name, agent: { addedBy: agent.addedBy } }} size={22} />
|
|
56
|
+
<div class="min-w-0 flex-1">
|
|
57
|
+
<p class="truncate">{agent.name}</p>
|
|
58
|
+
<p class="truncate text-xs text-vc-muted">Added by {agent.addedBy.name}</p>
|
|
59
|
+
</div>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
class="shrink-0 rounded-[2px] px-1.5 py-0.5 text-xs text-vc-muted outline-none hover:bg-vc-wash hover:text-vc-ink focus-visible:bg-vc-wash"
|
|
63
|
+
aria-label="Remove {agent.name}"
|
|
64
|
+
onclick={() => remove(agent)}
|
|
65
|
+
>Remove</button>
|
|
66
|
+
</div>
|
|
67
|
+
{:else}
|
|
68
|
+
<p class="px-3 pb-1 text-xs text-vc-muted">
|
|
69
|
+
None yet. An agent asks to join with <code class="font-mono">npx visual-comments connect</code>.
|
|
70
|
+
</p>
|
|
71
|
+
{/each}
|
|
72
|
+
</div>
|
|
73
|
+
{/if}
|
|
74
|
+
<MenuItem onselect={() => { close(); vc.signOut(); }}>Sign out</MenuItem>
|
|
75
|
+
{/snippet}
|
|
76
|
+
</Menu>
|
|
77
|
+
{:else}
|
|
78
|
+
<button type="button" part="button" class="{tool} px-3.5 font-medium" disabled={!vc.store.viewerKnown} onclick={() => vc.signIn()}>
|
|
79
|
+
{vc.store.viewerKnown ? 'Sign in' : 'Connecting…'}
|
|
80
|
+
</button>
|
|
81
|
+
{/if}
|
|
82
|
+
</span>
|
|
83
|
+
|
|
84
|
+
<style>
|
|
85
|
+
.vc-slot {
|
|
86
|
+
font-family: inherit;
|
|
87
|
+
font-size: inherit;
|
|
88
|
+
color: inherit;
|
|
89
|
+
}
|
|
90
|
+
:global(.vc-slot-tool) {
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
height: 2rem;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 0.375rem;
|
|
95
|
+
border-radius: 9999px;
|
|
96
|
+
color: inherit;
|
|
97
|
+
outline: none;
|
|
98
|
+
}
|
|
99
|
+
:global(.vc-slot-tool:hover),
|
|
100
|
+
:global(.vc-slot-tool[aria-expanded='true']) {
|
|
101
|
+
background: color-mix(in srgb, currentColor 14%, transparent);
|
|
102
|
+
}
|
|
103
|
+
:global(.vc-slot-tool:focus-visible) {
|
|
104
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 50%, transparent);
|
|
105
|
+
}
|
|
106
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
vc: VisualComments;
|
|
4
|
+
slot?: boolean;
|
|
5
|
+
};
|
|
6
|
+
declare const Account: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type Account = ReturnType<typeof Account>;
|
|
8
|
+
export default Account;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Everything the comments draw over the page, in one shadow root: layers that
|
|
3
|
+
catch clicks over frames in comment mode, pins and cards, the list of
|
|
4
|
+
threads, the floating toolbar and the dialog that lets an agent join.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
8
|
+
import type { Surface } from '../core/surfaces.js';
|
|
9
|
+
import { setComments } from './context.js';
|
|
10
|
+
import Connect from './Connect.svelte';
|
|
11
|
+
import Overlay from './Overlay.svelte';
|
|
12
|
+
import Panel from './Panel.svelte';
|
|
13
|
+
import Toolbar from './Toolbar.svelte';
|
|
14
|
+
|
|
15
|
+
let { vc }: { vc: VisualComments } = $props();
|
|
16
|
+
// svelte-ignore state_referenced_locally
|
|
17
|
+
setComments(vc);
|
|
18
|
+
|
|
19
|
+
// A thread deleted, here or elsewhere, is no longer open.
|
|
20
|
+
$effect(() => {
|
|
21
|
+
if (vc.openId !== undefined && !vc.loading && !vc.open) vc.openId = undefined;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Frames take pointer events themselves; in comment mode a layer over each catches them first. */
|
|
25
|
+
const catchers = $derived(vc.active ? vc.surfaces.filter((surface) => surface.catches) : []);
|
|
26
|
+
|
|
27
|
+
function wheel(surface: Surface) {
|
|
28
|
+
return (node: HTMLElement) => {
|
|
29
|
+
const onwheel = (event: WheelEvent) => vc.wheel(surface, event);
|
|
30
|
+
node.addEventListener('wheel', onwheel, { passive: false });
|
|
31
|
+
return () => node.removeEventListener('wheel', onwheel);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const panel = $derived(vc.panelRect);
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<div class="vc-ui pointer-events-none fixed inset-0 font-vc text-[13px] text-vc-ink">
|
|
39
|
+
{#each catchers as surface (surface.id)}
|
|
40
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
41
|
+
<div
|
|
42
|
+
{@attach wheel(surface)}
|
|
43
|
+
role="presentation"
|
|
44
|
+
class={['pointer-events-auto absolute', vc.cursor ? 'cursor-none!' : 'vc-comment-cursor']}
|
|
45
|
+
style:left="{surface.clip.left}px"
|
|
46
|
+
style:top="{surface.clip.top}px"
|
|
47
|
+
style:width="{Math.max(0, surface.clip.right - surface.clip.left)}px"
|
|
48
|
+
style:height="{Math.max(0, surface.clip.bottom - surface.clip.top)}px"
|
|
49
|
+
onpointermove={(event) => vc.move({ x: event.clientX, y: event.clientY, alt: event.altKey })}
|
|
50
|
+
onpointerleave={() => vc.move(undefined)}
|
|
51
|
+
onclick={(event) => vc.place(surface, event.clientX, event.clientY, event.altKey)}
|
|
52
|
+
></div>
|
|
53
|
+
{/each}
|
|
54
|
+
|
|
55
|
+
<Overlay />
|
|
56
|
+
|
|
57
|
+
{#if vc.active}
|
|
58
|
+
<Panel
|
|
59
|
+
class={panel ? 'absolute z-20' : vc.panelCollapsed ? 'absolute top-3 right-3 z-20 w-40' : 'absolute top-3 right-3 bottom-3 z-20 w-80'}
|
|
60
|
+
style={panel ? vc.panelCollapsed
|
|
61
|
+
? `left:${Math.max(panel.left, panel.right - 160)}px;top:${panel.top}px;width:${Math.min(160, panel.right - panel.left)}px;height:46px`
|
|
62
|
+
: `left:${panel.left}px;top:${panel.top}px;width:${panel.right - panel.left}px;height:${panel.bottom - panel.top}px` : ''}
|
|
63
|
+
onselect={(thread) => vc.focus(thread)}
|
|
64
|
+
onclose={() => vc.toggle(false)}
|
|
65
|
+
/>
|
|
66
|
+
{/if}
|
|
67
|
+
|
|
68
|
+
{#if vc.showToolbar}
|
|
69
|
+
<Toolbar />
|
|
70
|
+
{/if}
|
|
71
|
+
|
|
72
|
+
{#if vc.connecting}
|
|
73
|
+
{#key vc.connecting}
|
|
74
|
+
<Connect id={vc.connecting} />
|
|
75
|
+
{/key}
|
|
76
|
+
{/if}
|
|
77
|
+
</div>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A person's profile picture, or their initial on a colour of their own.
|
|
3
|
+
An agent's has a badge, and says who added it when hovered.
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts" module>
|
|
6
|
+
// Others get a stable colour from their id; the viewer keeps the account colour.
|
|
7
|
+
const COLORS = ['#7b61ff', '#f24822', '#14ae5c', '#ffa629', '#0d99ff', '#e5487d', '#667799'];
|
|
8
|
+
|
|
9
|
+
function colorFor(sub: string) {
|
|
10
|
+
let hash = 0;
|
|
11
|
+
for (const char of sub) hash = (hash * 31 + char.codePointAt(0)!) | 0;
|
|
12
|
+
return COLORS[Math.abs(hash) % COLORS.length];
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
import type { Author } from '../shared/types.js';
|
|
18
|
+
import { useComments } from './context.js';
|
|
19
|
+
import { authorLabel } from './text.js';
|
|
20
|
+
|
|
21
|
+
let { user, size = 24, title, class: className = '' }: {
|
|
22
|
+
user: Author;
|
|
23
|
+
size?: number;
|
|
24
|
+
/** Default: an agent's name and who added it. */
|
|
25
|
+
title?: string;
|
|
26
|
+
class?: string;
|
|
27
|
+
} = $props();
|
|
28
|
+
|
|
29
|
+
const vc = useComments();
|
|
30
|
+
let failed = $state<string>();
|
|
31
|
+
const initial = $derived(Array.from(user.name.trim() || '?')[0].toLocaleUpperCase());
|
|
32
|
+
const self = $derived(vc?.viewer?.sub === user.sub);
|
|
33
|
+
const badge = $derived(Math.max(10, Math.round(size * 0.45)));
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<span
|
|
37
|
+
class={['grid shrink-0 place-items-center rounded-full leading-none text-white select-none', className]}
|
|
38
|
+
style:width="{size}px"
|
|
39
|
+
style:height="{size}px"
|
|
40
|
+
style:font-size="{Math.round(size * 0.5)}px"
|
|
41
|
+
style:background={self ? 'var(--color-vc-avatar)' : colorFor(user.sub)}
|
|
42
|
+
title={title ?? (user.agent ? authorLabel(user) : undefined)}
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
>
|
|
45
|
+
{#if user.picture && user.picture !== failed}
|
|
46
|
+
<img src={user.picture} alt="" referrerpolicy="no-referrer" class="size-full rounded-full object-cover [grid-area:1/1]" draggable="false"
|
|
47
|
+
onerror={() => { failed = user.picture; }} />
|
|
48
|
+
{:else}
|
|
49
|
+
<span class="[grid-area:1/1]">{initial}</span>
|
|
50
|
+
{/if}
|
|
51
|
+
{#if user.agent}
|
|
52
|
+
<!-- Past the corner, like a status dot; nothing clips it. -->
|
|
53
|
+
<span
|
|
54
|
+
class="grid place-items-center self-end justify-self-end rounded-full bg-vc-agent text-white ring-[1.5px] ring-white [grid-area:1/1]"
|
|
55
|
+
style:width="{badge}px"
|
|
56
|
+
style:height="{badge}px"
|
|
57
|
+
style:margin="0 {-badge / 4}px {-badge / 4}px 0"
|
|
58
|
+
>
|
|
59
|
+
<svg width={badge - 3} height={badge - 3} viewBox="0 0 24 24" fill="currentColor">
|
|
60
|
+
<path d="M12 2c.8 5.6 4.4 9.2 10 10-5.6.8-9.2 4.4-10 10-.8-5.6-4.4-9.2-10-10 5.6-.8 9.2-4.4 10-10z" />
|
|
61
|
+
</svg>
|
|
62
|
+
</span>
|
|
63
|
+
{/if}
|
|
64
|
+
</span>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Author } from '../shared/types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
user: Author;
|
|
4
|
+
size?: number;
|
|
5
|
+
/** Default: an agent's name and who added it. */
|
|
6
|
+
title?: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
};
|
|
9
|
+
declare const Avatar: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type Avatar = ReturnType<typeof Avatar>;
|
|
11
|
+
export default Avatar;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A comment field that grows with its text. Enter sends, Shift+Enter starts a
|
|
3
|
+
new line, Escape cancels. The text clears at once and comes back if sending fails.
|
|
4
|
+
Images dropped on it or pasted into it upload at once and show as thumbnails;
|
|
5
|
+
a comment can be images alone.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { onDestroy, onMount } from 'svelte';
|
|
9
|
+
import { useComments } from './context.js';
|
|
10
|
+
import type { CommentImage } from '../shared/types.js';
|
|
11
|
+
import Icon from './Icon.svelte';
|
|
12
|
+
import { IMAGE_TYPES, MAX_BODY, MAX_IMAGE_BYTES, MAX_IMAGES } from '../shared/limits.js';
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
variant = 'reply',
|
|
16
|
+
placeholder = 'Reply',
|
|
17
|
+
initial = '',
|
|
18
|
+
initialImages = [],
|
|
19
|
+
autofocus = false,
|
|
20
|
+
onsubmit,
|
|
21
|
+
oncancel,
|
|
22
|
+
oninput,
|
|
23
|
+
onimages
|
|
24
|
+
}: {
|
|
25
|
+
/** `new` floats beside its pin; `reply` and `edit` sit inside a thread. */
|
|
26
|
+
variant?: 'new' | 'reply' | 'edit';
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
initial?: string;
|
|
29
|
+
/** The images of a comment being edited. */
|
|
30
|
+
initialImages?: CommentImage[];
|
|
31
|
+
autofocus?: boolean;
|
|
32
|
+
onsubmit: (body: string, images: CommentImage[]) => Promise<unknown> | unknown;
|
|
33
|
+
oncancel?: () => void;
|
|
34
|
+
oninput?: (value: string) => void;
|
|
35
|
+
/** How many images the field holds, as it changes. */
|
|
36
|
+
onimages?: (count: number) => void;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
interface Attachment {
|
|
40
|
+
key: number;
|
|
41
|
+
/** A local object URL while it uploads, then wherever it is shown from. */
|
|
42
|
+
preview: string;
|
|
43
|
+
/** Set once uploaded. */
|
|
44
|
+
image?: CommentImage;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const comments = useComments().store;
|
|
49
|
+
// svelte-ignore state_referenced_locally
|
|
50
|
+
let value = $state(initial);
|
|
51
|
+
// svelte-ignore state_referenced_locally
|
|
52
|
+
let attachments = $state<Attachment[]>(initialImages.map((image, key) => ({ key, preview: comments.imageUrl(image), image })));
|
|
53
|
+
// svelte-ignore state_referenced_locally
|
|
54
|
+
let nextKey = initialImages.length;
|
|
55
|
+
let sending = $state(false);
|
|
56
|
+
let dragging = $state(false);
|
|
57
|
+
let field = $state<HTMLTextAreaElement>();
|
|
58
|
+
let form = $state<HTMLFormElement>();
|
|
59
|
+
const uploading = $derived(attachments.some((a) => !a.image && !a.error));
|
|
60
|
+
const empty = $derived(!value.trim() && !attachments.some((a) => a.image));
|
|
61
|
+
const blocked = $derived(empty || sending || uploading || attachments.some((a) => a.error));
|
|
62
|
+
|
|
63
|
+
$effect(() => onimages?.(attachments.length));
|
|
64
|
+
|
|
65
|
+
export function focus() {
|
|
66
|
+
field?.focus();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fit() {
|
|
70
|
+
if (!field) return;
|
|
71
|
+
field.style.height = 'auto';
|
|
72
|
+
field.style.height = `${Math.min(field.scrollHeight, 160)}px`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function add(files: Iterable<File>) {
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (!IMAGE_TYPES.includes(file.type)) {
|
|
78
|
+
comments.error = 'Only PNG, JPEG, GIF and WebP images can be added.';
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (attachments.length >= MAX_IMAGES) {
|
|
82
|
+
comments.error = `A comment can have up to ${MAX_IMAGES} images.`;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const key = nextKey++;
|
|
86
|
+
attachments.push({ key, preview: URL.createObjectURL(file) });
|
|
87
|
+
void upload(key, file);
|
|
88
|
+
}
|
|
89
|
+
field?.focus();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function upload(key: number, file: File) {
|
|
93
|
+
const update = (change: Partial<Attachment>) => {
|
|
94
|
+
const attachment = attachments.find((a) => a.key === key);
|
|
95
|
+
if (attachment) Object.assign(attachment, change);
|
|
96
|
+
};
|
|
97
|
+
if (file.size > MAX_IMAGE_BYTES) {
|
|
98
|
+
update({ error: `Larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB` });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const bitmap = await createImageBitmap(file).catch(() => {
|
|
103
|
+
throw new Error('Not a readable image');
|
|
104
|
+
});
|
|
105
|
+
const { width, height } = bitmap;
|
|
106
|
+
bitmap.close();
|
|
107
|
+
update({ image: await comments.upload(file, width, height) });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
update({ error: (error as Error).message });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function release(list: Attachment[]) {
|
|
114
|
+
for (const a of list) if (a.preview.startsWith('blob:')) URL.revokeObjectURL(a.preview);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function remove(key: number) {
|
|
118
|
+
release(attachments.filter((a) => a.key === key));
|
|
119
|
+
attachments = attachments.filter((a) => a.key !== key);
|
|
120
|
+
field?.focus();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
onDestroy(() => release(attachments));
|
|
124
|
+
|
|
125
|
+
const carriesFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false;
|
|
126
|
+
|
|
127
|
+
function ondragover(event: DragEvent) {
|
|
128
|
+
if (!carriesFiles(event)) return;
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
event.dataTransfer!.dropEffect = 'copy';
|
|
131
|
+
dragging = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ondragleave(event: DragEvent) {
|
|
135
|
+
if (!form?.contains(event.relatedTarget as Node | null)) dragging = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ondrop(event: DragEvent) {
|
|
139
|
+
if (!carriesFiles(event)) return;
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
dragging = false;
|
|
142
|
+
add(event.dataTransfer!.files);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onpaste(event: ClipboardEvent) {
|
|
146
|
+
const files = [...(event.clipboardData?.files ?? [])].filter((file) => file.type.startsWith('image/'));
|
|
147
|
+
if (!files.length) return;
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
add(files);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function submit(event?: Event) {
|
|
153
|
+
event?.preventDefault();
|
|
154
|
+
if (blocked) return;
|
|
155
|
+
const body = value;
|
|
156
|
+
const sent = attachments;
|
|
157
|
+
sending = true;
|
|
158
|
+
if (variant !== 'edit') {
|
|
159
|
+
value = '';
|
|
160
|
+
attachments = [];
|
|
161
|
+
}
|
|
162
|
+
requestAnimationFrame(fit);
|
|
163
|
+
try {
|
|
164
|
+
await onsubmit(body.trim(), sent.map((a) => a.image!));
|
|
165
|
+
if (variant !== 'edit') release(sent);
|
|
166
|
+
} catch {
|
|
167
|
+
value = body;
|
|
168
|
+
attachments = sent;
|
|
169
|
+
requestAnimationFrame(fit);
|
|
170
|
+
} finally {
|
|
171
|
+
sending = false;
|
|
172
|
+
field?.focus();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onkeydown(event: KeyboardEvent) {
|
|
177
|
+
if (event.isComposing) return;
|
|
178
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
void submit();
|
|
181
|
+
} else if (event.key === 'Escape') {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
oncancel?.();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onMount(() => {
|
|
189
|
+
fit();
|
|
190
|
+
if (autofocus) {
|
|
191
|
+
field?.focus({ preventScroll: true });
|
|
192
|
+
field?.setSelectionRange(value.length, value.length);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<form
|
|
198
|
+
bind:this={form}
|
|
199
|
+
onsubmit={submit}
|
|
200
|
+
{ondragover}
|
|
201
|
+
ondragenter={ondragover}
|
|
202
|
+
{ondragleave}
|
|
203
|
+
{ondrop}
|
|
204
|
+
class={[
|
|
205
|
+
'flex flex-col bg-white text-[13px] text-vc-ink transition-colors',
|
|
206
|
+
variant === 'new'
|
|
207
|
+
? 'w-[296px] rounded-[18px] border border-vc-comment py-[5px] pr-[5px] pl-3.5 shadow-[0_2px_14px_rgb(0_0_0/0.18)]'
|
|
208
|
+
: 'rounded-[14px] border border-vc-line py-[3px] pr-[3px] pl-3 focus-within:border-vc-comment',
|
|
209
|
+
dragging && 'border-vc-comment! bg-vc-comment-soft ring-2 ring-vc-comment/30'
|
|
210
|
+
]}
|
|
211
|
+
>
|
|
212
|
+
{#if attachments.length}
|
|
213
|
+
<ul class="flex flex-wrap gap-1.5 pt-1.5 pr-1.5 pb-1" aria-label="Images">
|
|
214
|
+
{#each attachments as attachment (attachment.key)}
|
|
215
|
+
<li class="relative size-14 overflow-hidden rounded-lg border border-vc-line bg-vc-surface" title={attachment.error}>
|
|
216
|
+
<img src={attachment.preview} alt="" class={['size-full object-cover', !attachment.image && 'opacity-50']} />
|
|
217
|
+
{#if attachment.error}
|
|
218
|
+
<span class="absolute inset-0 grid place-items-center bg-white/85 px-1 text-center text-[10px] leading-tight font-medium text-red-600">
|
|
219
|
+
Not added
|
|
220
|
+
</span>
|
|
221
|
+
{:else if !attachment.image}
|
|
222
|
+
<span class="absolute inset-0 m-auto size-4 animate-spin rounded-full border-2 border-vc-comment border-t-transparent" aria-label="Uploading"></span>
|
|
223
|
+
{/if}
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
class="absolute top-0.5 right-0.5 grid size-4 place-items-center rounded-full bg-black/60 text-white hover:bg-black/80"
|
|
227
|
+
aria-label="Remove image"
|
|
228
|
+
title="Remove"
|
|
229
|
+
onclick={() => remove(attachment.key)}
|
|
230
|
+
>
|
|
231
|
+
<Icon name="close" size={10} />
|
|
232
|
+
</button>
|
|
233
|
+
</li>
|
|
234
|
+
{/each}
|
|
235
|
+
</ul>
|
|
236
|
+
{/if}
|
|
237
|
+
<div class="flex items-end gap-2">
|
|
238
|
+
<textarea
|
|
239
|
+
bind:this={field}
|
|
240
|
+
bind:value
|
|
241
|
+
rows="1"
|
|
242
|
+
{placeholder}
|
|
243
|
+
aria-label={placeholder}
|
|
244
|
+
maxlength={MAX_BODY}
|
|
245
|
+
{onkeydown}
|
|
246
|
+
{onpaste}
|
|
247
|
+
oninput={() => { fit(); oninput?.(value); }}
|
|
248
|
+
class="max-h-40 min-w-0 flex-1 resize-none self-center bg-transparent py-1 leading-5 outline-none placeholder:text-vc-muted/70"
|
|
249
|
+
></textarea>
|
|
250
|
+
{#if variant === 'edit'}
|
|
251
|
+
<button type="button" class="h-6 rounded-full px-2 text-xs text-vc-muted hover:bg-vc-wash" onclick={() => oncancel?.()}>Cancel</button>
|
|
252
|
+
{/if}
|
|
253
|
+
<button
|
|
254
|
+
type="submit"
|
|
255
|
+
disabled={blocked}
|
|
256
|
+
aria-label={variant === 'edit' ? 'Save' : 'Send'}
|
|
257
|
+
title={uploading ? 'Uploading images…' : variant === 'edit' ? 'Save (Enter)' : 'Send (Enter)'}
|
|
258
|
+
class={[
|
|
259
|
+
'grid shrink-0 place-items-center rounded-full text-white transition-colors',
|
|
260
|
+
variant === 'new' ? 'size-7' : 'size-6',
|
|
261
|
+
blocked ? 'bg-[#d9d9d9]' : 'bg-vc-comment hover:brightness-110'
|
|
262
|
+
]}
|
|
263
|
+
>
|
|
264
|
+
<Icon name={variant === 'edit' ? 'check' : 'send'} size={variant === 'new' ? 16 : 14} />
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
</form>
|