@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
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Plays a script: the drawn cursor travels each curve at a hand's pace, then clicks, types or
3
+ * just rests, and the page is told about every move and press as if a mouse made them
4
+ * (`pointer.ts`). Each target is found when its move begins, on its element if that's still
5
+ * there, so a scene survives a layout that shifted since it was drawn.
6
+ */
7
+ import type { DrawnCursor } from "./cursor.ts";
8
+ import { type PathNode, type Point } from "./path.ts";
9
+ import type { SyntheticPointer } from "./pointer.ts";
10
+ import { type Script, type Target } from "./script.ts";
11
+ /** A target found on screen, and the size of what it's on (for the pace of reaching it). */
12
+ export type Place = Point & {
13
+ size: number;
14
+ };
15
+ export type PlayOptions = {
16
+ cursor: DrawnCursor;
17
+ pointer: SyntheticPointer;
18
+ /** Where a point of the page (as laid out) is drawn on screen now. */
19
+ toScreen: (p: Point) => Point;
20
+ /** How much the page is enlarged: handles are stored in page pixels. */
21
+ scale: number;
22
+ /** Each character typed, for its sound. */
23
+ onKey?: () => void;
24
+ /** Each step as it begins, for an editor's timeline. */
25
+ onStep?: (index: number) => void;
26
+ signal?: AbortSignal;
27
+ /** Seconds to wait where it starts before the first move. */
28
+ lead?: number;
29
+ };
30
+ /** Where `target` is on screen now: on its element if that's still in the page and drawn, else at its page point. */
31
+ export declare function resolve(target: Target, toScreen: PlayOptions["toScreen"], scale: number): Place;
32
+ export declare function play(script: Script, options: PlayOptions): Promise<void>;
33
+ /**
34
+ * A move's curve on screen: the handles given (stored in page pixels, so enlarged by `scale`),
35
+ * else a gentle arc. The editor draws the same curve the player travels.
36
+ */
37
+ export declare function curve(from: Point, to: Point, out: Point | undefined, into: Point | undefined, scale: number): PathNode[];
38
+ /** About how long a script plays, as it would from here: the lead, every move at its pace, the actions, the rests, the tail. */
39
+ export declare function estimate(script: Script, place: (target: Target) => Place, scale: number): number;
package/dist/player.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Plays a script: the drawn cursor travels each curve at a hand's pace, then clicks, types or
3
+ * just rests, and the page is told about every move and press as if a mouse made them
4
+ * (`pointer.ts`). Each target is found when its move begins, on its element if that's still
5
+ * there, so a scene survives a layout that shifted since it was drawn.
6
+ */
7
+ import { measure, pointAt } from "./path.js";
8
+ import { actionDuration, autoHandles, LEAD, minimumJerk, moveDuration, PRESS, TAIL, typingDelays } from "./script.js";
9
+ /** Where `target` is on screen now: on its element if that's still in the page and drawn, else at its page point. */
10
+ export function resolve(target, toScreen, scale) {
11
+ if (target.on) {
12
+ let element = null;
13
+ try {
14
+ element = document.querySelector(target.on.selector);
15
+ }
16
+ catch {
17
+ // A selector this browser can't read: fall back to the point.
18
+ }
19
+ const box = element?.getBoundingClientRect();
20
+ if (box && (box.width || box.height)) {
21
+ return { x: box.left + target.on.u * box.width, y: box.top + target.on.v * box.height, size: Math.max(4, Math.min(box.width, box.height)) };
22
+ }
23
+ }
24
+ return { ...toScreen(target), size: 24 * scale };
25
+ }
26
+ const rest = (seconds, signal) => new Promise((done) => {
27
+ if (signal?.aborted || seconds <= 0)
28
+ return done();
29
+ const timer = setTimeout(done, seconds * 1000);
30
+ signal?.addEventListener("abort", () => (clearTimeout(timer), done()), { once: true });
31
+ });
32
+ /** Calls `frame` with 0→1 over `seconds`, once per animation frame, ending on 1 exactly. */
33
+ const animate = (seconds, frame, signal) => new Promise((done) => {
34
+ if (seconds <= 0)
35
+ return (frame(1), done());
36
+ const start = performance.now();
37
+ const tick = (now) => {
38
+ if (signal?.aborted)
39
+ return done();
40
+ const t = Math.min(1, (now - start) / (seconds * 1000));
41
+ frame(t);
42
+ if (t < 1)
43
+ requestAnimationFrame(tick);
44
+ else
45
+ done();
46
+ };
47
+ requestAnimationFrame(tick);
48
+ });
49
+ export async function play(script, options) {
50
+ const { cursor, pointer, signal, scale } = options;
51
+ const go = (p) => {
52
+ cursor.place(p.x, p.y);
53
+ pointer.moveTo(p.x, p.y);
54
+ };
55
+ let at = resolve(script.start, options.toScreen, scale);
56
+ let out = script.out;
57
+ go(at);
58
+ await rest(options.lead ?? LEAD, signal);
59
+ for (const [k, step] of script.steps.entries()) {
60
+ if (signal?.aborted)
61
+ return;
62
+ options.onStep?.(k);
63
+ const to = resolve(step.to, options.toScreen, scale);
64
+ await travel(at, to, out, step, go, options);
65
+ if (signal?.aborted)
66
+ return;
67
+ if (step.action !== "move")
68
+ await rest(step.hover ?? 0, signal);
69
+ if (step.action === "click" || step.action === "type")
70
+ await press(options);
71
+ if (step.action === "type")
72
+ await type(step.text ?? "", options);
73
+ await rest(step.wait, signal);
74
+ at = to;
75
+ out = step.out;
76
+ }
77
+ }
78
+ /**
79
+ * A move's curve on screen: the handles given (stored in page pixels, so enlarged by `scale`),
80
+ * else a gentle arc. The editor draws the same curve the player travels.
81
+ */
82
+ export function curve(from, to, out, into, scale) {
83
+ const arc = autoHandles(from, to);
84
+ const enlarge = (p) => ({ x: p.x * scale, y: p.y * scale });
85
+ return [
86
+ { at: from, out: out ? enlarge(out) : arc.out },
87
+ { at: to, in: into ? enlarge(into) : arc.in },
88
+ ];
89
+ }
90
+ /** About how long a script plays, as it would from here: the lead, every move at its pace, the actions, the rests, the tail. */
91
+ export function estimate(script, place, scale) {
92
+ let total = LEAD + TAIL;
93
+ let at = place(script.start);
94
+ let out = script.out;
95
+ for (const step of script.steps) {
96
+ const to = place(step.to);
97
+ total += moveDuration(step, measure(curve(at, to, out, step.in, scale)).total, to.size) + actionDuration(step) + step.wait;
98
+ at = to;
99
+ out = step.out;
100
+ }
101
+ return total;
102
+ }
103
+ /** One move, along its curve, at a hand's pace. */
104
+ function travel(from, to, out, step, go, { scale, signal }) {
105
+ const path = measure(curve(from, to, out, step.in, scale));
106
+ return animate(moveDuration(step, path.total, to.size), (t) => go(pointAt(path, minimumJerk(t) * path.total)), signal);
107
+ }
108
+ async function press({ cursor, pointer, signal }) {
109
+ pointer.down();
110
+ cursor.press(true);
111
+ await rest(PRESS, signal);
112
+ pointer.up();
113
+ cursor.press(false);
114
+ }
115
+ /** Types into whatever has focus, a character at a time, as the browser would insert them. */
116
+ async function type(text, { signal, onKey }) {
117
+ const delays = typingDelays(text);
118
+ for (const [k, character] of [...text].entries()) {
119
+ await rest(delays[k], signal);
120
+ if (signal?.aborted)
121
+ return;
122
+ const field = document.activeElement;
123
+ const key = character === "\n" ? "Enter" : character;
124
+ field?.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, composed: true }));
125
+ if (character === "\n") {
126
+ if (field instanceof HTMLInputElement)
127
+ field.form?.requestSubmit();
128
+ else
129
+ document.execCommand("insertLineBreak");
130
+ }
131
+ else if (!document.execCommand("insertText", false, character) && (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement)) {
132
+ // Where insertText isn't honoured: set the value as a framework's input would see it.
133
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(field), "value")?.set;
134
+ setter?.call(field, field.value + character);
135
+ field.dispatchEvent(new InputEvent("input", { bubbles: true, data: character, inputType: "insertText" }));
136
+ }
137
+ field?.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true, cancelable: true, composed: true }));
138
+ onKey?.();
139
+ }
140
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A pointer the page believes in: the events a mouse would send as the drawn cursor moves and
3
+ * presses (pointer events, then the mouse events browsers send after them), so the page's own
4
+ * code reacts as it would to a hand: hover effects, a cloud turning to look, a button pressed.
5
+ * Over and out carry the element left or entered, as React builds its enter and leave events
6
+ * from them; enter and leave go to each element crossed. Hover and press are also marked for
7
+ * CSS (`hover.ts`). The events aren't trusted, so a page that checks `isTrusted` ignores them.
8
+ */
9
+ type Hit = (x: number, y: number) => Element | null;
10
+ export declare class SyntheticPointer {
11
+ private readonly hit;
12
+ private over;
13
+ private pressed;
14
+ private x;
15
+ private y;
16
+ private buttons;
17
+ /** `hit` finds the page's element at a point, Closeup's own layers left out. */
18
+ constructor(hit: Hit);
19
+ /** Moves to a point on screen: over, out, enter and leave where the element under it changes, then a move. */
20
+ moveTo(x: number, y: number): void;
21
+ /** The button goes down here: the press, focus to what takes it, and :active on the way up the tree. */
22
+ down(): void;
23
+ /** The button comes up: the release, and a click if it's still over what it pressed. */
24
+ up(): void;
25
+ /** Leaves the page: out and leave to everything under the pointer, as when the mouse goes off the window. */
26
+ leave(): void;
27
+ private cross;
28
+ private send;
29
+ }
30
+ export {};
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A pointer the page believes in: the events a mouse would send as the drawn cursor moves and
3
+ * presses (pointer events, then the mouse events browsers send after them), so the page's own
4
+ * code reacts as it would to a hand: hover effects, a cloud turning to look, a button pressed.
5
+ * Over and out carry the element left or entered, as React builds its enter and leave events
6
+ * from them; enter and leave go to each element crossed. Hover and press are also marked for
7
+ * CSS (`hover.ts`). The events aren't trusted, so a page that checks `isTrusted` ignores them.
8
+ */
9
+ import { ACTIVE, HOVER } from "./hover.js";
10
+ const FOCUSABLE = "a[href], button, input, select, textarea, summary, [tabindex], [contenteditable]:not([contenteditable='false'])";
11
+ /** An element and its ancestors, nearest first. */
12
+ function chain(element) {
13
+ const out = [];
14
+ for (let e = element; e; e = e.parentElement)
15
+ out.push(e);
16
+ return out;
17
+ }
18
+ export class SyntheticPointer {
19
+ hit;
20
+ over = null;
21
+ pressed = null;
22
+ x = 0;
23
+ y = 0;
24
+ buttons = 0;
25
+ /** `hit` finds the page's element at a point, Closeup's own layers left out. */
26
+ constructor(hit) {
27
+ this.hit = hit;
28
+ }
29
+ /** Moves to a point on screen: over, out, enter and leave where the element under it changes, then a move. */
30
+ moveTo(x, y) {
31
+ const target = this.hit(x, y) ?? document.documentElement;
32
+ const moved = { x: x - this.x, y: y - this.y };
33
+ this.x = x;
34
+ this.y = y;
35
+ if (target !== this.over)
36
+ this.cross(target);
37
+ this.send(target, "pointermove", { movementX: moved.x, movementY: moved.y });
38
+ this.send(target, "mousemove", { movementX: moved.x, movementY: moved.y });
39
+ }
40
+ /** The button goes down here: the press, focus to what takes it, and :active on the way up the tree. */
41
+ down() {
42
+ const target = this.over ?? document.documentElement;
43
+ this.buttons = 1;
44
+ this.pressed = target;
45
+ this.send(target, "pointerdown");
46
+ this.send(target, "mousedown");
47
+ const focusable = target.closest(FOCUSABLE);
48
+ if (focusable)
49
+ focusable.focus({ preventScroll: true });
50
+ else if (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)
51
+ document.activeElement.blur();
52
+ for (const element of chain(focusable ?? target))
53
+ element.setAttribute(ACTIVE, "");
54
+ }
55
+ /** The button comes up: the release, and a click if it's still over what it pressed. */
56
+ up() {
57
+ const target = this.over ?? document.documentElement;
58
+ this.buttons = 0;
59
+ this.send(target, "pointerup");
60
+ this.send(target, "mouseup");
61
+ for (const element of document.querySelectorAll(`[${ACTIVE}]`))
62
+ element.removeAttribute(ACTIVE);
63
+ const pressed = this.pressed;
64
+ this.pressed = null;
65
+ if (!pressed)
66
+ return;
67
+ // As browsers do: the click goes to the nearest element holding both the press and the release.
68
+ const common = chain(pressed).find((element) => element.contains(target));
69
+ if (common)
70
+ this.send(common, "click", { detail: 1 });
71
+ }
72
+ /** Leaves the page: out and leave to everything under the pointer, as when the mouse goes off the window. */
73
+ leave() {
74
+ this.cross(null);
75
+ }
76
+ cross(target) {
77
+ const from = this.over;
78
+ const left = chain(from);
79
+ const entered = chain(target);
80
+ const stays = new Set(left.filter((element) => entered.includes(element)));
81
+ if (from) {
82
+ this.send(from, "pointerout", { relatedTarget: target });
83
+ for (const element of left)
84
+ if (!stays.has(element))
85
+ this.send(element, "pointerleave", { relatedTarget: target, bubbles: false });
86
+ this.send(from, "mouseout", { relatedTarget: target });
87
+ for (const element of left)
88
+ if (!stays.has(element))
89
+ this.send(element, "mouseleave", { relatedTarget: target, bubbles: false });
90
+ }
91
+ for (const element of left)
92
+ if (!stays.has(element))
93
+ element.removeAttribute(HOVER);
94
+ if (target) {
95
+ this.send(target, "pointerover", { relatedTarget: from });
96
+ for (const element of [...entered].reverse())
97
+ if (!stays.has(element))
98
+ this.send(element, "pointerenter", { relatedTarget: from, bubbles: false });
99
+ this.send(target, "mouseover", { relatedTarget: from });
100
+ for (const element of [...entered].reverse())
101
+ if (!stays.has(element))
102
+ this.send(element, "mouseenter", { relatedTarget: from, bubbles: false });
103
+ }
104
+ for (const element of entered)
105
+ element.setAttribute(HOVER, "");
106
+ this.over = target;
107
+ }
108
+ send(target, type, extra = {}) {
109
+ const init = {
110
+ bubbles: extra.bubbles ?? true,
111
+ cancelable: true,
112
+ composed: true,
113
+ view: window,
114
+ clientX: this.x,
115
+ clientY: this.y,
116
+ screenX: this.x + window.screenX,
117
+ screenY: this.y + window.screenY,
118
+ button: type.endsWith("down") || type.endsWith("up") || type === "click" ? 0 : -1,
119
+ buttons: this.buttons,
120
+ relatedTarget: extra.relatedTarget ?? null,
121
+ detail: extra.detail ?? 0,
122
+ movementX: extra.movementX ?? 0,
123
+ movementY: extra.movementY ?? 0,
124
+ pointerId: 1,
125
+ pointerType: "mouse",
126
+ isPrimary: true,
127
+ width: 1,
128
+ height: 1,
129
+ pressure: this.buttons ? 0.5 : 0,
130
+ };
131
+ const event = type.startsWith("pointer") ? new PointerEvent(type, init) : new MouseEvent(type, { ...init, button: Math.max(0, init.button ?? 0) });
132
+ target.dispatchEvent(event);
133
+ }
134
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A scripted take: where the drawn cursor starts, where it goes, by which curves, and what it
3
+ * does on arriving (click, type, or nothing but rest there, which is hovering). Plain data,
4
+ * saved as JSON, so a scene can be played again after the page changes. Pure: the player
5
+ * (`player.ts`) reads it, the editor writes it.
6
+ */
7
+ import { type Point } from "./path.ts";
8
+ /**
9
+ * Where the cursor goes: a point in the page as laid out (document CSS pixels, not enlarged),
10
+ * and the element it was put on, if any, with where in that element's box (0 to 1 across and
11
+ * down), so the point follows the element when the layout moves.
12
+ */
13
+ export type Target = {
14
+ x: number;
15
+ y: number;
16
+ on?: {
17
+ selector: string;
18
+ u: number;
19
+ v: number;
20
+ };
21
+ };
22
+ export type Action = "move" | "click" | "type";
23
+ export type Step = {
24
+ to: Target;
25
+ /** The curve's handle arriving here, relative to the point, in page pixels; none for a gentle arc. */
26
+ in?: Point;
27
+ /** The handle leaving here toward the next step. */
28
+ out?: Point;
29
+ /** What happens on arriving: "move" only rests there, which is hovering. */
30
+ action: Action;
31
+ /** What "type" types. */
32
+ text?: string;
33
+ /** Seconds resting on arrival before clicking or typing, to show what hovering does. */
34
+ hover?: number;
35
+ /** Seconds the move here takes; none for a hand's pace (Fitts's law). */
36
+ duration?: number;
37
+ /** Seconds to rest after arriving and acting. */
38
+ wait: number;
39
+ };
40
+ export type Script = {
41
+ version: 1;
42
+ start: Target;
43
+ /** The handle leaving the start. */
44
+ out?: Point;
45
+ steps: Step[];
46
+ };
47
+ /** How far a move without handles bows, as a share of its length: a wrist turns, so hands don't move in lines. */
48
+ export declare const BEND = 0.12;
49
+ /** Seconds the cursor waits where it starts before moving, and lingers after the last step, while filming. */
50
+ export declare const LEAD = 0.6;
51
+ export declare const TAIL = 0.8;
52
+ /** A click: the button held this long. */
53
+ export declare const PRESS = 0.075;
54
+ /**
55
+ * Minimum jerk: how a hand covers a reach, slow to start, quickest midway, slow to arrive
56
+ * (Flash and Hogan, 1985). Smoother than a cubic ease: its acceleration starts and ends at zero.
57
+ */
58
+ export declare const minimumJerk: (t: number) => number;
59
+ /**
60
+ * Handles for a move from `a` to `b` that wasn't given any: a third of the way along each end,
61
+ * pushed to the left of the direction of travel by `bend` of the length, so the move bows
62
+ * gently, as a wrist pivoting does. Relative to their points, as a script stores them.
63
+ */
64
+ export declare function autoHandles(a: Point, b: Point, bend?: number): {
65
+ out: Point;
66
+ in: Point;
67
+ };
68
+ /** How long a move takes: as given, or a hand's pace for its length and the size of what it reaches for. */
69
+ export declare const moveDuration: (step: Pick<Step, "duration">, distance: number, size: number) => number;
70
+ /** Seconds between typed characters: a person's uneven rhythm, the same every time for the same text. */
71
+ export declare function typingDelays(text: string): number[];
72
+ /** How long a step's action lasts once there: a press, or the press and the typing. */
73
+ export declare const actionDuration: (step: Step) => number;
74
+ /** A script read back from storage or a file, or null if it isn't one; anything unknown is dropped. */
75
+ export declare function parseScript(value: unknown): Script | null;
package/dist/script.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A scripted take: where the drawn cursor starts, where it goes, by which curves, and what it
3
+ * does on arriving (click, type, or nothing but rest there, which is hovering). Plain data,
4
+ * saved as JSON, so a scene can be played again after the page changes. Pure: the player
5
+ * (`player.ts`) reads it, the editor writes it.
6
+ */
7
+ import { reachTime } from "./path.js";
8
+ /** How far a move without handles bows, as a share of its length: a wrist turns, so hands don't move in lines. */
9
+ export const BEND = 0.12;
10
+ /** Seconds the cursor waits where it starts before moving, and lingers after the last step, while filming. */
11
+ export const LEAD = 0.6;
12
+ export const TAIL = 0.8;
13
+ /** A click: the button held this long. */
14
+ export const PRESS = 0.075;
15
+ /**
16
+ * Minimum jerk: how a hand covers a reach, slow to start, quickest midway, slow to arrive
17
+ * (Flash and Hogan, 1985). Smoother than a cubic ease: its acceleration starts and ends at zero.
18
+ */
19
+ export const minimumJerk = (t) => {
20
+ const u = Math.max(0, Math.min(1, t));
21
+ return u * u * u * (10 + u * (-15 + 6 * u));
22
+ };
23
+ /**
24
+ * Handles for a move from `a` to `b` that wasn't given any: a third of the way along each end,
25
+ * pushed to the left of the direction of travel by `bend` of the length, so the move bows
26
+ * gently, as a wrist pivoting does. Relative to their points, as a script stores them.
27
+ */
28
+ export function autoHandles(a, b, bend = BEND) {
29
+ const dx = b.x - a.x;
30
+ const dy = b.y - a.y;
31
+ // Left of travel on screen (y grows down): (dy, -dx).
32
+ const nx = dy * bend;
33
+ const ny = -dx * bend;
34
+ return { out: { x: dx / 3 + nx, y: dy / 3 + ny }, in: { x: -dx / 3 + nx, y: -dy / 3 + ny } };
35
+ }
36
+ /** How long a move takes: as given, or a hand's pace for its length and the size of what it reaches for. */
37
+ export const moveDuration = (step, distance, size) => step.duration ?? (distance < 1 ? 0 : reachTime(distance, size));
38
+ /** Seconds between typed characters: a person's uneven rhythm, the same every time for the same text. */
39
+ export function typingDelays(text) {
40
+ return [...text].map((character, k) => {
41
+ // A small hash of the character and its place, for an even spread in 0–1.
42
+ const h = Math.sin((character.codePointAt(0) ?? 0) * 12.9898 + k * 78.233) * 43758.5453;
43
+ const spread = h - Math.floor(h);
44
+ // Spaces come a little slower, as the next word is reached for.
45
+ return (character === " " ? 0.11 : 0.06) + spread * 0.07;
46
+ });
47
+ }
48
+ /** How long a step's action lasts once there: a press, or the press and the typing. */
49
+ export const actionDuration = (step) => step.action === "click"
50
+ ? (step.hover ?? 0) + PRESS
51
+ : step.action === "type"
52
+ ? (step.hover ?? 0) + PRESS + typingDelays(step.text ?? "").reduce((a, b) => a + b, 0)
53
+ : 0;
54
+ const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
55
+ const pointOf = (value) => {
56
+ const p = value;
57
+ return p && isNumber(p.x) && isNumber(p.y) ? { x: p.x, y: p.y } : undefined;
58
+ };
59
+ function targetOf(value) {
60
+ const t = value;
61
+ if (!t || !isNumber(t.x) || !isNumber(t.y))
62
+ return undefined;
63
+ const on = t.on;
64
+ const anchored = on && typeof on.selector === "string" && on.selector && isNumber(on.u) && isNumber(on.v);
65
+ return { x: t.x, y: t.y, ...(anchored ? { on: { selector: on.selector, u: on.u, v: on.v } } : {}) };
66
+ }
67
+ /** A script read back from storage or a file, or null if it isn't one; anything unknown is dropped. */
68
+ export function parseScript(value) {
69
+ const raw = value;
70
+ if (!raw || raw.version !== 1 || !Array.isArray(raw.steps))
71
+ return null;
72
+ const start = targetOf(raw.start);
73
+ if (!start)
74
+ return null;
75
+ const steps = [];
76
+ for (const item of raw.steps) {
77
+ const s = item;
78
+ const to = targetOf(s?.to);
79
+ if (!to)
80
+ return null;
81
+ const action = s.action === "click" || s.action === "type" ? s.action : "move";
82
+ steps.push({
83
+ to,
84
+ ...(pointOf(s.in) ? { in: pointOf(s.in) } : {}),
85
+ ...(pointOf(s.out) ? { out: pointOf(s.out) } : {}),
86
+ action,
87
+ ...(action === "type" ? { text: typeof s.text === "string" ? s.text : "" } : {}),
88
+ ...(action !== "move" && isNumber(s.hover) && s.hover > 0 ? { hover: s.hover } : {}),
89
+ ...(isNumber(s.duration) && s.duration >= 0 ? { duration: s.duration } : {}),
90
+ wait: isNumber(s.wait) && s.wait >= 0 ? s.wait : 0,
91
+ });
92
+ }
93
+ return { version: 1, start, ...(pointOf(raw.out) ? { out: pointOf(raw.out) } : {}), steps };
94
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Naming elements so a script can find them again: what a placed point is on, as a CSS
3
+ * selector that still finds that element after a reload. Classes that only style (Tailwind's
4
+ * and the like) and ids a framework makes up per render are passed over: they change between
5
+ * builds, or say nothing about what the element is.
6
+ */
7
+ export declare const isUtility: (name: string) => boolean;
8
+ /** Ids that frameworks generate per render (React's useId, Radix, Headless UI, MUI), or that carry a counter. */
9
+ export declare const isStableId: (id: string) => boolean;
10
+ /** The classes worth naming an element by. */
11
+ export declare const meaningfulClasses: (element: Element) => string[];
12
+ /** "div.mascot-body", "button Ask the cloud": enough to know what's outlined. */
13
+ export declare function describe(element: Element): string;
14
+ /** A selector that finds `element` alone in the document: by name if it has one, else by its place under the nearest named ancestor. */
15
+ export declare function selectorFor(element: Element): string;
16
+ /**
17
+ * What a point placed on `element` should follow: the nearest thing with a name of its own
18
+ * between it and what you'd click (a link, a button), else that clickable thing, else the
19
+ * element itself once it's big enough to be a thing. None for the page itself or a container
20
+ * nearly as big as the window: a point there is only a place.
21
+ */
22
+ export declare function anchorFor(element: Element | null): Element | null;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Naming elements so a script can find them again: what a placed point is on, as a CSS
3
+ * selector that still finds that element after a reload. Classes that only style (Tailwind's
4
+ * and the like) and ids a framework makes up per render are passed over: they change between
5
+ * builds, or say nothing about what the element is.
6
+ */
7
+ /** Utility classes say how something looks, not what it is. */
8
+ const UTILITY = /[[\]:/.]|^-?(?:[mp][xytrbl]?|w|h|min|max|gap|text|bg|border|rounded|items|justify|self|place|font|leading|tracking|basis|col|row|z|top|left|right|bottom|inset|opacity|duration|ease|delay|translate|scale|rotate|cursor|select|pointer|size|space|divide|whitespace|break|aspect|object|fill|stroke|decoration|origin|animate)-|^(?:flex|grid|block|inline|hidden|contents|relative|absolute|fixed|sticky|static|shrink|grow|overflow|shadow|ring|outline|transition|truncate|sr-only|isolate|group|peer|italic|uppercase|lowercase|capitalize|antialiased)/;
9
+ export const isUtility = (name) => UTILITY.test(name);
10
+ /** Ids that frameworks generate per render (React's useId, Radix, Headless UI, MUI), or that carry a counter. */
11
+ export const isStableId = (id) => !!id && !/^(?:_R_|:r|«r|radix-|headlessui-|mui-|react-aria)|[:«»]|\d{3,}/.test(id);
12
+ /** The classes worth naming an element by. */
13
+ export const meaningfulClasses = (element) => [...element.classList].filter((name) => !isUtility(name));
14
+ /** "div.mascot-body", "button Ask the cloud": enough to know what's outlined. */
15
+ export function describe(element) {
16
+ const name = element.getAttribute("aria-label") || (element.id && isStableId(element.id) ? `#${element.id}` : "") || meaningfulClasses(element).slice(0, 2).map((c) => `.${c}`).join("");
17
+ return `${element.tagName.toLowerCase()}${name ? (name.startsWith("#") || name.startsWith(".") ? name : ` ${name}`) : ""}`;
18
+ }
19
+ const quoted = (value) => `"${value.replace(/["\\]/g, "\\$&")}"`;
20
+ /** A selector that finds `element` alone in the document: by name if it has one, else by its place under the nearest named ancestor. */
21
+ export function selectorFor(element) {
22
+ const only = (selector) => {
23
+ try {
24
+ const found = document.querySelectorAll(selector);
25
+ return found.length === 1 && found[0] === element;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ };
31
+ const tag = element.tagName.toLowerCase();
32
+ if (element === document.body || element === document.documentElement)
33
+ return tag;
34
+ if (element.id && isStableId(element.id) && only(`#${CSS.escape(element.id)}`))
35
+ return `#${CSS.escape(element.id)}`;
36
+ for (const attribute of ["data-testid", "data-test", "data-cy", "name"]) {
37
+ const value = element.getAttribute(attribute);
38
+ if (value && only(`${tag}[${attribute}=${quoted(value)}]`))
39
+ return `${tag}[${attribute}=${quoted(value)}]`;
40
+ }
41
+ const href = element instanceof HTMLAnchorElement ? element.getAttribute("href") : null;
42
+ if (href && only(`a[href=${quoted(href)}]`))
43
+ return `a[href=${quoted(href)}]`;
44
+ for (const name of meaningfulClasses(element)) {
45
+ const selector = `${tag}.${CSS.escape(name)}`;
46
+ if (only(selector))
47
+ return selector;
48
+ }
49
+ const parent = element.parentElement;
50
+ if (!parent)
51
+ return tag;
52
+ const place = `${tag}:nth-child(${[...parent.children].indexOf(element) + 1})`;
53
+ return `${selectorFor(parent)} > ${place}`;
54
+ }
55
+ const INTERACTIVE = "a[href], button, input, select, textarea, label, summary, [role='button'], [role='link'], [role='tab'], [role='menuitem'], [role='option'], [role='switch'], [role='checkbox'], [tabindex]:not([tabindex='-1'])";
56
+ /** Under this, both ways, an element is a part (a glyph, a stroke) rather than a thing to aim at. */
57
+ const SMALLEST = 16;
58
+ /**
59
+ * What a point placed on `element` should follow: the nearest thing with a name of its own
60
+ * between it and what you'd click (a link, a button), else that clickable thing, else the
61
+ * element itself once it's big enough to be a thing. None for the page itself or a container
62
+ * nearly as big as the window: a point there is only a place.
63
+ */
64
+ export function anchorFor(element) {
65
+ if (!element || element === document.body || element === document.documentElement)
66
+ return null;
67
+ const clickable = element.closest(INTERACTIVE);
68
+ const top = clickable ?? element.parentElement?.parentElement ?? element;
69
+ for (let e = element; e; e = e.parentElement) {
70
+ const box = e.getBoundingClientRect();
71
+ const big = box.width >= SMALLEST || box.height >= SMALLEST;
72
+ if (big && !selectorFor(e).includes(":nth-child"))
73
+ return within(e);
74
+ if (e === top)
75
+ break;
76
+ }
77
+ return within(clickable ?? element);
78
+ }
79
+ /** The element, unless it's nearly the whole window. */
80
+ function within(element) {
81
+ const box = element.getBoundingClientRect();
82
+ return box.width * box.height > innerWidth * innerHeight * 0.5 ? null : element;
83
+ }