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,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,80 @@
|
|
|
1
|
+
import type { Api } from './api.js';
|
|
2
|
+
import type { Agent, AgentRequest, CommentImage, CommentThread, NewThreadInput, Participant, PresenceUpdate, Viewer } from '../shared/types.js';
|
|
3
|
+
/** What this tab says about itself; its connection is added once the event stream names it. */
|
|
4
|
+
export type OwnPresence = Omit<PresenceUpdate, 'connection' | 'key'>;
|
|
5
|
+
/**
|
|
6
|
+
* The space's threads, kept current with the server's event stream. Actions
|
|
7
|
+
* show their result at once and resync with the server when they fail.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Threads {
|
|
10
|
+
viewer: Viewer | null;
|
|
11
|
+
threads: CommentThread[];
|
|
12
|
+
spaceName: string;
|
|
13
|
+
loading: boolean;
|
|
14
|
+
/** Whether `viewer` is known: from the server's answer, or given when the page loaded. */
|
|
15
|
+
viewerKnown: boolean;
|
|
16
|
+
/** A failed action, shown until dismissed. */
|
|
17
|
+
error: string | null;
|
|
18
|
+
/** Ticks every 30 seconds so relative times stay current. */
|
|
19
|
+
now: number;
|
|
20
|
+
/** This tab's event stream, as the server named it, while it is open. */
|
|
21
|
+
connection: string | undefined;
|
|
22
|
+
/** Everyone present in the space, this tab included, while the event stream is open. */
|
|
23
|
+
participants: Participant[];
|
|
24
|
+
/** The space's agents once listed; undefined until then, or when the space has them turned off. */
|
|
25
|
+
agents: Agent[] | undefined;
|
|
26
|
+
private api;
|
|
27
|
+
private refreshing?;
|
|
28
|
+
private queued;
|
|
29
|
+
private listening?;
|
|
30
|
+
private nextPending;
|
|
31
|
+
private presence?;
|
|
32
|
+
/** The last presence sent, with its connection, so an unchanged one is not sent again. */
|
|
33
|
+
private posted;
|
|
34
|
+
/** The participants as last received, so an unchanged list does not wake every listener. */
|
|
35
|
+
private received;
|
|
36
|
+
/** `viewer`: who is signed in, when the page already knows it; undefined to wait for the server. */
|
|
37
|
+
constructor(api: Api, viewer?: Viewer | null);
|
|
38
|
+
get unreadCount(): number;
|
|
39
|
+
start(): () => void;
|
|
40
|
+
/** Coalesces overlapping refreshes into at most one more request. */
|
|
41
|
+
refresh(): Promise<void>;
|
|
42
|
+
private stopListening;
|
|
43
|
+
/** Without the stream, who is present is unknown, and the server has already let this tab go. */
|
|
44
|
+
private disconnected;
|
|
45
|
+
private setParticipants;
|
|
46
|
+
/** Follows the event stream; after a dropped connection, catches up on anything missed. */
|
|
47
|
+
private listen;
|
|
48
|
+
/** One message of the event stream. Servers without presence send only `change`. */
|
|
49
|
+
private receive;
|
|
50
|
+
/**
|
|
51
|
+
* Says what this tab is doing, unless it said so already. `again` sends it
|
|
52
|
+
* even so, as the heartbeat that keeps `typing` from lapsing.
|
|
53
|
+
*/
|
|
54
|
+
setPresence(presence: OwnPresence, again?: boolean): void;
|
|
55
|
+
private postPresence;
|
|
56
|
+
private request;
|
|
57
|
+
private apply;
|
|
58
|
+
/** Runs an action, showing its error and resyncing with the server if it fails. */
|
|
59
|
+
private run;
|
|
60
|
+
private update;
|
|
61
|
+
imageUrl(image: CommentImage): string;
|
|
62
|
+
/** Uploads an image for a comment being written. Send its id with the comment. */
|
|
63
|
+
upload(file: Blob, width: number, height: number): Promise<CommentImage>;
|
|
64
|
+
create(input: Omit<NewThreadInput, 'images'>, images?: CommentImage[]): Promise<CommentThread>;
|
|
65
|
+
/** Shows the reply at once; throws if it could not be sent. */
|
|
66
|
+
reply(threadId: number, body: string, images?: CommentImage[]): Promise<void>;
|
|
67
|
+
edit(commentId: number, body: string, images: CommentImage[]): Promise<void>;
|
|
68
|
+
removeComment(commentId: number): Promise<void>;
|
|
69
|
+
removeThread(threadId: number): Promise<void>;
|
|
70
|
+
resolve(threadId: number, resolved: boolean): Promise<void>;
|
|
71
|
+
markRead(threadId: number, read: boolean): Promise<void>;
|
|
72
|
+
markAllRead(): Promise<void>;
|
|
73
|
+
loadAgents(): Promise<void>;
|
|
74
|
+
/** Takes an agent out of the space at once: its token stops working. */
|
|
75
|
+
removeAgent(id: string): Promise<void>;
|
|
76
|
+
/** An agent asking to join, for the person approving it. Throws when it has expired or was answered. */
|
|
77
|
+
agentRequest(id: string): Promise<AgentRequest>;
|
|
78
|
+
approveAgent(id: string): Promise<Agent>;
|
|
79
|
+
denyAgent(id: string): Promise<void>;
|
|
80
|
+
}
|