@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/ui/panel.js
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toolbar and everything round it: pick what to film (an element, or a region drawn by
|
|
3
|
+
* hand), choose photo or video and the options, film it, and look at the result before
|
|
4
|
+
* keeping it. One instance, mounted outside the page's <body> (stage mode moves <body>), its
|
|
5
|
+
* controls in a shadow root no page's styles reach.
|
|
6
|
+
*/
|
|
7
|
+
import { actionsFor } from "../actions.js";
|
|
8
|
+
import { supportsCapture } from "../capture.js";
|
|
9
|
+
import { fillFor, pixelSize, SHAPES, stageFor, toScreen } from "../frame.js";
|
|
10
|
+
import { play } from "../player.js";
|
|
11
|
+
import { Mixer } from "../sounds.js";
|
|
12
|
+
import { enterStage } from "../stage.js";
|
|
13
|
+
import { GRADIENTS, loadSettings, saveSettings } from "../settings.js";
|
|
14
|
+
import { describe } from "../selector.js";
|
|
15
|
+
import { loadScript, saveScript } from "../store.js";
|
|
16
|
+
import { createActor, frameBox, frameOf, pageHit, startVideo, takePhoto, toRect } from "../take.js";
|
|
17
|
+
import { Editor } from "./editor.js";
|
|
18
|
+
import { ICONS, svg } from "./icons.js";
|
|
19
|
+
import { STYLES } from "./styles.js";
|
|
20
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
21
|
+
/** Smaller than this both ways, in CSS pixels, an element is a part (a glyph, a stroke) rather than a thing. */
|
|
22
|
+
const SMALLEST = 48;
|
|
23
|
+
const isSmall = (element) => {
|
|
24
|
+
const { width, height } = element.getBoundingClientRect();
|
|
25
|
+
return width < SMALLEST && height < SMALLEST;
|
|
26
|
+
};
|
|
27
|
+
const stamp = () => new Date().toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
|
|
28
|
+
export class Panel {
|
|
29
|
+
shortcutLabel;
|
|
30
|
+
ui = document.createElement("div");
|
|
31
|
+
layer = document.createElement("div");
|
|
32
|
+
shadow;
|
|
33
|
+
$;
|
|
34
|
+
settings = loadSettings();
|
|
35
|
+
image = null;
|
|
36
|
+
imageName = "";
|
|
37
|
+
state = "idle";
|
|
38
|
+
picked = null;
|
|
39
|
+
hovered = null;
|
|
40
|
+
trail = [];
|
|
41
|
+
dragFrom = null;
|
|
42
|
+
running = null;
|
|
43
|
+
result = null;
|
|
44
|
+
resultUrl = "";
|
|
45
|
+
/** The actions offered on the take on show, in the order their buttons are drawn. */
|
|
46
|
+
takeActions = [];
|
|
47
|
+
timer = 0;
|
|
48
|
+
follow = 0;
|
|
49
|
+
mounted = false;
|
|
50
|
+
optionsOpen = false;
|
|
51
|
+
/** While a script is edited: the editor, the stage it's on, and what puts the page back. */
|
|
52
|
+
editing = null;
|
|
53
|
+
rehearsal = null;
|
|
54
|
+
constructor(shortcutLabel) {
|
|
55
|
+
this.shortcutLabel = shortcutLabel;
|
|
56
|
+
this.shadow = this.ui.attachShadow({ mode: "open" });
|
|
57
|
+
this.$ = (selector) => this.shadow.querySelector(selector);
|
|
58
|
+
const fixed = "position:fixed;inset:0;pointer-events:none;";
|
|
59
|
+
this.ui.setAttribute("style", `${fixed}z-index:2147483647;`);
|
|
60
|
+
this.ui.setAttribute("data-closeup", "");
|
|
61
|
+
this.layer.setAttribute("style", `${fixed}z-index:2147483640;`);
|
|
62
|
+
this.layer.setAttribute("data-closeup", "");
|
|
63
|
+
this.shadow.innerHTML = `
|
|
64
|
+
<style>${STYLES}</style>
|
|
65
|
+
<div class="outline" hidden><div class="tag"></div></div>
|
|
66
|
+
<div class="catcher" hidden></div>
|
|
67
|
+
<div class="surface info" hidden></div>
|
|
68
|
+
<div class="surface options" hidden role="group" aria-label="Options"></div>
|
|
69
|
+
<div class="surface bar" role="toolbar" aria-label="Closeup" hidden>
|
|
70
|
+
<button data-action="close" aria-label="Close" title="Close (Esc)">${ICONS.x}</button>
|
|
71
|
+
<div class="sep"></div>
|
|
72
|
+
<button data-action="pick" data-value="element" aria-label="Pick an element" title="Pick an element">${ICONS.crosshair}</button>
|
|
73
|
+
<button data-action="pick" data-value="region" aria-label="Draw a region" title="Draw a region">${ICONS.square_dashed}</button>
|
|
74
|
+
<div class="sep"></div>
|
|
75
|
+
<button data-action="mode" data-value="photo" aria-label="Photo" title="Photo">${ICONS.camera}</button>
|
|
76
|
+
<button data-action="mode" data-value="video" aria-label="Video" title="Video">${ICONS.video}</button>
|
|
77
|
+
<button data-action="script" aria-label="Script the cursor" title="Script the cursor">${ICONS.spline}</button>
|
|
78
|
+
<div class="sep"></div>
|
|
79
|
+
<button data-action="options" aria-expanded="false"><span>Options</span><span class="chevron">${ICONS.chevron_down}</span></button>
|
|
80
|
+
<button class="go" data-action="go" disabled>Record</button>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="surface hud" hidden><span class="dot" aria-hidden="true"></span><span class="time">0:00</span><button class="stop" data-action="stop">${ICONS.square}Stop</button></div>
|
|
83
|
+
<div class="backdrop" hidden data-action="retake"></div>
|
|
84
|
+
<div class="surface sheet" hidden role="dialog" aria-label="Your take"></div>
|
|
85
|
+
<div class="surface toast" hidden role="status"></div>`;
|
|
86
|
+
this.shadow.addEventListener("click", this.onClick);
|
|
87
|
+
this.shadow.addEventListener("input", this.onInput);
|
|
88
|
+
this.$(".catcher").addEventListener("pointerdown", this.onDragStart);
|
|
89
|
+
}
|
|
90
|
+
get isOpen() {
|
|
91
|
+
return this.mounted;
|
|
92
|
+
}
|
|
93
|
+
open() {
|
|
94
|
+
if (!this.mounted) {
|
|
95
|
+
document.documentElement.append(this.layer, this.ui);
|
|
96
|
+
this.mounted = true;
|
|
97
|
+
addEventListener("keydown", this.onKey, true);
|
|
98
|
+
addEventListener("scroll", this.track, true);
|
|
99
|
+
addEventListener("resize", this.track);
|
|
100
|
+
}
|
|
101
|
+
if (!supportsCapture()) {
|
|
102
|
+
this.toast("Closeup films with Chrome's Region Capture: open this page in Chrome, Edge, Arc or Brave.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.$(".bar").hidden = false;
|
|
106
|
+
this.renderBar();
|
|
107
|
+
if (!this.picked)
|
|
108
|
+
this.startPick();
|
|
109
|
+
else
|
|
110
|
+
this.showPicked();
|
|
111
|
+
}
|
|
112
|
+
close() {
|
|
113
|
+
if (this.state === "recording" || this.state === "busy" || this.state === "rehearsing")
|
|
114
|
+
return;
|
|
115
|
+
if (this.state === "editing") {
|
|
116
|
+
void this.doneEditing().then(() => this.close());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.stopPick();
|
|
120
|
+
this.closePreview();
|
|
121
|
+
this.picked = null;
|
|
122
|
+
this.optionsOpen = false;
|
|
123
|
+
removeEventListener("keydown", this.onKey, true);
|
|
124
|
+
removeEventListener("scroll", this.track, true);
|
|
125
|
+
removeEventListener("resize", this.track);
|
|
126
|
+
cancelAnimationFrame(this.follow);
|
|
127
|
+
this.ui.remove();
|
|
128
|
+
this.layer.remove();
|
|
129
|
+
this.mounted = false;
|
|
130
|
+
this.state = "idle";
|
|
131
|
+
for (const selector of [".bar", ".info", ".options", ".outline", ".hud", ".toast"])
|
|
132
|
+
this.$(selector).hidden = true;
|
|
133
|
+
}
|
|
134
|
+
toggle() {
|
|
135
|
+
if (this.mounted)
|
|
136
|
+
this.close();
|
|
137
|
+
else
|
|
138
|
+
this.open();
|
|
139
|
+
}
|
|
140
|
+
/* ------------------------------------------------------------------ picking */
|
|
141
|
+
startPick() {
|
|
142
|
+
this.stopPick();
|
|
143
|
+
this.state = "picking";
|
|
144
|
+
this.picked = null;
|
|
145
|
+
this.hovered = null;
|
|
146
|
+
this.trail = [];
|
|
147
|
+
const outline = this.$(".outline");
|
|
148
|
+
outline.hidden = true;
|
|
149
|
+
outline.removeAttribute("data-picked");
|
|
150
|
+
if (this.settings.pick === "element") {
|
|
151
|
+
for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "click"])
|
|
152
|
+
addEventListener(type, this.swallow, true);
|
|
153
|
+
addEventListener("pointermove", this.onPickMove, true);
|
|
154
|
+
}
|
|
155
|
+
else
|
|
156
|
+
this.$(".catcher").hidden = false;
|
|
157
|
+
this.renderBar();
|
|
158
|
+
this.renderInfo();
|
|
159
|
+
}
|
|
160
|
+
stopPick() {
|
|
161
|
+
for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "click"])
|
|
162
|
+
removeEventListener(type, this.swallow, true);
|
|
163
|
+
removeEventListener("pointermove", this.onPickMove, true);
|
|
164
|
+
removeEventListener("pointermove", this.onDragMove, true);
|
|
165
|
+
removeEventListener("pointerup", this.onDragEnd, true);
|
|
166
|
+
this.$(".catcher").hidden = true;
|
|
167
|
+
this.dragFrom = null;
|
|
168
|
+
}
|
|
169
|
+
onPickMove = (event) => {
|
|
170
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
171
|
+
if (!element || element === this.ui || element === document.documentElement || element === document.body)
|
|
172
|
+
return;
|
|
173
|
+
if (element === this.trail[0] || element === this.hovered)
|
|
174
|
+
return;
|
|
175
|
+
// A glyph or an icon's stroke is part of what anyone means to film: start from the first
|
|
176
|
+
// ancestor big enough to be a thing, keeping the way back for ↓.
|
|
177
|
+
const trail = [element];
|
|
178
|
+
let picked = element;
|
|
179
|
+
while (picked.parentElement && picked.parentElement !== document.body && isSmall(picked)) {
|
|
180
|
+
picked = picked.parentElement;
|
|
181
|
+
trail.push(picked);
|
|
182
|
+
}
|
|
183
|
+
this.hovered = trail.pop();
|
|
184
|
+
this.trail = trail;
|
|
185
|
+
this.outline(toRect(this.hovered.getBoundingClientRect()), describe(this.hovered));
|
|
186
|
+
this.renderInfo();
|
|
187
|
+
};
|
|
188
|
+
/** While picking, the page doesn't get the clicks: the click that picks shouldn't also press what it picks. */
|
|
189
|
+
swallow = (event) => {
|
|
190
|
+
if (event.target === this.ui)
|
|
191
|
+
return;
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
event.stopImmediatePropagation();
|
|
194
|
+
if (event.type === "click" && this.hovered)
|
|
195
|
+
this.choose({ element: this.hovered, rect: toRect(this.hovered.getBoundingClientRect()), label: describe(this.hovered) });
|
|
196
|
+
};
|
|
197
|
+
onDragStart = (event) => {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
this.dragFrom = { x: event.clientX, y: event.clientY };
|
|
200
|
+
addEventListener("pointermove", this.onDragMove, true);
|
|
201
|
+
addEventListener("pointerup", this.onDragEnd, true);
|
|
202
|
+
};
|
|
203
|
+
dragRect(event) {
|
|
204
|
+
const from = this.dragFrom;
|
|
205
|
+
return { x: Math.min(from.x, event.clientX), y: Math.min(from.y, event.clientY), width: Math.abs(event.clientX - from.x), height: Math.abs(event.clientY - from.y) };
|
|
206
|
+
}
|
|
207
|
+
onDragMove = (event) => {
|
|
208
|
+
if (!this.dragFrom)
|
|
209
|
+
return;
|
|
210
|
+
const rect = this.dragRect(event);
|
|
211
|
+
this.outline(rect, `${Math.round(rect.width)} × ${Math.round(rect.height)}`);
|
|
212
|
+
this.hovered = null;
|
|
213
|
+
this.renderInfo(rect);
|
|
214
|
+
};
|
|
215
|
+
onDragEnd = (event) => {
|
|
216
|
+
if (!this.dragFrom)
|
|
217
|
+
return;
|
|
218
|
+
const rect = this.dragRect(event);
|
|
219
|
+
removeEventListener("pointermove", this.onDragMove, true);
|
|
220
|
+
removeEventListener("pointerup", this.onDragEnd, true);
|
|
221
|
+
this.dragFrom = null;
|
|
222
|
+
if (rect.width < 8 || rect.height < 8)
|
|
223
|
+
return;
|
|
224
|
+
this.choose({ element: null, rect, label: "Region", page: { ...rect, x: rect.x + scrollX, y: rect.y + scrollY } });
|
|
225
|
+
};
|
|
226
|
+
choose(pick) {
|
|
227
|
+
this.stopPick();
|
|
228
|
+
this.picked = pick;
|
|
229
|
+
this.state = "picked";
|
|
230
|
+
this.showPicked();
|
|
231
|
+
}
|
|
232
|
+
showPicked() {
|
|
233
|
+
if (!this.picked)
|
|
234
|
+
return;
|
|
235
|
+
this.state = "picked";
|
|
236
|
+
this.$(".outline").setAttribute("data-picked", "");
|
|
237
|
+
this.track();
|
|
238
|
+
this.renderBar();
|
|
239
|
+
this.renderInfo();
|
|
240
|
+
}
|
|
241
|
+
/** Where the pick is now: an element where it is, a region where it was drawn on the page. */
|
|
242
|
+
pickedRect() {
|
|
243
|
+
const pick = this.picked;
|
|
244
|
+
if (!pick)
|
|
245
|
+
return null;
|
|
246
|
+
if (pick.element?.isConnected)
|
|
247
|
+
return toRect(pick.element.getBoundingClientRect());
|
|
248
|
+
if (pick.page)
|
|
249
|
+
return { ...pick.page, x: pick.page.x - scrollX, y: pick.page.y - scrollY };
|
|
250
|
+
return pick.rect;
|
|
251
|
+
}
|
|
252
|
+
track = () => {
|
|
253
|
+
cancelAnimationFrame(this.follow);
|
|
254
|
+
this.follow = requestAnimationFrame(() => {
|
|
255
|
+
if (this.state !== "picked")
|
|
256
|
+
return;
|
|
257
|
+
const rect = this.pickedRect();
|
|
258
|
+
if (rect)
|
|
259
|
+
this.outline(rect, this.picked.label);
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
outline(rect, label) {
|
|
263
|
+
const outline = this.$(".outline");
|
|
264
|
+
Object.assign(outline.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` });
|
|
265
|
+
this.$(".tag").textContent = `${label} · ${Math.round(rect.width)}×${Math.round(rect.height)}`;
|
|
266
|
+
outline.hidden = false;
|
|
267
|
+
}
|
|
268
|
+
/* ------------------------------------------------------------------ rendering */
|
|
269
|
+
renderBar() {
|
|
270
|
+
const s = this.settings;
|
|
271
|
+
this.shadow.querySelectorAll("[data-action='pick']").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.value === s.pick && this.state === "picking")));
|
|
272
|
+
this.shadow.querySelectorAll("[data-action='mode']").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.value === s.mode)));
|
|
273
|
+
const go = this.$(".go");
|
|
274
|
+
go.textContent = s.mode === "photo" ? "Capture" : "Record";
|
|
275
|
+
go.disabled = !this.picked;
|
|
276
|
+
this.$("[data-action='options']").setAttribute("aria-expanded", String(this.optionsOpen));
|
|
277
|
+
const script = this.$("[data-action='script']");
|
|
278
|
+
script.hidden = s.mode !== "video";
|
|
279
|
+
script.disabled = !this.picked;
|
|
280
|
+
script.setAttribute("aria-pressed", String(s.moves === "script" && !!loadScript()));
|
|
281
|
+
}
|
|
282
|
+
/** What's outlined and what it would make: the take's size in pixels and how much it's enlarged. */
|
|
283
|
+
renderInfo(drawing) {
|
|
284
|
+
const info = this.$(".info");
|
|
285
|
+
const rect = drawing ?? (this.state === "picking" ? (this.hovered ? toRect(this.hovered.getBoundingClientRect()) : null) : this.pickedRect());
|
|
286
|
+
if (this.optionsOpen || (!rect && this.state !== "picking")) {
|
|
287
|
+
info.hidden = true;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!rect) {
|
|
291
|
+
const hint = this.settings.pick === "element" ? "Point at what to film and click" : "Drag across what to film";
|
|
292
|
+
info.innerHTML = `<span class="what">${hint}</span><span class="keys"><kbd>Esc</kbd> to close</span>`;
|
|
293
|
+
info.hidden = false;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const s = this.settings;
|
|
297
|
+
const element = drawing ? null : this.state === "picking" ? this.hovered : (this.picked?.element ?? null);
|
|
298
|
+
const frame = frameOf(rect, element, s);
|
|
299
|
+
const viewport = { width: innerWidth, height: innerHeight };
|
|
300
|
+
const stage = stageFor(frame, viewport, fillFor(viewport));
|
|
301
|
+
const size = pixelSize(stage.screen, devicePixelRatio);
|
|
302
|
+
const warn = stage.scale < 1 ? `<span class="warn">Bigger than the window: it'll be shrunk</span>` : stage.scale < 1.6 ? `<span class="warn">Pick tighter for sharper detail</span>` : "";
|
|
303
|
+
const keys = this.state === "picking" && s.pick === "element"
|
|
304
|
+
? `<span class="keys"><kbd>↑</kbd> wider <kbd>↓</kbd> narrower</span>`
|
|
305
|
+
: this.state === "picked"
|
|
306
|
+
? `<span class="keys"><kbd>Enter</kbd> to ${s.mode === "photo" ? "capture" : "record"}</span>`
|
|
307
|
+
: "";
|
|
308
|
+
info.innerHTML = `<span class="size">${size.width} × ${size.height}</span><span class="label">×${stage.scale.toFixed(stage.scale < 10 ? 1 : 0)}</span>${warn}${keys}`;
|
|
309
|
+
info.hidden = false;
|
|
310
|
+
}
|
|
311
|
+
renderOptions() {
|
|
312
|
+
const panel = this.$(".options");
|
|
313
|
+
panel.hidden = !this.optionsOpen;
|
|
314
|
+
if (!this.optionsOpen)
|
|
315
|
+
return;
|
|
316
|
+
const s = this.settings;
|
|
317
|
+
const choice = (setting, value, label) => `<button data-action="set" data-setting="${setting}" data-value="${value}" aria-pressed="${String(s[setting]) === value}">${label}</button>`;
|
|
318
|
+
const toggle = (setting, label) => `<button class="switch" role="switch" aria-checked="${s[setting]}" data-action="toggle" data-setting="${setting}">${label}</button>`;
|
|
319
|
+
const slider = (setting, label, min, max, step, show) => `<div class="slider"><input type="range" min="${min}" max="${max}" step="${step}" value="${s[setting]}" data-setting="${setting}" aria-label="${label}"><output data-for="${setting}">${show(s[setting])}</output></div>`;
|
|
320
|
+
const percent = (v) => `${Math.round(v * 100)}%`;
|
|
321
|
+
const pixels = (v) => `${Math.round(v)}`;
|
|
322
|
+
const row = (name, body, sub = false) => `<div class="row${sub ? " sub" : ""}"><span class="name">${name}</span>${body}</div>`;
|
|
323
|
+
const shapes = Object.keys(SHAPES).map((shape) => choice("shape", shape, shape === "free" ? "Free" : shape)).join("");
|
|
324
|
+
const backgrounds = [
|
|
325
|
+
["none", "None"],
|
|
326
|
+
["color", "Colour"],
|
|
327
|
+
["gradient", "Gradient"],
|
|
328
|
+
["image", "Image"],
|
|
329
|
+
]
|
|
330
|
+
.map(([value, label]) => choice("background", value, label))
|
|
331
|
+
.join("");
|
|
332
|
+
const swatches = GRADIENTS.map((g, k) => `<button class="swatch" data-action="set" data-setting="gradient" data-value="${k}" aria-pressed="${s.gradient === k}" aria-label="${g.name}" title="${g.name}" style="background:linear-gradient(${g.angle}deg, ${g.from}, ${g.to})"></button>`).join("");
|
|
333
|
+
panel.innerHTML = [
|
|
334
|
+
row("Shape", `<div class="choices">${shapes}</div>`),
|
|
335
|
+
// A drawn region is filmed as drawn: room is for elements.
|
|
336
|
+
s.pick === "element" ? row("Room", slider("padding", "Room round the pick", 0, 0.8, 0.01, percent)) : "",
|
|
337
|
+
row("Background", `<div class="choices">${backgrounds}</div>`),
|
|
338
|
+
s.background === "color" ? row("Colour", `<input type="color" value="${s.color}" data-setting="color" aria-label="Background colour">`, true) : "",
|
|
339
|
+
s.background === "gradient" ? row("Gradient", `<div class="swatches">${swatches}</div>`, true) : "",
|
|
340
|
+
s.background === "image" ? row("Image", `<div class="choices"><button data-action="choose-image">${ICONS.image}${this.imageName ? escapeHtml(this.imageName.slice(0, 24)) : "Choose…"}</button></div>`, true) : "",
|
|
341
|
+
s.background !== "none" ? row("Inset", slider("inset", "Room round the page", 0, 0.2, 0.005, percent), true) : "",
|
|
342
|
+
s.background !== "none" ? row("Corners", slider("radius", "Corner radius", 0, 60, 1, pixels), true) : "",
|
|
343
|
+
s.background !== "none" ? row("Shadow", toggle("shadow", "Shadow"), true) : "",
|
|
344
|
+
row("Cursor", `<div class="choices">${toggle("cursor", "Drawn cursor")}${toggle("ring", "Click ring")}</div>`),
|
|
345
|
+
s.mode === "video" ? row("Moves", `<div class="choices">${choice("moves", "yours", "Yours")}${choice("moves", "script", "Script")}</div>`, true) : "",
|
|
346
|
+
s.mode === "video" && s.moves === "script" && !loadScript() ? row("", `<span class="note">No script on this page yet</span>`, true) : "",
|
|
347
|
+
s.cursor || (s.mode === "video" && s.moves === "script") ? row("Size", slider("cursorSize", "Cursor size", 16, 96, 1, pixels), true) : "",
|
|
348
|
+
s.mode === "video" ? row("Sound", `<div class="choices">${toggle("clicks", "Clicks")}${toggle("tabAudio", "Tab audio")}</div>`) : "",
|
|
349
|
+
s.mode === "video" ? row("Quality", `<div class="choices">${choice("quality", "standard", "Standard")}${choice("quality", "high", "High")}</div>`) : "",
|
|
350
|
+
`<p class="hint"><kbd>${this.shortcutLabel}</kbd> opens and closes · <kbd>Esc</kbd> stops a take</p>`,
|
|
351
|
+
].join("");
|
|
352
|
+
}
|
|
353
|
+
/* ------------------------------------------------------------------ events */
|
|
354
|
+
onClick = async (event) => {
|
|
355
|
+
const button = event.target.closest("[data-action]");
|
|
356
|
+
if (!button)
|
|
357
|
+
return;
|
|
358
|
+
const { action, value, setting } = button.dataset;
|
|
359
|
+
switch (action) {
|
|
360
|
+
case "close":
|
|
361
|
+
return this.close();
|
|
362
|
+
case "pick":
|
|
363
|
+
this.settings.pick = value;
|
|
364
|
+
this.save();
|
|
365
|
+
this.optionsOpen = false;
|
|
366
|
+
this.renderOptions();
|
|
367
|
+
return this.startPick();
|
|
368
|
+
case "mode":
|
|
369
|
+
this.settings.mode = value;
|
|
370
|
+
this.save();
|
|
371
|
+
this.renderBar();
|
|
372
|
+
this.renderOptions();
|
|
373
|
+
return this.renderInfo();
|
|
374
|
+
case "options":
|
|
375
|
+
this.optionsOpen = !this.optionsOpen;
|
|
376
|
+
this.renderOptions();
|
|
377
|
+
this.renderBar();
|
|
378
|
+
return this.renderInfo();
|
|
379
|
+
case "set": {
|
|
380
|
+
const key = setting;
|
|
381
|
+
this.settings[key] = key === "gradient" ? Number(value) : value;
|
|
382
|
+
this.save();
|
|
383
|
+
this.renderOptions();
|
|
384
|
+
this.renderBar();
|
|
385
|
+
return this.renderInfo();
|
|
386
|
+
}
|
|
387
|
+
case "toggle": {
|
|
388
|
+
const key = setting;
|
|
389
|
+
this.settings[key] = !this.settings[key];
|
|
390
|
+
this.save();
|
|
391
|
+
return this.renderOptions();
|
|
392
|
+
}
|
|
393
|
+
case "choose-image":
|
|
394
|
+
return this.chooseImage();
|
|
395
|
+
case "script":
|
|
396
|
+
return this.editScript();
|
|
397
|
+
case "go":
|
|
398
|
+
return this.go();
|
|
399
|
+
case "stop":
|
|
400
|
+
return this.stop();
|
|
401
|
+
case "retake":
|
|
402
|
+
return this.closePreview();
|
|
403
|
+
case "download":
|
|
404
|
+
return this.download();
|
|
405
|
+
case "copy":
|
|
406
|
+
return this.copy();
|
|
407
|
+
case "take-action":
|
|
408
|
+
return this.runAction(Number(button.dataset.index), button);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
onInput = (event) => {
|
|
412
|
+
const input = event.target;
|
|
413
|
+
const key = input.dataset.setting;
|
|
414
|
+
if (!key)
|
|
415
|
+
return;
|
|
416
|
+
const value = input.type === "range" ? Number(input.value) : input.value;
|
|
417
|
+
this.settings[key] = value;
|
|
418
|
+
this.save();
|
|
419
|
+
const output = this.shadow.querySelector(`output[data-for="${key}"]`);
|
|
420
|
+
if (output && typeof value === "number")
|
|
421
|
+
output.textContent = key === "radius" || key === "cursorSize" ? `${Math.round(value)}` : `${Math.round(value * 100)}%`;
|
|
422
|
+
};
|
|
423
|
+
onKey = (event) => {
|
|
424
|
+
if (this.state === "editing" || this.state === "rehearsing") {
|
|
425
|
+
const used = this.state === "rehearsing" ? event.key === "Escape" && (this.rehearsal?.abort(), true) : this.editing?.editor.onKey(event);
|
|
426
|
+
if (used) {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
event.stopImmediatePropagation();
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const inField = event.composedPath()[0] instanceof HTMLInputElement;
|
|
433
|
+
if (event.key === "Escape") {
|
|
434
|
+
event.preventDefault();
|
|
435
|
+
event.stopImmediatePropagation();
|
|
436
|
+
if (this.state === "preview")
|
|
437
|
+
this.closePreview();
|
|
438
|
+
else if (this.state === "recording")
|
|
439
|
+
void this.stop();
|
|
440
|
+
else if (this.optionsOpen) {
|
|
441
|
+
this.optionsOpen = false;
|
|
442
|
+
this.renderOptions();
|
|
443
|
+
this.renderBar();
|
|
444
|
+
this.renderInfo();
|
|
445
|
+
}
|
|
446
|
+
else if (this.state === "picking" && this.dragFrom)
|
|
447
|
+
this.startPick();
|
|
448
|
+
else
|
|
449
|
+
this.close();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (event.key === "Enter" && !inField && this.state === "picked" && !this.optionsOpen) {
|
|
453
|
+
event.preventDefault();
|
|
454
|
+
void this.go();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (this.state === "picking" && this.hovered && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
|
|
458
|
+
event.preventDefault();
|
|
459
|
+
if (event.key === "ArrowUp" && this.hovered.parentElement && this.hovered.parentElement !== document.body && this.hovered.parentElement !== document.documentElement) {
|
|
460
|
+
this.trail.push(this.hovered);
|
|
461
|
+
this.hovered = this.hovered.parentElement;
|
|
462
|
+
}
|
|
463
|
+
else if (event.key === "ArrowDown" && this.trail.length)
|
|
464
|
+
this.hovered = this.trail.pop();
|
|
465
|
+
this.outline(toRect(this.hovered.getBoundingClientRect()), describe(this.hovered));
|
|
466
|
+
this.renderInfo();
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
/* ------------------------------------------------------------------ taking */
|
|
470
|
+
get layers() {
|
|
471
|
+
return { host: this.layer, ignore: (target) => target === this.ui };
|
|
472
|
+
}
|
|
473
|
+
hideControls() {
|
|
474
|
+
for (const selector of [".bar", ".info", ".options", ".outline"])
|
|
475
|
+
this.$(selector).hidden = true;
|
|
476
|
+
}
|
|
477
|
+
showControls() {
|
|
478
|
+
this.$(".bar").hidden = false;
|
|
479
|
+
this.renderBar();
|
|
480
|
+
this.renderOptions();
|
|
481
|
+
if (this.picked)
|
|
482
|
+
this.showPicked();
|
|
483
|
+
else
|
|
484
|
+
this.startPick();
|
|
485
|
+
}
|
|
486
|
+
async go() {
|
|
487
|
+
if (!this.picked || this.state === "busy" || this.state === "recording")
|
|
488
|
+
return;
|
|
489
|
+
const scripted = this.settings.mode === "video" && this.settings.moves === "script";
|
|
490
|
+
const script = scripted ? loadScript() : null;
|
|
491
|
+
if (scripted && !script)
|
|
492
|
+
return this.toast("No script on this page yet.");
|
|
493
|
+
this.state = "busy";
|
|
494
|
+
this.optionsOpen = false;
|
|
495
|
+
this.hideControls();
|
|
496
|
+
try {
|
|
497
|
+
if (this.settings.mode === "photo") {
|
|
498
|
+
this.showResult(await takePhoto(this.picked, this.settings, this.layers, this.image));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const running = await startVideo(this.picked, this.settings, this.layers, this.image, script);
|
|
502
|
+
this.running = running;
|
|
503
|
+
// A script ends the take itself, unless it was stopped first.
|
|
504
|
+
void running.finished?.then(() => {
|
|
505
|
+
if (this.running === running)
|
|
506
|
+
void this.stop();
|
|
507
|
+
});
|
|
508
|
+
this.state = "recording";
|
|
509
|
+
const hud = this.$(".hud");
|
|
510
|
+
hud.style.top = `${Math.max(10, this.running.screen.y - 50)}px`;
|
|
511
|
+
hud.hidden = false;
|
|
512
|
+
const time = this.$(".time");
|
|
513
|
+
const started = this.running.started;
|
|
514
|
+
const show = () => {
|
|
515
|
+
const seconds = Math.floor((performance.now() - started) / 1000);
|
|
516
|
+
time.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
517
|
+
};
|
|
518
|
+
show();
|
|
519
|
+
this.timer = window.setInterval(show, 250);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
this.state = "picked";
|
|
523
|
+
const name = error.name;
|
|
524
|
+
this.toast(name === "NotAllowedError" || name === "AbortError" ? "Sharing was cancelled." : `Couldn't film: ${error.message}`);
|
|
525
|
+
this.showControls();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/* ------------------------------------------------------------------ scripts */
|
|
529
|
+
/** Opens the script editor on the stage, at the size the take is filmed. */
|
|
530
|
+
async editScript() {
|
|
531
|
+
if (!this.picked || this.state !== "picked")
|
|
532
|
+
return;
|
|
533
|
+
const rect = this.pickedRect();
|
|
534
|
+
if (!rect)
|
|
535
|
+
return;
|
|
536
|
+
this.state = "editing";
|
|
537
|
+
this.optionsOpen = false;
|
|
538
|
+
this.hideControls();
|
|
539
|
+
const viewport = { width: innerWidth, height: innerHeight };
|
|
540
|
+
const stage = stageFor(frameOf(rect, this.picked.element, this.settings), viewport, fillFor(viewport), { x: scrollX, y: scrollY });
|
|
541
|
+
const leave = await enterStage(stage);
|
|
542
|
+
const box = frameBox(stage.screen, this.layer);
|
|
543
|
+
const editor = new Editor({
|
|
544
|
+
root: this.shadow,
|
|
545
|
+
stage,
|
|
546
|
+
hit: pageHit(this.layers),
|
|
547
|
+
script: loadScript(),
|
|
548
|
+
onChange: (script) => saveScript(script),
|
|
549
|
+
onPreview: (script) => void this.rehearse(script),
|
|
550
|
+
onDone: () => void this.doneEditing(),
|
|
551
|
+
});
|
|
552
|
+
this.editing = { editor, stage, box, leave };
|
|
553
|
+
}
|
|
554
|
+
/** Leaves the editor: the page back as it was, and the script set to play if there is one. */
|
|
555
|
+
async doneEditing() {
|
|
556
|
+
const editing = this.editing;
|
|
557
|
+
if (!editing || this.state !== "editing")
|
|
558
|
+
return;
|
|
559
|
+
this.editing = null;
|
|
560
|
+
this.state = "busy";
|
|
561
|
+
editing.editor.destroy();
|
|
562
|
+
editing.box.remove();
|
|
563
|
+
await editing.leave();
|
|
564
|
+
if (loadScript()?.steps.length) {
|
|
565
|
+
this.settings.moves = "script";
|
|
566
|
+
this.save();
|
|
567
|
+
}
|
|
568
|
+
this.state = "picked";
|
|
569
|
+
this.showControls();
|
|
570
|
+
}
|
|
571
|
+
/** Plays the script on the stage without filming it, to see it before recording. */
|
|
572
|
+
async rehearse(script) {
|
|
573
|
+
const editing = this.editing;
|
|
574
|
+
if (!editing || this.state !== "editing")
|
|
575
|
+
return;
|
|
576
|
+
this.state = "rehearsing";
|
|
577
|
+
editing.editor.hidden = true;
|
|
578
|
+
// Made in the click that asked for it: browsers start sound only from one.
|
|
579
|
+
const mixer = this.settings.clicks ? new Mixer() : null;
|
|
580
|
+
const actor = createActor(this.layers, { size: this.settings.cursorSize, onPress: mixer ? (down) => mixer.click(down) : undefined, ring: this.settings.ring });
|
|
581
|
+
const rehearsal = new AbortController();
|
|
582
|
+
this.rehearsal = rehearsal;
|
|
583
|
+
const hud = this.$(".hud");
|
|
584
|
+
hud.style.top = `${Math.max(10, editing.stage.screen.y - 50)}px`;
|
|
585
|
+
this.$(".time").textContent = "Preview";
|
|
586
|
+
// Not filming: no red dot.
|
|
587
|
+
this.$(".dot").hidden = true;
|
|
588
|
+
hud.hidden = false;
|
|
589
|
+
try {
|
|
590
|
+
await play(script, {
|
|
591
|
+
...actor,
|
|
592
|
+
toScreen: (p) => toScreen(editing.stage, { x: scrollX, y: scrollY }, p),
|
|
593
|
+
scale: editing.stage.scale,
|
|
594
|
+
onKey: mixer ? () => mixer.key() : undefined,
|
|
595
|
+
signal: rehearsal.signal,
|
|
596
|
+
lead: 0.3,
|
|
597
|
+
});
|
|
598
|
+
if (!rehearsal.signal.aborted)
|
|
599
|
+
await new Promise((done) => setTimeout(done, 500));
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
actor.dispose();
|
|
603
|
+
mixer?.close();
|
|
604
|
+
hud.hidden = true;
|
|
605
|
+
this.$(".dot").hidden = false;
|
|
606
|
+
this.rehearsal = null;
|
|
607
|
+
this.state = "editing";
|
|
608
|
+
editing.editor.hidden = false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async stop() {
|
|
612
|
+
if (this.state === "rehearsing")
|
|
613
|
+
return this.rehearsal?.abort();
|
|
614
|
+
if (!this.running)
|
|
615
|
+
return;
|
|
616
|
+
clearInterval(this.timer);
|
|
617
|
+
this.$(".hud").hidden = true;
|
|
618
|
+
const running = this.running;
|
|
619
|
+
this.running = null;
|
|
620
|
+
this.state = "busy";
|
|
621
|
+
this.showResult(await running.stop());
|
|
622
|
+
}
|
|
623
|
+
showResult(result) {
|
|
624
|
+
this.result = result;
|
|
625
|
+
this.state = "preview";
|
|
626
|
+
URL.revokeObjectURL(this.resultUrl);
|
|
627
|
+
this.resultUrl = URL.createObjectURL(result.blob);
|
|
628
|
+
const media = result.kind === "photo"
|
|
629
|
+
? `<img src="${this.resultUrl}" alt="The photo">`
|
|
630
|
+
: `<video src="${this.resultUrl}" autoplay loop muted playsinline controls></video>`;
|
|
631
|
+
const ext = result.type.includes("mp4") ? "MP4" : result.type.includes("png") ? "PNG" : "WebM";
|
|
632
|
+
const parts = [`${result.width} × ${result.height}`, result.seconds ? `${result.seconds.toFixed(1)} s` : "", `${(result.blob.size / 1e6).toFixed(1)} MB`, ext].filter(Boolean);
|
|
633
|
+
this.takeActions = actionsFor(result);
|
|
634
|
+
const actions = this.takeActions
|
|
635
|
+
.map((action, index) => `<button data-action="take-action" data-index="${index}">${action.icon ? svg(action.icon) : ""}<span>${escapeHtml(action.label)}</span></button>`)
|
|
636
|
+
.join("");
|
|
637
|
+
const sheet = this.$(".sheet");
|
|
638
|
+
sheet.innerHTML = `
|
|
639
|
+
<div class="media">${media}</div>
|
|
640
|
+
<div class="footer">
|
|
641
|
+
<span class="meta">${parts.join(" · ")}</span>
|
|
642
|
+
<button data-action="retake">${ICONS.rotate_ccw}Retake</button>
|
|
643
|
+
${result.kind === "photo" ? `<button data-action="copy">${ICONS.copy}Copy</button>` : ""}
|
|
644
|
+
${actions}
|
|
645
|
+
<button class="go" data-action="download">${ICONS.download}Download</button>
|
|
646
|
+
</div>`;
|
|
647
|
+
this.$(".backdrop").hidden = false;
|
|
648
|
+
sheet.hidden = false;
|
|
649
|
+
sheet.querySelector(".go")?.focus();
|
|
650
|
+
}
|
|
651
|
+
closePreview() {
|
|
652
|
+
if (this.state !== "preview")
|
|
653
|
+
return;
|
|
654
|
+
this.$(".sheet").hidden = true;
|
|
655
|
+
this.$(".backdrop").hidden = true;
|
|
656
|
+
this.$(".sheet").innerHTML = "";
|
|
657
|
+
URL.revokeObjectURL(this.resultUrl);
|
|
658
|
+
this.resultUrl = "";
|
|
659
|
+
this.result = null;
|
|
660
|
+
this.state = "picked";
|
|
661
|
+
this.showControls();
|
|
662
|
+
}
|
|
663
|
+
download() {
|
|
664
|
+
if (!this.result)
|
|
665
|
+
return;
|
|
666
|
+
const ext = this.result.type.includes("mp4") ? "mp4" : this.result.type.includes("png") ? "png" : "webm";
|
|
667
|
+
const link = document.createElement("a");
|
|
668
|
+
link.href = this.resultUrl;
|
|
669
|
+
link.download = `closeup-${stamp()}.${ext}`;
|
|
670
|
+
link.click();
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Runs one of the page's own actions on the take: its button counts up while it works, then
|
|
674
|
+
* shows it's done (once is enough), and what it said, or why it failed, is said in a toast.
|
|
675
|
+
*/
|
|
676
|
+
async runAction(index, button) {
|
|
677
|
+
const action = this.takeActions[index];
|
|
678
|
+
const result = this.result;
|
|
679
|
+
if (!action || !result || button.disabled)
|
|
680
|
+
return;
|
|
681
|
+
button.disabled = true;
|
|
682
|
+
button.setAttribute("aria-busy", "true");
|
|
683
|
+
const label = button.querySelector("span");
|
|
684
|
+
const progress = (done) => {
|
|
685
|
+
if (label)
|
|
686
|
+
label.textContent = `${action.label} ${Math.round(Math.min(1, Math.max(0, done)) * 100)}%`;
|
|
687
|
+
};
|
|
688
|
+
try {
|
|
689
|
+
const said = await action.run(result, { progress });
|
|
690
|
+
button.innerHTML = `${ICONS.check}<span>${escapeHtml(action.label)}</span>`;
|
|
691
|
+
this.toast(said || "Saved.");
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (label)
|
|
695
|
+
label.textContent = action.label;
|
|
696
|
+
button.disabled = false;
|
|
697
|
+
this.toast(error.message || "That didn't work.");
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
button.removeAttribute("aria-busy");
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async copy() {
|
|
704
|
+
if (this.result?.kind !== "photo")
|
|
705
|
+
return;
|
|
706
|
+
try {
|
|
707
|
+
await navigator.clipboard.write([new ClipboardItem({ "image/png": this.result.blob })]);
|
|
708
|
+
this.toast("Copied.");
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
this.toast("Couldn't copy: download it instead.");
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
chooseImage() {
|
|
715
|
+
const input = document.createElement("input");
|
|
716
|
+
input.type = "file";
|
|
717
|
+
input.accept = "image/*";
|
|
718
|
+
input.addEventListener("change", async () => {
|
|
719
|
+
const file = input.files?.[0];
|
|
720
|
+
if (!file)
|
|
721
|
+
return;
|
|
722
|
+
this.image?.close();
|
|
723
|
+
this.image = await createImageBitmap(file);
|
|
724
|
+
this.imageName = file.name;
|
|
725
|
+
this.renderOptions();
|
|
726
|
+
});
|
|
727
|
+
input.click();
|
|
728
|
+
}
|
|
729
|
+
save() {
|
|
730
|
+
saveSettings(this.settings);
|
|
731
|
+
}
|
|
732
|
+
toast(message) {
|
|
733
|
+
const toast = this.$(".toast");
|
|
734
|
+
toast.textContent = message;
|
|
735
|
+
toast.hidden = false;
|
|
736
|
+
clearTimeout(Number(toast.dataset.timer));
|
|
737
|
+
toast.dataset.timer = String(setTimeout(() => (toast.hidden = true), 3200));
|
|
738
|
+
}
|
|
739
|
+
}
|