visual-comments 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -2
  3. package/dist/core/aim.d.ts +21 -0
  4. package/dist/core/aim.js +21 -0
  5. package/dist/core/api.d.ts +5 -0
  6. package/dist/core/api.js +11 -0
  7. package/dist/core/metaball.d.ts +15 -0
  8. package/dist/core/metaball.js +42 -0
  9. package/dist/core/surfaces.d.ts +95 -0
  10. package/dist/core/surfaces.js +197 -0
  11. package/dist/core/target.d.ts +59 -0
  12. package/dist/core/target.js +382 -0
  13. package/dist/core/threads.svelte.d.ts +46 -0
  14. package/dist/core/threads.svelte.js +219 -0
  15. package/dist/core/time.d.ts +3 -0
  16. package/dist/core/time.js +25 -0
  17. package/dist/index.d.ts +5 -0
  18. package/dist/index.js +6 -0
  19. package/dist/server/index.d.ts +33 -0
  20. package/dist/server/index.js +67 -0
  21. package/dist/shared/limits.d.ts +9 -0
  22. package/dist/shared/limits.js +10 -0
  23. package/dist/shared/protocol.d.ts +2 -0
  24. package/dist/shared/protocol.js +3 -0
  25. package/dist/shared/types.d.ts +93 -0
  26. package/dist/shared/types.js +2 -0
  27. package/dist/ui/Account.svelte +68 -0
  28. package/dist/ui/Account.svelte.d.ts +8 -0
  29. package/dist/ui/App.svelte +68 -0
  30. package/dist/ui/App.svelte.d.ts +7 -0
  31. package/dist/ui/Avatar.svelte +42 -0
  32. package/dist/ui/Avatar.svelte.d.ts +12 -0
  33. package/dist/ui/Composer.svelte +267 -0
  34. package/dist/ui/Composer.svelte.d.ts +20 -0
  35. package/dist/ui/Icon.svelte +44 -0
  36. package/dist/ui/Icon.svelte.d.ts +29 -0
  37. package/dist/ui/Menu.svelte +65 -0
  38. package/dist/ui/Menu.svelte.d.ts +13 -0
  39. package/dist/ui/MenuItem.svelte +38 -0
  40. package/dist/ui/MenuItem.svelte.d.ts +12 -0
  41. package/dist/ui/Overlay.svelte +488 -0
  42. package/dist/ui/Overlay.svelte.d.ts +3 -0
  43. package/dist/ui/Panel.svelte +206 -0
  44. package/dist/ui/Panel.svelte.d.ts +10 -0
  45. package/dist/ui/Pin.svelte +136 -0
  46. package/dist/ui/Pin.svelte.d.ts +13 -0
  47. package/dist/ui/ThreadCard.svelte +177 -0
  48. package/dist/ui/ThreadCard.svelte.d.ts +13 -0
  49. package/dist/ui/Toolbar.svelte +35 -0
  50. package/dist/ui/Toolbar.svelte.d.ts +18 -0
  51. package/dist/ui/context.d.ts +3 -0
  52. package/dist/ui/context.js +8 -0
  53. package/dist/ui/generated/styles.css +2 -0
  54. package/dist/ui/generated/styles.d.ts +2 -0
  55. package/dist/ui/generated/styles.js +3 -0
  56. package/dist/ui/styles.css +92 -0
  57. package/dist/ui/text.d.ts +3 -0
  58. package/dist/ui/text.js +7 -0
  59. package/dist/visual-comments.svelte.d.ts +181 -0
  60. package/dist/visual-comments.svelte.js +568 -0
  61. package/dist-widget/visual-comments.js +51 -0
  62. package/package.json +66 -5
@@ -0,0 +1,568 @@
1
+ import { mount, unmount, untrack } from 'svelte';
2
+ import { aimAt } from './core/aim.js';
3
+ import { Api } from './core/api.js';
4
+ import { discover, inside, surfaceAt, toClient } from './core/surfaces.js';
5
+ import { reveal, scrollerFrom } from './core/target.js';
6
+ import { Threads } from './core/threads.svelte.js';
7
+ import App from './ui/App.svelte';
8
+ import Account from './ui/Account.svelte';
9
+ import { properties, styles } from './ui/generated/styles.js';
10
+ /** Pointer moves under this many pixels between press and release still count as a click. */
11
+ const DRAG = 4;
12
+ /**
13
+ * Comments on a page: pins, the comment mode, the list of threads and the
14
+ * sign-in, drawn in a shadow root over the page. One per page.
15
+ */
16
+ export class VisualComments {
17
+ api;
18
+ store;
19
+ options = $state.raw();
20
+ /** Comment mode: the list is shown, and clicking a surface starts a thread. Pins always show. */
21
+ active = $state(false);
22
+ /** The thread whose conversation is open. */
23
+ openId = $state();
24
+ draft = $state();
25
+ filters = $state({ query: '', sort: 'date', showResolved: false, onlyMine: false, allPages: false });
26
+ /** Measured on every change of the page. */
27
+ surfaces = $state.raw([]);
28
+ /** Bumped whenever anything on the page may have moved. */
29
+ layout = $state(0);
30
+ page = $state('');
31
+ /** Where the host asked for the list to go. */
32
+ panelRect = $state.raw();
33
+ hostToggles = $state(false);
34
+ /** Where a click would start a thread, while it would. */
35
+ aim = $state.raw();
36
+ /** The pointer while it is over a surface in comment mode: the comment cursor is drawn there. */
37
+ cursor = $state.raw();
38
+ /** Opening a thread scrolls its element into view once it is found. */
39
+ revealing = $state();
40
+ doc;
41
+ host;
42
+ app;
43
+ slots = new Map();
44
+ frames = new Map();
45
+ cleanup = [];
46
+ pointer;
47
+ pressed;
48
+ measuring = 0;
49
+ aiming = 0;
50
+ animating = 0;
51
+ cursorSheet = new CSSStyleSheet();
52
+ focusDraftHandler;
53
+ linked;
54
+ constructor(options, doc = document) {
55
+ this.options = options;
56
+ this.doc = doc;
57
+ this.api = new Api(options.endpoint ?? '/_comments');
58
+ this.store = new Threads(this.api);
59
+ this.host = doc.createElement('visual-comments');
60
+ this.host.setAttribute('data-vc-ignore', '');
61
+ this.host.style.cssText = 'position:fixed;inset:0;z-index:2147483600;pointer-events:none;display:block;';
62
+ this.start();
63
+ }
64
+ // State for the UI and hosts.
65
+ get viewer() { return this.store.viewer; }
66
+ get threads() { return this.store.threads; }
67
+ get unreadCount() { return this.store.unreadCount; }
68
+ get loading() { return this.store.loading; }
69
+ get open() {
70
+ return this.store.threads.find((thread) => thread.id === this.openId);
71
+ }
72
+ /** Threads shown as pins: on this page, unresolved unless resolved ones are shown or it is open. */
73
+ get pinned() {
74
+ return this.store.threads.filter((thread) => thread.page === this.page
75
+ && (!thread.resolvedAt || this.filters.showResolved || thread.id === this.openId));
76
+ }
77
+ get showToolbar() {
78
+ return this.options.toolbar ?? !this.hostToggles;
79
+ }
80
+ surface(id) {
81
+ return this.surfaces.find((surface) => surface.id === id);
82
+ }
83
+ /** A surface's title for the list, whether or not it is on screen. */
84
+ titleOf(thread) {
85
+ return this.surface(thread.surface)?.title || this.options.describe?.(thread.surface)?.title || thread.title
86
+ || new URL(thread.url).pathname;
87
+ }
88
+ orderOf(thread) {
89
+ return this.options.describe?.(thread.surface)?.order
90
+ ?? this.surfaces.findIndex((surface) => surface.id === thread.surface);
91
+ }
92
+ linkOf(thread) {
93
+ return this.options.link?.(thread) ?? `${thread.url}#vc=${thread.id}`;
94
+ }
95
+ imageUrl(image) {
96
+ return this.store.imageUrl(image);
97
+ }
98
+ /** Whether an element belongs to the comment UI. */
99
+ ours = (element) => element === this.host || this.slots.has(element);
100
+ // Actions.
101
+ /** Changes options after creation, e.g. when a host's view mounts and can show threads. */
102
+ configure(options) {
103
+ // Hosts call this from their own effects, which must not depend on the options.
104
+ this.options = { ...untrack(() => this.options), ...options };
105
+ this.schedule();
106
+ }
107
+ toggle(on = !this.active) {
108
+ this.active = on;
109
+ this.cursor = undefined;
110
+ this.aim = undefined;
111
+ if (!on) {
112
+ this.draft = undefined;
113
+ this.openId = undefined;
114
+ }
115
+ this.updateCursorStyle();
116
+ this.updateAim();
117
+ }
118
+ openThread(id) {
119
+ this.openId = id;
120
+ const thread = this.open;
121
+ if (thread?.unread)
122
+ void this.store.markRead(thread.id, true);
123
+ }
124
+ /** Opens a thread from the list or a link, bringing it into view. */
125
+ focus(thread) {
126
+ this.draft = undefined;
127
+ this.active = true;
128
+ this.openThread(thread.id);
129
+ this.revealing = thread.id;
130
+ if (this.options.show?.(thread))
131
+ return;
132
+ if (thread.page !== this.page) {
133
+ location.assign(this.linkOf(thread));
134
+ return;
135
+ }
136
+ const surface = this.surface(thread.surface);
137
+ if (!surface)
138
+ return;
139
+ if (surface.element) {
140
+ const { clip } = surface;
141
+ if (clip.right - clip.left < 80 || clip.bottom - clip.top < 80)
142
+ surface.element.scrollIntoView({ block: 'center', inline: 'center' });
143
+ }
144
+ else if (!thread.anchor) {
145
+ const at = toClient(surface, thread.x, thread.y);
146
+ if (!inside(surface.clip, at.x, at.y))
147
+ window.scrollTo({ top: thread.y - innerHeight / 3, behavior: 'smooth' });
148
+ }
149
+ }
150
+ /** The thread's element, once found, scrolled into view inside its document. */
151
+ revealElement(element) {
152
+ reveal(element);
153
+ this.revealing = undefined;
154
+ }
155
+ /** Leaves for the site's sign-in, and comes back to this address. */
156
+ signIn() {
157
+ const auth = this.options.auth ?? '/auth';
158
+ if (typeof auth === 'object')
159
+ return auth.signIn();
160
+ const returnTo = `${location.pathname}${location.search}${location.hash}`;
161
+ location.assign(`${auth.replace(/\/$/, '')}/login?${new URLSearchParams({ returnTo })}`);
162
+ }
163
+ signOut() {
164
+ this.toggle(false);
165
+ const auth = this.options.auth ?? '/auth';
166
+ if (typeof auth === 'object')
167
+ return auth.signOut();
168
+ // A form, so the site's server can send the browser on to the identity provider.
169
+ const form = this.doc.createElement('form');
170
+ form.method = 'POST';
171
+ form.action = `${auth.replace(/\/$/, '')}/logout`;
172
+ form.hidden = true;
173
+ this.doc.body.appendChild(form);
174
+ form.submit();
175
+ }
176
+ /** Measures the page again, e.g. after the host moved surfaces in a way it cannot see. */
177
+ relayout() {
178
+ this.schedule();
179
+ }
180
+ /** For hosts that are not built with Svelte: called after any change of state. */
181
+ subscribe(listener) {
182
+ return $effect.root(() => {
183
+ $effect(() => {
184
+ void [this.active, this.openId, this.unreadCount, this.viewer, this.store.threads.length, this.loading];
185
+ listener(this);
186
+ });
187
+ });
188
+ }
189
+ destroy() {
190
+ for (const stop of this.cleanup.splice(0))
191
+ stop();
192
+ for (const stop of this.frames.values())
193
+ stop();
194
+ this.frames.clear();
195
+ for (const app of this.slots.values())
196
+ void unmount(app);
197
+ this.slots.clear();
198
+ if (this.app)
199
+ void unmount(this.app);
200
+ this.host.remove();
201
+ this.doc.adoptedStyleSheets = this.doc.adoptedStyleSheets.filter((sheet) => sheet !== this.cursorSheet);
202
+ }
203
+ /** Called by the UI so a click elsewhere can bring the unsent comment back to attention. */
204
+ onFocusDraft(handler) {
205
+ this.focusDraftHandler = handler;
206
+ }
207
+ get draftHasContent() {
208
+ return Boolean(this.draft?.text.trim() || this.draft?.images);
209
+ }
210
+ // Setup.
211
+ start() {
212
+ const doc = this.doc;
213
+ const view = doc.defaultView;
214
+ const shadow = this.host.attachShadow({ mode: 'open' });
215
+ addStyles(shadow);
216
+ addProperties(doc);
217
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, this.cursorSheet];
218
+ doc.documentElement.appendChild(this.host);
219
+ this.app = mount(App, { target: shadow, props: { vc: this } });
220
+ 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}` : ''}`);
226
+ }
227
+ this.cleanup.push($effect.root(() => {
228
+ // Open a linked thread once threads have loaded.
229
+ $effect(() => {
230
+ if (this.linked === undefined || this.store.loading)
231
+ return;
232
+ const thread = this.store.threads.find((t) => t.id === this.linked);
233
+ if (!thread && !this.viewer)
234
+ return;
235
+ this.linked = undefined;
236
+ if (thread) {
237
+ this.filters.showResolved ||= Boolean(thread.resolvedAt);
238
+ this.focus(thread);
239
+ }
240
+ });
241
+ // Buttons the host marked with data-vc-toggle say whether comment mode is on and if anything is unread.
242
+ $effect(() => {
243
+ const unread = this.unreadCount > 0 && !this.active;
244
+ const pressed = String(this.active);
245
+ void this.layout;
246
+ for (const button of doc.querySelectorAll('[data-vc-toggle]')) {
247
+ if (button.getAttribute('aria-pressed') !== pressed)
248
+ button.setAttribute('aria-pressed', pressed);
249
+ if (button.hasAttribute('data-vc-unread') !== unread)
250
+ button.toggleAttribute('data-vc-unread', unread);
251
+ }
252
+ });
253
+ }));
254
+ const on = (target, type, listener, options) => {
255
+ target.addEventListener(type, listener, options);
256
+ this.cleanup.push(() => target.removeEventListener(type, listener, options));
257
+ };
258
+ const capture = { capture: true };
259
+ on(view, 'pointermove', (event) => this.onPointerMove(event), { capture: true, passive: true });
260
+ on(view, 'pointerdown', (event) => this.onPress(event), capture);
261
+ on(view, 'mousedown', (event) => this.onPress(event), capture);
262
+ on(view, 'pointerup', (event) => this.onRelease(event), capture);
263
+ on(view, 'mouseup', (event) => this.onRelease(event), capture);
264
+ on(view, 'click', (event) => this.onClick(event), capture);
265
+ for (const type of ['dblclick', 'auxclick', 'contextmenu'])
266
+ on(view, type, (event) => this.onOtherClick(event), capture);
267
+ on(view, 'keydown', (event) => this.onKey(event));
268
+ on(view, 'keyup', (event) => this.onKey(event));
269
+ on(view, 'resize', () => this.schedule());
270
+ on(doc, 'scroll', () => this.schedule(), { capture: true, passive: true });
271
+ const leave = () => this.move(undefined);
272
+ doc.documentElement.addEventListener('pointerleave', leave);
273
+ this.cleanup.push(() => doc.documentElement.removeEventListener('pointerleave', leave));
274
+ on(view, 'blur', () => this.move(undefined));
275
+ // An image dropped anywhere but a comment field would make the browser open it.
276
+ const nodrop = (event) => {
277
+ if (this.active && event.dataTransfer?.types.includes('Files') && !event.composedPath().includes(this.host))
278
+ event.preventDefault();
279
+ };
280
+ on(view, 'dragover', nodrop);
281
+ on(view, 'drop', nodrop);
282
+ // While CSS transitions and animations run, things move without any change to observe.
283
+ for (const type of ['transitionrun', 'animationstart'])
284
+ on(doc, type, () => { this.animating++; this.schedule(); }, capture);
285
+ for (const type of ['transitionend', 'transitioncancel', 'animationend', 'animationcancel']) {
286
+ on(doc, type, () => { this.animating = Math.max(0, this.animating - 1); this.schedule(); }, capture);
287
+ }
288
+ const observer = new MutationObserver(() => this.schedule());
289
+ observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
290
+ this.cleanup.push(() => observer.disconnect());
291
+ // Catches what nothing announces, such as a navigation that only changed the address.
292
+ const timer = setInterval(() => { this.animating = 0; this.schedule(); }, 1000);
293
+ this.cleanup.push(() => clearInterval(timer));
294
+ this.measure();
295
+ }
296
+ /** Watches a frame's own document: its scrolling, its changes and its keys. */
297
+ watchFrame(frame) {
298
+ const view = frame.defaultView;
299
+ if (!view || this.frames.has(frame))
300
+ return;
301
+ const schedule = () => this.schedule();
302
+ const key = (event) => this.onKey(event);
303
+ const observer = new MutationObserver(schedule);
304
+ observer.observe(frame.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
305
+ frame.addEventListener('scroll', schedule, { capture: true, passive: true });
306
+ view.addEventListener('resize', schedule);
307
+ view.addEventListener('keydown', key);
308
+ view.addEventListener('keyup', key);
309
+ this.frames.set(frame, () => {
310
+ observer.disconnect();
311
+ frame.removeEventListener('scroll', schedule, { capture: true });
312
+ view.removeEventListener('resize', schedule);
313
+ view.removeEventListener('keydown', key);
314
+ view.removeEventListener('keyup', key);
315
+ });
316
+ }
317
+ // Measuring.
318
+ schedule() {
319
+ if (this.measuring)
320
+ return;
321
+ this.measuring = requestAnimationFrame(() => {
322
+ this.measuring = 0;
323
+ this.measure();
324
+ if (this.animating)
325
+ this.schedule();
326
+ });
327
+ }
328
+ measure() {
329
+ const doc = this.doc;
330
+ this.surfaces = discover(doc, { ours: this.ours, documentOf: this.options.documentOf });
331
+ 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.
333
+ if (this.draft && !this.surface(this.draft.surface))
334
+ this.draft = undefined;
335
+ const docs = new Set(this.surfaces.map((surface) => surface.doc).filter((d) => Boolean(d) && d !== doc));
336
+ for (const frame of docs)
337
+ this.watchFrame(frame);
338
+ for (const [frame, stop] of this.frames) {
339
+ if (!docs.has(frame)) {
340
+ stop();
341
+ this.frames.delete(frame);
342
+ }
343
+ }
344
+ const panel = doc.querySelector('[data-vc-panel]')?.getBoundingClientRect();
345
+ const rect = panel && panel.width > 0 && panel.height > 0
346
+ ? { left: panel.left, top: panel.top, right: panel.right, bottom: panel.bottom }
347
+ : undefined;
348
+ if (rect?.left !== this.panelRect?.left || rect?.top !== this.panelRect?.top
349
+ || rect?.right !== this.panelRect?.right || rect?.bottom !== this.panelRect?.bottom)
350
+ this.panelRect = rect;
351
+ this.hostToggles = Boolean(doc.querySelector('[data-vc-toggle]'));
352
+ this.mountSlots();
353
+ this.layout++;
354
+ this.updateAim();
355
+ }
356
+ /** Puts the account button in every `[data-vc-account]` element of the host's own toolbar. */
357
+ mountSlots() {
358
+ for (const element of this.doc.querySelectorAll('[data-vc-account]')) {
359
+ if (this.slots.has(element) || element.shadowRoot)
360
+ continue;
361
+ const shadow = element.attachShadow({ mode: 'open' });
362
+ addStyles(shadow);
363
+ this.slots.set(element, mount(Account, { target: shadow, props: { vc: this, slot: true } }));
364
+ }
365
+ for (const [element, app] of this.slots) {
366
+ if (!element.isConnected) {
367
+ void unmount(app);
368
+ this.slots.delete(element);
369
+ }
370
+ }
371
+ }
372
+ // Pointer.
373
+ fromUs(event) {
374
+ return event.composedPath().includes(this.host);
375
+ }
376
+ surfaceAt(x, y) {
377
+ return surfaceAt(this.surfaces, this.doc, x, y, this.ours);
378
+ }
379
+ /** The pointer moved, or left (undefined). Also called by the layers over frames. */
380
+ move(pointer) {
381
+ this.pointer = pointer;
382
+ this.updateAim();
383
+ }
384
+ onPointerMove(event) {
385
+ if (!this.active)
386
+ return;
387
+ if (this.fromUs(event))
388
+ return this.move(undefined);
389
+ this.move({ x: event.clientX, y: event.clientY, alt: event.altKey });
390
+ }
391
+ /** Aims again, at most once per frame: the pointer moved, or the page moved under it. */
392
+ updateAim() {
393
+ if (this.aiming)
394
+ return;
395
+ this.aiming = requestAnimationFrame(() => {
396
+ this.aiming = 0;
397
+ const pointer = this.pointer;
398
+ const surface = this.active && pointer ? this.surfaceAt(pointer.x, pointer.y) : undefined;
399
+ const cursor = surface && pointer ? { x: pointer.x, y: pointer.y } : undefined;
400
+ this.cursor = cursor;
401
+ this.aim = surface && pointer && !this.draft && this.openId === undefined
402
+ ? aimAt(surface, pointer.x, pointer.y, pointer.alt)
403
+ : undefined;
404
+ this.updateCursorStyle();
405
+ });
406
+ }
407
+ /** Over a surface in comment mode the page's cursor is hidden; the comment cursor is drawn instead. */
408
+ updateCursorStyle() {
409
+ const rule = this.active && this.cursor ? '*, *::before, *::after { cursor: none !important; }' : '';
410
+ if (this.cursorSheet.cssRules.length !== (rule ? 1 : 0))
411
+ this.cursorSheet.replaceSync(rule);
412
+ }
413
+ /** Pressing on a surface in comment mode does not reach the page, unless the host keeps its gestures there. */
414
+ onPress(event) {
415
+ if (!this.active || this.fromUs(event))
416
+ return;
417
+ this.pressed = { x: event.clientX, y: event.clientY };
418
+ const surface = this.surfaceAt(event.clientX, event.clientY);
419
+ if (surface && !surface.passGestures)
420
+ stop(event);
421
+ }
422
+ onRelease(event) {
423
+ if (!this.active || this.fromUs(event))
424
+ return;
425
+ const surface = this.surfaceAt(event.clientX, event.clientY);
426
+ if (surface && !surface.passGestures)
427
+ stop(event);
428
+ }
429
+ onOtherClick(event) {
430
+ if (!this.active || this.fromUs(event))
431
+ return;
432
+ if (this.surfaceAt(event.clientX, event.clientY))
433
+ stop(event);
434
+ }
435
+ onClick(event) {
436
+ const path = event.composedPath();
437
+ if (path.some((node) => node instanceof Element && node.matches('[data-vc-toggle]'))) {
438
+ this.toggle();
439
+ return;
440
+ }
441
+ if (this.fromUs(event))
442
+ return;
443
+ if (!this.active) {
444
+ // Pins show outside comment mode too; a click away closes the thread one opened.
445
+ if (this.openId !== undefined)
446
+ this.openThread(undefined);
447
+ return;
448
+ }
449
+ const surface = this.surfaceAt(event.clientX, event.clientY);
450
+ if (!surface) {
451
+ // A click away from the comment: keep an unsent one, drop an empty one, close an open thread.
452
+ if (this.draftHasContent)
453
+ this.focusDraftHandler?.();
454
+ else if (this.draft)
455
+ this.draft = undefined;
456
+ else if (this.openId !== undefined)
457
+ this.openThread(undefined);
458
+ return;
459
+ }
460
+ stop(event);
461
+ const pressed = this.pressed;
462
+ this.pressed = undefined;
463
+ if (pressed && Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > DRAG)
464
+ return;
465
+ this.place(surface, event.clientX, event.clientY, event.altKey);
466
+ }
467
+ /** A click on a surface in comment mode starts a thread. A click away from an open thread closes it first. */
468
+ place(surface, x, y, free) {
469
+ if (this.draftHasContent) {
470
+ this.focusDraftHandler?.();
471
+ return;
472
+ }
473
+ if (this.openId !== undefined) {
474
+ this.openThread(undefined);
475
+ return;
476
+ }
477
+ this.draft = { ...aimAt(surface, x, y, free), text: '' };
478
+ this.updateAim();
479
+ }
480
+ /** A layer over a frame passes wheel turns to whatever scrolls under the pointer. */
481
+ wheel(surface, event) {
482
+ const unit = event.deltaMode === 1 ? 16 : 1;
483
+ const dx = event.deltaX * unit;
484
+ const dy = event.deltaY * unit;
485
+ if (surface.doc && surface.doc !== this.doc) {
486
+ const d = { x: (event.clientX - surface.docOrigin.x) / surface.docScale, y: (event.clientY - surface.docOrigin.y) / surface.docScale };
487
+ const scroller = scrollerFrom(surface.doc.elementFromPoint(d.x, d.y), dx / surface.docScale, dy / surface.docScale);
488
+ if (scroller) {
489
+ event.preventDefault();
490
+ scroller.scrollBy(dx / surface.docScale, dy / surface.docScale);
491
+ return;
492
+ }
493
+ }
494
+ const outer = scrollerFrom(surface.element?.parentElement ?? null, dx, dy);
495
+ if (outer) {
496
+ event.preventDefault();
497
+ outer.scrollBy(dx, dy);
498
+ }
499
+ }
500
+ onKey(event) {
501
+ if (event.key === 'Alt' && this.pointer) {
502
+ this.pointer = { ...this.pointer, alt: event.type === 'keydown' };
503
+ this.updateAim();
504
+ return;
505
+ }
506
+ if (event.type !== 'keydown' || event.defaultPrevented)
507
+ return;
508
+ const target = event.composedPath()[0];
509
+ if (editable(target))
510
+ 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
+ const shortcut = this.options.shortcut ?? 'c';
526
+ if (shortcut && event.key.toLowerCase() === shortcut && !event.metaKey && !event.ctrlKey && !event.altKey) {
527
+ event.preventDefault();
528
+ this.toggle();
529
+ }
530
+ }
531
+ }
532
+ function stop(event) {
533
+ event.preventDefault();
534
+ event.stopImmediatePropagation();
535
+ }
536
+ function editable(target) {
537
+ const element = target;
538
+ return Boolean(element && (element.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName)));
539
+ }
540
+ /** The page key: the address's origin and path, without a trailing slash. */
541
+ export function pageOf(option) {
542
+ const url = new URL(location.href);
543
+ if (typeof option === 'string')
544
+ return option;
545
+ if (option)
546
+ return option(url);
547
+ const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') : url.pathname;
548
+ return `${url.origin}${path}`;
549
+ }
550
+ function addStyles(root) {
551
+ const style = root.ownerDocument.createElement('style');
552
+ style.textContent = styles;
553
+ root.appendChild(style);
554
+ }
555
+ /**
556
+ * Custom property registrations only work in the document, not in a shadow
557
+ * root, and the styles' shadows and rings need them. They are the same for
558
+ * every copy of the styles, so they are added once.
559
+ */
560
+ const registered = new WeakSet();
561
+ function addProperties(doc) {
562
+ if (registered.has(doc) || !properties)
563
+ return;
564
+ registered.add(doc);
565
+ const sheet = new CSSStyleSheet();
566
+ sheet.replaceSync(properties);
567
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
568
+ }