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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Convex Works
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# visual-comments
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Comments pinned to the elements of any web page or app, like a design tool's
|
|
4
|
+
comments or Vercel's preview comments. People press **C**, click the element
|
|
5
|
+
they mean, and start a thread pinned to it. One self-hosted comment server
|
|
6
|
+
holds the threads of many sites; each site signs its own people in and passes
|
|
7
|
+
their comment requests on through its own server.
|
|
8
|
+
|
|
9
|
+
This package is the browser library (Svelte 5), the server-side proxy, and the
|
|
10
|
+
script-tag build. The comment server and full documentation live in the
|
|
11
|
+
[repository](https://github.com/Convex-Works/visual-comments).
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install visual-comments
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Proxy the comment requests
|
|
18
|
+
|
|
19
|
+
The site's server passes `/_comments/*` on to the comment server with the
|
|
20
|
+
space's secret and the signed-in person:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/hooks.server.ts
|
|
24
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
25
|
+
import { commentsProxy } from 'visual-comments/server';
|
|
26
|
+
|
|
27
|
+
const comments = commentsProxy({
|
|
28
|
+
server: 'https://comments.example.com',
|
|
29
|
+
space: 'my-team',
|
|
30
|
+
secret: env.COMMENTS_SECRET // the space's secret; never sent to browsers
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// After the site's sign-in hook, which sets `event.locals.user`.
|
|
34
|
+
export const handle = sequence(signIn, comments.sveltekit());
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Other servers call `comments.handle(request, user)`. See the
|
|
38
|
+
[proxy protocol](https://github.com/Convex-Works/visual-comments/blob/main/docs/PROTOCOL.md).
|
|
39
|
+
|
|
40
|
+
## Add comments to the page
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createVisualComments } from 'visual-comments';
|
|
44
|
+
|
|
45
|
+
const comments = createVisualComments({
|
|
46
|
+
endpoint: '/_comments', // the default
|
|
47
|
+
auth: '/auth',
|
|
48
|
+
show: (thread) => canvas.zoomTo(thread.surface) // return true when handled
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The library compiles with the host's Svelte (a peer dependency). Its options
|
|
53
|
+
are in the [library reference](https://github.com/Convex-Works/visual-comments/blob/main/docs/LIBRARY.md).
|
|
54
|
+
Pages without a bundler can use the script build instead, served by the
|
|
55
|
+
comment server at `/v1/visual-comments.js` and also shipped here as
|
|
56
|
+
`visual-comments/widget.js`; see the
|
|
57
|
+
[markup reference](https://github.com/Convex-Works/visual-comments/blob/main/docs/MARKUP.md).
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CommentTarget } from './target.js';
|
|
2
|
+
import { type Surface } from './surfaces.js';
|
|
3
|
+
import type { Box } from './target.js';
|
|
4
|
+
/** Where the pointer is aiming in comment mode. */
|
|
5
|
+
export interface Aim {
|
|
6
|
+
surface: string;
|
|
7
|
+
/** Where a pin would go, in surface pixels: the pointer, moved onto the target if it is pulled to one. */
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
/** The pointer itself, in surface pixels. */
|
|
11
|
+
point: {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
};
|
|
15
|
+
/** In the surface's document. */
|
|
16
|
+
target?: CommentTarget;
|
|
17
|
+
/** The target's box in surface pixels when it was aimed at. */
|
|
18
|
+
targetBox?: Box;
|
|
19
|
+
}
|
|
20
|
+
/** Where a pin goes for a pointer at a client point. With `free`, or without a document, it stays at the pointer. */
|
|
21
|
+
export declare function aimAt(surface: Surface, x: number, y: number, free: boolean): Aim;
|
package/dist/core/aim.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { clamp, MAGNET, targetAt } from './target.js';
|
|
2
|
+
import { clientToDoc, docBoxToSurface, fromClient } from './surfaces.js';
|
|
3
|
+
/** Where a pin goes for a pointer at a client point. With `free`, or without a document, it stays at the pointer. */
|
|
4
|
+
export function aimAt(surface, x, y, free) {
|
|
5
|
+
const point = fromClient(surface, x, y);
|
|
6
|
+
const plain = { surface: surface.id, x: point.x, y: point.y, point };
|
|
7
|
+
if (free || !surface.doc)
|
|
8
|
+
return plain;
|
|
9
|
+
const d = clientToDoc(surface, x, y);
|
|
10
|
+
const target = targetAt(surface.doc, d.x, d.y, MAGNET / surface.docScale, surface.search);
|
|
11
|
+
if (!target)
|
|
12
|
+
return plain;
|
|
13
|
+
const box = docBoxToSurface(surface, target.box);
|
|
14
|
+
return {
|
|
15
|
+
...plain,
|
|
16
|
+
x: clamp(point.x, box.x + 1, box.x + box.width - 1),
|
|
17
|
+
y: clamp(point.y, box.y + 1, box.y + box.height - 1),
|
|
18
|
+
target,
|
|
19
|
+
targetBox: box
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/core/api.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The comments on the site's own server, which passes requests on to the
|
|
2
|
+
// comment server with the space's secret and the person signed in on the site.
|
|
3
|
+
export class Api {
|
|
4
|
+
endpoint;
|
|
5
|
+
constructor(endpoint) {
|
|
6
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
7
|
+
}
|
|
8
|
+
fetch(path, init = {}) {
|
|
9
|
+
return fetch(`${this.endpoint}${path}`, { ...init, credentials: 'same-origin', cache: 'no-store' });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A rounded box in pixels. */
|
|
2
|
+
export interface RoundedBox {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
radius: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The bridge from a dot of radius `r` at `x`, `y` to the nearest edge of a
|
|
11
|
+
* box, as between two metaballs about to part: wide where it meets each,
|
|
12
|
+
* pinched between, and thinner the farther it stretches. Undefined when the
|
|
13
|
+
* dot touches or is inside the box, or is beyond `reach` from it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function neck(x: number, y: number, r: number, box: RoundedBox, reach: number): string | undefined;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge from a dot of radius `r` at `x`, `y` to the nearest edge of a
|
|
3
|
+
* box, as between two metaballs about to part: wide where it meets each,
|
|
4
|
+
* pinched between, and thinner the farther it stretches. Undefined when the
|
|
5
|
+
* dot touches or is inside the box, or is beyond `reach` from it.
|
|
6
|
+
*/
|
|
7
|
+
export function neck(x, y, r, box, reach) {
|
|
8
|
+
const corner = Math.min(box.radius, box.width / 2, box.height / 2);
|
|
9
|
+
// The nearest point on a rounded box: clamp to the box shrunk by its corner, then step out by the corner.
|
|
10
|
+
const cx = clamp(x, box.left + corner, box.left + box.width - corner);
|
|
11
|
+
const cy = clamp(y, box.top + corner, box.top + box.height - corner);
|
|
12
|
+
const away = Math.hypot(x - cx, y - cy);
|
|
13
|
+
const gap = away - corner;
|
|
14
|
+
if (gap <= r || gap > reach)
|
|
15
|
+
return undefined;
|
|
16
|
+
const ux = (cx - x) / away;
|
|
17
|
+
const uy = (cy - y) / away;
|
|
18
|
+
const nx = -uy;
|
|
19
|
+
const ny = ux;
|
|
20
|
+
// Sink the foot into the outline's stroke so the two read as one shape.
|
|
21
|
+
const qx = cx - ux * (corner - 1);
|
|
22
|
+
const qy = cy - uy * (corner - 1);
|
|
23
|
+
const stretch = Math.min(1, (gap - r) / Math.max(reach - r, 1));
|
|
24
|
+
const start = r * 0.85;
|
|
25
|
+
const foot = r * (1.8 - 0.8 * stretch);
|
|
26
|
+
const waist = Math.max(0.6, r * (0.55 - 0.35 * stretch));
|
|
27
|
+
// A quadratic's middle sits at (start + foot) / 4 + control / 2; solve for the waist.
|
|
28
|
+
const control = 2 * waist - (start + foot) / 2;
|
|
29
|
+
const ax = x + ux * r * 0.3;
|
|
30
|
+
const ay = y + uy * r * 0.3;
|
|
31
|
+
const mx = (ax + qx) / 2;
|
|
32
|
+
const my = (ay + qy) / 2;
|
|
33
|
+
const point = (px, py, side, width) => `${(px + nx * side * width).toFixed(2)} ${(py + ny * side * width).toFixed(2)}`;
|
|
34
|
+
return [
|
|
35
|
+
`M ${point(ax, ay, 1, start)}`,
|
|
36
|
+
`Q ${point(mx, my, 1, control)} ${point(qx, qy, 1, foot)}`,
|
|
37
|
+
`L ${point(qx, qy, -1, foot)}`,
|
|
38
|
+
`Q ${point(mx, my, -1, control)} ${point(ax, ay, -1, start)}`,
|
|
39
|
+
'Z'
|
|
40
|
+
].join(' ');
|
|
41
|
+
}
|
|
42
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type Box, type Search } from './target.js';
|
|
2
|
+
import type { CommentThread } from '../shared/types.js';
|
|
3
|
+
export interface Rect {
|
|
4
|
+
left: number;
|
|
5
|
+
top: number;
|
|
6
|
+
right: number;
|
|
7
|
+
bottom: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Surface {
|
|
10
|
+
/** '' for the page. */
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
/** Null for the page. */
|
|
14
|
+
element: Element | null;
|
|
15
|
+
/** Where its elements are found, if that document can be read. */
|
|
16
|
+
doc?: Document;
|
|
17
|
+
/** Surface pixels to client pixels: client = origin + surface × scale. */
|
|
18
|
+
origin: {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
};
|
|
22
|
+
scale: number;
|
|
23
|
+
/** In surface pixels. */
|
|
24
|
+
size: {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
/** The visible part of the surface, in client pixels. */
|
|
29
|
+
clip: Rect;
|
|
30
|
+
/** The visible part of what holds it, in client pixels: where its cards can go. */
|
|
31
|
+
stage: Rect;
|
|
32
|
+
/** The document's client pixels to the host's: client = docOrigin + doc × docScale. */
|
|
33
|
+
docOrigin: {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
};
|
|
37
|
+
docScale: number;
|
|
38
|
+
/** Where targets are looked for in the document. */
|
|
39
|
+
search: Search;
|
|
40
|
+
/** A frame that takes pointer events itself, so comment mode needs a layer over it to catch them. */
|
|
41
|
+
catches: boolean;
|
|
42
|
+
/** The host keeps its own drag and pan gestures over this surface in comment mode. */
|
|
43
|
+
passGestures: boolean;
|
|
44
|
+
/** From `data-vc-context`. */
|
|
45
|
+
context: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
export interface DiscoverOptions {
|
|
48
|
+
/** Whether an element belongs to the comment UI. */
|
|
49
|
+
ours: (element: Element) => boolean;
|
|
50
|
+
documentOf?: (surface: Element, frame: HTMLIFrameElement) => Document | undefined;
|
|
51
|
+
}
|
|
52
|
+
/** The surfaces on the page now, outermost first. */
|
|
53
|
+
export declare function discover(doc: Document, options: DiscoverOptions): Surface[];
|
|
54
|
+
/** A same-origin frame's document once it has loaded, or undefined. */
|
|
55
|
+
export declare function frameDocument(_surface: Element, frame: HTMLIFrameElement): Document | undefined;
|
|
56
|
+
/** The surface under a client point, if a comment can go there. */
|
|
57
|
+
export declare function surfaceAt(surfaces: Surface[], doc: Document, x: number, y: number, ours: (element: Element) => boolean): Surface | undefined;
|
|
58
|
+
export declare const toClient: (s: Surface, x: number, y: number) => {
|
|
59
|
+
x: number;
|
|
60
|
+
y: number;
|
|
61
|
+
};
|
|
62
|
+
export declare const fromClient: (s: Surface, x: number, y: number) => {
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
};
|
|
66
|
+
export declare const docToClient: (s: Surface, x: number, y: number) => {
|
|
67
|
+
x: number;
|
|
68
|
+
y: number;
|
|
69
|
+
};
|
|
70
|
+
export declare const clientToDoc: (s: Surface, x: number, y: number) => {
|
|
71
|
+
x: number;
|
|
72
|
+
y: number;
|
|
73
|
+
};
|
|
74
|
+
/** A box in the surface's document, in client pixels. */
|
|
75
|
+
export declare function docBoxToClient(s: Surface, b: Box): Rect;
|
|
76
|
+
/** A box in the surface's document, in surface pixels. */
|
|
77
|
+
export declare function docBoxToSurface(s: Surface, b: Box): Box;
|
|
78
|
+
export interface Spot {
|
|
79
|
+
/** The pin, in client pixels. */
|
|
80
|
+
x: number;
|
|
81
|
+
y: number;
|
|
82
|
+
/** The element under it, if found, and its box in client pixels. */
|
|
83
|
+
element?: Element;
|
|
84
|
+
box?: Rect;
|
|
85
|
+
/** Out of view, or its element is gone. */
|
|
86
|
+
hidden: boolean;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Where a thread's pin is now. A pin on an element moves with it, at the same
|
|
90
|
+
* share of its width and height, and hides while it is out of view or gone; a
|
|
91
|
+
* free pin, or one whose document cannot be read, stays where it was placed.
|
|
92
|
+
*/
|
|
93
|
+
export declare function spotOf(s: Surface, thread: Pick<CommentThread, 'x' | 'y' | 'anchor'>): Spot;
|
|
94
|
+
export declare function inside(r: Rect, x: number, y: number): boolean;
|
|
95
|
+
export declare function intersect(a: Rect, b: Rect): Rect;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Surfaces: the parts of a page that take comments. Without any declared
|
|
2
|
+
// surface, the page itself is one. A host declares surfaces in markup:
|
|
3
|
+
//
|
|
4
|
+
// <div data-vc-surface="inbox" data-vc-title="Inbox"> part of the page
|
|
5
|
+
// <iframe data-vc-surface="inbox" data-vc-title="Inbox"> a frame
|
|
6
|
+
// <div data-vc-surface="inbox"> … <iframe data-vc-frame> … a box holding a frame
|
|
7
|
+
//
|
|
8
|
+
// Each surface has its own pixels, from its top-left corner, so pins stay put
|
|
9
|
+
// however the host zooms, pans or scrolls it: the scale and position come from
|
|
10
|
+
// measuring the element on screen against its layout size. The page's pixels
|
|
11
|
+
// are the document's, so pins scroll with it. Elements are found in the
|
|
12
|
+
// surface's document: the host page, or a same-origin frame's own document.
|
|
13
|
+
import { elementsAt, findAnchor, shows, clamp } from './target.js';
|
|
14
|
+
/** The surfaces on the page now, outermost first. */
|
|
15
|
+
export function discover(doc, options) {
|
|
16
|
+
const view = doc.defaultView;
|
|
17
|
+
const elements = [...doc.querySelectorAll('[data-vc-surface]')];
|
|
18
|
+
const viewport = { left: 0, top: 0, right: doc.documentElement.clientWidth, bottom: doc.documentElement.clientHeight };
|
|
19
|
+
const skip = (element) => options.ours(element) || Boolean(element.closest('[data-vc-ignore]'));
|
|
20
|
+
if (!elements.length) {
|
|
21
|
+
const root = doc.scrollingElement ?? doc.documentElement;
|
|
22
|
+
return [{
|
|
23
|
+
id: '',
|
|
24
|
+
title: doc.title,
|
|
25
|
+
element: null,
|
|
26
|
+
doc,
|
|
27
|
+
origin: { x: -view.scrollX, y: -view.scrollY },
|
|
28
|
+
scale: 1,
|
|
29
|
+
size: { width: root.scrollWidth, height: root.scrollHeight },
|
|
30
|
+
clip: viewport,
|
|
31
|
+
stage: viewport,
|
|
32
|
+
docOrigin: { x: 0, y: 0 },
|
|
33
|
+
docScale: 1,
|
|
34
|
+
search: { region: box(viewport), skip },
|
|
35
|
+
catches: false,
|
|
36
|
+
passGestures: false,
|
|
37
|
+
context: {}
|
|
38
|
+
}];
|
|
39
|
+
}
|
|
40
|
+
const styles = new Map();
|
|
41
|
+
const style = (element) => {
|
|
42
|
+
let value = styles.get(element);
|
|
43
|
+
if (!value)
|
|
44
|
+
styles.set(element, (value = view.getComputedStyle(element)));
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
const surfaces = [];
|
|
48
|
+
for (const element of elements) {
|
|
49
|
+
const rect = element.getBoundingClientRect();
|
|
50
|
+
if (rect.width < 1 || rect.height < 1)
|
|
51
|
+
continue;
|
|
52
|
+
const html = element;
|
|
53
|
+
const layoutWidth = html.offsetWidth || rect.width;
|
|
54
|
+
const scale = rect.width / layoutWidth;
|
|
55
|
+
const stage = intersect(viewport, clipOf(element, style));
|
|
56
|
+
const frame = (element.localName === 'iframe' ? element : element.querySelector('iframe[data-vc-frame]'));
|
|
57
|
+
const surface = {
|
|
58
|
+
id: element.getAttribute('data-vc-surface') ?? '',
|
|
59
|
+
title: element.getAttribute('data-vc-title') ?? frame?.title ?? element.getAttribute('aria-label') ?? '',
|
|
60
|
+
element,
|
|
61
|
+
origin: { x: rect.left + html.clientLeft * scale, y: rect.top + html.clientTop * scale },
|
|
62
|
+
scale,
|
|
63
|
+
size: { width: html.clientWidth || layoutWidth, height: html.clientHeight || html.offsetHeight || rect.height / scale },
|
|
64
|
+
clip: intersect(stage, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }),
|
|
65
|
+
stage,
|
|
66
|
+
docOrigin: { x: 0, y: 0 },
|
|
67
|
+
docScale: 1,
|
|
68
|
+
search: { region: { x: 0, y: 0, width: 0, height: 0 }, skip },
|
|
69
|
+
catches: false,
|
|
70
|
+
passGestures: Boolean(element.closest('[data-vc-gestures="host"]')),
|
|
71
|
+
context: contextOf(element)
|
|
72
|
+
};
|
|
73
|
+
if (frame) {
|
|
74
|
+
const doc = (options.documentOf ?? frameDocument)(element, frame);
|
|
75
|
+
const r = frame.getBoundingClientRect();
|
|
76
|
+
const docScale = frame.offsetWidth ? r.width / frame.offsetWidth : scale;
|
|
77
|
+
surface.doc = doc;
|
|
78
|
+
surface.docOrigin = { x: r.left + frame.clientLeft * docScale, y: r.top + frame.clientTop * docScale };
|
|
79
|
+
surface.docScale = docScale;
|
|
80
|
+
surface.search = { region: { x: 0, y: 0, width: frame.clientWidth, height: frame.clientHeight }, skip };
|
|
81
|
+
surface.catches = style(frame).pointerEvents !== 'none';
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
surface.doc = doc;
|
|
85
|
+
surface.search = { region: box(surface.clip), within: element, skip };
|
|
86
|
+
}
|
|
87
|
+
surfaces.push(surface);
|
|
88
|
+
}
|
|
89
|
+
return surfaces;
|
|
90
|
+
}
|
|
91
|
+
/** A same-origin frame's document once it has loaded, or undefined. */
|
|
92
|
+
export function frameDocument(_surface, frame) {
|
|
93
|
+
try {
|
|
94
|
+
const doc = frame.contentDocument;
|
|
95
|
+
if (!doc || doc.readyState === 'loading' || doc.URL === 'about:blank')
|
|
96
|
+
return undefined;
|
|
97
|
+
return doc;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The surface under a client point, if a comment can go there. */
|
|
104
|
+
export function surfaceAt(surfaces, doc, x, y, ours) {
|
|
105
|
+
const top = elementsAt(doc, x, y, ours)[0];
|
|
106
|
+
if (!top || top.closest('[data-vc-ignore]'))
|
|
107
|
+
return undefined;
|
|
108
|
+
let found;
|
|
109
|
+
for (const surface of surfaces) {
|
|
110
|
+
if (!inside(surface.clip, x, y))
|
|
111
|
+
continue;
|
|
112
|
+
if (surface.element && !(surface.element === top || surface.element.contains(top)))
|
|
113
|
+
continue;
|
|
114
|
+
// The innermost of nested surfaces.
|
|
115
|
+
if (!found || (found.element && surface.element && found.element.contains(surface.element)))
|
|
116
|
+
found = surface;
|
|
117
|
+
}
|
|
118
|
+
return found;
|
|
119
|
+
}
|
|
120
|
+
export const toClient = (s, x, y) => ({ x: s.origin.x + x * s.scale, y: s.origin.y + y * s.scale });
|
|
121
|
+
export const fromClient = (s, x, y) => ({ x: (x - s.origin.x) / s.scale, y: (y - s.origin.y) / s.scale });
|
|
122
|
+
export const docToClient = (s, x, y) => ({ x: s.docOrigin.x + x * s.docScale, y: s.docOrigin.y + y * s.docScale });
|
|
123
|
+
export const clientToDoc = (s, x, y) => ({ x: (x - s.docOrigin.x) / s.docScale, y: (y - s.docOrigin.y) / s.docScale });
|
|
124
|
+
/** A box in the surface's document, in client pixels. */
|
|
125
|
+
export function docBoxToClient(s, b) {
|
|
126
|
+
const a = docToClient(s, b.x, b.y);
|
|
127
|
+
return { left: a.x, top: a.y, right: a.x + b.width * s.docScale, bottom: a.y + b.height * s.docScale };
|
|
128
|
+
}
|
|
129
|
+
/** A box in the surface's document, in surface pixels. */
|
|
130
|
+
export function docBoxToSurface(s, b) {
|
|
131
|
+
const a = docToClient(s, b.x, b.y);
|
|
132
|
+
const p = fromClient(s, a.x, a.y);
|
|
133
|
+
const k = s.docScale / s.scale;
|
|
134
|
+
return { x: p.x, y: p.y, width: b.width * k, height: b.height * k };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Where a thread's pin is now. A pin on an element moves with it, at the same
|
|
138
|
+
* share of its width and height, and hides while it is out of view or gone; a
|
|
139
|
+
* free pin, or one whose document cannot be read, stays where it was placed.
|
|
140
|
+
*/
|
|
141
|
+
export function spotOf(s, thread) {
|
|
142
|
+
const free = () => {
|
|
143
|
+
const c = toClient(s, Math.min(thread.x, s.size.width), Math.min(thread.y, s.size.height));
|
|
144
|
+
return { ...c, hidden: !inside(s.clip, c.x, c.y) };
|
|
145
|
+
};
|
|
146
|
+
const { anchor } = thread;
|
|
147
|
+
if (!anchor || !s.doc)
|
|
148
|
+
return free();
|
|
149
|
+
const element = findAnchor(s.doc, anchor);
|
|
150
|
+
if (!element)
|
|
151
|
+
return { ...free(), hidden: true };
|
|
152
|
+
const r = element.getBoundingClientRect();
|
|
153
|
+
const b = docBoxToSurface(s, { x: r.left, y: r.top, width: r.width, height: r.height });
|
|
154
|
+
const share = (offset, size) => (size > 0 ? offset / size : 0);
|
|
155
|
+
const x = b.x + clamp(share(thread.x - anchor.x, anchor.width) * b.width, 1, b.width - 1);
|
|
156
|
+
const y = b.y + clamp(share(thread.y - anchor.y, anchor.height) * b.height, 1, b.height - 1);
|
|
157
|
+
const c = toClient(s, x, y);
|
|
158
|
+
const d = clientToDoc(s, c.x, c.y);
|
|
159
|
+
const box = docBoxToClient(s, { x: r.left, y: r.top, width: r.width, height: r.height });
|
|
160
|
+
const visible = r.width > 0 && r.height > 0 && inside(s.clip, c.x, c.y) && shows(element, d.x, d.y, s.search);
|
|
161
|
+
return { x: c.x, y: c.y, element, box, hidden: !visible };
|
|
162
|
+
}
|
|
163
|
+
export function inside(r, x, y) {
|
|
164
|
+
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
165
|
+
}
|
|
166
|
+
export function intersect(a, b) {
|
|
167
|
+
return { left: Math.max(a.left, b.left), top: Math.max(a.top, b.top), right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom) };
|
|
168
|
+
}
|
|
169
|
+
const box = (r) => ({ x: r.left, y: r.top, width: Math.max(0, r.right - r.left), height: Math.max(0, r.bottom - r.top) });
|
|
170
|
+
/** What clips an element: every ancestor that does not let its overflow show. */
|
|
171
|
+
function clipOf(element, style) {
|
|
172
|
+
let clip = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity };
|
|
173
|
+
const body = element.ownerDocument.body;
|
|
174
|
+
for (let node = element.parentElement; node && node !== body; node = node.parentElement) {
|
|
175
|
+
const s = style(node);
|
|
176
|
+
if (s.overflowX === 'visible' && s.overflowY === 'visible')
|
|
177
|
+
continue;
|
|
178
|
+
const r = node.getBoundingClientRect();
|
|
179
|
+
clip = intersect(clip, { left: r.left, top: r.top, right: r.right, bottom: r.bottom });
|
|
180
|
+
}
|
|
181
|
+
return clip;
|
|
182
|
+
}
|
|
183
|
+
function contextOf(element) {
|
|
184
|
+
const raw = element.getAttribute('data-vc-context');
|
|
185
|
+
if (!raw)
|
|
186
|
+
return {};
|
|
187
|
+
try {
|
|
188
|
+
const value = JSON.parse(raw);
|
|
189
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
190
|
+
return {};
|
|
191
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string' || typeof v === 'number')
|
|
192
|
+
.map(([k, v]) => [k, String(v)]));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** A box in a document's client pixels. */
|
|
2
|
+
export interface Box {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
/** An element a pin can attach to. */
|
|
9
|
+
export interface CommentTarget {
|
|
10
|
+
element: Element;
|
|
11
|
+
box: Box;
|
|
12
|
+
/** Corner radius in document pixels, so the highlight takes the element's shape. */
|
|
13
|
+
radius: number;
|
|
14
|
+
/** What it is and what it says, e.g. `button “Add prompts”`. */
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
/** Where to look, and what to pass over. */
|
|
18
|
+
export interface Search {
|
|
19
|
+
/** The part of the document that takes comments: a frame's viewport, or a surface's box. */
|
|
20
|
+
region: Box;
|
|
21
|
+
/** Only elements inside this one, for a surface that is part of a page. */
|
|
22
|
+
within?: Element;
|
|
23
|
+
/** Elements that are never targets, with what they contain: the comment UI, host chrome. */
|
|
24
|
+
skip: (element: Element) => boolean;
|
|
25
|
+
}
|
|
26
|
+
/** How far the pull reaches, in screen pixels. */
|
|
27
|
+
export declare const MAGNET = 14;
|
|
28
|
+
/** The elements at a point, top first, without skipped ones. */
|
|
29
|
+
export declare function elementsAt(doc: Document, x: number, y: number, skip: Search['skip']): Element[];
|
|
30
|
+
/**
|
|
31
|
+
* The element nearest the point, or undefined for open space. Under the
|
|
32
|
+
* pointer, the innermost meaningful element wins. A smaller one within
|
|
33
|
+
* `radius` pulls the pointer onto it, as long as it sits inside that element.
|
|
34
|
+
*/
|
|
35
|
+
export declare function targetAt(doc: Document, x: number, y: number, radius: number, search: Search): CommentTarget | undefined;
|
|
36
|
+
/** The meaningful element around this one, for when the pin meant more than it caught. */
|
|
37
|
+
export declare function widen(target: CommentTarget, search: Search): CommentTarget | undefined;
|
|
38
|
+
/** The element an anchor names in a document, if it is still there and still says the same. */
|
|
39
|
+
export declare function findAnchor(doc: Document, anchor: {
|
|
40
|
+
selector: string;
|
|
41
|
+
text: string;
|
|
42
|
+
}): Element | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the point of the element shows: inside the region, not clipped by a
|
|
45
|
+
* scrolling container, and not covered (a sticky header).
|
|
46
|
+
*/
|
|
47
|
+
export declare function shows(element: Element, x: number, y: number, search: Search): boolean;
|
|
48
|
+
/** Scrolls the document's containers until the element is in the middle of them. */
|
|
49
|
+
export declare function reveal(element: Element): void;
|
|
50
|
+
/** The scrolling container from this element up that can still scroll, to pass wheel turns to. */
|
|
51
|
+
export declare function scrollerFrom(start: Element | null, dx: number, dy: number): Element | undefined;
|
|
52
|
+
/** What a thread stores about its target: a selector, its label and text. */
|
|
53
|
+
export declare function describeTarget(target: CommentTarget): {
|
|
54
|
+
selector: string;
|
|
55
|
+
label: string;
|
|
56
|
+
text: string;
|
|
57
|
+
};
|
|
58
|
+
export declare const clamp: (value: number, min: number, max: number) => number;
|
|
59
|
+
export declare const shorten: (text: string, max: number) => string;
|