visual-comments 0.1.0 → 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.
Files changed (55) hide show
  1. package/README.md +19 -2
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +477 -0
  4. package/dist/core/presence.d.ts +13 -0
  5. package/dist/core/presence.js +25 -0
  6. package/dist/core/threads.svelte.d.ts +36 -2
  7. package/dist/core/threads.svelte.js +120 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/server/env.d.ts +78 -0
  10. package/dist/server/env.js +94 -0
  11. package/dist/server/index.d.ts +1 -0
  12. package/dist/server/index.js +10 -3
  13. package/dist/server/variables.d.ts +28 -0
  14. package/dist/server/variables.js +73 -0
  15. package/dist/shared/protocol.d.ts +4 -0
  16. package/dist/shared/protocol.js +4 -0
  17. package/dist/shared/types.d.ts +64 -0
  18. package/dist/sveltekit/CommentTools.svelte +98 -0
  19. package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
  20. package/dist/sveltekit/Comments.svelte +104 -0
  21. package/dist/sveltekit/Comments.svelte.d.ts +25 -0
  22. package/dist/sveltekit/Toolbar.svelte +64 -0
  23. package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
  24. package/dist/sveltekit/ToolbarButton.svelte +66 -0
  25. package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
  26. package/dist/sveltekit/context.d.ts +16 -0
  27. package/dist/sveltekit/context.js +9 -0
  28. package/dist/sveltekit/index.d.ts +6 -0
  29. package/dist/sveltekit/index.js +6 -0
  30. package/dist/ui/Account.svelte +45 -7
  31. package/dist/ui/App.svelte +12 -3
  32. package/dist/ui/Avatar.svelte +28 -6
  33. package/dist/ui/Avatar.svelte.d.ts +4 -5
  34. package/dist/ui/Connect.svelte +128 -0
  35. package/dist/ui/Connect.svelte.d.ts +6 -0
  36. package/dist/ui/Menu.svelte +5 -1
  37. package/dist/ui/Menu.svelte.d.ts +2 -0
  38. package/dist/ui/Overlay.svelte +15 -1
  39. package/dist/ui/Panel.svelte +141 -127
  40. package/dist/ui/Pin.svelte +22 -2
  41. package/dist/ui/Pin.svelte.d.ts +2 -0
  42. package/dist/ui/Presence.svelte +43 -0
  43. package/dist/ui/Presence.svelte.d.ts +8 -0
  44. package/dist/ui/ThreadCard.svelte +26 -2
  45. package/dist/ui/generated/styles.css +1 -1
  46. package/dist/ui/generated/styles.js +2 -2
  47. package/dist/ui/styles.css +21 -0
  48. package/dist/ui/text.d.ts +3 -1
  49. package/dist/ui/text.js +4 -0
  50. package/dist/visual-comments.svelte.d.ts +53 -4
  51. package/dist/visual-comments.svelte.js +154 -29
  52. package/dist/vite/index.d.ts +20 -0
  53. package/dist/vite/index.js +78 -0
  54. package/dist-widget/visual-comments.js +17 -10
  55. package/package.json +88 -66
@@ -17,8 +17,10 @@ export class VisualComments {
17
17
  api;
18
18
  store;
19
19
  options = $state.raw();
20
- /** Comment mode: the list is shown, and clicking a surface starts a thread. Pins always show. */
20
+ /** Comment mode: clicking a surface starts a thread. Pins always show. */
21
21
  active = $state(false);
22
+ /** Hide the list while keeping comment mode active. */
23
+ panelCollapsed = $state(false);
22
24
  /** The thread whose conversation is open. */
23
25
  openId = $state();
24
26
  draft = $state();
@@ -37,6 +39,12 @@ export class VisualComments {
37
39
  cursor = $state.raw();
38
40
  /** Opening a thread scrolls its element into view once it is found. */
39
41
  revealing = $state();
42
+ /** An agent's request to join, from `#vc-connect=<id>`, while the dialog to approve it is shown. */
43
+ connecting = $state();
44
+ /** The reply field of the open thread is being written in. */
45
+ typing = $state(false);
46
+ /** Where the host says the viewer is, for others to see. */
47
+ whereabouts = $state.raw({});
40
48
  doc;
41
49
  host;
42
50
  app;
@@ -51,11 +59,13 @@ export class VisualComments {
51
59
  cursorSheet = new CSSStyleSheet();
52
60
  focusDraftHandler;
53
61
  linked;
62
+ typingTimer;
63
+ presenceTimer;
54
64
  constructor(options, doc = document) {
55
65
  this.options = options;
56
66
  this.doc = doc;
57
67
  this.api = new Api(options.endpoint ?? '/_comments');
58
- this.store = new Threads(this.api);
68
+ this.store = new Threads(this.api, options.viewer);
59
69
  this.host = doc.createElement('visual-comments');
60
70
  this.host.setAttribute('data-vc-ignore', '');
61
71
  this.host.style.cssText = 'position:fixed;inset:0;z-index:2147483600;pointer-events:none;display:block;';
@@ -69,6 +79,19 @@ export class VisualComments {
69
79
  get open() {
70
80
  return this.store.threads.find((thread) => thread.id === this.openId);
71
81
  }
82
+ /** Everyone present in the space: people's tabs, this one included, and agents' tasks. Empty when signed out. */
83
+ get participants() { return this.store.participants; }
84
+ /** This tab's connection to the server's event stream, as in `Participant.id`, while it is open. */
85
+ get connection() { return this.store.connection; }
86
+ /** What this tab tells others: the host's location, the open thread, and whether its reply is being written. */
87
+ get ownPresence() {
88
+ return {
89
+ state: this.typing && this.openId !== undefined ? 'typing' : 'viewing',
90
+ page: this.whereabouts.page ?? (this.page || undefined),
91
+ surface: this.whereabouts.surface,
92
+ thread: this.openId
93
+ };
94
+ }
72
95
  /** Threads shown as pins: on this page, unresolved unless resolved ones are shown or it is open. */
73
96
  get pinned() {
74
97
  return this.store.threads.filter((thread) => thread.page === this.page
@@ -106,6 +129,8 @@ export class VisualComments {
106
129
  }
107
130
  toggle(on = !this.active) {
108
131
  this.active = on;
132
+ if (!on)
133
+ this.panelCollapsed = false;
109
134
  this.cursor = undefined;
110
135
  this.aim = undefined;
111
136
  if (!on) {
@@ -116,6 +141,8 @@ export class VisualComments {
116
141
  this.updateAim();
117
142
  }
118
143
  openThread(id) {
144
+ if (id !== this.openId)
145
+ this.stopTyping();
119
146
  this.openId = id;
120
147
  const thread = this.open;
121
148
  if (thread?.unread)
@@ -130,7 +157,11 @@ export class VisualComments {
130
157
  if (this.options.show?.(thread))
131
158
  return;
132
159
  if (thread.page !== this.page) {
133
- location.assign(this.linkOf(thread));
160
+ const url = this.linkOf(thread);
161
+ if (this.options.navigate)
162
+ this.options.navigate(url, thread);
163
+ else
164
+ location.assign(url);
134
165
  return;
135
166
  }
136
167
  const surface = this.surface(thread.surface);
@@ -152,6 +183,33 @@ export class VisualComments {
152
183
  reveal(element);
153
184
  this.revealing = undefined;
154
185
  }
186
+ /**
187
+ * Tells others where the viewer is: the page, by default the page key, and
188
+ * the surface, e.g. the frame on screen. Each call replaces the last.
189
+ */
190
+ setPresence(update) {
191
+ const current = untrack(() => this.whereabouts);
192
+ if (current.page === update.page && current.surface === update.surface)
193
+ return;
194
+ this.whereabouts = { page: update.page, surface: update.surface };
195
+ }
196
+ /** Called by the UI as the viewer writes in the open thread; they stop typing 5 seconds after the last key. */
197
+ noteTyping() {
198
+ if (this.openId === undefined)
199
+ return;
200
+ this.typing = true;
201
+ clearTimeout(this.typingTimer);
202
+ this.typingTimer = setTimeout(() => this.stopTyping(), 5000);
203
+ }
204
+ stopTyping() {
205
+ clearTimeout(this.typingTimer);
206
+ this.typing = false;
207
+ }
208
+ /** Closes the dialog for an agent's request and takes it out of the address. */
209
+ endConnect() {
210
+ this.connecting = undefined;
211
+ dropFromHash('vc-connect');
212
+ }
155
213
  /** Leaves for the site's sign-in, and comes back to this address. */
156
214
  signIn() {
157
215
  const auth = this.options.auth ?? '/auth';
@@ -181,7 +239,8 @@ export class VisualComments {
181
239
  subscribe(listener) {
182
240
  return $effect.root(() => {
183
241
  $effect(() => {
184
- void [this.active, this.openId, this.unreadCount, this.viewer, this.store.threads.length, this.loading];
242
+ void [this.active, this.openId, this.unreadCount, this.viewer, this.store.threads.length, this.loading,
243
+ this.store.participants, this.store.connection];
185
244
  listener(this);
186
245
  });
187
246
  });
@@ -191,6 +250,8 @@ export class VisualComments {
191
250
  stop();
192
251
  for (const stop of this.frames.values())
193
252
  stop();
253
+ clearTimeout(this.typingTimer);
254
+ clearTimeout(this.presenceTimer);
194
255
  this.frames.clear();
195
256
  for (const app of this.slots.values())
196
257
  void unmount(app);
@@ -218,12 +279,13 @@ export class VisualComments {
218
279
  doc.documentElement.appendChild(this.host);
219
280
  this.app = mount(App, { target: shadow, props: { vc: this } });
220
281
  this.cleanup.push(this.store.start());
221
- const hash = /(?:^#|&)vc=(\d+)/.exec(location.hash);
222
- if (hash) {
223
- this.linked = Number(hash[1]);
224
- const rest = location.hash.slice(1).split('&').filter((part) => !part.startsWith('vc=')).join('&');
225
- history.replaceState(history.state, '', `${location.pathname}${location.search}${rest ? `#${rest}` : ''}`);
282
+ const linked = hashPart('vc');
283
+ if (linked && /^\d+$/.test(linked)) {
284
+ this.linked = Number(linked);
285
+ dropFromHash('vc');
226
286
  }
287
+ // The hash stays until the request is answered, so it survives signing in first.
288
+ this.connecting = hashPart('vc-connect') || undefined;
227
289
  this.cleanup.push($effect.root(() => {
228
290
  // Open a linked thread once threads have loaded.
229
291
  $effect(() => {
@@ -238,6 +300,19 @@ export class VisualComments {
238
300
  this.focus(thread);
239
301
  }
240
302
  });
303
+ // Others see what this tab is doing: where it is, the open thread, and whether its reply is being written.
304
+ $effect(() => {
305
+ const presence = this.ownPresence;
306
+ clearTimeout(this.presenceTimer);
307
+ this.presenceTimer = setTimeout(() => this.store.setPresence(presence), 150);
308
+ });
309
+ // The server lets typing lapse unless it hears it again.
310
+ $effect(() => {
311
+ if (!this.typing)
312
+ return;
313
+ const timer = setInterval(() => this.store.setPresence(untrack(() => this.ownPresence), true), 3000);
314
+ return () => clearInterval(timer);
315
+ });
241
316
  // Buttons the host marked with data-vc-toggle say whether comment mode is on and if anything is unread.
242
317
  $effect(() => {
243
318
  const unread = this.unreadCount > 0 && !this.active;
@@ -264,9 +339,34 @@ export class VisualComments {
264
339
  on(view, 'click', (event) => this.onClick(event), capture);
265
340
  for (const type of ['dblclick', 'auxclick', 'contextmenu'])
266
341
  on(view, type, (event) => this.onOtherClick(event), capture);
342
+ // Escape closes the comments' own things before the page's (an open dialog) sees it; in the
343
+ // comment UI, after the UI's own handlers.
344
+ on(view, 'keydown', (event) => { if (!this.fromUs(event))
345
+ this.onEscape(event); }, capture);
267
346
  on(view, 'keydown', (event) => this.onKey(event));
268
347
  on(view, 'keyup', (event) => this.onKey(event));
348
+ // What happens in the comment UI is not the page's: a modal's focus trap (bits-ui, focus-trap, …)
349
+ // would take focus back from it, and a press or key in it would close the modal or work its
350
+ // shortcuts. Focus is stopped before any trap sees it, the rest where it leaves the UI.
351
+ on(view, 'focusin', (event) => { if (this.fromUs(event))
352
+ event.stopImmediatePropagation(); }, capture);
353
+ for (const type of ['keydown', 'keyup', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'touchstart', 'touchend', 'focusout']) {
354
+ const keep = (event) => {
355
+ if (event instanceof KeyboardEvent) {
356
+ this.onEscape(event);
357
+ this.onKey(event);
358
+ }
359
+ event.stopPropagation();
360
+ };
361
+ this.host.addEventListener(type, keep);
362
+ this.cleanup.push(() => this.host.removeEventListener(type, keep));
363
+ }
269
364
  on(view, 'resize', () => this.schedule());
365
+ on(view, 'hashchange', () => {
366
+ const id = hashPart('vc-connect');
367
+ if (id)
368
+ this.connecting = id;
369
+ });
270
370
  on(doc, 'scroll', () => this.schedule(), { capture: true, passive: true });
271
371
  const leave = () => this.move(undefined);
272
372
  doc.documentElement.addEventListener('pointerleave', leave);
@@ -299,7 +399,10 @@ export class VisualComments {
299
399
  if (!view || this.frames.has(frame))
300
400
  return;
301
401
  const schedule = () => this.schedule();
302
- const key = (event) => this.onKey(event);
402
+ const key = (event) => {
403
+ this.onEscape(event);
404
+ this.onKey(event);
405
+ };
303
406
  const observer = new MutationObserver(schedule);
304
407
  observer.observe(frame.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
305
408
  frame.addEventListener('scroll', schedule, { capture: true, passive: true });
@@ -329,9 +432,12 @@ export class VisualComments {
329
432
  const doc = this.doc;
330
433
  this.surfaces = discover(doc, { ours: this.ours, documentOf: this.options.documentOf });
331
434
  this.page = pageOf(this.options.page);
332
- // A new comment on a surface that has gone, like a frame the viewer left, goes with it.
435
+ // A new comment on a surface that has gone, like a frame the viewer left, goes with it; an
436
+ // empty one also with its element, like a dialog's that closed.
333
437
  if (this.draft && !this.surface(this.draft.surface))
334
438
  this.draft = undefined;
439
+ if (this.draft?.target && !this.draft.target.element.isConnected && !this.draftHasContent)
440
+ this.draft = undefined;
335
441
  const docs = new Set(this.surfaces.map((surface) => surface.doc).filter((d) => Boolean(d) && d !== doc));
336
442
  for (const frame of docs)
337
443
  this.watchFrame(frame);
@@ -360,6 +466,10 @@ export class VisualComments {
360
466
  continue;
361
467
  const shadow = element.attachShadow({ mode: 'open' });
362
468
  addStyles(shadow);
469
+ // In the host's toolbar the button takes the toolbar's colour and type, not the comments' ink.
470
+ const own = this.doc.createElement('style');
471
+ own.textContent = ':host{color:inherit;font-family:inherit;font-size:inherit}';
472
+ shadow.appendChild(own);
363
473
  this.slots.set(element, mount(Account, { target: shadow, props: { vc: this, slot: true } }));
364
474
  }
365
475
  for (const [element, app] of this.slots) {
@@ -503,31 +613,46 @@ export class VisualComments {
503
613
  this.updateAim();
504
614
  return;
505
615
  }
506
- if (event.type !== 'keydown' || event.defaultPrevented)
507
- return;
508
- const target = event.composedPath()[0];
509
- if (editable(target))
616
+ if (event.type !== 'keydown' || event.defaultPrevented || !this.ownsKey(event))
510
617
  return;
511
- if (event.key === 'Escape') {
512
- if (this.host.shadowRoot?.querySelector(':popover-open') || this.doc.querySelector(':popover-open'))
513
- return;
514
- if (this.draft)
515
- this.draft = undefined;
516
- else if (this.openId !== undefined)
517
- this.openThread(undefined);
518
- else if (this.active)
519
- this.toggle(false);
520
- else
521
- return;
522
- event.preventDefault();
523
- return;
524
- }
525
618
  const shortcut = this.options.shortcut ?? 'c';
526
619
  if (shortcut && event.key.toLowerCase() === shortcut && !event.metaKey && !event.ctrlKey && !event.altKey) {
527
620
  event.preventDefault();
528
621
  this.toggle();
529
622
  }
530
623
  }
624
+ /** Escape drops the new comment, else closes the open thread, else leaves comment mode. */
625
+ onEscape(event) {
626
+ if (event.type !== 'keydown' || event.key !== 'Escape' || event.defaultPrevented || !this.ownsKey(event))
627
+ return;
628
+ if (this.host.shadowRoot?.querySelector(':popover-open') || this.doc.querySelector(':popover-open'))
629
+ return;
630
+ if (this.draft)
631
+ this.draft = undefined;
632
+ else if (this.openId !== undefined)
633
+ this.openThread(undefined);
634
+ else if (this.active)
635
+ this.toggle(false);
636
+ else
637
+ return;
638
+ stop(event);
639
+ }
640
+ /** Whether a key is the comments' to act on: not typed into a field, nor into the dialog to approve an agent. */
641
+ ownsKey(event) {
642
+ return !this.host.shadowRoot?.querySelector('dialog:modal') && !editable(event.composedPath()[0]);
643
+ }
644
+ }
645
+ /** A `name=value` part of the address's fragment, whose parts are joined by `&`. */
646
+ function hashPart(name) {
647
+ const part = location.hash.slice(1).split('&').find((p) => p.startsWith(`${name}=`));
648
+ return part?.slice(name.length + 1);
649
+ }
650
+ function dropFromHash(name) {
651
+ const parts = location.hash.slice(1).split('&');
652
+ const rest = parts.filter((part) => part && !part.startsWith(`${name}=`)).join('&');
653
+ if (rest === parts.join('&'))
654
+ return;
655
+ history.replaceState(history.state, '', `${location.pathname}${location.search}${rest ? `#${rest}` : ''}`);
531
656
  }
532
657
  function stop(event) {
533
658
  event.preventDefault();
@@ -0,0 +1,20 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface VisualCommentsPluginOptions {
3
+ /**
4
+ * Whether the comments are in the build. Default: under the dev server, and
5
+ * in a build only with `VISUAL_COMMENTS_BUILD=true` in its environment.
6
+ */
7
+ include?: boolean;
8
+ }
9
+ /**
10
+ * Puts the comments in development and in the builds that ask for them, and
11
+ * leaves them out of every other build:
12
+ *
13
+ * ```ts
14
+ * // vite.config.ts
15
+ * import { visualComments } from 'visual-comments/vite';
16
+ *
17
+ * export default defineConfig({ plugins: [visualComments(), sveltekit()] });
18
+ * ```
19
+ */
20
+ export declare function visualComments(options?: VisualCommentsPluginOptions): Plugin;
@@ -0,0 +1,78 @@
1
+ // A Vite plugin that leaves the comments out of a build altogether: for builds
2
+ // that must not carry a review tool, such as production's, whatever the
3
+ // variables say when they run. The site's code stays as it is: `<Comments>`
4
+ // of `visual-comments/sveltekit` renders only what it wraps, `<Toolbar>` only
5
+ // the app's own tools (or nothing), and `commentsFromEnv` of
6
+ // `visual-comments/server` is always off, so the comments' client, the proxy
7
+ // and their sign-in are not in the build. The variables are still declared,
8
+ // so the same `src/env.ts` serves both.
9
+ /** This package's `dist/`, as Vite writes module ids. */
10
+ const dist = (() => {
11
+ const path = decodeURIComponent(new URL('../', import.meta.url).pathname);
12
+ return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
13
+ })();
14
+ const COMMENTS = `<script>
15
+ // Left out of this build (visual-comments/vite): renders what it wraps.
16
+ let { comments = $bindable(), children } = $props();
17
+ </script>
18
+
19
+ {@render children?.()}
20
+ `;
21
+ const COMMENT_TOOLS = `<script>
22
+ // Left out of this build (visual-comments/vite).
23
+ let { divider } = $props();
24
+ </script>
25
+ `;
26
+ const ENV = `// Left out of this build (visual-comments/vite): comments are off, whatever the variables say.
27
+ export { commentsVariables } from './variables.js';
28
+
29
+ let warned = false;
30
+
31
+ export function commentsFromEnv(env) {
32
+ if (!warned && env?.VISUAL_COMMENTS_ENABLED === 'true') {
33
+ warned = true;
34
+ console.warn('[visual-comments] VISUAL_COMMENTS_ENABLED is true, but this build leaves the comments out (visual-comments/vite).');
35
+ }
36
+ return {
37
+ enabled: false,
38
+ client: null,
39
+ load: async () => null,
40
+ handle: async () => undefined,
41
+ sveltekit: () => ({ event, resolve }) => Promise.resolve(resolve(event))
42
+ };
43
+ }
44
+ `;
45
+ const STUBS = {
46
+ [`${dist}sveltekit/Comments.svelte`]: COMMENTS,
47
+ [`${dist}sveltekit/CommentTools.svelte`]: COMMENT_TOOLS,
48
+ [`${dist}server/env.js`]: ENV
49
+ };
50
+ function environment(name) {
51
+ return globalThis.process?.env?.[name];
52
+ }
53
+ /**
54
+ * Puts the comments in development and in the builds that ask for them, and
55
+ * leaves them out of every other build:
56
+ *
57
+ * ```ts
58
+ * // vite.config.ts
59
+ * import { visualComments } from 'visual-comments/vite';
60
+ *
61
+ * export default defineConfig({ plugins: [visualComments(), sveltekit()] });
62
+ * ```
63
+ */
64
+ export function visualComments(options = {}) {
65
+ let include = true;
66
+ return {
67
+ name: 'visual-comments',
68
+ enforce: 'pre',
69
+ config(_, { command }) {
70
+ include = options.include ?? (command === 'serve' || environment('VISUAL_COMMENTS_BUILD') === 'true');
71
+ // Bundled into the server too, where it would otherwise be loaded from node_modules as it is.
72
+ return include ? undefined : { ssr: { noExternal: ['visual-comments'] } };
73
+ },
74
+ load(id) {
75
+ return include ? undefined : STUBS[id];
76
+ }
77
+ };
78
+ }