@heyramzi/ui 3.7.0 → 3.7.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.
@@ -0,0 +1,76 @@
1
+ /** How far below the scroller's top edge a heading counts as "reached" and lands. */
2
+ export const READ_LINE = 80;
3
+ export class ScrollSpy {
4
+ active = $state("");
5
+ #scroller = null;
6
+ #items = [];
7
+ #locate;
8
+ #frame = 0;
9
+ /** Point the spy at a scroller and a list. Returns the cleanup. */
10
+ attach(scroller, items, locate) {
11
+ this.#scroller = scroller;
12
+ this.#items = items;
13
+ this.#locate = locate;
14
+ if (!scroller || items.length === 0) {
15
+ this.active = "";
16
+ return () => {};
17
+ }
18
+ const schedule = () => {
19
+ this.#frame ||= requestAnimationFrame(() => {
20
+ this.#frame = 0;
21
+ this.update();
22
+ });
23
+ };
24
+ scroller.addEventListener("scroll", schedule, { passive: true });
25
+ const observer = new ResizeObserver(schedule);
26
+ observer.observe(scroller);
27
+ // A document that grows after load (a diagram, a chart) moves every
28
+ // heading below it, so watch the content too.
29
+ for (const child of scroller.children) observer.observe(child);
30
+ this.update();
31
+ return () => {
32
+ scroller.removeEventListener("scroll", schedule);
33
+ observer.disconnect();
34
+ if (this.#frame) cancelAnimationFrame(this.#frame);
35
+ this.#frame = 0;
36
+ };
37
+ }
38
+ topOf(id) {
39
+ const scroller = this.#scroller;
40
+ if (!scroller) return null;
41
+ if (this.#locate) return this.#locate(id);
42
+ const el = scroller.querySelector(`#${CSS.escape(id)}`);
43
+ if (!el) return null;
44
+ return el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
45
+ }
46
+ update() {
47
+ const scroller = this.#scroller;
48
+ if (!scroller || this.#items.length === 0) return;
49
+ // At the very bottom the last heading wins, or a short final section could
50
+ // never be the current one.
51
+ const atEnd = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
52
+ if (atEnd && scroller.scrollTop > 0) {
53
+ this.active = this.#items[this.#items.length - 1].id;
54
+ return;
55
+ }
56
+ const line = scroller.scrollTop + READ_LINE + 1;
57
+ let current = this.#items[0].id;
58
+ for (const item of this.#items) {
59
+ const top = this.topOf(item.id);
60
+ if (top !== null && top <= line) current = item.id;
61
+ }
62
+ this.active = current;
63
+ }
64
+ /** Scroll so the heading lands on the read line, instant under reduced motion. */
65
+ jump(id) {
66
+ const scroller = this.#scroller;
67
+ const top = this.topOf(id);
68
+ if (!scroller || top === null) return;
69
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
70
+ this.active = id;
71
+ scroller.scrollTo({
72
+ top: Math.max(0, top - READ_LINE),
73
+ behavior: reduced ? "auto" : "smooth"
74
+ });
75
+ }
76
+ }
@@ -0,0 +1,126 @@
1
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ rmSync,
11
+ symlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { createRequire } from "node:module";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join, relative } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import { afterAll, describe, expect, it } from "vite-plus/test";
19
+ import { writeSvelteModules } from "../scripts/lib/svelte-modules.mjs";
20
+
21
+ // A consumer's dev server prebundles this package, types still in each `.svelte.ts`. 3.6.0's
22
+ // reveal.svelte.ts killed Locameublé's dev server at startup on `import type`.
23
+
24
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
26
+ scripts: Record<string, string>;
27
+ exports: Record<string, string | string[] | Record<string, string | string[]>>;
28
+ };
29
+
30
+ /** The Vite core a consumer gets beside vite-plugin-svelte; this package only depends on vite-plus. */
31
+ type Vite = {
32
+ resolveConfig(config: object, command: "serve", mode: string): Promise<unknown>;
33
+ optimizeDeps(
34
+ config: unknown,
35
+ force: boolean,
36
+ ): Promise<{ optimized: Record<string, { file: string }> }>;
37
+ };
38
+ const plugin = createRequire(import.meta.url).resolve("@sveltejs/vite-plugin-svelte");
39
+ const vite: Vite = await import(pathToFileURL(createRequire(plugin).resolve("vite")).href);
40
+
41
+ function walk(dir: string, out: string[] = []): string[] {
42
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
43
+ const p = join(dir, e.name);
44
+ if (e.isDirectory()) walk(p, out);
45
+ else out.push(p);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** The tarball's shape: `files` as prepack leaves them, under a consumer's node_modules. */
51
+ async function consumer(): Promise<{ dir: string; ui: string }> {
52
+ const dir = mkdtempSync(join(tmpdir(), "ui-consumer-"));
53
+ const nm = join(dir, "node_modules");
54
+ const ui = join(nm, "@heyramzi/ui");
55
+ mkdirSync(ui, { recursive: true });
56
+ cpSync(join(root, "src"), join(ui, "src"), { recursive: true });
57
+ cpSync(join(root, "package.json"), join(ui, "package.json"));
58
+ await writeSvelteModules(join(ui, "src"));
59
+ for (const name of readdirSync(join(root, "node_modules"))) {
60
+ const from = join(root, "node_modules", name);
61
+ const scoped = name.startsWith("@") ? readdirSync(from).map((s) => join(name, s)) : [name];
62
+ if (name.startsWith("@")) mkdirSync(join(nm, name));
63
+ for (const dep of scoped)
64
+ symlinkSync(realpathSync(join(root, "node_modules", dep)), join(nm, dep));
65
+ }
66
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "consumer", type: "module" }));
67
+ return { dir, ui };
68
+ }
69
+
70
+ /** Every subpath a Svelte app can import, wildcards expanded against the tarball's files. */
71
+ function browserEntries(ui: string): string[] {
72
+ const out = new Set<string>();
73
+ for (const [key, value] of Object.entries(pkg.exports)) {
74
+ if (typeof value !== "object" || Array.isArray(value)) continue;
75
+ const target = value.svelte;
76
+ if (typeof target !== "string") continue;
77
+ if (!key.includes("*")) {
78
+ out.add(`@heyramzi/ui${key.slice(1)}`);
79
+ continue;
80
+ }
81
+ const [head, tail] = target.split("*");
82
+ for (const file of walk(join(ui, head))) {
83
+ const rel = relative(join(ui, head), file);
84
+ if (!rel.endsWith(tail) || /\.(test|spec)\./.test(rel)) continue;
85
+ out.add(`@heyramzi/ui${key.slice(1).replace("*", rel.slice(0, -tail.length))}`);
86
+ }
87
+ }
88
+ return [...out].toSorted();
89
+ }
90
+
91
+ const made: string[] = [];
92
+ afterAll(() => {
93
+ for (const dir of made) rmSync(dir, { recursive: true, force: true });
94
+ });
95
+
96
+ describe("a consumer's dev server", () => {
97
+ it("writes the type-free modules on every pack and removes them after", () => {
98
+ expect(pkg.scripts.prepack).toContain("node scripts/svelte-modules.mjs");
99
+ expect(pkg.scripts.postpack).toContain("node scripts/svelte-modules.mjs --clean");
100
+ });
101
+
102
+ it("prebundles every Svelte subpath, chat windows included", async () => {
103
+ const { dir, ui } = await consumer();
104
+ made.push(dir);
105
+ const include = browserEntries(ui);
106
+ expect(include).toContain("@heyramzi/ui/ai/chat-window");
107
+ expect(include).toContain("@heyramzi/ui/shadcn/hooks/use-highlighted-code.svelte");
108
+ expect(include).toContain("@heyramzi/ui/canvas/editor/dispatch.svelte.js");
109
+
110
+ const config = await vite.resolveConfig(
111
+ {
112
+ root: dir,
113
+ configFile: false,
114
+ logLevel: "error",
115
+ plugins: [svelte({ configFile: false })],
116
+ optimizeDeps: { include, noDiscovery: true },
117
+ },
118
+ "serve",
119
+ "development",
120
+ );
121
+ const { optimized } = await vite.optimizeDeps(config, true);
122
+ const chat = optimized["@heyramzi/ui/ai/chat-window"];
123
+ expect(chat && existsSync(chat.file)).toBe(true);
124
+ expect(readFileSync(chat.file, "utf8")).toContain("REVEAL_BEATS");
125
+ }, 120_000);
126
+ });
@@ -0,0 +1,54 @@
1
+ function anchorX(rect, containerLeft, anchor, offset) {
2
+ const left = rect.left - containerLeft;
3
+ if (anchor === "left") return left + offset;
4
+ if (anchor === "right") return left + rect.width + offset;
5
+ return left + rect.width / 2 + offset;
6
+ }
7
+ export function usePathCalculator() {
8
+ let pathD = $state("");
9
+ let svgDimensions = $state({
10
+ width: 0,
11
+ height: 0
12
+ });
13
+ function calculatePath(containerRef, fromRef, toRef, curvature, startXOffset, startYOffset, endXOffset, endYOffset, fromAnchor = "center", toAnchor = "center") {
14
+ if (!containerRef || !fromRef || !toRef) return;
15
+ const containerRect = containerRef.getBoundingClientRect();
16
+ const rectA = fromRef.getBoundingClientRect();
17
+ const rectB = toRef.getBoundingClientRect();
18
+ // A scaled ancestor shrinks every rect, but the SVG draws in the container's own pixels.
19
+ // Divide the scale out, or the beams stop short of their nodes.
20
+ const scaleX = containerRef.offsetWidth ? containerRect.width / containerRef.offsetWidth : 1;
21
+ const scaleY = containerRef.offsetHeight ? containerRect.height / containerRef.offsetHeight : 1;
22
+ const local = (rect) => new DOMRect((rect.left - containerRect.left) / scaleX, (rect.top - containerRect.top) / scaleY, rect.width / scaleX, rect.height / scaleY);
23
+ const a = local(rectA);
24
+ const b = local(rectB);
25
+ svgDimensions = {
26
+ width: containerRect.width / scaleX,
27
+ height: containerRect.height / scaleY
28
+ };
29
+ const startX = anchorX(a, 0, fromAnchor, startXOffset);
30
+ const startY = a.top + a.height / 2 + startYOffset;
31
+ const endX = anchorX(b, 0, toAnchor, endXOffset);
32
+ const endY = b.top + b.height / 2 + endYOffset;
33
+ // A cubic with horizontal handles, not a quadratic through a raised midpoint.
34
+ // The quadratic put its single control point at `midY` minus `curvature`, which for a fan into one node lands past the far end:
35
+ // the top beam dove below the hub and crossed the middle beam before arriving, and three beams bundled into a lens for the last stretch.
36
+ // Handles along the axis of travel make each beam leave and arrive perpendicular to its anchor, so a fan converges at the anchor and nowhere else.
37
+ // `curvature` still bows the middle, and zero still draws a straight line between two anchors at the same height.
38
+ const span = Math.abs(endX - startX);
39
+ const handle = Math.max(span * .5, 24);
40
+ const direction = endX >= startX ? 1 : -1;
41
+ const c1x = startX + handle * direction;
42
+ const c2x = endX - handle * direction;
43
+ pathD = `M ${startX},${startY} C ${c1x},${startY - curvature} ${c2x},${endY - curvature} ${endX},${endY}`;
44
+ }
45
+ return {
46
+ get pathD() {
47
+ return pathD;
48
+ },
49
+ get svgDimensions() {
50
+ return svgDimensions;
51
+ },
52
+ calculatePath
53
+ };
54
+ }
@@ -0,0 +1,19 @@
1
+ class Onboarding {
2
+ /** True when the current page has a tour, so a menu item can hide itself. */
3
+ hasTour = $state(false);
4
+ /** True while a tour is on screen. */
5
+ running = $state(false);
6
+ #replay = null;
7
+ /** The provider registers itself here. Returns the undo for cleanup. */
8
+ bind(replay) {
9
+ this.#replay = replay;
10
+ return () => {
11
+ if (this.#replay === replay) this.#replay = null;
12
+ };
13
+ }
14
+ /** Restarts this page's tour, or the one named. Writes no seen-state. False when nothing started. */
15
+ replay(tourId) {
16
+ return this.#replay ? this.#replay(tourId) : Promise.resolve(false);
17
+ }
18
+ }
19
+ export const onboarding = new Onboarding();
@@ -0,0 +1,7 @@
1
+ import { MediaQuery } from "svelte/reactivity";
2
+ const DEFAULT_MOBILE_BREAKPOINT = 768;
3
+ export class IsMobile extends MediaQuery {
4
+ constructor(breakpoint = DEFAULT_MOBILE_BREAKPOINT) {
5
+ super(`max-width: ${breakpoint - 1}px`);
6
+ }
7
+ }
@@ -0,0 +1,65 @@
1
+ import { loadHighlighter } from "./highlighter.js";
2
+ /**
3
+ * Highlights `code`. Each `current` entry is a plain string until the highlighter loads, then
4
+ * tokens, so pass it to `CodeBlock.Line`'s `code` prop either way.
5
+ */
6
+ export function useHighlightedCode(value, language) {
7
+ let loaded = $state.raw(null);
8
+ $effect(() => {
9
+ const lang = language();
10
+ let cancelled = false;
11
+ loadHighlighter(lang).then((next) => {
12
+ if (!cancelled) {
13
+ loaded = {
14
+ language: lang,
15
+ highlighter: next
16
+ };
17
+ }
18
+ }).catch(() => {});
19
+ return () => {
20
+ cancelled = true;
21
+ };
22
+ });
23
+ // The complete lines, highlighted. While the code streams, only the new
24
+ // lines are tokenized, starting from the grammar state of the last one.
25
+ let done = {
26
+ highlighter: null,
27
+ text: "",
28
+ lines: []
29
+ };
30
+ const lines = $derived.by(() => {
31
+ const raw = value();
32
+ // A trailing newline would render as an extra empty line.
33
+ const code = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
34
+ const lang = language();
35
+ const highlighter = loaded?.language === lang ? loaded.highlighter : null;
36
+ if (!highlighter) {
37
+ return code.split("\n");
38
+ }
39
+ let next = done;
40
+ if (next.highlighter !== highlighter || !code.startsWith(next.text)) {
41
+ next = {
42
+ highlighter,
43
+ text: "",
44
+ lines: []
45
+ };
46
+ }
47
+ const end = code.lastIndexOf("\n") + 1;
48
+ if (end > next.text.length) {
49
+ const result = highlighter.highlight(code.slice(next.text.length, end - 1), next.state);
50
+ next = {
51
+ highlighter,
52
+ text: code.slice(0, end),
53
+ lines: [...next.lines, ...result.lines],
54
+ state: result.state
55
+ };
56
+ }
57
+ done = next;
58
+ // The last line is still being written, so it is tokenized every time.
59
+ const tail = code.slice(end);
60
+ return [...next.lines, highlighter.highlight(tail, next.state).lines[0]];
61
+ });
62
+ return { get current() {
63
+ return lines;
64
+ } };
65
+ }
@@ -0,0 +1,63 @@
1
+ import { IsMobile } from "../hooks/is-mobile.svelte.js";
2
+ import { getContext, setContext } from "svelte";
3
+ import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
4
+ // Cmd+B in a field means bold, so the shortcut leaves typing alone.
5
+ function isEditable(target) {
6
+ if (!(target instanceof HTMLElement)) return false;
7
+ return target.closest("input, textarea, [contenteditable]:not([contenteditable=\"false\"])") !== null;
8
+ }
9
+ class SidebarState {
10
+ props;
11
+ open = $derived.by(() => this.props.open());
12
+ openMobile = $state(false);
13
+ setOpen;
14
+ #isMobile;
15
+ state = $derived.by(() => this.open ? "expanded" : "collapsed");
16
+ id;
17
+ constructor(props) {
18
+ this.setOpen = props.setOpen;
19
+ this.id = props.id;
20
+ this.#isMobile = new IsMobile();
21
+ this.props = props;
22
+ }
23
+ // Convenience getter for checking if the sidebar is mobile
24
+ // without this, we would need to use `sidebar.isMobile.current` everywhere
25
+ get isMobile() {
26
+ return this.#isMobile.current;
27
+ }
28
+ /** Whether the sidebar a trigger controls is showing: the sheet on a phone, the panel otherwise. */
29
+ get expanded() {
30
+ return this.#isMobile.current ? this.openMobile : this.open;
31
+ }
32
+ // Event handler to apply to the `<svelte:window>`
33
+ handleShortcutKeydown = (e) => {
34
+ if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey) && !isEditable(e.target)) {
35
+ e.preventDefault();
36
+ this.toggle();
37
+ }
38
+ };
39
+ setOpenMobile = (value) => {
40
+ this.openMobile = value;
41
+ };
42
+ toggle = () => {
43
+ return this.#isMobile.current ? this.openMobile = !this.openMobile : this.setOpen(!this.open);
44
+ };
45
+ }
46
+ const SYMBOL_KEY = "scn-sidebar";
47
+ /**
48
+ * Instantiates a new `SidebarState` instance and sets it in the context.
49
+ *
50
+ * @param props The constructor props for the `SidebarState` class.
51
+ * @returns The `SidebarState` instance.
52
+ */
53
+ export function setSidebar(props) {
54
+ return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
55
+ }
56
+ /**
57
+ * Retrieves the `SidebarState` instance from the context. This is a class instance,
58
+ * so you cannot destructure it.
59
+ * @returns The `SidebarState` instance.
60
+ */
61
+ export function useSidebar() {
62
+ return getContext(Symbol.for(SYMBOL_KEY));
63
+ }
@@ -0,0 +1,254 @@
1
+ import { untrack } from "svelte";
2
+ import { fitBox, gridBoxes } from "./layout.js";
3
+ import { keyAction, nextPosition, ordinalOf, prevPosition, progressOf, settle, visibleIndexes } from "./navigation.js";
4
+ import { resolveLabels } from "./labels.js";
5
+ import { DEFAULT_CANVAS } from "./types.js";
6
+ /** Height the controls bar takes at the bottom of the overview, in px. */
7
+ const CONTROLS_ROOM = 56;
8
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
9
+ /**
10
+ * Where `cell` lands when the camera moves `target` onto `fit`. Every box shares one scale and
11
+ * shift, and a transform tween is linear in both, so the grid stays rigid while it zooms.
12
+ */
13
+ function frame(cell, target, fit) {
14
+ const k = fit.scale / target.scale;
15
+ return {
16
+ x: fit.x + (cell.x - target.x) * k,
17
+ y: fit.y + (cell.y - target.y) * k,
18
+ scale: cell.scale * k
19
+ };
20
+ }
21
+ function isEditable(el) {
22
+ return el.isContentEditable || [
23
+ "INPUT",
24
+ "TEXTAREA",
25
+ "SELECT"
26
+ ].includes(el.tagName);
27
+ }
28
+ const byDocumentOrder = (a, b) => a.el && b.el ? a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 : 0;
29
+ export class DeckController {
30
+ entries = $state.raw([]);
31
+ view = $state("slide");
32
+ /** The highlighted thumbnail in the overview, as an index among the visible slides. */
33
+ cursor = $state(0);
34
+ direction = $state(1);
35
+ zoom = $state(null);
36
+ size = $state({
37
+ w: 0,
38
+ h: 0
39
+ });
40
+ options;
41
+ #listeners = new Set();
42
+ #timer;
43
+ #frame = 0;
44
+ constructor(options) {
45
+ this.options = options;
46
+ }
47
+ canvas = $derived(this.options.canvas ?? DEFAULT_CANVAS);
48
+ labels = $derived(resolveLabels(this.options.labels));
49
+ slides = $derived(this.entries.map((entry) => ({
50
+ steps: this.options.fragments === false ? 0 : entry.steps,
51
+ hidden: entry.hidden
52
+ })));
53
+ visible = $derived(visibleIndexes(this.slides));
54
+ position = $derived(settle(this.slides, {
55
+ index: this.options.index,
56
+ step: this.options.step
57
+ }));
58
+ ordinal = $derived(ordinalOf(this.slides, this.position.index));
59
+ progress = $derived(progressOf(this.slides, this.position));
60
+ current = $derived(this.entries[this.position.index]);
61
+ /** The slide after this one, for a presenter's preview. Null on the last. */
62
+ upcoming = $derived(this.visible.find((i) => i > this.position.index) ?? null);
63
+ overview = $derived(this.view === "overview");
64
+ /** True when every fragment shows: in the overview, or on a deck that doesn't stage reveals. */
65
+ revealAll = $derived(this.overview || this.options.fragments === false);
66
+ atStart = $derived(prevPosition(this.slides, this.position, this.options.loop) === null);
67
+ atEnd = $derived(nextPosition(this.slides, this.position, this.options.loop) === null);
68
+ fit = $derived(fitBox(this.size, this.canvas));
69
+ grid = $derived(gridBoxes({
70
+ w: this.size.w,
71
+ h: Math.max(0, this.size.h - (this.options.controls === false ? 0 : CONTROLS_ROOM))
72
+ }, this.canvas, this.visible.length));
73
+ /** Where slide `index` sits right now: centred to fit, or in its overview cell. */
74
+ boxOf(index) {
75
+ const zoom = this.zoom;
76
+ const cell = this.grid.boxes[this.visible.indexOf(index)];
77
+ if (zoom?.framed) {
78
+ const target = this.grid.boxes[this.visible.indexOf(zoom.index)];
79
+ return cell && target ? frame(cell, target, this.fit) : this.fit;
80
+ }
81
+ return this.overview || zoom ? cell : this.fit;
82
+ }
83
+ register(entry) {
84
+ untrack(() => {
85
+ this.entries = [...this.entries, entry].sort(byDocumentOrder);
86
+ });
87
+ return () => untrack(() => {
88
+ this.entries = this.entries.filter((candidate) => candidate !== entry);
89
+ this.#write(this.position);
90
+ });
91
+ }
92
+ listen(listener) {
93
+ this.#listeners.add(listener);
94
+ return () => this.#listeners.delete(listener);
95
+ }
96
+ mount() {
97
+ return () => this.#stopZoom();
98
+ }
99
+ /** Which deck hears a key is the page's call: Deck passes only keys from focus inside it. */
100
+ get listening() {
101
+ return this.options.keyboard !== false;
102
+ }
103
+ #write(position) {
104
+ this.options.index = position.index;
105
+ this.options.step = position.step;
106
+ }
107
+ /** `animate: false` skips the slide transition, for a move the overview zoom already shows. */
108
+ commit(target, source = "local", animate = true) {
109
+ if (!target) return;
110
+ const from = this.position;
111
+ const to = settle(this.slides, target);
112
+ if (to.index === from.index && to.step === from.step) return;
113
+ this.direction = to.index >= from.index ? 1 : -1;
114
+ const apply = () => this.#write(to);
115
+ if (animate && to.index !== from.index && this.options.transition) this.options.transition(apply);
116
+ else apply();
117
+ for (const listener of this.#listeners) listener(to, source);
118
+ }
119
+ next() {
120
+ this.commit(nextPosition(this.slides, this.position, this.options.loop));
121
+ }
122
+ prev() {
123
+ this.commit(prevPosition(this.slides, this.position, this.options.loop));
124
+ }
125
+ go(index, step = 0, source = "local") {
126
+ this.commit({
127
+ index,
128
+ step
129
+ }, source);
130
+ }
131
+ /**
132
+ * Opening zooms the camera out from the current slide to the whole grid. Closing zooms into
133
+ * the current slide, so `select` moves there first.
134
+ */
135
+ toggleOverview(force) {
136
+ const open = force ?? !this.overview;
137
+ if (open === this.overview) return;
138
+ const index = this.position.index;
139
+ if (open) this.cursor = Math.max(0, this.visible.indexOf(index));
140
+ this.view = open ? "overview" : "slide";
141
+ this.#stopZoom();
142
+ const ms = this.options.zoomMs?.() ?? 0;
143
+ if (ms <= 0 || !this.visible.includes(index)) return;
144
+ /*
145
+ * Each way paints its start state first and moves on a later frame. Moved in the same
146
+ * flush, a style read in between (the overview handing focus back) commits the boxes
147
+ * before the transition rule is on, and the zoom jumps to its end.
148
+ */
149
+ this.zoom = {
150
+ index,
151
+ framed: open,
152
+ running: !open
153
+ };
154
+ this.#frame = requestAnimationFrame(() => {
155
+ this.#frame = requestAnimationFrame(() => {
156
+ this.zoom = {
157
+ index,
158
+ framed: !open,
159
+ running: true
160
+ };
161
+ this.#timer = setTimeout(() => this.#stopZoom(), ms);
162
+ });
163
+ });
164
+ }
165
+ /** Leaves the overview on slide `index`. Picking the current slide keeps its step. */
166
+ select(index) {
167
+ const keep = index === this.position.index;
168
+ this.commit({
169
+ index,
170
+ step: keep ? this.position.step : 0
171
+ }, "local", false);
172
+ this.toggleOverview(false);
173
+ }
174
+ #stopZoom() {
175
+ clearTimeout(this.#timer);
176
+ cancelAnimationFrame(this.#frame);
177
+ this.zoom = null;
178
+ }
179
+ #moveCursor(to) {
180
+ this.cursor = clamp(to, 0, this.visible.length - 1);
181
+ }
182
+ /**
183
+ * Runs the deck's own keys. Returns the action it took, or null when the key wasn't its.
184
+ * `fullscreen` and `presenter` are returned untouched, since they need the page.
185
+ */
186
+ handleKey(event) {
187
+ if (event.defaultPrevented || !this.listening || event.altKey || event.ctrlKey || event.metaKey) return null;
188
+ const target = event.target instanceof HTMLElement ? event.target : null;
189
+ if (target && isEditable(target)) return null;
190
+ const action = keyAction(event.key, event.shiftKey);
191
+ if (!action) return null;
192
+ /* Space and Enter belong to a focused control, not to the deck. */
193
+ if ((event.key === " " || event.key === "Enter") && target?.closest("button, a, summary")) return null;
194
+ const handled = this.overview ? this.#overviewKey(action) : this.#slideKey(action);
195
+ if (!handled) return null;
196
+ event.preventDefault();
197
+ return action;
198
+ }
199
+ #slideKey(action) {
200
+ switch (action) {
201
+ case "next":
202
+ this.next();
203
+ return true;
204
+ case "prev":
205
+ this.prev();
206
+ return true;
207
+ case "first":
208
+ this.go(this.visible[0] ?? 0);
209
+ return true;
210
+ case "last":
211
+ this.go(this.visible[this.visible.length - 1] ?? 0);
212
+ return true;
213
+ case "overview":
214
+ this.toggleOverview(true);
215
+ return true;
216
+ case "fullscreen": return true;
217
+ case "presenter": return !!this.options.presenter;
218
+ default: return false;
219
+ }
220
+ }
221
+ #overviewKey(action) {
222
+ const cols = this.grid.cols;
223
+ switch (action) {
224
+ case "next":
225
+ this.#moveCursor(this.cursor + 1);
226
+ return true;
227
+ case "prev":
228
+ this.#moveCursor(this.cursor - 1);
229
+ return true;
230
+ case "down":
231
+ this.#moveCursor(this.cursor + cols);
232
+ return true;
233
+ case "up":
234
+ this.#moveCursor(this.cursor - cols);
235
+ return true;
236
+ case "first":
237
+ this.#moveCursor(0);
238
+ return true;
239
+ case "last":
240
+ this.#moveCursor(this.visible.length - 1);
241
+ return true;
242
+ case "select":
243
+ this.select(this.visible[this.cursor] ?? this.position.index);
244
+ return true;
245
+ case "close":
246
+ case "overview":
247
+ this.toggleOverview(false);
248
+ return true;
249
+ case "fullscreen": return true;
250
+ case "presenter": return !!this.options.presenter;
251
+ default: return false;
252
+ }
253
+ }
254
+ }