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,693 @@
|
|
|
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: clicking a surface starts a thread. Pins always show. */
|
|
21
|
+
active = $state(false);
|
|
22
|
+
/** Hide the list while keeping comment mode active. */
|
|
23
|
+
panelCollapsed = $state(false);
|
|
24
|
+
/** The thread whose conversation is open. */
|
|
25
|
+
openId = $state();
|
|
26
|
+
draft = $state();
|
|
27
|
+
filters = $state({ query: '', sort: 'date', showResolved: false, onlyMine: false, allPages: false });
|
|
28
|
+
/** Measured on every change of the page. */
|
|
29
|
+
surfaces = $state.raw([]);
|
|
30
|
+
/** Bumped whenever anything on the page may have moved. */
|
|
31
|
+
layout = $state(0);
|
|
32
|
+
page = $state('');
|
|
33
|
+
/** Where the host asked for the list to go. */
|
|
34
|
+
panelRect = $state.raw();
|
|
35
|
+
hostToggles = $state(false);
|
|
36
|
+
/** Where a click would start a thread, while it would. */
|
|
37
|
+
aim = $state.raw();
|
|
38
|
+
/** The pointer while it is over a surface in comment mode: the comment cursor is drawn there. */
|
|
39
|
+
cursor = $state.raw();
|
|
40
|
+
/** Opening a thread scrolls its element into view once it is found. */
|
|
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({});
|
|
48
|
+
doc;
|
|
49
|
+
host;
|
|
50
|
+
app;
|
|
51
|
+
slots = new Map();
|
|
52
|
+
frames = new Map();
|
|
53
|
+
cleanup = [];
|
|
54
|
+
pointer;
|
|
55
|
+
pressed;
|
|
56
|
+
measuring = 0;
|
|
57
|
+
aiming = 0;
|
|
58
|
+
animating = 0;
|
|
59
|
+
cursorSheet = new CSSStyleSheet();
|
|
60
|
+
focusDraftHandler;
|
|
61
|
+
linked;
|
|
62
|
+
typingTimer;
|
|
63
|
+
presenceTimer;
|
|
64
|
+
constructor(options, doc = document) {
|
|
65
|
+
this.options = options;
|
|
66
|
+
this.doc = doc;
|
|
67
|
+
this.api = new Api(options.endpoint ?? '/_comments');
|
|
68
|
+
this.store = new Threads(this.api, options.viewer);
|
|
69
|
+
this.host = doc.createElement('visual-comments');
|
|
70
|
+
this.host.setAttribute('data-vc-ignore', '');
|
|
71
|
+
this.host.style.cssText = 'position:fixed;inset:0;z-index:2147483600;pointer-events:none;display:block;';
|
|
72
|
+
this.start();
|
|
73
|
+
}
|
|
74
|
+
// State for the UI and hosts.
|
|
75
|
+
get viewer() { return this.store.viewer; }
|
|
76
|
+
get threads() { return this.store.threads; }
|
|
77
|
+
get unreadCount() { return this.store.unreadCount; }
|
|
78
|
+
get loading() { return this.store.loading; }
|
|
79
|
+
get open() {
|
|
80
|
+
return this.store.threads.find((thread) => thread.id === this.openId);
|
|
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
|
+
}
|
|
95
|
+
/** Threads shown as pins: on this page, unresolved unless resolved ones are shown or it is open. */
|
|
96
|
+
get pinned() {
|
|
97
|
+
return this.store.threads.filter((thread) => thread.page === this.page
|
|
98
|
+
&& (!thread.resolvedAt || this.filters.showResolved || thread.id === this.openId));
|
|
99
|
+
}
|
|
100
|
+
get showToolbar() {
|
|
101
|
+
return this.options.toolbar ?? !this.hostToggles;
|
|
102
|
+
}
|
|
103
|
+
surface(id) {
|
|
104
|
+
return this.surfaces.find((surface) => surface.id === id);
|
|
105
|
+
}
|
|
106
|
+
/** A surface's title for the list, whether or not it is on screen. */
|
|
107
|
+
titleOf(thread) {
|
|
108
|
+
return this.surface(thread.surface)?.title || this.options.describe?.(thread.surface)?.title || thread.title
|
|
109
|
+
|| new URL(thread.url).pathname;
|
|
110
|
+
}
|
|
111
|
+
orderOf(thread) {
|
|
112
|
+
return this.options.describe?.(thread.surface)?.order
|
|
113
|
+
?? this.surfaces.findIndex((surface) => surface.id === thread.surface);
|
|
114
|
+
}
|
|
115
|
+
linkOf(thread) {
|
|
116
|
+
return this.options.link?.(thread) ?? `${thread.url}#vc=${thread.id}`;
|
|
117
|
+
}
|
|
118
|
+
imageUrl(image) {
|
|
119
|
+
return this.store.imageUrl(image);
|
|
120
|
+
}
|
|
121
|
+
/** Whether an element belongs to the comment UI. */
|
|
122
|
+
ours = (element) => element === this.host || this.slots.has(element);
|
|
123
|
+
// Actions.
|
|
124
|
+
/** Changes options after creation, e.g. when a host's view mounts and can show threads. */
|
|
125
|
+
configure(options) {
|
|
126
|
+
// Hosts call this from their own effects, which must not depend on the options.
|
|
127
|
+
this.options = { ...untrack(() => this.options), ...options };
|
|
128
|
+
this.schedule();
|
|
129
|
+
}
|
|
130
|
+
toggle(on = !this.active) {
|
|
131
|
+
this.active = on;
|
|
132
|
+
if (!on)
|
|
133
|
+
this.panelCollapsed = false;
|
|
134
|
+
this.cursor = undefined;
|
|
135
|
+
this.aim = undefined;
|
|
136
|
+
if (!on) {
|
|
137
|
+
this.draft = undefined;
|
|
138
|
+
this.openId = undefined;
|
|
139
|
+
}
|
|
140
|
+
this.updateCursorStyle();
|
|
141
|
+
this.updateAim();
|
|
142
|
+
}
|
|
143
|
+
openThread(id) {
|
|
144
|
+
if (id !== this.openId)
|
|
145
|
+
this.stopTyping();
|
|
146
|
+
this.openId = id;
|
|
147
|
+
const thread = this.open;
|
|
148
|
+
if (thread?.unread)
|
|
149
|
+
void this.store.markRead(thread.id, true);
|
|
150
|
+
}
|
|
151
|
+
/** Opens a thread from the list or a link, bringing it into view. */
|
|
152
|
+
focus(thread) {
|
|
153
|
+
this.draft = undefined;
|
|
154
|
+
this.active = true;
|
|
155
|
+
this.openThread(thread.id);
|
|
156
|
+
this.revealing = thread.id;
|
|
157
|
+
if (this.options.show?.(thread))
|
|
158
|
+
return;
|
|
159
|
+
if (thread.page !== this.page) {
|
|
160
|
+
const url = this.linkOf(thread);
|
|
161
|
+
if (this.options.navigate)
|
|
162
|
+
this.options.navigate(url, thread);
|
|
163
|
+
else
|
|
164
|
+
location.assign(url);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const surface = this.surface(thread.surface);
|
|
168
|
+
if (!surface)
|
|
169
|
+
return;
|
|
170
|
+
if (surface.element) {
|
|
171
|
+
const { clip } = surface;
|
|
172
|
+
if (clip.right - clip.left < 80 || clip.bottom - clip.top < 80)
|
|
173
|
+
surface.element.scrollIntoView({ block: 'center', inline: 'center' });
|
|
174
|
+
}
|
|
175
|
+
else if (!thread.anchor) {
|
|
176
|
+
const at = toClient(surface, thread.x, thread.y);
|
|
177
|
+
if (!inside(surface.clip, at.x, at.y))
|
|
178
|
+
window.scrollTo({ top: thread.y - innerHeight / 3, behavior: 'smooth' });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** The thread's element, once found, scrolled into view inside its document. */
|
|
182
|
+
revealElement(element) {
|
|
183
|
+
reveal(element);
|
|
184
|
+
this.revealing = undefined;
|
|
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
|
+
}
|
|
213
|
+
/** Leaves for the site's sign-in, and comes back to this address. */
|
|
214
|
+
signIn() {
|
|
215
|
+
const auth = this.options.auth ?? '/auth';
|
|
216
|
+
if (typeof auth === 'object')
|
|
217
|
+
return auth.signIn();
|
|
218
|
+
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
|
219
|
+
location.assign(`${auth.replace(/\/$/, '')}/login?${new URLSearchParams({ returnTo })}`);
|
|
220
|
+
}
|
|
221
|
+
signOut() {
|
|
222
|
+
this.toggle(false);
|
|
223
|
+
const auth = this.options.auth ?? '/auth';
|
|
224
|
+
if (typeof auth === 'object')
|
|
225
|
+
return auth.signOut();
|
|
226
|
+
// A form, so the site's server can send the browser on to the identity provider.
|
|
227
|
+
const form = this.doc.createElement('form');
|
|
228
|
+
form.method = 'POST';
|
|
229
|
+
form.action = `${auth.replace(/\/$/, '')}/logout`;
|
|
230
|
+
form.hidden = true;
|
|
231
|
+
this.doc.body.appendChild(form);
|
|
232
|
+
form.submit();
|
|
233
|
+
}
|
|
234
|
+
/** Measures the page again, e.g. after the host moved surfaces in a way it cannot see. */
|
|
235
|
+
relayout() {
|
|
236
|
+
this.schedule();
|
|
237
|
+
}
|
|
238
|
+
/** For hosts that are not built with Svelte: called after any change of state. */
|
|
239
|
+
subscribe(listener) {
|
|
240
|
+
return $effect.root(() => {
|
|
241
|
+
$effect(() => {
|
|
242
|
+
void [this.active, this.openId, this.unreadCount, this.viewer, this.store.threads.length, this.loading,
|
|
243
|
+
this.store.participants, this.store.connection];
|
|
244
|
+
listener(this);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
destroy() {
|
|
249
|
+
for (const stop of this.cleanup.splice(0))
|
|
250
|
+
stop();
|
|
251
|
+
for (const stop of this.frames.values())
|
|
252
|
+
stop();
|
|
253
|
+
clearTimeout(this.typingTimer);
|
|
254
|
+
clearTimeout(this.presenceTimer);
|
|
255
|
+
this.frames.clear();
|
|
256
|
+
for (const app of this.slots.values())
|
|
257
|
+
void unmount(app);
|
|
258
|
+
this.slots.clear();
|
|
259
|
+
if (this.app)
|
|
260
|
+
void unmount(this.app);
|
|
261
|
+
this.host.remove();
|
|
262
|
+
this.doc.adoptedStyleSheets = this.doc.adoptedStyleSheets.filter((sheet) => sheet !== this.cursorSheet);
|
|
263
|
+
}
|
|
264
|
+
/** Called by the UI so a click elsewhere can bring the unsent comment back to attention. */
|
|
265
|
+
onFocusDraft(handler) {
|
|
266
|
+
this.focusDraftHandler = handler;
|
|
267
|
+
}
|
|
268
|
+
get draftHasContent() {
|
|
269
|
+
return Boolean(this.draft?.text.trim() || this.draft?.images);
|
|
270
|
+
}
|
|
271
|
+
// Setup.
|
|
272
|
+
start() {
|
|
273
|
+
const doc = this.doc;
|
|
274
|
+
const view = doc.defaultView;
|
|
275
|
+
const shadow = this.host.attachShadow({ mode: 'open' });
|
|
276
|
+
addStyles(shadow);
|
|
277
|
+
addProperties(doc);
|
|
278
|
+
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, this.cursorSheet];
|
|
279
|
+
doc.documentElement.appendChild(this.host);
|
|
280
|
+
this.app = mount(App, { target: shadow, props: { vc: this } });
|
|
281
|
+
this.cleanup.push(this.store.start());
|
|
282
|
+
const linked = hashPart('vc');
|
|
283
|
+
if (linked && /^\d+$/.test(linked)) {
|
|
284
|
+
this.linked = Number(linked);
|
|
285
|
+
dropFromHash('vc');
|
|
286
|
+
}
|
|
287
|
+
// The hash stays until the request is answered, so it survives signing in first.
|
|
288
|
+
this.connecting = hashPart('vc-connect') || undefined;
|
|
289
|
+
this.cleanup.push($effect.root(() => {
|
|
290
|
+
// Open a linked thread once threads have loaded.
|
|
291
|
+
$effect(() => {
|
|
292
|
+
if (this.linked === undefined || this.store.loading)
|
|
293
|
+
return;
|
|
294
|
+
const thread = this.store.threads.find((t) => t.id === this.linked);
|
|
295
|
+
if (!thread && !this.viewer)
|
|
296
|
+
return;
|
|
297
|
+
this.linked = undefined;
|
|
298
|
+
if (thread) {
|
|
299
|
+
this.filters.showResolved ||= Boolean(thread.resolvedAt);
|
|
300
|
+
this.focus(thread);
|
|
301
|
+
}
|
|
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
|
+
});
|
|
316
|
+
// Buttons the host marked with data-vc-toggle say whether comment mode is on and if anything is unread.
|
|
317
|
+
$effect(() => {
|
|
318
|
+
const unread = this.unreadCount > 0 && !this.active;
|
|
319
|
+
const pressed = String(this.active);
|
|
320
|
+
void this.layout;
|
|
321
|
+
for (const button of doc.querySelectorAll('[data-vc-toggle]')) {
|
|
322
|
+
if (button.getAttribute('aria-pressed') !== pressed)
|
|
323
|
+
button.setAttribute('aria-pressed', pressed);
|
|
324
|
+
if (button.hasAttribute('data-vc-unread') !== unread)
|
|
325
|
+
button.toggleAttribute('data-vc-unread', unread);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}));
|
|
329
|
+
const on = (target, type, listener, options) => {
|
|
330
|
+
target.addEventListener(type, listener, options);
|
|
331
|
+
this.cleanup.push(() => target.removeEventListener(type, listener, options));
|
|
332
|
+
};
|
|
333
|
+
const capture = { capture: true };
|
|
334
|
+
on(view, 'pointermove', (event) => this.onPointerMove(event), { capture: true, passive: true });
|
|
335
|
+
on(view, 'pointerdown', (event) => this.onPress(event), capture);
|
|
336
|
+
on(view, 'mousedown', (event) => this.onPress(event), capture);
|
|
337
|
+
on(view, 'pointerup', (event) => this.onRelease(event), capture);
|
|
338
|
+
on(view, 'mouseup', (event) => this.onRelease(event), capture);
|
|
339
|
+
on(view, 'click', (event) => this.onClick(event), capture);
|
|
340
|
+
for (const type of ['dblclick', 'auxclick', 'contextmenu'])
|
|
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);
|
|
346
|
+
on(view, 'keydown', (event) => this.onKey(event));
|
|
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
|
+
}
|
|
364
|
+
on(view, 'resize', () => this.schedule());
|
|
365
|
+
on(view, 'hashchange', () => {
|
|
366
|
+
const id = hashPart('vc-connect');
|
|
367
|
+
if (id)
|
|
368
|
+
this.connecting = id;
|
|
369
|
+
});
|
|
370
|
+
on(doc, 'scroll', () => this.schedule(), { capture: true, passive: true });
|
|
371
|
+
const leave = () => this.move(undefined);
|
|
372
|
+
doc.documentElement.addEventListener('pointerleave', leave);
|
|
373
|
+
this.cleanup.push(() => doc.documentElement.removeEventListener('pointerleave', leave));
|
|
374
|
+
on(view, 'blur', () => this.move(undefined));
|
|
375
|
+
// An image dropped anywhere but a comment field would make the browser open it.
|
|
376
|
+
const nodrop = (event) => {
|
|
377
|
+
if (this.active && event.dataTransfer?.types.includes('Files') && !event.composedPath().includes(this.host))
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
};
|
|
380
|
+
on(view, 'dragover', nodrop);
|
|
381
|
+
on(view, 'drop', nodrop);
|
|
382
|
+
// While CSS transitions and animations run, things move without any change to observe.
|
|
383
|
+
for (const type of ['transitionrun', 'animationstart'])
|
|
384
|
+
on(doc, type, () => { this.animating++; this.schedule(); }, capture);
|
|
385
|
+
for (const type of ['transitionend', 'transitioncancel', 'animationend', 'animationcancel']) {
|
|
386
|
+
on(doc, type, () => { this.animating = Math.max(0, this.animating - 1); this.schedule(); }, capture);
|
|
387
|
+
}
|
|
388
|
+
const observer = new MutationObserver(() => this.schedule());
|
|
389
|
+
observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
390
|
+
this.cleanup.push(() => observer.disconnect());
|
|
391
|
+
// Catches what nothing announces, such as a navigation that only changed the address.
|
|
392
|
+
const timer = setInterval(() => { this.animating = 0; this.schedule(); }, 1000);
|
|
393
|
+
this.cleanup.push(() => clearInterval(timer));
|
|
394
|
+
this.measure();
|
|
395
|
+
}
|
|
396
|
+
/** Watches a frame's own document: its scrolling, its changes and its keys. */
|
|
397
|
+
watchFrame(frame) {
|
|
398
|
+
const view = frame.defaultView;
|
|
399
|
+
if (!view || this.frames.has(frame))
|
|
400
|
+
return;
|
|
401
|
+
const schedule = () => this.schedule();
|
|
402
|
+
const key = (event) => {
|
|
403
|
+
this.onEscape(event);
|
|
404
|
+
this.onKey(event);
|
|
405
|
+
};
|
|
406
|
+
const observer = new MutationObserver(schedule);
|
|
407
|
+
observer.observe(frame.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
408
|
+
frame.addEventListener('scroll', schedule, { capture: true, passive: true });
|
|
409
|
+
view.addEventListener('resize', schedule);
|
|
410
|
+
view.addEventListener('keydown', key);
|
|
411
|
+
view.addEventListener('keyup', key);
|
|
412
|
+
this.frames.set(frame, () => {
|
|
413
|
+
observer.disconnect();
|
|
414
|
+
frame.removeEventListener('scroll', schedule, { capture: true });
|
|
415
|
+
view.removeEventListener('resize', schedule);
|
|
416
|
+
view.removeEventListener('keydown', key);
|
|
417
|
+
view.removeEventListener('keyup', key);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// Measuring.
|
|
421
|
+
schedule() {
|
|
422
|
+
if (this.measuring)
|
|
423
|
+
return;
|
|
424
|
+
this.measuring = requestAnimationFrame(() => {
|
|
425
|
+
this.measuring = 0;
|
|
426
|
+
this.measure();
|
|
427
|
+
if (this.animating)
|
|
428
|
+
this.schedule();
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
measure() {
|
|
432
|
+
const doc = this.doc;
|
|
433
|
+
this.surfaces = discover(doc, { ours: this.ours, documentOf: this.options.documentOf });
|
|
434
|
+
this.page = pageOf(this.options.page);
|
|
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.
|
|
437
|
+
if (this.draft && !this.surface(this.draft.surface))
|
|
438
|
+
this.draft = undefined;
|
|
439
|
+
if (this.draft?.target && !this.draft.target.element.isConnected && !this.draftHasContent)
|
|
440
|
+
this.draft = undefined;
|
|
441
|
+
const docs = new Set(this.surfaces.map((surface) => surface.doc).filter((d) => Boolean(d) && d !== doc));
|
|
442
|
+
for (const frame of docs)
|
|
443
|
+
this.watchFrame(frame);
|
|
444
|
+
for (const [frame, stop] of this.frames) {
|
|
445
|
+
if (!docs.has(frame)) {
|
|
446
|
+
stop();
|
|
447
|
+
this.frames.delete(frame);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const panel = doc.querySelector('[data-vc-panel]')?.getBoundingClientRect();
|
|
451
|
+
const rect = panel && panel.width > 0 && panel.height > 0
|
|
452
|
+
? { left: panel.left, top: panel.top, right: panel.right, bottom: panel.bottom }
|
|
453
|
+
: undefined;
|
|
454
|
+
if (rect?.left !== this.panelRect?.left || rect?.top !== this.panelRect?.top
|
|
455
|
+
|| rect?.right !== this.panelRect?.right || rect?.bottom !== this.panelRect?.bottom)
|
|
456
|
+
this.panelRect = rect;
|
|
457
|
+
this.hostToggles = Boolean(doc.querySelector('[data-vc-toggle]'));
|
|
458
|
+
this.mountSlots();
|
|
459
|
+
this.layout++;
|
|
460
|
+
this.updateAim();
|
|
461
|
+
}
|
|
462
|
+
/** Puts the account button in every `[data-vc-account]` element of the host's own toolbar. */
|
|
463
|
+
mountSlots() {
|
|
464
|
+
for (const element of this.doc.querySelectorAll('[data-vc-account]')) {
|
|
465
|
+
if (this.slots.has(element) || element.shadowRoot)
|
|
466
|
+
continue;
|
|
467
|
+
const shadow = element.attachShadow({ mode: 'open' });
|
|
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);
|
|
473
|
+
this.slots.set(element, mount(Account, { target: shadow, props: { vc: this, slot: true } }));
|
|
474
|
+
}
|
|
475
|
+
for (const [element, app] of this.slots) {
|
|
476
|
+
if (!element.isConnected) {
|
|
477
|
+
void unmount(app);
|
|
478
|
+
this.slots.delete(element);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Pointer.
|
|
483
|
+
fromUs(event) {
|
|
484
|
+
return event.composedPath().includes(this.host);
|
|
485
|
+
}
|
|
486
|
+
surfaceAt(x, y) {
|
|
487
|
+
return surfaceAt(this.surfaces, this.doc, x, y, this.ours);
|
|
488
|
+
}
|
|
489
|
+
/** The pointer moved, or left (undefined). Also called by the layers over frames. */
|
|
490
|
+
move(pointer) {
|
|
491
|
+
this.pointer = pointer;
|
|
492
|
+
this.updateAim();
|
|
493
|
+
}
|
|
494
|
+
onPointerMove(event) {
|
|
495
|
+
if (!this.active)
|
|
496
|
+
return;
|
|
497
|
+
if (this.fromUs(event))
|
|
498
|
+
return this.move(undefined);
|
|
499
|
+
this.move({ x: event.clientX, y: event.clientY, alt: event.altKey });
|
|
500
|
+
}
|
|
501
|
+
/** Aims again, at most once per frame: the pointer moved, or the page moved under it. */
|
|
502
|
+
updateAim() {
|
|
503
|
+
if (this.aiming)
|
|
504
|
+
return;
|
|
505
|
+
this.aiming = requestAnimationFrame(() => {
|
|
506
|
+
this.aiming = 0;
|
|
507
|
+
const pointer = this.pointer;
|
|
508
|
+
const surface = this.active && pointer ? this.surfaceAt(pointer.x, pointer.y) : undefined;
|
|
509
|
+
const cursor = surface && pointer ? { x: pointer.x, y: pointer.y } : undefined;
|
|
510
|
+
this.cursor = cursor;
|
|
511
|
+
this.aim = surface && pointer && !this.draft && this.openId === undefined
|
|
512
|
+
? aimAt(surface, pointer.x, pointer.y, pointer.alt)
|
|
513
|
+
: undefined;
|
|
514
|
+
this.updateCursorStyle();
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/** Over a surface in comment mode the page's cursor is hidden; the comment cursor is drawn instead. */
|
|
518
|
+
updateCursorStyle() {
|
|
519
|
+
const rule = this.active && this.cursor ? '*, *::before, *::after { cursor: none !important; }' : '';
|
|
520
|
+
if (this.cursorSheet.cssRules.length !== (rule ? 1 : 0))
|
|
521
|
+
this.cursorSheet.replaceSync(rule);
|
|
522
|
+
}
|
|
523
|
+
/** Pressing on a surface in comment mode does not reach the page, unless the host keeps its gestures there. */
|
|
524
|
+
onPress(event) {
|
|
525
|
+
if (!this.active || this.fromUs(event))
|
|
526
|
+
return;
|
|
527
|
+
this.pressed = { x: event.clientX, y: event.clientY };
|
|
528
|
+
const surface = this.surfaceAt(event.clientX, event.clientY);
|
|
529
|
+
if (surface && !surface.passGestures)
|
|
530
|
+
stop(event);
|
|
531
|
+
}
|
|
532
|
+
onRelease(event) {
|
|
533
|
+
if (!this.active || this.fromUs(event))
|
|
534
|
+
return;
|
|
535
|
+
const surface = this.surfaceAt(event.clientX, event.clientY);
|
|
536
|
+
if (surface && !surface.passGestures)
|
|
537
|
+
stop(event);
|
|
538
|
+
}
|
|
539
|
+
onOtherClick(event) {
|
|
540
|
+
if (!this.active || this.fromUs(event))
|
|
541
|
+
return;
|
|
542
|
+
if (this.surfaceAt(event.clientX, event.clientY))
|
|
543
|
+
stop(event);
|
|
544
|
+
}
|
|
545
|
+
onClick(event) {
|
|
546
|
+
const path = event.composedPath();
|
|
547
|
+
if (path.some((node) => node instanceof Element && node.matches('[data-vc-toggle]'))) {
|
|
548
|
+
this.toggle();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (this.fromUs(event))
|
|
552
|
+
return;
|
|
553
|
+
if (!this.active) {
|
|
554
|
+
// Pins show outside comment mode too; a click away closes the thread one opened.
|
|
555
|
+
if (this.openId !== undefined)
|
|
556
|
+
this.openThread(undefined);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const surface = this.surfaceAt(event.clientX, event.clientY);
|
|
560
|
+
if (!surface) {
|
|
561
|
+
// A click away from the comment: keep an unsent one, drop an empty one, close an open thread.
|
|
562
|
+
if (this.draftHasContent)
|
|
563
|
+
this.focusDraftHandler?.();
|
|
564
|
+
else if (this.draft)
|
|
565
|
+
this.draft = undefined;
|
|
566
|
+
else if (this.openId !== undefined)
|
|
567
|
+
this.openThread(undefined);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
stop(event);
|
|
571
|
+
const pressed = this.pressed;
|
|
572
|
+
this.pressed = undefined;
|
|
573
|
+
if (pressed && Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > DRAG)
|
|
574
|
+
return;
|
|
575
|
+
this.place(surface, event.clientX, event.clientY, event.altKey);
|
|
576
|
+
}
|
|
577
|
+
/** A click on a surface in comment mode starts a thread. A click away from an open thread closes it first. */
|
|
578
|
+
place(surface, x, y, free) {
|
|
579
|
+
if (this.draftHasContent) {
|
|
580
|
+
this.focusDraftHandler?.();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (this.openId !== undefined) {
|
|
584
|
+
this.openThread(undefined);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this.draft = { ...aimAt(surface, x, y, free), text: '' };
|
|
588
|
+
this.updateAim();
|
|
589
|
+
}
|
|
590
|
+
/** A layer over a frame passes wheel turns to whatever scrolls under the pointer. */
|
|
591
|
+
wheel(surface, event) {
|
|
592
|
+
const unit = event.deltaMode === 1 ? 16 : 1;
|
|
593
|
+
const dx = event.deltaX * unit;
|
|
594
|
+
const dy = event.deltaY * unit;
|
|
595
|
+
if (surface.doc && surface.doc !== this.doc) {
|
|
596
|
+
const d = { x: (event.clientX - surface.docOrigin.x) / surface.docScale, y: (event.clientY - surface.docOrigin.y) / surface.docScale };
|
|
597
|
+
const scroller = scrollerFrom(surface.doc.elementFromPoint(d.x, d.y), dx / surface.docScale, dy / surface.docScale);
|
|
598
|
+
if (scroller) {
|
|
599
|
+
event.preventDefault();
|
|
600
|
+
scroller.scrollBy(dx / surface.docScale, dy / surface.docScale);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const outer = scrollerFrom(surface.element?.parentElement ?? null, dx, dy);
|
|
605
|
+
if (outer) {
|
|
606
|
+
event.preventDefault();
|
|
607
|
+
outer.scrollBy(dx, dy);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
onKey(event) {
|
|
611
|
+
if (event.key === 'Alt' && this.pointer) {
|
|
612
|
+
this.pointer = { ...this.pointer, alt: event.type === 'keydown' };
|
|
613
|
+
this.updateAim();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (event.type !== 'keydown' || event.defaultPrevented || !this.ownsKey(event))
|
|
617
|
+
return;
|
|
618
|
+
const shortcut = this.options.shortcut ?? 'c';
|
|
619
|
+
if (shortcut && event.key.toLowerCase() === shortcut && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
620
|
+
event.preventDefault();
|
|
621
|
+
this.toggle();
|
|
622
|
+
}
|
|
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}` : ''}`);
|
|
656
|
+
}
|
|
657
|
+
function stop(event) {
|
|
658
|
+
event.preventDefault();
|
|
659
|
+
event.stopImmediatePropagation();
|
|
660
|
+
}
|
|
661
|
+
function editable(target) {
|
|
662
|
+
const element = target;
|
|
663
|
+
return Boolean(element && (element.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName)));
|
|
664
|
+
}
|
|
665
|
+
/** The page key: the address's origin and path, without a trailing slash. */
|
|
666
|
+
export function pageOf(option) {
|
|
667
|
+
const url = new URL(location.href);
|
|
668
|
+
if (typeof option === 'string')
|
|
669
|
+
return option;
|
|
670
|
+
if (option)
|
|
671
|
+
return option(url);
|
|
672
|
+
const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') : url.pathname;
|
|
673
|
+
return `${url.origin}${path}`;
|
|
674
|
+
}
|
|
675
|
+
function addStyles(root) {
|
|
676
|
+
const style = root.ownerDocument.createElement('style');
|
|
677
|
+
style.textContent = styles;
|
|
678
|
+
root.appendChild(style);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Custom property registrations only work in the document, not in a shadow
|
|
682
|
+
* root, and the styles' shadows and rings need them. They are the same for
|
|
683
|
+
* every copy of the styles, so they are added once.
|
|
684
|
+
*/
|
|
685
|
+
const registered = new WeakSet();
|
|
686
|
+
function addProperties(doc) {
|
|
687
|
+
if (registered.has(doc) || !properties)
|
|
688
|
+
return;
|
|
689
|
+
registered.add(doc);
|
|
690
|
+
const sheet = new CSSStyleSheet();
|
|
691
|
+
sheet.replaceSync(properties);
|
|
692
|
+
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
|
693
|
+
}
|