@cesarvenzac/closeup 0.0.0-stage → 0.1.1
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 +127 -2
- package/dist/actions.d.ts +27 -0
- package/dist/actions.js +13 -0
- package/dist/capture.d.ts +28 -0
- package/dist/capture.js +33 -0
- package/dist/compositor.d.ts +62 -0
- package/dist/compositor.js +164 -0
- package/dist/cursor.d.ts +49 -0
- package/dist/cursor.js +152 -0
- package/dist/cursors.d.ts +11 -0
- package/dist/cursors.js +24 -0
- package/dist/frame.d.ts +86 -0
- package/dist/frame.js +78 -0
- package/dist/hover.d.ts +23 -0
- package/dist/hover.js +89 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +37 -0
- package/dist/path.d.ts +41 -0
- package/dist/path.js +65 -0
- package/dist/player.d.ts +39 -0
- package/dist/player.js +140 -0
- package/dist/pointer.d.ts +30 -0
- package/dist/pointer.js +134 -0
- package/dist/script.d.ts +75 -0
- package/dist/script.js +94 -0
- package/dist/selector.d.ts +22 -0
- package/dist/selector.js +83 -0
- package/dist/settings.d.ts +61 -0
- package/dist/settings.js +59 -0
- package/dist/shortcut.d.ts +23 -0
- package/dist/shortcut.js +48 -0
- package/dist/sounds.d.ts +22 -0
- package/dist/sounds.js +94 -0
- package/dist/stage.d.ts +12 -0
- package/dist/stage.js +53 -0
- package/dist/store.d.ts +4 -0
- package/dist/store.js +22 -0
- package/dist/take.d.ts +69 -0
- package/dist/take.js +220 -0
- package/dist/ui/editor.d.ts +90 -0
- package/dist/ui/editor.js +612 -0
- package/dist/ui/icons.d.ts +28 -0
- package/dist/ui/icons.js +28 -0
- package/dist/ui/panel.d.ts +83 -0
- package/dist/ui/panel.js +739 -0
- package/dist/ui/styles.d.ts +6 -0
- package/dist/ui/styles.js +183 -0
- package/package.json +36 -4
package/dist/cursor.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
import { CURSORS } from "./cursors.js";
|
|
9
|
+
export class DrawnCursor {
|
|
10
|
+
host;
|
|
11
|
+
options;
|
|
12
|
+
element = document.createElement("div");
|
|
13
|
+
hide = document.createElement("style");
|
|
14
|
+
shape = null;
|
|
15
|
+
shown = false;
|
|
16
|
+
x = 0;
|
|
17
|
+
y = 0;
|
|
18
|
+
constructor(host, options) {
|
|
19
|
+
this.host = host;
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.hide.textContent = "*, *::before, *::after { cursor: none !important; }";
|
|
22
|
+
Object.assign(this.element.style, {
|
|
23
|
+
position: "fixed",
|
|
24
|
+
left: "0",
|
|
25
|
+
top: "0",
|
|
26
|
+
width: `${options.size}px`,
|
|
27
|
+
height: `${options.size}px`,
|
|
28
|
+
pointerEvents: "none",
|
|
29
|
+
zIndex: "2147483646",
|
|
30
|
+
visibility: "hidden",
|
|
31
|
+
filter: "drop-shadow(0 1.5px 2.5px rgb(0 0 0 / 0.3))",
|
|
32
|
+
transition: "scale 90ms ease-out",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Puts it on the page, hiding the real cursor there; it appears at its first `place`. */
|
|
36
|
+
show() {
|
|
37
|
+
if (this.shown)
|
|
38
|
+
return;
|
|
39
|
+
this.shown = true;
|
|
40
|
+
document.head.append(this.hide);
|
|
41
|
+
this.host.append(this.element);
|
|
42
|
+
}
|
|
43
|
+
/** Follows the real pointer. */
|
|
44
|
+
follow() {
|
|
45
|
+
this.show();
|
|
46
|
+
addEventListener("pointermove", this.onMove, true);
|
|
47
|
+
addEventListener("pointerdown", this.onDown, true);
|
|
48
|
+
addEventListener("pointerup", this.onUp, true);
|
|
49
|
+
document.addEventListener("pointerout", this.onLeave, true);
|
|
50
|
+
}
|
|
51
|
+
destroy() {
|
|
52
|
+
removeEventListener("pointermove", this.onMove, true);
|
|
53
|
+
removeEventListener("pointerdown", this.onDown, true);
|
|
54
|
+
removeEventListener("pointerup", this.onUp, true);
|
|
55
|
+
document.removeEventListener("pointerout", this.onLeave, true);
|
|
56
|
+
this.hide.remove();
|
|
57
|
+
this.element.remove();
|
|
58
|
+
this.shown = false;
|
|
59
|
+
}
|
|
60
|
+
/** Draws it at a point on screen, in the shape the real cursor would take there. */
|
|
61
|
+
place(x, y) {
|
|
62
|
+
this.x = x;
|
|
63
|
+
this.y = y;
|
|
64
|
+
const shape = this.shapeAt(x, y);
|
|
65
|
+
const { hot, svg } = CURSORS[shape];
|
|
66
|
+
const size = this.options.size;
|
|
67
|
+
if (shape !== this.shape) {
|
|
68
|
+
this.element.innerHTML = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" style="display:block;overflow:visible">${svg}</svg>`;
|
|
69
|
+
this.shape = shape;
|
|
70
|
+
}
|
|
71
|
+
const unit = size / 24;
|
|
72
|
+
this.element.style.translate = `${x - hot[0] * unit}px ${y - hot[1] * unit}px`;
|
|
73
|
+
this.element.style.transformOrigin = `${hot[0] * unit}px ${hot[1] * unit}px`;
|
|
74
|
+
this.element.style.visibility = "visible";
|
|
75
|
+
}
|
|
76
|
+
/** Presses a little going down, and back coming up, with the click's sound (and the ring, going down). */
|
|
77
|
+
press(down) {
|
|
78
|
+
this.element.style.scale = down ? "0.86" : "1";
|
|
79
|
+
this.options.onPress?.(down);
|
|
80
|
+
if (down && this.options.ring)
|
|
81
|
+
this.ripple();
|
|
82
|
+
}
|
|
83
|
+
/** The ring: from the tip, out to about the cursor's size, fading as it goes. Still under reduced motion. */
|
|
84
|
+
ripple() {
|
|
85
|
+
const size = this.options.size * 1.5;
|
|
86
|
+
const ring = document.createElement("div");
|
|
87
|
+
Object.assign(ring.style, {
|
|
88
|
+
position: "fixed",
|
|
89
|
+
left: `${this.x - size / 2}px`,
|
|
90
|
+
top: `${this.y - size / 2}px`,
|
|
91
|
+
width: `${size}px`,
|
|
92
|
+
height: `${size}px`,
|
|
93
|
+
borderRadius: "50%",
|
|
94
|
+
pointerEvents: "none",
|
|
95
|
+
zIndex: "2147483645",
|
|
96
|
+
// A dark edge with a light halo, so it shows on light pages and dark ones.
|
|
97
|
+
background: "rgb(0 0 0 / 0.07)",
|
|
98
|
+
boxShadow: "inset 0 0 0 2px rgb(0 0 0 / 0.32), 0 0 0 1.5px rgb(255 255 255 / 0.55)",
|
|
99
|
+
});
|
|
100
|
+
this.host.append(ring);
|
|
101
|
+
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
102
|
+
const frames = still ? [{ opacity: 1 }, { opacity: 0 }] : [{ scale: 0.2, opacity: 1 }, { scale: 1, opacity: 0 }];
|
|
103
|
+
ring.animate(frames, { duration: still ? 360 : 560, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }).finished.finally(() => ring.remove());
|
|
104
|
+
}
|
|
105
|
+
onMove = (event) => {
|
|
106
|
+
if (this.options.ignore?.(event.target))
|
|
107
|
+
this.element.style.visibility = "hidden";
|
|
108
|
+
else
|
|
109
|
+
this.place(event.clientX, event.clientY);
|
|
110
|
+
};
|
|
111
|
+
onDown = (event) => {
|
|
112
|
+
if (!this.options.ignore?.(event.target))
|
|
113
|
+
this.press(true);
|
|
114
|
+
};
|
|
115
|
+
onUp = (event) => {
|
|
116
|
+
if (!this.options.ignore?.(event.target))
|
|
117
|
+
this.press(false);
|
|
118
|
+
};
|
|
119
|
+
onLeave = (event) => {
|
|
120
|
+
if (!event.relatedTarget)
|
|
121
|
+
this.element.style.visibility = "hidden";
|
|
122
|
+
};
|
|
123
|
+
/** The shape the real cursor would take here: read with the hiding rule off for a moment. */
|
|
124
|
+
shapeAt(x, y) {
|
|
125
|
+
const under = this.options.hit(x, y);
|
|
126
|
+
if (!under)
|
|
127
|
+
return "arrow";
|
|
128
|
+
this.hide.disabled = true;
|
|
129
|
+
const style = getComputedStyle(under);
|
|
130
|
+
const cursor = style.cursor;
|
|
131
|
+
const userSelect = style.userSelect;
|
|
132
|
+
this.hide.disabled = false;
|
|
133
|
+
if (cursor === "pointer")
|
|
134
|
+
return "hand";
|
|
135
|
+
if (cursor === "text" || cursor === "vertical-text")
|
|
136
|
+
return "text";
|
|
137
|
+
if (cursor !== "auto" || userSelect === "none")
|
|
138
|
+
return "arrow";
|
|
139
|
+
if (under.matches("input, textarea, [contenteditable='true'], [contenteditable='']"))
|
|
140
|
+
return "text";
|
|
141
|
+
// Over text a browser shows the I-beam: is the point on a line of text?
|
|
142
|
+
const caret = this.options.caretAt ? this.options.caretAt(x, y) : document.caretRangeFromPoint?.(x, y);
|
|
143
|
+
if (caret?.startContainer.nodeType === Node.TEXT_NODE) {
|
|
144
|
+
const range = document.createRange();
|
|
145
|
+
range.selectNodeContents(caret.startContainer);
|
|
146
|
+
for (const rect of range.getClientRects())
|
|
147
|
+
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom)
|
|
148
|
+
return "text";
|
|
149
|
+
}
|
|
150
|
+
return "arrow";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drawn cursors, macOS-like and our own drawings (not Apple's images), on a 24-unit grid
|
|
3
|
+
* with each one's hotspot: the point that clicks. Each outline is painted under its fill
|
|
4
|
+
* (`paint-order: stroke`), so the white edge sits outside the black as on a Mac, never eating
|
|
5
|
+
* into it.
|
|
6
|
+
*/
|
|
7
|
+
export type CursorShape = "arrow" | "hand" | "text";
|
|
8
|
+
export declare const CURSORS: Record<CursorShape, {
|
|
9
|
+
hot: [number, number];
|
|
10
|
+
svg: string;
|
|
11
|
+
}>;
|
package/dist/cursors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drawn cursors, macOS-like and our own drawings (not Apple's images), on a 24-unit grid
|
|
3
|
+
* with each one's hotspot: the point that clicks. Each outline is painted under its fill
|
|
4
|
+
* (`paint-order: stroke`), so the white edge sits outside the black as on a Mac, never eating
|
|
5
|
+
* into it.
|
|
6
|
+
*/
|
|
7
|
+
/** The hand's outline: index finger up, three fingers folded beside it, the thumb out to the left. */
|
|
8
|
+
const HAND = "M7 15.2V3.4a1.5 1.5 0 0 1 3 0v6.2a1.5 1.5 0 0 1 3 .2v.6a1.5 1.5 0 0 1 3 .3v.7a1.5 1.5 0 0 1 3 .2V16c0 3.3-2.1 5.6-5 5.6h-2.6c-1.6 0-2.7-.6-3.6-1.6l-3.3-3.6a1.3 1.3 0 0 1 1.8-1.9z";
|
|
9
|
+
/** The I-beam: a stem with a curl at each end. */
|
|
10
|
+
const BEAM = "M9 3.5h1.2c1 0 1.8.8 1.8 1.8m3-1.8h-1.2c-1 0-1.8.8-1.8 1.8v13.4c0 1-.8 1.8-1.8 1.8H9m6 0h-1.2c-1 0-1.8-.8-1.8-1.8";
|
|
11
|
+
export const CURSORS = {
|
|
12
|
+
arrow: {
|
|
13
|
+
hot: [5, 2.5],
|
|
14
|
+
svg: `<path d="M5 2.5v16.6l4.2-3.9 2.7 6.1 2.6-1.1-2.7-6h5.8z" fill="#000" stroke="#fff" stroke-width="2.4" stroke-linejoin="round" paint-order="stroke"/>`,
|
|
15
|
+
},
|
|
16
|
+
hand: {
|
|
17
|
+
hot: [8.5, 2],
|
|
18
|
+
svg: `<path d="${HAND}" fill="#fff" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/><path d="M10 9.6v2.6M13 10.4v2.4M16 11.2v2.2" fill="none" stroke="#000" stroke-width="1.1" stroke-linecap="round"/>`,
|
|
19
|
+
},
|
|
20
|
+
text: {
|
|
21
|
+
hot: [12, 12],
|
|
22
|
+
svg: `<path d="${BEAM}" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/><path d="${BEAM}" fill="none" stroke="#000" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`,
|
|
23
|
+
},
|
|
24
|
+
};
|
package/dist/frame.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where to film and how much to enlarge it: pure arithmetic, so it's tested apart from the
|
|
3
|
+
* browser. Rectangles are in CSS pixels in the viewport, as getBoundingClientRect gives them.
|
|
4
|
+
*/
|
|
5
|
+
export type Rect = {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
export type Size = {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
};
|
|
15
|
+
/** The shapes a take can have; `free` keeps the shape of what was picked. */
|
|
16
|
+
export declare const SHAPES: {
|
|
17
|
+
readonly "16:9": number;
|
|
18
|
+
readonly "1:1": 1;
|
|
19
|
+
readonly "4:5": number;
|
|
20
|
+
readonly "9:16": number;
|
|
21
|
+
readonly free: null;
|
|
22
|
+
};
|
|
23
|
+
export type Shape = keyof typeof SHAPES;
|
|
24
|
+
/** The most the page is enlarged: past this a few pixels fill the screen and the browser's work grows for nothing. */
|
|
25
|
+
export declare const MOST_SCALE = 40;
|
|
26
|
+
/** How much of the window a staged frame fills: the margin round it holds the controls, uncaptured. */
|
|
27
|
+
export declare const FILL = 0.84;
|
|
28
|
+
/** The least margin above and below the frame, in CSS pixels: the recording's controls sit there, never in the take. */
|
|
29
|
+
export declare const ROOM = 60;
|
|
30
|
+
/** The share of `viewport` a frame fills: FILL, less in a window too short to leave ROOM above and below. */
|
|
31
|
+
export declare const fillFor: (viewport: Size) => number;
|
|
32
|
+
/**
|
|
33
|
+
* The area to film round `target`: grown by `padding` (a share of its size on each side),
|
|
34
|
+
* then widened or heightened, never cropped, to `aspect` (null keeps the target's own).
|
|
35
|
+
*/
|
|
36
|
+
export declare function frameAround(target: Rect, padding: number, aspect: number | null): Rect;
|
|
37
|
+
export type Stage = {
|
|
38
|
+
/** How much the page is enlarged. */
|
|
39
|
+
scale: number;
|
|
40
|
+
/** The translation, in CSS pixels, that brings the frame to the middle of the viewport once scaled about the page's top left. */
|
|
41
|
+
tx: number;
|
|
42
|
+
ty: number;
|
|
43
|
+
/** Where the frame sits on screen once staged: what's captured. */
|
|
44
|
+
screen: Rect;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The stage for `frame`: the page scaled about its top left (the document's, so scrolling is
|
|
48
|
+
* counted) until the frame fills `fill` of the viewport in its tighter direction, and moved so
|
|
49
|
+
* the frame sits in the middle. What's outside the frame stays drawn but isn't captured.
|
|
50
|
+
*/
|
|
51
|
+
export declare function stageFor(frame: Rect, viewport: Size, fill: number, scroll?: {
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
}): Stage;
|
|
55
|
+
/**
|
|
56
|
+
* Where a point of the page (document CSS pixels, as laid out) is drawn on screen once staged,
|
|
57
|
+
* the page scrolled by `scroll`: scaled about the page's top left and moved by the stage.
|
|
58
|
+
*/
|
|
59
|
+
export declare const toScreen: (stage: Pick<Stage, "scale" | "tx" | "ty">, scroll: {
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
}, p: {
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
}) => {
|
|
66
|
+
x: number;
|
|
67
|
+
y: number;
|
|
68
|
+
};
|
|
69
|
+
/** The point of the page drawn at screen point `q` on the stage: `toScreen` the other way. */
|
|
70
|
+
export declare const toPage: (stage: Pick<Stage, "scale" | "tx" | "ty">, scroll: {
|
|
71
|
+
x: number;
|
|
72
|
+
y: number;
|
|
73
|
+
}, q: {
|
|
74
|
+
x: number;
|
|
75
|
+
y: number;
|
|
76
|
+
}) => {
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
};
|
|
80
|
+
/** The take's size in pixels: the frame on screen at the screen's density, rounded to even numbers as video encoders want. */
|
|
81
|
+
export declare function pixelSize(screen: Rect, density: number): Size;
|
|
82
|
+
/**
|
|
83
|
+
* Where a frame of `inner`'s shape sits inside a take of `outer` size once a background with
|
|
84
|
+
* `padding` (a share of the take's shorter side) surrounds it: centred, shape kept.
|
|
85
|
+
*/
|
|
86
|
+
export declare function insetRect(outer: Size, padding: number): Rect;
|
package/dist/frame.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where to film and how much to enlarge it: pure arithmetic, so it's tested apart from the
|
|
3
|
+
* browser. Rectangles are in CSS pixels in the viewport, as getBoundingClientRect gives them.
|
|
4
|
+
*/
|
|
5
|
+
/** The shapes a take can have; `free` keeps the shape of what was picked. */
|
|
6
|
+
export const SHAPES = { "16:9": 16 / 9, "1:1": 1, "4:5": 4 / 5, "9:16": 9 / 16, free: null };
|
|
7
|
+
/** The most the page is enlarged: past this a few pixels fill the screen and the browser's work grows for nothing. */
|
|
8
|
+
export const MOST_SCALE = 40;
|
|
9
|
+
/** How much of the window a staged frame fills: the margin round it holds the controls, uncaptured. */
|
|
10
|
+
export const FILL = 0.84;
|
|
11
|
+
/** The least margin above and below the frame, in CSS pixels: the recording's controls sit there, never in the take. */
|
|
12
|
+
export const ROOM = 60;
|
|
13
|
+
/** The share of `viewport` a frame fills: FILL, less in a window too short to leave ROOM above and below. */
|
|
14
|
+
export const fillFor = (viewport) => Math.max(0.2, Math.min(FILL, 1 - (2 * ROOM) / viewport.height));
|
|
15
|
+
/**
|
|
16
|
+
* The area to film round `target`: grown by `padding` (a share of its size on each side),
|
|
17
|
+
* then widened or heightened, never cropped, to `aspect` (null keeps the target's own).
|
|
18
|
+
*/
|
|
19
|
+
export function frameAround(target, padding, aspect) {
|
|
20
|
+
const cx = target.x + target.width / 2;
|
|
21
|
+
const cy = target.y + target.height / 2;
|
|
22
|
+
let width = Math.max(1, target.width * (1 + 2 * padding));
|
|
23
|
+
let height = Math.max(1, target.height * (1 + 2 * padding));
|
|
24
|
+
if (aspect) {
|
|
25
|
+
if (width / height < aspect)
|
|
26
|
+
width = height * aspect;
|
|
27
|
+
else
|
|
28
|
+
height = width / aspect;
|
|
29
|
+
}
|
|
30
|
+
return { x: cx - width / 2, y: cy - height / 2, width, height };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The stage for `frame`: the page scaled about its top left (the document's, so scrolling is
|
|
34
|
+
* counted) until the frame fills `fill` of the viewport in its tighter direction, and moved so
|
|
35
|
+
* the frame sits in the middle. What's outside the frame stays drawn but isn't captured.
|
|
36
|
+
*/
|
|
37
|
+
export function stageFor(frame, viewport, fill, scroll = { x: 0, y: 0 }) {
|
|
38
|
+
const scale = Math.min(MOST_SCALE, (viewport.width * fill) / frame.width, (viewport.height * fill) / frame.height);
|
|
39
|
+
const cx = frame.x + frame.width / 2 + scroll.x;
|
|
40
|
+
const cy = frame.y + frame.height / 2 + scroll.y;
|
|
41
|
+
const width = frame.width * scale;
|
|
42
|
+
const height = frame.height * scale;
|
|
43
|
+
return {
|
|
44
|
+
scale,
|
|
45
|
+
tx: viewport.width / 2 + scroll.x - cx * scale,
|
|
46
|
+
ty: viewport.height / 2 + scroll.y - cy * scale,
|
|
47
|
+
screen: { x: (viewport.width - width) / 2, y: (viewport.height - height) / 2, width, height },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Where a point of the page (document CSS pixels, as laid out) is drawn on screen once staged,
|
|
52
|
+
* the page scrolled by `scroll`: scaled about the page's top left and moved by the stage.
|
|
53
|
+
*/
|
|
54
|
+
export const toScreen = (stage, scroll, p) => ({
|
|
55
|
+
x: p.x * stage.scale + stage.tx - scroll.x,
|
|
56
|
+
y: p.y * stage.scale + stage.ty - scroll.y,
|
|
57
|
+
});
|
|
58
|
+
/** The point of the page drawn at screen point `q` on the stage: `toScreen` the other way. */
|
|
59
|
+
export const toPage = (stage, scroll, q) => ({
|
|
60
|
+
x: (q.x + scroll.x - stage.tx) / stage.scale,
|
|
61
|
+
y: (q.y + scroll.y - stage.ty) / stage.scale,
|
|
62
|
+
});
|
|
63
|
+
/** The take's size in pixels: the frame on screen at the screen's density, rounded to even numbers as video encoders want. */
|
|
64
|
+
export function pixelSize(screen, density) {
|
|
65
|
+
const even = (value) => Math.max(2, 2 * Math.round((value * density) / 2));
|
|
66
|
+
return { width: even(screen.width), height: even(screen.height) };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Where a frame of `inner`'s shape sits inside a take of `outer` size once a background with
|
|
70
|
+
* `padding` (a share of the take's shorter side) surrounds it: centred, shape kept.
|
|
71
|
+
*/
|
|
72
|
+
export function insetRect(outer, padding) {
|
|
73
|
+
const margin = padding * Math.min(outer.width, outer.height);
|
|
74
|
+
const scale = Math.min((outer.width - 2 * margin) / outer.width, (outer.height - 2 * margin) / outer.height);
|
|
75
|
+
const width = outer.width * scale;
|
|
76
|
+
const height = outer.height * scale;
|
|
77
|
+
return { x: (outer.width - width) / 2, y: (outer.height - height) / 2, width, height };
|
|
78
|
+
}
|
package/dist/hover.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hover and press, for a cursor that isn't real. A page's script can be told the pointer moved
|
|
3
|
+
* (`pointer.ts` sends the events), but CSS's :hover and :active follow only the real pointer, and
|
|
4
|
+
* no page can set them. So every rule that uses them is copied once, each :hover and :active
|
|
5
|
+
* swapped for an attribute Closeup puts on the elements under the drawn cursor, and the copies
|
|
6
|
+
* added in a stylesheet of their own, in the same cascade layers and media queries as the rules
|
|
7
|
+
* they copy. Rules in another site's stylesheets can't be read, so they can't be copied.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HOVER = "data-closeup-hover";
|
|
10
|
+
export declare const ACTIVE = "data-closeup-active";
|
|
11
|
+
/** A selector with :hover and :active swapped for Closeup's attributes. Same specificity: a pseudo-class and an attribute weigh alike. */
|
|
12
|
+
export declare const markSelector: (selector: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* The part of `rule` that hovering or pressing changes, rewritten for the attributes; "" when
|
|
15
|
+
* there's none. A rule whose own selector hovers is copied whole (its nested rules apply only
|
|
16
|
+
* while hovered); one that doesn't keeps only its nested rules that do; grouping rules (layers,
|
|
17
|
+
* media, supports, containers) wrap what's kept inside them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function copyRule(rule: CSSRule): string;
|
|
20
|
+
/** Copies the page's hover and press rules for the drawn cursor. `remove()` takes them away again. */
|
|
21
|
+
export declare function emulateHover(): {
|
|
22
|
+
remove(): void;
|
|
23
|
+
};
|
package/dist/hover.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hover and press, for a cursor that isn't real. A page's script can be told the pointer moved
|
|
3
|
+
* (`pointer.ts` sends the events), but CSS's :hover and :active follow only the real pointer, and
|
|
4
|
+
* no page can set them. So every rule that uses them is copied once, each :hover and :active
|
|
5
|
+
* swapped for an attribute Closeup puts on the elements under the drawn cursor, and the copies
|
|
6
|
+
* added in a stylesheet of their own, in the same cascade layers and media queries as the rules
|
|
7
|
+
* they copy. Rules in another site's stylesheets can't be read, so they can't be copied.
|
|
8
|
+
*/
|
|
9
|
+
export const HOVER = "data-closeup-hover";
|
|
10
|
+
export const ACTIVE = "data-closeup-active";
|
|
11
|
+
const HOVER_RE = /:hover(?![\w-])/g;
|
|
12
|
+
const ACTIVE_RE = /:active(?![\w-])/g;
|
|
13
|
+
const USES = /:(?:hover|active)(?![\w-])/;
|
|
14
|
+
/** A selector with :hover and :active swapped for Closeup's attributes. Same specificity: a pseudo-class and an attribute weigh alike. */
|
|
15
|
+
export const markSelector = (selector) => selector.replace(HOVER_RE, `[${HOVER}]`).replace(ACTIVE_RE, `[${ACTIVE}]`);
|
|
16
|
+
/** A media query only about whether the device can hover: always true for the drawn cursor, which does. */
|
|
17
|
+
const onlyHover = (condition) => /^\(\s*hover\s*:\s*hover\s*\)$/.test(condition.trim());
|
|
18
|
+
/**
|
|
19
|
+
* The part of `rule` that hovering or pressing changes, rewritten for the attributes; "" when
|
|
20
|
+
* there's none. A rule whose own selector hovers is copied whole (its nested rules apply only
|
|
21
|
+
* while hovered); one that doesn't keeps only its nested rules that do; grouping rules (layers,
|
|
22
|
+
* media, supports, containers) wrap what's kept inside them.
|
|
23
|
+
*/
|
|
24
|
+
export function copyRule(rule) {
|
|
25
|
+
if (rule instanceof CSSStyleRule) {
|
|
26
|
+
const nested = [...rule.cssRules];
|
|
27
|
+
if (USES.test(rule.selectorText)) {
|
|
28
|
+
return `${markSelector(rule.selectorText)}{${rule.style.cssText}${nested.map(copyAll).join("")}}`;
|
|
29
|
+
}
|
|
30
|
+
const inner = nested.map(copyRule).join("");
|
|
31
|
+
return inner ? `${rule.selectorText}{${inner}}` : "";
|
|
32
|
+
}
|
|
33
|
+
if (rule instanceof CSSMediaRule) {
|
|
34
|
+
const inner = [...rule.cssRules].map(copyRule).join("");
|
|
35
|
+
if (!inner)
|
|
36
|
+
return "";
|
|
37
|
+
return onlyHover(rule.conditionText) ? inner : `@media ${rule.conditionText}{${inner}}`;
|
|
38
|
+
}
|
|
39
|
+
if (rule instanceof CSSLayerBlockRule) {
|
|
40
|
+
const inner = [...rule.cssRules].map(copyRule).join("");
|
|
41
|
+
return inner ? `@layer ${rule.name}{${inner}}` : "";
|
|
42
|
+
}
|
|
43
|
+
if (rule instanceof CSSSupportsRule || (typeof CSSContainerRule !== "undefined" && rule instanceof CSSContainerRule)) {
|
|
44
|
+
const inner = [...rule.cssRules].map(copyRule).join("");
|
|
45
|
+
const at = rule instanceof CSSSupportsRule ? "@supports" : "@container";
|
|
46
|
+
return inner ? `${at} ${rule.conditionText}{${inner}}` : "";
|
|
47
|
+
}
|
|
48
|
+
if (rule instanceof CSSImportRule && rule.styleSheet) {
|
|
49
|
+
const inner = rulesOf(rule.styleSheet).map(copyRule).join("");
|
|
50
|
+
if (!inner)
|
|
51
|
+
return "";
|
|
52
|
+
const layered = rule.layerName !== null ? `@layer ${rule.layerName}{${inner}}` : inner;
|
|
53
|
+
return rule.media.mediaText ? `@media ${rule.media.mediaText}{${layered}}` : layered;
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
/** A nested rule under a hovered one: all of it, its own :hover and :active swapped too. */
|
|
58
|
+
function copyAll(rule) {
|
|
59
|
+
if (rule instanceof CSSStyleRule)
|
|
60
|
+
return `${markSelector(rule.selectorText)}{${rule.style.cssText}${[...rule.cssRules].map(copyAll).join("")}}`;
|
|
61
|
+
if (rule instanceof CSSGroupingRule)
|
|
62
|
+
return copyRule(rule) || rule.cssText;
|
|
63
|
+
return rule.cssText;
|
|
64
|
+
}
|
|
65
|
+
/** A stylesheet's rules, or none if it's another site's (reading them throws). */
|
|
66
|
+
function rulesOf(sheet) {
|
|
67
|
+
try {
|
|
68
|
+
return [...sheet.cssRules];
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Copies the page's hover and press rules for the drawn cursor. `remove()` takes them away again. */
|
|
75
|
+
export function emulateHover() {
|
|
76
|
+
const style = document.createElement("style");
|
|
77
|
+
style.dataset.closeup = "";
|
|
78
|
+
style.textContent = [...document.styleSheets].flatMap(rulesOf).map(copyRule).join("\n");
|
|
79
|
+
document.head.append(style);
|
|
80
|
+
return {
|
|
81
|
+
remove() {
|
|
82
|
+
style.remove();
|
|
83
|
+
for (const element of document.querySelectorAll(`[${HOVER}], [${ACTIVE}]`)) {
|
|
84
|
+
element.removeAttribute(HOVER);
|
|
85
|
+
element.removeAttribute(ACTIVE);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closeup: film any part of a web page in close-up, sharp at any size. Press the shortcut,
|
|
3
|
+
* pick an element or draw a region, choose photo or video, and film it: the page is enlarged
|
|
4
|
+
* round your pick so the browser draws it large, a drawn cursor stands in for yours (or plays a
|
|
5
|
+
* script you draw), and the take can sit on a background with its clicks and the tab's sound.
|
|
6
|
+
*
|
|
7
|
+
* import { install } from "@cesarvenzac/closeup";
|
|
8
|
+
* install(); // ⌥⇧C opens and closes it
|
|
9
|
+
*
|
|
10
|
+
* `addAction` puts a button of your own on the finished take, to keep it somewhere.
|
|
11
|
+
*/
|
|
12
|
+
export { addAction, type Take, type TakeAction } from "./actions.ts";
|
|
13
|
+
export { supportsCapture } from "./capture.ts";
|
|
14
|
+
export { parseScript, type Script, type Step, type Target } from "./script.ts";
|
|
15
|
+
export type { Settings } from "./settings.ts";
|
|
16
|
+
export { loadScript, saveScript } from "./store.ts";
|
|
17
|
+
export declare const DEFAULT_SHORTCUT = "Alt+Shift+C";
|
|
18
|
+
export declare const open: () => void;
|
|
19
|
+
export declare const close: () => void | undefined;
|
|
20
|
+
export declare const toggle: () => void;
|
|
21
|
+
/** Opens and closes Closeup with `shortcut` (⌥⇧C unless told otherwise). Returns what removes it. */
|
|
22
|
+
export declare function install({ shortcut }?: {
|
|
23
|
+
shortcut?: string;
|
|
24
|
+
}): () => void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closeup: film any part of a web page in close-up, sharp at any size. Press the shortcut,
|
|
3
|
+
* pick an element or draw a region, choose photo or video, and film it: the page is enlarged
|
|
4
|
+
* round your pick so the browser draws it large, a drawn cursor stands in for yours (or plays a
|
|
5
|
+
* script you draw), and the take can sit on a background with its clicks and the tab's sound.
|
|
6
|
+
*
|
|
7
|
+
* import { install } from "@cesarvenzac/closeup";
|
|
8
|
+
* install(); // ⌥⇧C opens and closes it
|
|
9
|
+
*
|
|
10
|
+
* `addAction` puts a button of your own on the finished take, to keep it somewhere.
|
|
11
|
+
*/
|
|
12
|
+
import { matches, parseShortcut, shortcutLabel, typing } from "./shortcut.js";
|
|
13
|
+
import { Panel } from "./ui/panel.js";
|
|
14
|
+
export { addAction } from "./actions.js";
|
|
15
|
+
export { supportsCapture } from "./capture.js";
|
|
16
|
+
export { parseScript } from "./script.js";
|
|
17
|
+
export { loadScript, saveScript } from "./store.js";
|
|
18
|
+
export const DEFAULT_SHORTCUT = "Alt+Shift+C";
|
|
19
|
+
let panel = null;
|
|
20
|
+
let label = shortcutLabel(parseShortcut(DEFAULT_SHORTCUT));
|
|
21
|
+
const instance = () => (panel ??= new Panel(label));
|
|
22
|
+
export const open = () => instance().open();
|
|
23
|
+
export const close = () => panel?.close();
|
|
24
|
+
export const toggle = () => instance().toggle();
|
|
25
|
+
/** Opens and closes Closeup with `shortcut` (⌥⇧C unless told otherwise). Returns what removes it. */
|
|
26
|
+
export function install({ shortcut = DEFAULT_SHORTCUT } = {}) {
|
|
27
|
+
const parsed = parseShortcut(shortcut);
|
|
28
|
+
label = shortcutLabel(parsed);
|
|
29
|
+
const onKey = (event) => {
|
|
30
|
+
if (event.repeat || !matches(event, parsed) || typing(event.composedPath()[0] ?? event.target))
|
|
31
|
+
return;
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
toggle();
|
|
34
|
+
};
|
|
35
|
+
addEventListener("keydown", onKey, true);
|
|
36
|
+
return () => removeEventListener("keydown", onKey, true);
|
|
37
|
+
}
|
package/dist/path.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cursor's path as an editor draws one: nodes, each with handles, joined by cubic curves
|
|
3
|
+
* (Figma's way), measured by length so the cursor can travel it at a chosen pace whatever
|
|
4
|
+
* the curves' shape. Pure, for scripted takes: the editor draws these nodes, the player
|
|
5
|
+
* reads positions from them.
|
|
6
|
+
*/
|
|
7
|
+
export type Point = {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
};
|
|
11
|
+
/** A node and its handles, relative to the node (Figma stores them so); none means a sharp corner. */
|
|
12
|
+
export type PathNode = {
|
|
13
|
+
at: Point;
|
|
14
|
+
in?: Point;
|
|
15
|
+
out?: Point;
|
|
16
|
+
};
|
|
17
|
+
type Segment = {
|
|
18
|
+
p0: Point;
|
|
19
|
+
c1: Point;
|
|
20
|
+
c2: Point;
|
|
21
|
+
p3: Point;
|
|
22
|
+
};
|
|
23
|
+
export declare function segments(nodes: PathNode[]): Segment[];
|
|
24
|
+
/** The path laid out as a table of lengths, finely enough to read positions by distance. */
|
|
25
|
+
export type Measured = {
|
|
26
|
+
points: Point[];
|
|
27
|
+
lengths: number[];
|
|
28
|
+
total: number;
|
|
29
|
+
nodeAt: number[];
|
|
30
|
+
};
|
|
31
|
+
export declare function measure(nodes: PathNode[], perSegment?: number): Measured;
|
|
32
|
+
/** Where the path is `distance` along it (clamped to its ends). */
|
|
33
|
+
export declare function pointAt({ points, lengths, total }: Measured, distance: number): Point;
|
|
34
|
+
/**
|
|
35
|
+
* How long a hand takes to cover `distance` to a target `size` across (Fitts's law): a short
|
|
36
|
+
* fixed part, then a little more for each doubling of distance over size. Seconds.
|
|
37
|
+
*/
|
|
38
|
+
export declare function reachTime(distance: number, size?: number): number;
|
|
39
|
+
/** Eases in and out, as a hand does between two points: slow to start, quick, slow to arrive. */
|
|
40
|
+
export declare const easeInOut: (t: number) => number;
|
|
41
|
+
export {};
|
package/dist/path.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cursor's path as an editor draws one: nodes, each with handles, joined by cubic curves
|
|
3
|
+
* (Figma's way), measured by length so the cursor can travel it at a chosen pace whatever
|
|
4
|
+
* the curves' shape. Pure, for scripted takes: the editor draws these nodes, the player
|
|
5
|
+
* reads positions from them.
|
|
6
|
+
*/
|
|
7
|
+
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
|
|
8
|
+
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
|
9
|
+
function cubic({ p0, c1, c2, p3 }, t) {
|
|
10
|
+
const u = 1 - t;
|
|
11
|
+
return {
|
|
12
|
+
x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x,
|
|
13
|
+
y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function segments(nodes) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let k = 0; k + 1 < nodes.length; k++) {
|
|
19
|
+
const a = nodes[k];
|
|
20
|
+
const b = nodes[k + 1];
|
|
21
|
+
out.push({ p0: a.at, c1: a.out ? add(a.at, a.out) : a.at, c2: b.in ? add(b.at, b.in) : b.at, p3: b.at });
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
export function measure(nodes, perSegment = 64) {
|
|
26
|
+
const points = nodes.length ? [nodes[0].at] : [];
|
|
27
|
+
const lengths = nodes.length ? [0] : [];
|
|
28
|
+
const nodeAt = nodes.length ? [0] : [];
|
|
29
|
+
for (const segment of segments(nodes)) {
|
|
30
|
+
for (let k = 1; k <= perSegment; k++) {
|
|
31
|
+
const p = cubic(segment, k / perSegment);
|
|
32
|
+
const last = points[points.length - 1];
|
|
33
|
+
lengths.push(lengths[lengths.length - 1] + Math.hypot(p.x - last.x, p.y - last.y));
|
|
34
|
+
points.push(p);
|
|
35
|
+
}
|
|
36
|
+
nodeAt.push(lengths[lengths.length - 1]);
|
|
37
|
+
}
|
|
38
|
+
return { points, lengths, total: lengths[lengths.length - 1] ?? 0, nodeAt };
|
|
39
|
+
}
|
|
40
|
+
/** Where the path is `distance` along it (clamped to its ends). */
|
|
41
|
+
export function pointAt({ points, lengths, total }, distance) {
|
|
42
|
+
if (!points.length)
|
|
43
|
+
return { x: 0, y: 0 };
|
|
44
|
+
const d = Math.max(0, Math.min(total, distance));
|
|
45
|
+
let lo = 0;
|
|
46
|
+
let hi = lengths.length - 1;
|
|
47
|
+
while (hi - lo > 1) {
|
|
48
|
+
const mid = (lo + hi) >> 1;
|
|
49
|
+
if (lengths[mid] <= d)
|
|
50
|
+
lo = mid;
|
|
51
|
+
else
|
|
52
|
+
hi = mid;
|
|
53
|
+
}
|
|
54
|
+
const span = lengths[hi] - lengths[lo];
|
|
55
|
+
return lerp(points[lo], points[hi], span > 0 ? (d - lengths[lo]) / span : 0);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* How long a hand takes to cover `distance` to a target `size` across (Fitts's law): a short
|
|
59
|
+
* fixed part, then a little more for each doubling of distance over size. Seconds.
|
|
60
|
+
*/
|
|
61
|
+
export function reachTime(distance, size = 24) {
|
|
62
|
+
return 0.18 + 0.11 * Math.log2(distance / Math.max(4, size) + 1);
|
|
63
|
+
}
|
|
64
|
+
/** Eases in and out, as a hand does between two points: slow to start, quick, slow to arrive. */
|
|
65
|
+
export const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|