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
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { VisualComments, pageOf } from './visual-comments.svelte.js';
2
+ import { VisualComments } from './visual-comments.svelte.js';
3
+ /** Adds comments to the page. Call once, in the browser. */
4
+ export function createVisualComments(options, doc) {
5
+ return new VisualComments(options, doc);
6
+ }
@@ -0,0 +1,33 @@
1
+ export interface CommentsProxyOptions {
2
+ /** The comment server, e.g. `https://comments.convex.works`. */
3
+ server: string;
4
+ /** The site's space on it. */
5
+ space: string;
6
+ /** The space's secret. Keep it on the server. */
7
+ secret: string;
8
+ /** Where the browser reaches the comments on this site. Default `/_comments`. */
9
+ path?: string;
10
+ }
11
+ /** The person signed in on the site, as its sign-in knows them. Null or undefined when nobody is. */
12
+ export type CommentsUser = {
13
+ sub: string;
14
+ name: string;
15
+ email?: string;
16
+ picture?: string;
17
+ } | null | undefined;
18
+ /** Matches SvelteKit's `Handle` without depending on SvelteKit. */
19
+ export type Handle<E extends {
20
+ request: Request;
21
+ locals: object;
22
+ }> = (input: {
23
+ event: E;
24
+ resolve: (event: E) => Response | Promise<Response>;
25
+ }) => Promise<Response>;
26
+ export declare function commentsProxy(options: CommentsProxyOptions): {
27
+ path: string;
28
+ handle: (request: Request, user: CommentsUser) => Promise<Response | undefined>;
29
+ sveltekit: <E extends {
30
+ request: Request;
31
+ locals: object;
32
+ }>(user?: (event: E) => CommentsUser | Promise<CommentsUser>) => Handle<E>;
33
+ };
@@ -0,0 +1,67 @@
1
+ // For the site's server: passes the browser's comment requests on to the
2
+ // comment server, with the space's secret and the person signed in on the site.
3
+ // Uses only the Fetch API, so it runs in Node, Bun, Deno and Workers.
4
+ import { USER_HEADER } from '../shared/protocol.js';
5
+ const REQUEST_HEADERS = ['content-type', 'accept', 'last-event-id'];
6
+ const RESPONSE_HEADERS = ['content-type', 'cache-control', 'content-security-policy', 'x-content-type-options', 'x-accel-buffering'];
7
+ export function commentsProxy(options) {
8
+ const server = options.server.replace(/\/$/, '');
9
+ const path = (options.path ?? '/_comments').replace(/\/$/, '');
10
+ const space = encodeURIComponent(options.space);
11
+ /** Answers requests under `path`; undefined for any other request. */
12
+ async function handle(request, user) {
13
+ const url = new URL(request.url);
14
+ if (url.pathname !== path && !url.pathname.startsWith(`${path}/`))
15
+ return undefined;
16
+ const headers = new Headers({ Authorization: `Bearer ${options.secret}` });
17
+ for (const name of REQUEST_HEADERS) {
18
+ const value = request.headers.get(name);
19
+ if (value)
20
+ headers.set(name, value);
21
+ }
22
+ if (user) {
23
+ const { sub, name, email, picture } = user;
24
+ headers.set(USER_HEADER, base64url(JSON.stringify({ sub, name, email, picture })));
25
+ }
26
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
27
+ let response;
28
+ try {
29
+ response = await fetch(`${server}/v1/spaces/${space}${url.pathname.slice(path.length)}${url.search}`, {
30
+ method: request.method,
31
+ headers,
32
+ body: hasBody ? await request.arrayBuffer() : undefined,
33
+ signal: request.signal,
34
+ redirect: 'manual'
35
+ });
36
+ }
37
+ catch {
38
+ return Response.json({ error: 'The comments cannot be reached. Try again.' }, { status: 502, headers: { 'Cache-Control': 'no-store' } });
39
+ }
40
+ const out = new Headers();
41
+ for (const name of RESPONSE_HEADERS) {
42
+ const value = response.headers.get(name);
43
+ if (value)
44
+ out.set(name, value);
45
+ }
46
+ return new Response(response.body, { status: response.status, headers: out });
47
+ }
48
+ /**
49
+ * SvelteKit's `handle` hook. Put it after the sign-in's hook; by default the
50
+ * user is `event.locals.user`.
51
+ */
52
+ function sveltekit(user = (event) => event.locals.user) {
53
+ return async ({ event, resolve }) => {
54
+ const url = new URL(event.request.url);
55
+ if (url.pathname !== path && !url.pathname.startsWith(`${path}/`))
56
+ return resolve(event);
57
+ return (await handle(event.request, await user(event)));
58
+ };
59
+ }
60
+ return { path, handle, sveltekit };
61
+ }
62
+ function base64url(text) {
63
+ let binary = '';
64
+ for (const byte of new TextEncoder().encode(text))
65
+ binary += String.fromCharCode(byte);
66
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
67
+ }
@@ -0,0 +1,9 @@
1
+ /** Per image. A proxy in front of the server must allow request bodies this large. */
2
+ export declare const MAX_IMAGE_BYTES: number;
3
+ export declare const MAX_IMAGES = 6;
4
+ /** No SVG: it can carry script. */
5
+ export declare const IMAGE_TYPES: string[];
6
+ export declare const MAX_BODY = 5000;
7
+ /** Context entries a thread keeps, and the length of each value. */
8
+ export declare const MAX_CONTEXT = 20;
9
+ export declare const MAX_CONTEXT_VALUE = 200;
@@ -0,0 +1,10 @@
1
+ // Limits shared by the browser and the server.
2
+ /** Per image. A proxy in front of the server must allow request bodies this large. */
3
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
4
+ export const MAX_IMAGES = 6;
5
+ /** No SVG: it can carry script. */
6
+ export const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
7
+ export const MAX_BODY = 5000;
8
+ /** Context entries a thread keeps, and the length of each value. */
9
+ export const MAX_CONTEXT = 20;
10
+ export const MAX_CONTEXT_VALUE = 200;
@@ -0,0 +1,2 @@
1
+ /** The header with who is signed in on the site: their `Viewer` as base64url-encoded JSON. */
2
+ export declare const USER_HEADER = "visual-comments-user";
@@ -0,0 +1,3 @@
1
+ // How a site's server talks to the comment server.
2
+ /** The header with who is signed in on the site: their `Viewer` as base64url-encoded JSON. */
3
+ export const USER_HEADER = 'visual-comments-user';
@@ -0,0 +1,93 @@
1
+ /** Who wrote a comment. From the site's sign-in, saved by the server. */
2
+ export interface Author {
3
+ sub: string;
4
+ name: string;
5
+ picture?: string;
6
+ }
7
+ /** The person signed in on the site. */
8
+ export interface Viewer extends Author {
9
+ email?: string;
10
+ }
11
+ /** An image in a comment, served at `<endpoint>/images/<id>/<key>`. */
12
+ export interface CommentImage {
13
+ id: number;
14
+ /** Part of its address, so addresses cannot be guessed from ids. */
15
+ key: string;
16
+ /** Its size in pixels, so it can be laid out before it loads. */
17
+ width: number;
18
+ height: number;
19
+ }
20
+ export interface Comment {
21
+ id: number;
22
+ author: Author;
23
+ /** Empty when the comment is only images. */
24
+ body: string;
25
+ images: CommentImage[];
26
+ /** Milliseconds since the epoch. */
27
+ createdAt: number;
28
+ editedAt: number | null;
29
+ /** Sent but not yet confirmed by the server. */
30
+ pending?: boolean;
31
+ }
32
+ /** The element a pin was placed on, as it was then. */
33
+ export interface CommentAnchor {
34
+ /** A CSS selector for it in its document. */
35
+ selector: string;
36
+ /** What it is and what it says, e.g. `button “Add prompts”`. */
37
+ label: string;
38
+ /** Its visible text, shortened. */
39
+ text: string;
40
+ /** Its box in the surface's CSS pixels. */
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ }
46
+ /** A pin and the conversation under it. The first comment opens the thread. */
47
+ export interface CommentThread {
48
+ /** Its number in the space, shown as #id. Never reused. */
49
+ id: number;
50
+ /** The page it is on: by default the URL's origin and path. */
51
+ page: string;
52
+ /** The address it was placed at. */
53
+ url: string;
54
+ /** The surface on the page, or '' for the page itself. */
55
+ surface: string;
56
+ /** The surface's title, or the page's, when the thread was placed. */
57
+ title: string;
58
+ /** Pin position in the surface's CSS pixels, from its top-left corner. */
59
+ x: number;
60
+ y: number;
61
+ /** Null for a pin in open space. */
62
+ anchor: CommentAnchor | null;
63
+ /** What the host said about where the thread was placed, e.g. the viewport size. */
64
+ context: Record<string, string>;
65
+ createdAt: number;
66
+ resolvedAt: number | null;
67
+ resolvedBy: Author | null;
68
+ /** Someone else has written in the thread since the viewer last opened it. */
69
+ unread: boolean;
70
+ comments: Comment[];
71
+ }
72
+ export interface CommentsSnapshot {
73
+ space: {
74
+ id: string;
75
+ name: string;
76
+ };
77
+ /** Null when signed out. */
78
+ viewer: Viewer | null;
79
+ threads: CommentThread[];
80
+ }
81
+ /** What a client sends to start a thread. */
82
+ export interface NewThreadInput {
83
+ page: string;
84
+ url: string;
85
+ surface: string;
86
+ title: string;
87
+ x: number;
88
+ y: number;
89
+ anchor?: CommentAnchor | null;
90
+ context?: Record<string, string>;
91
+ body: string;
92
+ images?: number[];
93
+ }
@@ -0,0 +1,2 @@
1
+ // Shapes shared by the browser client and the server.
2
+ export {};
@@ -0,0 +1,68 @@
1
+ <svelte:options css="injected" />
2
+
3
+ <!--
4
+ The account button: the viewer's avatar with a menu to sign out, or Sign in.
5
+ It sits in the floating toolbar, or in a host's own toolbar where the host
6
+ puts a `data-vc-account` element; there it takes the host's text colour.
7
+ -->
8
+ <script lang="ts">
9
+ import type { VisualComments } from '../visual-comments.svelte.js';
10
+ import Avatar from './Avatar.svelte';
11
+ import { setComments } from './context.js';
12
+ import Icon from './Icon.svelte';
13
+ import Menu from './Menu.svelte';
14
+ import MenuItem from './MenuItem.svelte';
15
+
16
+ let { vc, slot = false }: { vc: VisualComments; slot?: boolean } = $props();
17
+ // svelte-ignore state_referenced_locally
18
+ if (slot) setComments(vc);
19
+
20
+ const tool = $derived(slot ? 'vc-slot-tool' : 'vc-island-tool');
21
+ </script>
22
+
23
+ <span class={['vc-ui inline-flex', slot && 'vc-slot']}>
24
+ {#if vc.viewer}
25
+ <Menu label={`Account: ${vc.viewer.name}`} align="end" class="{tool} pr-2 pl-0.5">
26
+ {#snippet trigger()}
27
+ {#if vc.viewer}<Avatar user={vc.viewer} size={28} />{/if}
28
+ <Icon name="chevron-down" size={14} />
29
+ {/snippet}
30
+ {#snippet children(close)}
31
+ <div class="max-w-72 border-b border-vc-line px-3 py-2">
32
+ <p class="truncate font-medium">{vc.viewer?.name}</p>
33
+ {#if vc.viewer?.email}<p class="mt-0.5 truncate text-xs text-vc-muted">{vc.viewer.email}</p>{/if}
34
+ {#if vc.store.spaceName}<p class="mt-0.5 truncate text-xs text-vc-muted">Comments in {vc.store.spaceName}</p>{/if}
35
+ </div>
36
+ <MenuItem onselect={() => { close(); vc.signOut(); }}>Sign out</MenuItem>
37
+ {/snippet}
38
+ </Menu>
39
+ {:else}
40
+ <button type="button" part="button" class="{tool} px-3.5 font-medium" disabled={vc.loading} onclick={() => vc.signIn()}>
41
+ {vc.loading ? 'Connecting…' : 'Sign in'}
42
+ </button>
43
+ {/if}
44
+ </span>
45
+
46
+ <style>
47
+ .vc-slot {
48
+ font-family: inherit;
49
+ font-size: inherit;
50
+ color: inherit;
51
+ }
52
+ :global(.vc-slot-tool) {
53
+ display: inline-flex;
54
+ height: 2rem;
55
+ align-items: center;
56
+ gap: 0.375rem;
57
+ border-radius: 9999px;
58
+ color: inherit;
59
+ outline: none;
60
+ }
61
+ :global(.vc-slot-tool:hover),
62
+ :global(.vc-slot-tool[aria-expanded='true']) {
63
+ background: color-mix(in srgb, currentColor 14%, transparent);
64
+ }
65
+ :global(.vc-slot-tool:focus-visible) {
66
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 50%, transparent);
67
+ }
68
+ </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,68 @@
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 and the floating toolbar.
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 Overlay from './Overlay.svelte';
11
+ import Panel from './Panel.svelte';
12
+ import Toolbar from './Toolbar.svelte';
13
+
14
+ let { vc }: { vc: VisualComments } = $props();
15
+ // svelte-ignore state_referenced_locally
16
+ setComments(vc);
17
+
18
+ // A thread deleted, here or elsewhere, is no longer open.
19
+ $effect(() => {
20
+ if (vc.openId !== undefined && !vc.loading && !vc.open) vc.openId = undefined;
21
+ });
22
+
23
+ /** Frames take pointer events themselves; in comment mode a layer over each catches them first. */
24
+ const catchers = $derived(vc.active ? vc.surfaces.filter((surface) => surface.catches) : []);
25
+
26
+ function wheel(surface: Surface) {
27
+ return (node: HTMLElement) => {
28
+ const onwheel = (event: WheelEvent) => vc.wheel(surface, event);
29
+ node.addEventListener('wheel', onwheel, { passive: false });
30
+ return () => node.removeEventListener('wheel', onwheel);
31
+ };
32
+ }
33
+
34
+ const panel = $derived(vc.panelRect);
35
+ </script>
36
+
37
+ <div class="vc-ui pointer-events-none fixed inset-0 font-vc text-[13px] text-vc-ink">
38
+ {#each catchers as surface (surface.id)}
39
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
40
+ <div
41
+ {@attach wheel(surface)}
42
+ role="presentation"
43
+ class={['pointer-events-auto absolute', vc.cursor ? 'cursor-none!' : 'vc-comment-cursor']}
44
+ style:left="{surface.clip.left}px"
45
+ style:top="{surface.clip.top}px"
46
+ style:width="{Math.max(0, surface.clip.right - surface.clip.left)}px"
47
+ style:height="{Math.max(0, surface.clip.bottom - surface.clip.top)}px"
48
+ onpointermove={(event) => vc.move({ x: event.clientX, y: event.clientY, alt: event.altKey })}
49
+ onpointerleave={() => vc.move(undefined)}
50
+ onclick={(event) => vc.place(surface, event.clientX, event.clientY, event.altKey)}
51
+ ></div>
52
+ {/each}
53
+
54
+ <Overlay />
55
+
56
+ {#if vc.active}
57
+ <Panel
58
+ class={panel ? 'absolute z-20' : 'absolute top-3 right-3 bottom-3 z-20 w-80'}
59
+ style={panel ? `left:${panel.left}px;top:${panel.top}px;width:${panel.right - panel.left}px;height:${panel.bottom - panel.top}px` : ''}
60
+ onselect={(thread) => vc.focus(thread)}
61
+ onclose={() => vc.toggle(false)}
62
+ />
63
+ {/if}
64
+
65
+ {#if vc.showToolbar}
66
+ <Toolbar />
67
+ {/if}
68
+ </div>
@@ -0,0 +1,7 @@
1
+ import type { VisualComments } from '../visual-comments.svelte.js';
2
+ type $$ComponentProps = {
3
+ vc: VisualComments;
4
+ };
5
+ declare const App: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type App = ReturnType<typeof App>;
7
+ export default App;
@@ -0,0 +1,42 @@
1
+ <!-- A person's profile picture, or their initial on a colour of their own. -->
2
+ <script lang="ts" module>
3
+ // Others get a stable colour from their id; the viewer keeps the account colour.
4
+ const COLORS = ['#7b61ff', '#f24822', '#14ae5c', '#ffa629', '#0d99ff', '#e5487d', '#667799'];
5
+
6
+ function colorFor(sub: string) {
7
+ let hash = 0;
8
+ for (const char of sub) hash = (hash * 31 + char.codePointAt(0)!) | 0;
9
+ return COLORS[Math.abs(hash) % COLORS.length];
10
+ }
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import { useComments } from './context.js';
15
+
16
+ let { user, size = 24, class: className = '' }: {
17
+ user: { sub: string; name: string; picture?: string };
18
+ size?: number;
19
+ class?: string;
20
+ } = $props();
21
+
22
+ const vc = useComments();
23
+ let failed = $state<string>();
24
+ const initial = $derived(Array.from(user.name.trim() || '?')[0].toLocaleUpperCase());
25
+ const self = $derived(vc?.viewer?.sub === user.sub);
26
+ </script>
27
+
28
+ <span
29
+ class={['flex shrink-0 items-center justify-center overflow-hidden rounded-full leading-none text-white select-none', className]}
30
+ style:width="{size}px"
31
+ style:height="{size}px"
32
+ style:font-size="{Math.round(size * 0.5)}px"
33
+ style:background={self ? 'var(--color-vc-avatar)' : colorFor(user.sub)}
34
+ aria-hidden="true"
35
+ >
36
+ {#if user.picture && user.picture !== failed}
37
+ <img src={user.picture} alt="" referrerpolicy="no-referrer" class="size-full object-cover" draggable="false"
38
+ onerror={() => { failed = user.picture; }} />
39
+ {:else}
40
+ {initial}
41
+ {/if}
42
+ </span>
@@ -0,0 +1,12 @@
1
+ type $$ComponentProps = {
2
+ user: {
3
+ sub: string;
4
+ name: string;
5
+ picture?: string;
6
+ };
7
+ size?: number;
8
+ class?: string;
9
+ };
10
+ declare const Avatar: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type Avatar = ReturnType<typeof Avatar>;
12
+ export default Avatar;