@cesarvenzac/closeup 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -2
  3. package/dist/actions.d.ts +27 -0
  4. package/dist/actions.js +13 -0
  5. package/dist/capture.d.ts +28 -0
  6. package/dist/capture.js +33 -0
  7. package/dist/compositor.d.ts +62 -0
  8. package/dist/compositor.js +164 -0
  9. package/dist/cursor.d.ts +49 -0
  10. package/dist/cursor.js +152 -0
  11. package/dist/cursors.d.ts +11 -0
  12. package/dist/cursors.js +24 -0
  13. package/dist/frame.d.ts +86 -0
  14. package/dist/frame.js +78 -0
  15. package/dist/hover.d.ts +23 -0
  16. package/dist/hover.js +89 -0
  17. package/dist/index.d.ts +24 -0
  18. package/dist/index.js +37 -0
  19. package/dist/path.d.ts +41 -0
  20. package/dist/path.js +65 -0
  21. package/dist/player.d.ts +39 -0
  22. package/dist/player.js +140 -0
  23. package/dist/pointer.d.ts +30 -0
  24. package/dist/pointer.js +134 -0
  25. package/dist/script.d.ts +75 -0
  26. package/dist/script.js +94 -0
  27. package/dist/selector.d.ts +22 -0
  28. package/dist/selector.js +83 -0
  29. package/dist/settings.d.ts +61 -0
  30. package/dist/settings.js +59 -0
  31. package/dist/shortcut.d.ts +23 -0
  32. package/dist/shortcut.js +48 -0
  33. package/dist/sounds.d.ts +22 -0
  34. package/dist/sounds.js +94 -0
  35. package/dist/stage.d.ts +12 -0
  36. package/dist/stage.js +53 -0
  37. package/dist/store.d.ts +4 -0
  38. package/dist/store.js +22 -0
  39. package/dist/take.d.ts +69 -0
  40. package/dist/take.js +220 -0
  41. package/dist/ui/editor.d.ts +90 -0
  42. package/dist/ui/editor.js +612 -0
  43. package/dist/ui/icons.d.ts +28 -0
  44. package/dist/ui/icons.js +28 -0
  45. package/dist/ui/panel.d.ts +83 -0
  46. package/dist/ui/panel.js +739 -0
  47. package/dist/ui/styles.d.ts +6 -0
  48. package/dist/ui/styles.js +183 -0
  49. package/package.json +36 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 César Venzac
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,128 @@
1
- # Temporary Holding Version
1
+ # @cesarvenzac/closeup
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Film any part of a web page in close-up, sharp at any size: photos and videos of an element or
4
+ a region, with a drawn macOS-like cursor that follows your mouse or plays a path you draw, on a
5
+ background, with click sounds.
6
+
7
+ ```sh
8
+ npm install @cesarvenzac/closeup
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```ts
14
+ import { install } from "@cesarvenzac/closeup";
15
+
16
+ install(); // ⌥⇧C opens and closes it
17
+ ```
18
+
19
+ Or load it only when it's wanted, on a shortcut of your own:
20
+
21
+ ```ts
22
+ addEventListener("keydown", (event) => {
23
+ if (event.code === "KeyC" && event.altKey && event.shiftKey) import("@cesarvenzac/closeup").then((closeup) => closeup.toggle());
24
+ });
25
+ ```
26
+
27
+ Press the shortcut, point at an element (↑ and ↓ widen and narrow the pick) or draw a region,
28
+ choose a photo or a video, and press Record. Chrome asks once to share the tab. Options set the
29
+ shape (16:9, 1:1, 4:5, 9:16 or the pick's own), the room round the pick, a background (a colour,
30
+ a gradient or an image of your own) with inset, corners and shadow, the cursor, its click ring,
31
+ the sounds and the quality. They're kept in the browser.
32
+
33
+ ## Scripts
34
+
35
+ The pen button (video only) opens the script editor on the stage, at the size the take is filmed:
36
+
37
+ - Click where the cursor starts, then each place it goes. A click makes a gentle arc, as a wrist
38
+ moves; a drag pulls the curve's handles, as Figma's pen does. Drag points and handles to adjust
39
+ them, ⌥ to break a handle's mirror, double-click a point to switch between handles and an arc.
40
+ - Each point moves there, clicks or types, after hovering a moment if you like, then rests. The
41
+ pace is a hand's (Fitts's law, with minimum-jerk speed) unless you set it. Type clicks the point
42
+ and types into the text field that click focused, a character at a time: put it on a field.
43
+ - A point is tied to the element it's placed on, so it follows that element if the layout moves.
44
+ - Preview plays it on the page without filming. Done saves it for this page and sets Moves to
45
+ Script, so Record plays it and stops by itself. Undo, redo, import and export are in the bar.
46
+
47
+ While a script plays, the page is told about every move and press as a mouse would tell it
48
+ (pointer and mouse events, focus), so its own code reacts: hover effects, menus, a character that
49
+ turns to look. CSS's `:hover` and `:active` only follow the real pointer, so every rule that uses
50
+ them is copied with an attribute in their place, and the attribute is set on what the cursor is
51
+ over. Your mouse is kept off the page meanwhile.
52
+
53
+ A script is plain JSON:
54
+
55
+ ```json
56
+ {
57
+ "version": 1,
58
+ "start": { "x": 480, "y": 130 },
59
+ "steps": [
60
+ {
61
+ "to": { "x": 450, "y": 110, "on": { "selector": "button.like", "u": 0.5, "v": 0.5 } },
62
+ "action": "click",
63
+ "hover": 0.4,
64
+ "wait": 1
65
+ },
66
+ { "to": { "x": 600, "y": 300 }, "action": "type", "text": "Hello", "wait": 0.6 }
67
+ ]
68
+ }
69
+ ```
70
+
71
+ Points are in page pixels (as laid out, not enlarged); `on` names the element and where in its box
72
+ (0 to 1). Handles (`in`, `out`) are optional, relative to their point.
73
+
74
+ ## Your own actions
75
+
76
+ `addAction` puts a button of your own on the finished take, beside Download, to keep it somewhere:
77
+
78
+ ```ts
79
+ import { addAction } from "@cesarvenzac/closeup";
80
+
81
+ addAction({
82
+ label: "Save to library",
83
+ icon: `<path d="M12 13v8"/><path d="m8 17 4-4 4 4"/>`, // optional: paths on a 24-unit grid
84
+ when: (take) => take.kind === "video", // optional: which takes to offer it on
85
+ async run(take, { progress }) {
86
+ // take.blob, take.type, take.width, take.height, take.seconds, take.script
87
+ await upload(take.blob, progress); // call progress(0…1) as it goes
88
+ return "Saved."; // said when it's done; a thrown error's message is said instead
89
+ },
90
+ });
91
+ ```
92
+
93
+ The button counts up while it works and stays pressed once it's done. `addAction` returns what
94
+ takes it away again.
95
+
96
+ ## How it stays sharp
97
+
98
+ A capture of a small part of the screen is small: a 120-pixel button filmed and enlarged is a
99
+ blur. Closeup enlarges the page first. A transform on `<body>` scales it round the pick until the
100
+ pick fills the window, so the browser draws it large at the screen's full resolution; the layout
101
+ doesn't change, only how big it's drawn. The tab is then captured (`getDisplayMedia`) and cropped
102
+ to the frame by Region Capture (`CropTarget`, `cropTo`), so Closeup's own controls, outside the
103
+ frame, never show. The real cursor would be drawn into the capture small and soft, so it's hidden
104
+ and a drawn one stands in. The frames are laid on their background in a canvas and recorded.
105
+
106
+ ## Needs
107
+
108
+ A Chromium browser on a desktop (Chrome, Edge, Arc, Brave): Region Capture is Chromium's. Chrome
109
+ records H.264 MP4; other builds may record WebM. Styles from another site's stylesheets can't be
110
+ read, so their hover rules can't be copied; code that checks `event.isTrusted` ignores a script's
111
+ events. The page doesn't scroll during a take.
112
+
113
+ ## Development
114
+
115
+ ```sh
116
+ npm ci
117
+ npm test
118
+ npm run typecheck
119
+ npm run check # packed as npm publishes it, then installed and run as a user's would be
120
+ ```
121
+
122
+ This repository is a copy. The package is developed inside the site it was made for, whose
123
+ repository is private, and every release copies it here and publishes it to npm from here, with
124
+ provenance. Issues are welcome; pull requests are read, and their changes made at the source.
125
+
126
+ ## Licence
127
+
128
+ MIT
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Actions of your own on a finished take: buttons on the take's sheet, beside Download, that hand
3
+ * the take to your code (to keep it in a library, send it somewhere, …). Closeup shows the button,
4
+ * its progress while it runs, and what it says when it's done or fails.
5
+ */
6
+ import type { Result } from "./take.ts";
7
+ /** A finished take: the file, its size, its length, and the script it played if one did. */
8
+ export type Take = Result;
9
+ export type TakeAction = {
10
+ /** The button's words, e.g. "Save to library". */
11
+ label: string;
12
+ /** An icon beside them: the inside of an SVG on a 24-unit grid, drawn with Closeup's stroke (Lucide's paths fit). */
13
+ icon?: string;
14
+ /** Offered for this take? Every take when left out. */
15
+ when?: (take: Take) => boolean;
16
+ /**
17
+ * Does the work. Call `progress` with 0 to 1 as it goes; what it resolves to is said when it's
18
+ * done ("Saved." when nothing), and a thrown error's message is said instead.
19
+ */
20
+ run: (take: Take, context: {
21
+ progress: (done: number) => void;
22
+ }) => Promise<string | void>;
23
+ };
24
+ /** Offers `action` on every take from now on. Returns what takes it away. */
25
+ export declare function addAction(action: TakeAction): () => undefined;
26
+ /** The actions offered for a take, in the order they were added. */
27
+ export declare const actionsFor: (take: Take) => TakeAction[];
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Actions of your own on a finished take: buttons on the take's sheet, beside Download, that hand
3
+ * the take to your code (to keep it in a library, send it somewhere, …). Closeup shows the button,
4
+ * its progress while it runs, and what it says when it's done or fails.
5
+ */
6
+ const registered = new Set();
7
+ /** Offers `action` on every take from now on. Returns what takes it away. */
8
+ export function addAction(action) {
9
+ registered.add(action);
10
+ return () => void registered.delete(action);
11
+ }
12
+ /** The actions offered for a take, in the order they were added. */
13
+ export const actionsFor = (take) => [...registered].filter((action) => action.when?.(take) ?? true);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * This tab as a video track, cropped to one element by Region Capture: what's on screen inside
3
+ * the element's box, exactly as drawn (3D canvases and video included), and nothing else, so
4
+ * Closeup's own controls outside it never show. Chromium only, for now.
5
+ */
6
+ type CropTargetLike = object;
7
+ type CroppableTrack = MediaStreamTrack & {
8
+ cropTo(target: CropTargetLike | null): Promise<void>;
9
+ };
10
+ /** Whether this browser can share a tab and crop it to an element (cropTo lives on the captured track's own class). */
11
+ export declare const supportsCapture: () => boolean;
12
+ /**
13
+ * Asks to share this tab. Must run in the click that asked for it: browsers refuse a capture
14
+ * started without a gesture. `audio` offers the tab's own sound too (Chrome shows a checkbox).
15
+ */
16
+ export declare function shareTab({ audio, frameRate }: {
17
+ audio: boolean;
18
+ frameRate?: number;
19
+ }): Promise<{
20
+ stream: MediaStream;
21
+ video: CroppableTrack;
22
+ audio: MediaStreamTrack;
23
+ }>;
24
+ /** Crops the captured tab to `element`'s box, wherever it is on screen. */
25
+ export declare function cropTo(track: MediaStreamTrack, element: Element): Promise<void>;
26
+ /** Crops again from scratch: a capture cropped as it starts can stay silent, and this wakes it. */
27
+ export declare function recrop(track: MediaStreamTrack, element: Element): Promise<void>;
28
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * This tab as a video track, cropped to one element by Region Capture: what's on screen inside
3
+ * the element's box, exactly as drawn (3D canvases and video included), and nothing else, so
4
+ * Closeup's own controls outside it never show. Chromium only, for now.
5
+ */
6
+ /** Whether this browser can share a tab and crop it to an element (cropTo lives on the captured track's own class). */
7
+ export const supportsCapture = () => typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia && typeof CropTarget !== "undefined";
8
+ /**
9
+ * Asks to share this tab. Must run in the click that asked for it: browsers refuse a capture
10
+ * started without a gesture. `audio` offers the tab's own sound too (Chrome shows a checkbox).
11
+ */
12
+ export async function shareTab({ audio, frameRate = 60 }) {
13
+ const stream = await navigator.mediaDevices.getDisplayMedia({
14
+ // As large as the tab's real pixels: the crop is taken from these.
15
+ video: { frameRate: { ideal: frameRate, max: frameRate }, width: { ideal: 8192 }, height: { ideal: 8192 } },
16
+ audio,
17
+ // Not standard everywhere yet; Chrome reads them.
18
+ ...{ preferCurrentTab: true, selfBrowserSurface: "include", systemAudio: "exclude" },
19
+ });
20
+ const [video] = stream.getVideoTracks();
21
+ return { stream, video: video, audio: stream.getAudioTracks()[0] ?? null };
22
+ }
23
+ /** Crops the captured tab to `element`'s box, wherever it is on screen. */
24
+ export async function cropTo(track, element) {
25
+ if (typeof CropTarget === "undefined")
26
+ throw new Error("This browser can't crop a capture (Region Capture is Chromium's)");
27
+ await track.cropTo(await CropTarget.fromElement(element));
28
+ }
29
+ /** Crops again from scratch: a capture cropped as it starts can stay silent, and this wakes it. */
30
+ export async function recrop(track, element) {
31
+ await track.cropTo(null);
32
+ await cropTo(track, element);
33
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Draws each captured frame onto a canvas, alone or set on a background (a colour, a gradient
3
+ * or a picture) with room round it, rounded corners and a soft shadow, and gives the canvas
4
+ * back as a video track, one canvas frame per captured frame. Sizes are the capture's own
5
+ * pixels, so nothing is enlarged.
6
+ */
7
+ export type Background = {
8
+ kind: "none";
9
+ } | {
10
+ kind: "color";
11
+ color: string;
12
+ } | {
13
+ kind: "gradient";
14
+ from: string;
15
+ to: string;
16
+ angle: number;
17
+ } | {
18
+ kind: "image";
19
+ image: ImageBitmap;
20
+ };
21
+ export type Look = {
22
+ background: Background;
23
+ /** Room between the take's edge and the page, a share of its shorter side (with a background only). */
24
+ inset: number;
25
+ /** Corner radius of the page on its background, in pixels at 1080p (with a background only). */
26
+ radius: number;
27
+ shadow: boolean;
28
+ };
29
+ export declare class Compositor {
30
+ private readonly input;
31
+ private readonly look;
32
+ /** Where the hidden video playing the capture lives: outside the page's <body>. */
33
+ private readonly host;
34
+ readonly canvas: HTMLCanvasElement;
35
+ private readonly context;
36
+ private readonly video;
37
+ private output;
38
+ private callback;
39
+ private stopped;
40
+ constructor(input: MediaStreamTrack, look: Look,
41
+ /** Where the hidden video playing the capture lives: outside the page's <body>. */
42
+ host: Element);
43
+ /**
44
+ * Starts once the first frame arrives (it sets the size) and resolves with the composed track.
45
+ * A tab sends a frame only when something in it repaints, and a capture cropped as it starts
46
+ * can stay silent, so until the first frame `nudge` is called every animation frame (it should
47
+ * repaint something in the frame, unseen) and `wake` every half second (it should crop again).
48
+ */
49
+ start({ nudge, wake }: Waking): Promise<MediaStreamTrack>;
50
+ get size(): {
51
+ width: number;
52
+ height: number;
53
+ };
54
+ /** The composed frame now, as a PNG. */
55
+ snapshot(): Promise<Blob>;
56
+ stop(): void;
57
+ private draw;
58
+ }
59
+ export type Waking = {
60
+ nudge: () => void;
61
+ wake: () => Promise<unknown>;
62
+ };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Draws each captured frame onto a canvas, alone or set on a background (a colour, a gradient
3
+ * or a picture) with room round it, rounded corners and a soft shadow, and gives the canvas
4
+ * back as a video track, one canvas frame per captured frame. Sizes are the capture's own
5
+ * pixels, so nothing is enlarged.
6
+ */
7
+ import { insetRect } from "./frame.js";
8
+ export class Compositor {
9
+ input;
10
+ look;
11
+ host;
12
+ canvas = document.createElement("canvas");
13
+ context = this.canvas.getContext("2d", { alpha: false });
14
+ video = document.createElement("video");
15
+ output = null;
16
+ callback = 0;
17
+ stopped = false;
18
+ constructor(input, look,
19
+ /** Where the hidden video playing the capture lives: outside the page's <body>. */
20
+ host) {
21
+ this.input = input;
22
+ this.look = look;
23
+ this.host = host;
24
+ }
25
+ /**
26
+ * Starts once the first frame arrives (it sets the size) and resolves with the composed track.
27
+ * A tab sends a frame only when something in it repaints, and a capture cropped as it starts
28
+ * can stay silent, so until the first frame `nudge` is called every animation frame (it should
29
+ * repaint something in the frame, unseen) and `wake` every half second (it should crop again).
30
+ */
31
+ async start({ nudge, wake }) {
32
+ const video = this.video;
33
+ video.muted = true;
34
+ video.playsInline = true;
35
+ video.srcObject = new MediaStream([this.input]);
36
+ // Laid out but unseen, so the browser keeps decoding it.
37
+ Object.assign(video.style, { position: "fixed", left: "0", top: "0", width: "2px", height: "2px", opacity: "0", pointerEvents: "none" });
38
+ this.host.append(video);
39
+ const playing = video.play();
40
+ await firstFrame(video, nudge, wake);
41
+ await playing;
42
+ this.canvas.width = video.videoWidth;
43
+ this.canvas.height = video.videoHeight;
44
+ this.output = this.canvas.captureStream(0).getVideoTracks()[0];
45
+ this.draw();
46
+ const loop = () => {
47
+ if (this.stopped)
48
+ return;
49
+ this.draw();
50
+ this.output?.requestFrame();
51
+ this.callback = video.requestVideoFrameCallback(loop);
52
+ };
53
+ this.callback = video.requestVideoFrameCallback(loop);
54
+ return this.output;
55
+ }
56
+ get size() {
57
+ return { width: this.canvas.width, height: this.canvas.height };
58
+ }
59
+ /** The composed frame now, as a PNG. */
60
+ snapshot() {
61
+ this.draw();
62
+ return new Promise((resolve, reject) => this.canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("No frame"))), "image/png"));
63
+ }
64
+ stop() {
65
+ this.stopped = true;
66
+ this.video.cancelVideoFrameCallback?.(this.callback);
67
+ this.output?.stop();
68
+ this.video.srcObject = null;
69
+ this.video.remove();
70
+ }
71
+ draw() {
72
+ const { context: c, canvas, video, look } = this;
73
+ const { width, height } = canvas;
74
+ if (!width || video.readyState < 2)
75
+ return;
76
+ if (look.background.kind === "none") {
77
+ c.drawImage(video, 0, 0, width, height);
78
+ return;
79
+ }
80
+ paintBackground(c, look.background, width, height);
81
+ const inner = insetRect({ width, height }, look.inset);
82
+ const unit = Math.min(width, height) / 1080;
83
+ const radius = look.radius * unit;
84
+ if (look.shadow) {
85
+ c.save();
86
+ c.shadowColor = "rgb(0 0 0 / 0.28)";
87
+ c.shadowBlur = 48 * unit;
88
+ c.shadowOffsetY = 16 * unit;
89
+ c.fillStyle = "#000";
90
+ c.beginPath();
91
+ c.roundRect(inner.x, inner.y, inner.width, inner.height, radius);
92
+ c.fill();
93
+ c.restore();
94
+ }
95
+ c.save();
96
+ c.beginPath();
97
+ c.roundRect(inner.x, inner.y, inner.width, inner.height, radius);
98
+ c.clip();
99
+ c.drawImage(video, inner.x, inner.y, inner.width, inner.height);
100
+ c.restore();
101
+ }
102
+ }
103
+ function paintBackground(c, background, width, height) {
104
+ if (background.kind === "color") {
105
+ c.fillStyle = background.color;
106
+ c.fillRect(0, 0, width, height);
107
+ }
108
+ else if (background.kind === "gradient") {
109
+ const angle = (background.angle * Math.PI) / 180;
110
+ const half = (Math.abs(width * Math.sin(angle)) + Math.abs(height * Math.cos(angle))) / 2;
111
+ const [dx, dy] = [Math.sin(angle) * half, -Math.cos(angle) * half];
112
+ const gradient = c.createLinearGradient(width / 2 - dx, height / 2 - dy, width / 2 + dx, height / 2 + dy);
113
+ gradient.addColorStop(0, background.from);
114
+ gradient.addColorStop(1, background.to);
115
+ c.fillStyle = gradient;
116
+ c.fillRect(0, 0, width, height);
117
+ }
118
+ else if (background.kind === "image") {
119
+ // Cover: the picture fills the take, cropped evenly on the long side.
120
+ const { image } = background;
121
+ const scale = Math.max(width / image.width, height / image.height);
122
+ const w = image.width * scale;
123
+ const h = image.height * scale;
124
+ c.drawImage(image, (width - w) / 2, (height - h) / 2, w, h);
125
+ }
126
+ }
127
+ /** How long a capture may take to send its first picture before the take gives up, and how often it's woken meanwhile. */
128
+ const FIRST_FRAME_MS = 5000;
129
+ const WAKE_MS = 500;
130
+ function firstFrame(video, nudge, wake) {
131
+ return new Promise((resolve, reject) => {
132
+ let done = false;
133
+ let frame = 0;
134
+ const started = performance.now();
135
+ const end = () => {
136
+ done = true;
137
+ cancelAnimationFrame(frame);
138
+ };
139
+ video.requestVideoFrameCallback(() => {
140
+ end();
141
+ resolve();
142
+ });
143
+ const tick = () => {
144
+ if (done)
145
+ return;
146
+ nudge();
147
+ frame = requestAnimationFrame(tick);
148
+ };
149
+ tick();
150
+ void (async () => {
151
+ while (!done) {
152
+ await new Promise((next) => setTimeout(next, WAKE_MS));
153
+ if (done)
154
+ return;
155
+ if (performance.now() - started > FIRST_FRAME_MS) {
156
+ end();
157
+ reject(new Error("the tab sent no picture"));
158
+ return;
159
+ }
160
+ await wake().catch(() => undefined);
161
+ }
162
+ })();
163
+ });
164
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A drawn cursor in place of the real one while a take lasts. Chrome adds the real cursor to a
3
+ * tab capture as a small, soft picture laid on top, which stage mode can't sharpen; this one is
4
+ * drawn by the page at the screen's full resolution. It takes the shape the real one would
5
+ * have there (the hand over links and buttons, the I-beam over text, else the arrow) and
6
+ * presses a little on a click. It lives outside <body>, so stage mode doesn't enlarge it.
7
+ */
8
+ export type CursorOptions = {
9
+ /** How big it's drawn, in CSS pixels across its 24-unit box. */
10
+ size: number;
11
+ /** Each press and release, for the click sound. */
12
+ onPress?: (down: boolean) => void;
13
+ /** A soft ring that spreads from the tip on each click, so a viewer sees it. */
14
+ ring?: boolean;
15
+ /** Events over Closeup's own controls: the real cursor shows there, the drawn one hides. */
16
+ ignore?: (target: EventTarget | null) => boolean;
17
+ /** The page's element at a point, Closeup's own layers left out. */
18
+ hit: (x: number, y: number) => Element | null;
19
+ /** Where a caret would go at a point, Closeup's own layers left out (to tell text from space). */
20
+ caretAt?: (x: number, y: number) => Range | null;
21
+ };
22
+ export declare class DrawnCursor {
23
+ private readonly host;
24
+ private readonly options;
25
+ private readonly element;
26
+ private readonly hide;
27
+ private shape;
28
+ private shown;
29
+ private x;
30
+ private y;
31
+ constructor(host: Element, options: CursorOptions);
32
+ /** Puts it on the page, hiding the real cursor there; it appears at its first `place`. */
33
+ show(): void;
34
+ /** Follows the real pointer. */
35
+ follow(): void;
36
+ destroy(): void;
37
+ /** Draws it at a point on screen, in the shape the real cursor would take there. */
38
+ place(x: number, y: number): void;
39
+ /** Presses a little going down, and back coming up, with the click's sound (and the ring, going down). */
40
+ press(down: boolean): void;
41
+ /** The ring: from the tip, out to about the cursor's size, fading as it goes. Still under reduced motion. */
42
+ private ripple;
43
+ private onMove;
44
+ private onDown;
45
+ private onUp;
46
+ private onLeave;
47
+ /** The shape the real cursor would take here: read with the hiding rule off for a moment. */
48
+ private shapeAt;
49
+ }