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.
- package/LICENSE +21 -0
- package/README.md +60 -2
- 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/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 +46 -0
- package/dist/core/threads.svelte.js +219 -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/index.d.ts +33 -0
- package/dist/server/index.js +67 -0
- package/dist/shared/limits.d.ts +9 -0
- package/dist/shared/limits.js +10 -0
- package/dist/shared/protocol.d.ts +2 -0
- package/dist/shared/protocol.js +3 -0
- package/dist/shared/types.d.ts +93 -0
- package/dist/shared/types.js +2 -0
- package/dist/ui/Account.svelte +68 -0
- package/dist/ui/Account.svelte.d.ts +8 -0
- package/dist/ui/App.svelte +68 -0
- package/dist/ui/App.svelte.d.ts +7 -0
- package/dist/ui/Avatar.svelte +42 -0
- package/dist/ui/Avatar.svelte.d.ts +12 -0
- package/dist/ui/Composer.svelte +267 -0
- package/dist/ui/Composer.svelte.d.ts +20 -0
- package/dist/ui/Icon.svelte +44 -0
- package/dist/ui/Icon.svelte.d.ts +29 -0
- package/dist/ui/Menu.svelte +65 -0
- package/dist/ui/Menu.svelte.d.ts +13 -0
- package/dist/ui/MenuItem.svelte +38 -0
- package/dist/ui/MenuItem.svelte.d.ts +12 -0
- package/dist/ui/Overlay.svelte +488 -0
- package/dist/ui/Overlay.svelte.d.ts +3 -0
- package/dist/ui/Panel.svelte +206 -0
- package/dist/ui/Panel.svelte.d.ts +10 -0
- package/dist/ui/Pin.svelte +136 -0
- package/dist/ui/Pin.svelte.d.ts +13 -0
- package/dist/ui/ThreadCard.svelte +177 -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 +92 -0
- package/dist/ui/text.d.ts +3 -0
- package/dist/ui/text.js +7 -0
- package/dist/visual-comments.svelte.d.ts +181 -0
- package/dist/visual-comments.svelte.js +568 -0
- package/dist-widget/visual-comments.js +51 -0
- package/package.json +66 -5
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// Finds the element a comment pin is meant for, in a document: the host page,
|
|
2
|
+
// or a same-origin frame's own document. A frame's elements belong to another
|
|
3
|
+
// realm: test them by name, never with instanceof. Everything here is in the
|
|
4
|
+
// document's client pixels.
|
|
5
|
+
/** How far the pull reaches, in screen pixels. */
|
|
6
|
+
export const MAGNET = 14;
|
|
7
|
+
/** Elements covering more of the region than this are layout, not something to comment on. */
|
|
8
|
+
const LAYOUT_SHARE = 0.6;
|
|
9
|
+
/** Controls up to this size take in what they contain, as a button takes its icon and text. */
|
|
10
|
+
const COMPACT = { width: 480, height: 56 };
|
|
11
|
+
const NAMED = '[data-vc-target],[data-pf-comment]';
|
|
12
|
+
const CONTROL = 'a[href],button,input,select,textarea,label,summary,[contenteditable],[tabindex]:not([tabindex="-1"]),' +
|
|
13
|
+
'[role=button],[role=link],[role=tab],[role=menuitem],[role=checkbox],[role=switch],[role=option],' +
|
|
14
|
+
'[data-vc-interactive],[data-pf-link],[data-pf-interactive]';
|
|
15
|
+
const MEDIA = 'img,svg,canvas,video,picture,iframe';
|
|
16
|
+
const CONTENT = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dt,dd,blockquote,pre,figcaption,caption,legend';
|
|
17
|
+
const GROUP = 'section,article,aside,nav,header,footer,form,table,tr,fieldset,dialog,figure,ul,ol,' +
|
|
18
|
+
'[role=region],[role=dialog],[role=row],[role=grid],[role=table],[role=tablist],[role=listitem],' +
|
|
19
|
+
'[role=cell],[role=gridcell],[role=columnheader]';
|
|
20
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
21
|
+
const TRANSPARENT = /^transparent$|,\s*0\)$|\/\s*0\)$/;
|
|
22
|
+
/** The elements at a point, top first, without skipped ones. */
|
|
23
|
+
export function elementsAt(doc, x, y, skip) {
|
|
24
|
+
return doc.elementsFromPoint(x, y).filter((element) => !skip(element));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The element nearest the point, or undefined for open space. Under the
|
|
28
|
+
* pointer, the innermost meaningful element wins. A smaller one within
|
|
29
|
+
* `radius` pulls the pointer onto it, as long as it sits inside that element.
|
|
30
|
+
*/
|
|
31
|
+
export function targetAt(doc, x, y, radius, search) {
|
|
32
|
+
const scan = new Scan(doc, search);
|
|
33
|
+
const { region } = search;
|
|
34
|
+
const found = new Map();
|
|
35
|
+
for (const [dx, dy] of probes(radius)) {
|
|
36
|
+
const px = x + dx;
|
|
37
|
+
const py = y + dy;
|
|
38
|
+
if (px < region.x || py < region.y || px >= region.x + region.width || py >= region.y + region.height)
|
|
39
|
+
continue;
|
|
40
|
+
for (const hit of elementsAt(doc, px, py, search.skip)) {
|
|
41
|
+
const element = scan.target(hit);
|
|
42
|
+
if (element && !found.has(element))
|
|
43
|
+
found.set(element, scan.box(element));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let inner;
|
|
47
|
+
for (const [element, box] of found) {
|
|
48
|
+
if (distance(box, x, y) === 0 && (!inner || area(box) < area(inner.box)))
|
|
49
|
+
inner = { element, box };
|
|
50
|
+
}
|
|
51
|
+
let pull;
|
|
52
|
+
for (const [element, box] of found) {
|
|
53
|
+
const gap = distance(box, x, y);
|
|
54
|
+
if (gap === 0 || gap > radius)
|
|
55
|
+
continue;
|
|
56
|
+
if (inner && !(area(box) < area(inner.box) && inner.element.contains(element)))
|
|
57
|
+
continue;
|
|
58
|
+
if (!pull || gap < pull.gap || (gap === pull.gap && area(box) < area(pull.box)))
|
|
59
|
+
pull = { element, box, gap };
|
|
60
|
+
}
|
|
61
|
+
const chosen = pull ?? inner;
|
|
62
|
+
return chosen && scan.describe(chosen.element, chosen.box);
|
|
63
|
+
}
|
|
64
|
+
/** The meaningful element around this one, for when the pin meant more than it caught. */
|
|
65
|
+
export function widen(target, search) {
|
|
66
|
+
if (!target.element.ownerDocument.defaultView)
|
|
67
|
+
return undefined;
|
|
68
|
+
const scan = new Scan(target.element.ownerDocument, search);
|
|
69
|
+
for (let element = target.element.parentElement; element; element = element.parentElement) {
|
|
70
|
+
if (search.within && !search.within.contains(element))
|
|
71
|
+
break;
|
|
72
|
+
if (!scan.candidate(element))
|
|
73
|
+
continue;
|
|
74
|
+
const box = scan.box(element);
|
|
75
|
+
if (area(box) > area(target.box) * 1.02)
|
|
76
|
+
return scan.describe(element, box);
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/** The element an anchor names in a document, if it is still there and still says the same. */
|
|
81
|
+
export function findAnchor(doc, anchor) {
|
|
82
|
+
let element = null;
|
|
83
|
+
try {
|
|
84
|
+
element = doc.querySelector(anchor.selector);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
if (!element)
|
|
90
|
+
return undefined;
|
|
91
|
+
const said = normalize(anchor.text).replace(/…$/, '').slice(0, 40);
|
|
92
|
+
return !said || normalize(visibleText(element)).startsWith(said) ? element : undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether the point of the element shows: inside the region, not clipped by a
|
|
96
|
+
* scrolling container, and not covered (a sticky header).
|
|
97
|
+
*/
|
|
98
|
+
export function shows(element, x, y, search) {
|
|
99
|
+
const { region } = search;
|
|
100
|
+
if (x < region.x || y < region.y || x >= region.x + region.width || y >= region.y + region.height)
|
|
101
|
+
return false;
|
|
102
|
+
if (clipped(element, x, y))
|
|
103
|
+
return false;
|
|
104
|
+
const top = elementsAt(element.ownerDocument, x, y, search.skip)[0];
|
|
105
|
+
// The element itself may not take pointer events (a chart), so its container counts as showing it.
|
|
106
|
+
return Boolean(top && (top === element || element.contains(top) || top.contains(element)));
|
|
107
|
+
}
|
|
108
|
+
/** Scrolls the document's containers until the element is in the middle of them. */
|
|
109
|
+
export function reveal(element) {
|
|
110
|
+
const view = element.ownerDocument.defaultView;
|
|
111
|
+
if (!view)
|
|
112
|
+
return;
|
|
113
|
+
for (let node = element.parentElement; node; node = node.parentElement) {
|
|
114
|
+
const style = view.getComputedStyle(node);
|
|
115
|
+
const scrolls = /auto|scroll/.test(style.overflowX + style.overflowY);
|
|
116
|
+
if (!scrolls || node === element.ownerDocument.body)
|
|
117
|
+
continue;
|
|
118
|
+
const outer = node.getBoundingClientRect();
|
|
119
|
+
const r = element.getBoundingClientRect();
|
|
120
|
+
if (r.top < outer.top || r.bottom > outer.bottom)
|
|
121
|
+
node.scrollTop += r.top - outer.top - (outer.height - r.height) / 2;
|
|
122
|
+
if (r.left < outer.left || r.right > outer.right)
|
|
123
|
+
node.scrollLeft += r.left - outer.left - (outer.width - r.width) / 2;
|
|
124
|
+
}
|
|
125
|
+
const r = element.getBoundingClientRect();
|
|
126
|
+
if (r.top < 0 || r.bottom > view.innerHeight)
|
|
127
|
+
view.scrollBy(0, r.top - (view.innerHeight - r.height) / 2);
|
|
128
|
+
}
|
|
129
|
+
/** The scrolling container from this element up that can still scroll, to pass wheel turns to. */
|
|
130
|
+
export function scrollerFrom(start, dx, dy) {
|
|
131
|
+
const view = start?.ownerDocument.defaultView;
|
|
132
|
+
if (!view)
|
|
133
|
+
return undefined;
|
|
134
|
+
for (let node = start; node; node = node.parentElement) {
|
|
135
|
+
const style = view.getComputedStyle(node);
|
|
136
|
+
const root = node === node.ownerDocument.scrollingElement;
|
|
137
|
+
const canY = (root || /auto|scroll/.test(style.overflowY)) && node.scrollHeight > node.clientHeight
|
|
138
|
+
&& (dy < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight - 1);
|
|
139
|
+
const canX = (root || /auto|scroll/.test(style.overflowX)) && node.scrollWidth > node.clientWidth
|
|
140
|
+
&& (dx < 0 ? node.scrollLeft > 0 : node.scrollLeft + node.clientWidth < node.scrollWidth - 1);
|
|
141
|
+
if ((dy && canY) || (dx && canX))
|
|
142
|
+
return node;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
/** Whether a container that clips its overflow hides this point of the element. */
|
|
147
|
+
function clipped(element, x, y) {
|
|
148
|
+
const view = element.ownerDocument.defaultView;
|
|
149
|
+
for (let node = element.parentElement; node && node !== element.ownerDocument.body; node = node.parentElement) {
|
|
150
|
+
const style = view.getComputedStyle(node);
|
|
151
|
+
if (style.overflowX === 'visible' && style.overflowY === 'visible')
|
|
152
|
+
continue;
|
|
153
|
+
const r = node.getBoundingClientRect();
|
|
154
|
+
if (x < r.left || x > r.right || y < r.top || y > r.bottom)
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/** What a thread stores about its target: a selector, its label and text. */
|
|
160
|
+
export function describeTarget(target) {
|
|
161
|
+
return {
|
|
162
|
+
selector: selectorOf(target.element),
|
|
163
|
+
label: target.label,
|
|
164
|
+
text: shorten(visibleText(target.element), 300)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** One pass over a document, remembering what it has measured. */
|
|
168
|
+
class Scan {
|
|
169
|
+
view;
|
|
170
|
+
search;
|
|
171
|
+
ranks = new Map();
|
|
172
|
+
constructor(doc, search) {
|
|
173
|
+
this.view = doc.defaultView;
|
|
174
|
+
this.search = search;
|
|
175
|
+
}
|
|
176
|
+
/** The visible part of an element's box, within the region. */
|
|
177
|
+
box(element) {
|
|
178
|
+
const r = element.getBoundingClientRect();
|
|
179
|
+
const { region } = this.search;
|
|
180
|
+
const x = Math.max(r.left, region.x);
|
|
181
|
+
const y = Math.max(r.top, region.y);
|
|
182
|
+
return {
|
|
183
|
+
x,
|
|
184
|
+
y,
|
|
185
|
+
width: Math.max(Math.min(r.right, region.x + region.width) - x, 0),
|
|
186
|
+
height: Math.max(Math.min(r.bottom, region.y + region.height) - y, 0)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* How much an element means on its own: 0 for wrappers and layout, then
|
|
191
|
+
* groups and boxes, content, controls and media, and elements the page
|
|
192
|
+
* names with `data-vc-target`.
|
|
193
|
+
*/
|
|
194
|
+
rank(element) {
|
|
195
|
+
let rank = this.ranks.get(element);
|
|
196
|
+
if (rank === undefined) {
|
|
197
|
+
rank = this.measure(element);
|
|
198
|
+
this.ranks.set(element, rank);
|
|
199
|
+
}
|
|
200
|
+
return rank;
|
|
201
|
+
}
|
|
202
|
+
measure(element) {
|
|
203
|
+
const name = element.localName;
|
|
204
|
+
if (name === 'html' || name === 'body' || name === 'head')
|
|
205
|
+
return 0;
|
|
206
|
+
const { within, region, skip } = this.search;
|
|
207
|
+
if (within && (element === within || !within.contains(element)))
|
|
208
|
+
return 0;
|
|
209
|
+
if (skip(element))
|
|
210
|
+
return 0;
|
|
211
|
+
// Paths and groups inside an SVG belong to the SVG.
|
|
212
|
+
if (element.namespaceURI === SVG && (name !== 'svg' || element.parentElement?.closest('svg')))
|
|
213
|
+
return 0;
|
|
214
|
+
const r = element.getBoundingClientRect();
|
|
215
|
+
if (r.width < 2 || r.height < 2)
|
|
216
|
+
return 0;
|
|
217
|
+
const style = this.view.getComputedStyle(element);
|
|
218
|
+
if (style.visibility !== 'visible' || style.opacity === '0')
|
|
219
|
+
return 0;
|
|
220
|
+
if (element.matches(NAMED))
|
|
221
|
+
return 5;
|
|
222
|
+
if (r.width * r.height > region.width * region.height * LAYOUT_SHARE)
|
|
223
|
+
return 0;
|
|
224
|
+
if (element.matches(CONTROL) || element.matches(MEDIA))
|
|
225
|
+
return 4;
|
|
226
|
+
if (element.matches(CONTENT) || ownText(element))
|
|
227
|
+
return 3;
|
|
228
|
+
if (element.matches(GROUP) || boxed(style))
|
|
229
|
+
return 2;
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
candidate(element) {
|
|
233
|
+
return this.rank(element) > 0;
|
|
234
|
+
}
|
|
235
|
+
/** The element a hit stands for: its nearest meaningful ancestor, taken in by a compact control. */
|
|
236
|
+
target(hit) {
|
|
237
|
+
let element = hit;
|
|
238
|
+
while (element && !this.candidate(element))
|
|
239
|
+
element = element.parentElement;
|
|
240
|
+
if (!element)
|
|
241
|
+
return undefined;
|
|
242
|
+
const control = element.parentElement?.closest(CONTROL);
|
|
243
|
+
if (control && this.candidate(control)) {
|
|
244
|
+
const r = control.getBoundingClientRect();
|
|
245
|
+
if (r.width <= COMPACT.width && r.height <= COMPACT.height)
|
|
246
|
+
element = control;
|
|
247
|
+
}
|
|
248
|
+
// A wrapper the same size as what it wraps is the same thing; keep the one that means more.
|
|
249
|
+
for (let parent = element.parentElement; parent && sameBox(parent, element); parent = parent.parentElement) {
|
|
250
|
+
if (this.rank(parent) < this.rank(element))
|
|
251
|
+
break;
|
|
252
|
+
element = parent;
|
|
253
|
+
}
|
|
254
|
+
return element;
|
|
255
|
+
}
|
|
256
|
+
describe(element, box) {
|
|
257
|
+
const radius = parseFloat(this.view.getComputedStyle(element).borderTopLeftRadius) || 0;
|
|
258
|
+
return { element, box, radius: Math.min(radius, box.width / 2, box.height / 2), label: labelOf(element) };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function labelOf(element) {
|
|
262
|
+
const name = element.getAttribute('data-vc-target') ||
|
|
263
|
+
element.getAttribute('data-pf-comment') ||
|
|
264
|
+
element.getAttribute('aria-label') ||
|
|
265
|
+
element.getAttribute('alt') ||
|
|
266
|
+
element.getAttribute('title') ||
|
|
267
|
+
element.getAttribute('placeholder') ||
|
|
268
|
+
visibleText(element) ||
|
|
269
|
+
// A label or wrapper around a field is named by the field.
|
|
270
|
+
element.querySelector('input,textarea,select')?.getAttribute('placeholder') ||
|
|
271
|
+
element.querySelector('input,textarea,select')?.getAttribute('aria-label');
|
|
272
|
+
const label = name ? `${kindOf(element)} “${shorten(name, 60)}”` : kindOf(element);
|
|
273
|
+
const column = columnOf(element);
|
|
274
|
+
return column ? `${label} in “${shorten(column, 40)}”` : label;
|
|
275
|
+
}
|
|
276
|
+
/** A table cell's column heading, so a bare number says what it counts. */
|
|
277
|
+
function columnOf(element) {
|
|
278
|
+
if (element.localName !== 'td')
|
|
279
|
+
return undefined;
|
|
280
|
+
const index = [...(element.parentElement?.children ?? [])].indexOf(element);
|
|
281
|
+
const head = element.closest('table')?.querySelector('thead tr')?.children[index];
|
|
282
|
+
return head ? visibleText(head) || undefined : undefined;
|
|
283
|
+
}
|
|
284
|
+
const ROLES = {
|
|
285
|
+
button: 'button', link: 'link', tab: 'tab', menuitem: 'menu item', checkbox: 'checkbox',
|
|
286
|
+
switch: 'switch', option: 'option', row: 'row', cell: 'cell', gridcell: 'cell',
|
|
287
|
+
columnheader: 'column header', listitem: 'list item', region: 'region', dialog: 'dialog',
|
|
288
|
+
tablist: 'tabs', grid: 'table', table: 'table', img: 'image'
|
|
289
|
+
};
|
|
290
|
+
const TAGS = {
|
|
291
|
+
a: 'link', button: 'button', select: 'menu', textarea: 'field', label: 'label', summary: 'button',
|
|
292
|
+
img: 'image', picture: 'image', svg: 'graphic', canvas: 'graphic', video: 'video', iframe: 'embed',
|
|
293
|
+
h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
|
|
294
|
+
p: 'text', li: 'list item', td: 'cell', th: 'column header', tr: 'row', table: 'table',
|
|
295
|
+
ul: 'list', ol: 'list', nav: 'navigation', header: 'header', footer: 'footer', aside: 'sidebar',
|
|
296
|
+
section: 'section', article: 'article', form: 'form', dialog: 'dialog', figure: 'figure',
|
|
297
|
+
blockquote: 'quote', pre: 'code', caption: 'caption', figcaption: 'caption', legend: 'legend'
|
|
298
|
+
};
|
|
299
|
+
/** Tags that say more than a role a page gives them, e.g. a table row that is also a link. */
|
|
300
|
+
const STRUCTURE = new Set(['tr', 'td', 'th', 'li']);
|
|
301
|
+
function kindOf(element) {
|
|
302
|
+
if (STRUCTURE.has(element.localName))
|
|
303
|
+
return TAGS[element.localName];
|
|
304
|
+
const role = ROLES[element.getAttribute('role') ?? ''];
|
|
305
|
+
if (role)
|
|
306
|
+
return role;
|
|
307
|
+
if (element.localName === 'input') {
|
|
308
|
+
const type = element.getAttribute('type') ?? 'text';
|
|
309
|
+
return type === 'checkbox' || type === 'radio' ? type : type === 'button' || type === 'submit' ? 'button' : 'field';
|
|
310
|
+
}
|
|
311
|
+
return TAGS[element.localName] ?? (ownText(element) ? 'text' : 'box');
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* A short path to the element: from the nearest ancestor with an id, or one
|
|
315
|
+
* the page names with `data-vc-target` or `data-testid`, by tag and position.
|
|
316
|
+
*/
|
|
317
|
+
function selectorOf(element) {
|
|
318
|
+
const parts = [];
|
|
319
|
+
for (let node = element; node && node.localName !== 'html'; node = node.parentElement) {
|
|
320
|
+
if (node.id) {
|
|
321
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
const named = ['data-vc-target', 'data-testid'].find((name) => node.hasAttribute(name));
|
|
325
|
+
if (named) {
|
|
326
|
+
const selector = `[${named}="${CSS.escape(node.getAttribute(named))}"]`;
|
|
327
|
+
if (node.ownerDocument.querySelectorAll(selector).length === 1) {
|
|
328
|
+
parts.unshift(selector);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
let part = node.localName;
|
|
333
|
+
const twins = node.parentElement
|
|
334
|
+
? [...node.parentElement.children].filter((child) => child.localName === node.localName)
|
|
335
|
+
: [];
|
|
336
|
+
if (twins.length > 1)
|
|
337
|
+
part += `:nth-of-type(${twins.indexOf(node) + 1})`;
|
|
338
|
+
parts.unshift(part);
|
|
339
|
+
}
|
|
340
|
+
return parts.join(' > ');
|
|
341
|
+
}
|
|
342
|
+
function visibleText(element) {
|
|
343
|
+
const text = element.innerText ?? element.textContent ?? '';
|
|
344
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
345
|
+
}
|
|
346
|
+
const normalize = (text) => text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
347
|
+
function ownText(element) {
|
|
348
|
+
for (const node of element.childNodes)
|
|
349
|
+
if (node.nodeType === 3 && node.textContent?.trim())
|
|
350
|
+
return true;
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
function boxed(style) {
|
|
354
|
+
if (!TRANSPARENT.test(style.backgroundColor) || style.backgroundImage !== 'none' || style.boxShadow !== 'none')
|
|
355
|
+
return true;
|
|
356
|
+
return ['Top', 'Right', 'Bottom', 'Left'].some((side) => parseFloat(style[`border${side}Width`]) > 0 && !TRANSPARENT.test(style[`border${side}Color`]));
|
|
357
|
+
}
|
|
358
|
+
function sameBox(a, b) {
|
|
359
|
+
const r = a.getBoundingClientRect();
|
|
360
|
+
const s = b.getBoundingClientRect();
|
|
361
|
+
return Math.abs(r.left - s.left) < 1 && Math.abs(r.top - s.top) < 1
|
|
362
|
+
&& Math.abs(r.right - s.right) < 1 && Math.abs(r.bottom - s.bottom) < 1;
|
|
363
|
+
}
|
|
364
|
+
/** The pointer, then rings around it, so nearby elements are found as well as the one under it. */
|
|
365
|
+
function probes(radius) {
|
|
366
|
+
const points = [[0, 0]];
|
|
367
|
+
for (const [reach, count] of [[radius / 2, 4], [radius, 8]]) {
|
|
368
|
+
for (let i = 0; i < count; i++) {
|
|
369
|
+
const angle = (i / count) * Math.PI * 2 + (count === 4 ? Math.PI / 4 : 0);
|
|
370
|
+
points.push([Math.cos(angle) * reach, Math.sin(angle) * reach]);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return points;
|
|
374
|
+
}
|
|
375
|
+
function distance(box, x, y) {
|
|
376
|
+
const dx = Math.max(box.x - x, 0, x - (box.x + box.width));
|
|
377
|
+
const dy = Math.max(box.y - y, 0, y - (box.y + box.height));
|
|
378
|
+
return Math.hypot(dx, dy);
|
|
379
|
+
}
|
|
380
|
+
const area = (box) => box.width * box.height;
|
|
381
|
+
export const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
|
|
382
|
+
export const shorten = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Api } from './api.js';
|
|
2
|
+
import type { CommentImage, CommentThread, NewThreadInput, Viewer } from '../shared/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The space's threads, kept current with the server's event stream. Actions
|
|
5
|
+
* show their result at once and resync with the server when they fail.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Threads {
|
|
8
|
+
viewer: Viewer | null;
|
|
9
|
+
threads: CommentThread[];
|
|
10
|
+
spaceName: string;
|
|
11
|
+
loading: boolean;
|
|
12
|
+
/** A failed action, shown until dismissed. */
|
|
13
|
+
error: string | null;
|
|
14
|
+
/** Ticks every 30 seconds so relative times stay current. */
|
|
15
|
+
now: number;
|
|
16
|
+
private api;
|
|
17
|
+
private refreshing?;
|
|
18
|
+
private queued;
|
|
19
|
+
private listening?;
|
|
20
|
+
private nextPending;
|
|
21
|
+
constructor(api: Api);
|
|
22
|
+
get unreadCount(): number;
|
|
23
|
+
start(): () => void;
|
|
24
|
+
/** Coalesces overlapping refreshes into at most one more request. */
|
|
25
|
+
refresh(): Promise<void>;
|
|
26
|
+
private stopListening;
|
|
27
|
+
/** Follows the event stream; after a dropped connection, catches up on anything missed. */
|
|
28
|
+
private listen;
|
|
29
|
+
private request;
|
|
30
|
+
private apply;
|
|
31
|
+
/** Runs an action, showing its error and resyncing with the server if it fails. */
|
|
32
|
+
private run;
|
|
33
|
+
private update;
|
|
34
|
+
imageUrl(image: CommentImage): string;
|
|
35
|
+
/** Uploads an image for a comment being written. Send its id with the comment. */
|
|
36
|
+
upload(file: Blob, width: number, height: number): Promise<CommentImage>;
|
|
37
|
+
create(input: Omit<NewThreadInput, 'images'>, images?: CommentImage[]): Promise<CommentThread>;
|
|
38
|
+
/** Shows the reply at once; throws if it could not be sent. */
|
|
39
|
+
reply(threadId: number, body: string, images?: CommentImage[]): Promise<void>;
|
|
40
|
+
edit(commentId: number, body: string, images: CommentImage[]): Promise<void>;
|
|
41
|
+
removeComment(commentId: number): Promise<void>;
|
|
42
|
+
removeThread(threadId: number): Promise<void>;
|
|
43
|
+
resolve(threadId: number, resolved: boolean): Promise<void>;
|
|
44
|
+
markRead(threadId: number, read: boolean): Promise<void>;
|
|
45
|
+
markAllRead(): Promise<void>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The space's threads, kept current with the server's event stream. Actions
|
|
3
|
+
* show their result at once and resync with the server when they fail.
|
|
4
|
+
*/
|
|
5
|
+
export class Threads {
|
|
6
|
+
viewer = $state(null);
|
|
7
|
+
threads = $state([]);
|
|
8
|
+
spaceName = $state('');
|
|
9
|
+
loading = $state(true);
|
|
10
|
+
/** A failed action, shown until dismissed. */
|
|
11
|
+
error = $state(null);
|
|
12
|
+
/** Ticks every 30 seconds so relative times stay current. */
|
|
13
|
+
now = $state(Date.now());
|
|
14
|
+
api;
|
|
15
|
+
refreshing;
|
|
16
|
+
queued = false;
|
|
17
|
+
listening;
|
|
18
|
+
nextPending = -1;
|
|
19
|
+
constructor(api) {
|
|
20
|
+
this.api = api;
|
|
21
|
+
}
|
|
22
|
+
get unreadCount() {
|
|
23
|
+
return this.threads.filter((thread) => thread.unread && !thread.resolvedAt).length;
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
void this.refresh();
|
|
27
|
+
const onfocus = () => { void this.refresh(); };
|
|
28
|
+
window.addEventListener('focus', onfocus);
|
|
29
|
+
const timer = setInterval(() => { this.now = Date.now(); }, 30_000);
|
|
30
|
+
return () => {
|
|
31
|
+
window.removeEventListener('focus', onfocus);
|
|
32
|
+
clearInterval(timer);
|
|
33
|
+
this.stopListening();
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Coalesces overlapping refreshes into at most one more request. */
|
|
37
|
+
refresh() {
|
|
38
|
+
if (this.refreshing) {
|
|
39
|
+
this.queued = true;
|
|
40
|
+
return this.refreshing;
|
|
41
|
+
}
|
|
42
|
+
this.refreshing = (async () => {
|
|
43
|
+
try {
|
|
44
|
+
const response = await this.api.fetch('');
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
throw new Error();
|
|
47
|
+
const snapshot = (await response.json());
|
|
48
|
+
this.viewer = snapshot.viewer;
|
|
49
|
+
this.spaceName = snapshot.space.name;
|
|
50
|
+
// Keep replies that are still on their way to the server.
|
|
51
|
+
const pending = new Map(this.threads.map((t) => [t.id, t.comments.filter((c) => c.pending)]));
|
|
52
|
+
this.threads = snapshot.threads.map((thread) => {
|
|
53
|
+
const waiting = pending.get(thread.id);
|
|
54
|
+
return waiting?.length ? { ...thread, comments: [...thread.comments, ...waiting] } : thread;
|
|
55
|
+
});
|
|
56
|
+
if (this.viewer)
|
|
57
|
+
this.listen();
|
|
58
|
+
else
|
|
59
|
+
this.stopListening();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Keep what is shown; the next event or focus retries.
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
this.loading = false;
|
|
66
|
+
this.refreshing = undefined;
|
|
67
|
+
if (this.queued) {
|
|
68
|
+
this.queued = false;
|
|
69
|
+
void this.refresh();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
return this.refreshing;
|
|
74
|
+
}
|
|
75
|
+
stopListening() {
|
|
76
|
+
this.listening?.abort();
|
|
77
|
+
this.listening = undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Follows the event stream; after a dropped connection, catches up on anything missed. */
|
|
80
|
+
listen() {
|
|
81
|
+
if (this.listening)
|
|
82
|
+
return;
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
this.listening = controller;
|
|
85
|
+
void (async () => {
|
|
86
|
+
let connected = false;
|
|
87
|
+
while (!controller.signal.aborted && this.viewer) {
|
|
88
|
+
try {
|
|
89
|
+
const response = await this.api.fetch('/events', { signal: controller.signal });
|
|
90
|
+
if (!response.ok || !response.body)
|
|
91
|
+
break;
|
|
92
|
+
if (connected)
|
|
93
|
+
void this.refresh();
|
|
94
|
+
connected = true;
|
|
95
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
96
|
+
let buffer = '';
|
|
97
|
+
for (;;) {
|
|
98
|
+
const { value, done } = await reader.read();
|
|
99
|
+
if (done)
|
|
100
|
+
break;
|
|
101
|
+
buffer += value;
|
|
102
|
+
let end;
|
|
103
|
+
while ((end = buffer.indexOf('\n\n')) !== -1) {
|
|
104
|
+
if (/^event: change$/m.test(buffer.slice(0, end)))
|
|
105
|
+
void this.refresh();
|
|
106
|
+
buffer = buffer.slice(end + 2);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Dropped; try again shortly.
|
|
112
|
+
}
|
|
113
|
+
if (controller.signal.aborted)
|
|
114
|
+
break;
|
|
115
|
+
await new Promise((done) => setTimeout(done, 3000));
|
|
116
|
+
}
|
|
117
|
+
if (this.listening === controller)
|
|
118
|
+
this.listening = undefined;
|
|
119
|
+
})();
|
|
120
|
+
}
|
|
121
|
+
async request(method, path, body) {
|
|
122
|
+
const response = await this.api.fetch(path, {
|
|
123
|
+
method,
|
|
124
|
+
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
|
125
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
126
|
+
});
|
|
127
|
+
const result = await response.json().catch(() => ({}));
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(result.error ?? 'Something went wrong. Try again.');
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
apply(result) {
|
|
134
|
+
if (result.thread) {
|
|
135
|
+
const thread = result.thread;
|
|
136
|
+
const index = this.threads.findIndex((t) => t.id === thread.id);
|
|
137
|
+
if (index === -1)
|
|
138
|
+
this.threads.push(thread);
|
|
139
|
+
else
|
|
140
|
+
this.threads[index] = thread;
|
|
141
|
+
}
|
|
142
|
+
if (result.deleted !== undefined)
|
|
143
|
+
this.threads = this.threads.filter((t) => t.id !== result.deleted);
|
|
144
|
+
}
|
|
145
|
+
/** Runs an action, showing its error and resyncing with the server if it fails. */
|
|
146
|
+
async run(action, rethrow = false) {
|
|
147
|
+
try {
|
|
148
|
+
this.apply(await action());
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
this.error = error.message;
|
|
152
|
+
void this.refresh();
|
|
153
|
+
if (rethrow)
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
update(id, change) {
|
|
158
|
+
const index = this.threads.findIndex((t) => t.id === id);
|
|
159
|
+
if (index !== -1)
|
|
160
|
+
this.threads[index] = change(this.threads[index]);
|
|
161
|
+
}
|
|
162
|
+
imageUrl(image) {
|
|
163
|
+
return `${this.api.endpoint}/images/${image.id}/${image.key}`;
|
|
164
|
+
}
|
|
165
|
+
/** Uploads an image for a comment being written. Send its id with the comment. */
|
|
166
|
+
async upload(file, width, height) {
|
|
167
|
+
const response = await this.api.fetch(`/images?width=${width}&height=${height}`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': file.type },
|
|
170
|
+
body: file
|
|
171
|
+
});
|
|
172
|
+
const result = await response.json().catch(() => ({}));
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(result.error ?? (response.status === 413 ? 'This image is too large.' : 'The image could not be uploaded.'));
|
|
175
|
+
}
|
|
176
|
+
return result.image;
|
|
177
|
+
}
|
|
178
|
+
async create(input, images = []) {
|
|
179
|
+
const { thread } = await this.request('POST', '/threads', { ...input, images: images.map((i) => i.id) });
|
|
180
|
+
this.apply({ thread });
|
|
181
|
+
return thread;
|
|
182
|
+
}
|
|
183
|
+
/** Shows the reply at once; throws if it could not be sent. */
|
|
184
|
+
async reply(threadId, body, images = []) {
|
|
185
|
+
const pending = {
|
|
186
|
+
id: this.nextPending--, author: this.viewer, body, images, createdAt: Date.now(), editedAt: null, pending: true
|
|
187
|
+
};
|
|
188
|
+
this.update(threadId, (t) => ({ ...t, resolvedAt: null, resolvedBy: null, comments: [...t.comments, pending] }));
|
|
189
|
+
try {
|
|
190
|
+
await this.run(() => this.request('POST', `/threads/${threadId}/comments`, { body, images: images.map((i) => i.id) }), true);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
this.update(threadId, (t) => ({ ...t, comments: t.comments.filter((c) => c.id !== pending.id) }));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async edit(commentId, body, images) {
|
|
197
|
+
await this.run(() => this.request('PATCH', `/comments/${commentId}`, { body, images: images.map((i) => i.id) }), true);
|
|
198
|
+
}
|
|
199
|
+
async removeComment(commentId) {
|
|
200
|
+
await this.run(() => this.request('DELETE', `/comments/${commentId}`));
|
|
201
|
+
}
|
|
202
|
+
async removeThread(threadId) {
|
|
203
|
+
this.threads = this.threads.filter((t) => t.id !== threadId);
|
|
204
|
+
await this.run(() => this.request('DELETE', `/threads/${threadId}`));
|
|
205
|
+
}
|
|
206
|
+
async resolve(threadId, resolved) {
|
|
207
|
+
this.update(threadId, (t) => ({
|
|
208
|
+
...t, resolvedAt: resolved ? Date.now() : null, resolvedBy: resolved ? this.viewer : null
|
|
209
|
+
}));
|
|
210
|
+
await this.run(() => this.request('PATCH', `/threads/${threadId}`, { resolved }));
|
|
211
|
+
}
|
|
212
|
+
async markRead(threadId, read) {
|
|
213
|
+
this.update(threadId, (t) => ({ ...t, unread: !read && t.comments.some((c) => c.author.sub !== this.viewer?.sub) }));
|
|
214
|
+
await this.run(() => this.request('POST', `/threads/${threadId}/read`, { read }));
|
|
215
|
+
}
|
|
216
|
+
async markAllRead() {
|
|
217
|
+
await Promise.all(this.threads.filter((t) => t.unread).map((t) => this.markRead(t.id, true)));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
|
2
|
+
const UNITS = [
|
|
3
|
+
['minute', 60_000],
|
|
4
|
+
['hour', 3_600_000],
|
|
5
|
+
['day', 86_400_000]
|
|
6
|
+
];
|
|
7
|
+
/** "just now", "5 minutes ago", "yesterday", then a date. */
|
|
8
|
+
export function ago(time, now) {
|
|
9
|
+
const elapsed = Math.max(0, now - time);
|
|
10
|
+
if (elapsed < 60_000)
|
|
11
|
+
return 'just now';
|
|
12
|
+
if (elapsed < 7 * 86_400_000) {
|
|
13
|
+
const [unit, size] = UNITS.findLast(([, size]) => elapsed >= size);
|
|
14
|
+
return relative.format(-Math.floor(elapsed / size), unit);
|
|
15
|
+
}
|
|
16
|
+
const date = new Date(time);
|
|
17
|
+
return date.toLocaleDateString(undefined, {
|
|
18
|
+
day: 'numeric',
|
|
19
|
+
month: 'short',
|
|
20
|
+
year: date.getFullYear() === new Date(now).getFullYear() ? undefined : 'numeric'
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function fullDate(time) {
|
|
24
|
+
return new Date(time).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { VisualComments, pageOf, type VisualCommentsOptions, type SurfaceInfo, type CommentFilters } from './visual-comments.svelte.js';
|
|
2
|
+
export type * from './shared/types.js';
|
|
3
|
+
import { VisualComments, type VisualCommentsOptions } from './visual-comments.svelte.js';
|
|
4
|
+
/** Adds comments to the page. Call once, in the browser. */
|
|
5
|
+
export declare function createVisualComments(options: VisualCommentsOptions, doc?: Document): VisualComments;
|