@heyramzi/ui 3.7.0 → 3.8.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 (47) hide show
  1. package/COMPONENTS.md +1 -0
  2. package/dist/canvas/pen/node-name.svelte.js +29 -6
  3. package/dist/components/file-drop-zone/files.js +111 -0
  4. package/dist/components/file-drop-zone/index.js +2 -0
  5. package/package.json +13 -1
  6. package/scripts/lib/svelte-modules.mjs +38 -0
  7. package/scripts/svelte-dts.mjs +2 -0
  8. package/scripts/svelte-modules.mjs +14 -0
  9. package/scripts/sync-shadcn.mjs +0 -1
  10. package/src/ai/chat-window/reveal.svelte.js +106 -0
  11. package/src/ai/confirmation/confirmation-context.svelte.js +12 -0
  12. package/src/ai/reasoning/reasoning-context.svelte.js +12 -0
  13. package/src/canvas/editor/dispatch.svelte.js +160 -0
  14. package/src/canvas/lod/lod-mode.svelte.js +24 -0
  15. package/src/canvas/lod/render-all.svelte.js +42 -0
  16. package/src/canvas/pen/node-name.svelte.js +48 -0
  17. package/src/canvas/pen/node-name.svelte.ts +30 -6
  18. package/src/components/copy/copied.svelte.js +34 -0
  19. package/src/components/docs/scroll-spy.svelte.js +76 -0
  20. package/src/components/file-drop-zone/file-drop-zone.svelte +348 -0
  21. package/src/components/file-drop-zone/file-drop-zone.svelte.d.ts +43 -0
  22. package/src/components/file-drop-zone/file-drop-zone.test.ts +148 -0
  23. package/src/components/file-drop-zone/files.test.ts +105 -0
  24. package/src/components/file-drop-zone/files.ts +144 -0
  25. package/src/components/file-drop-zone/index.ts +14 -0
  26. package/src/consumer-prebundle.test.ts +126 -0
  27. package/src/effects/animated-beam/path-calculator.svelte.js +54 -0
  28. package/src/onboarding/onboarding.svelte.js +19 -0
  29. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  30. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  31. package/src/shadcn/calendar/calendar.svelte +40 -30
  32. package/src/shadcn/calendar/calendar.svelte.d.ts +0 -1
  33. package/src/shadcn/hooks/is-mobile.svelte.js +7 -0
  34. package/src/shadcn/hooks/use-highlighted-code.svelte.js +65 -0
  35. package/src/shadcn/input/input.svelte +1 -1
  36. package/src/shadcn/input/input.svelte.d.ts +1 -1
  37. package/src/shadcn/input-group/input-group-input.svelte.d.ts +1 -1
  38. package/src/shadcn/input-otp/input-otp-group.svelte +1 -0
  39. package/src/shadcn/input-otp/input-otp-slot.svelte +5 -1
  40. package/src/shadcn/input-otp/input-otp.svelte +2 -0
  41. package/src/shadcn/input-otp/input-otp.svelte.d.ts +1 -1
  42. package/src/shadcn/sidebar/context.svelte.js +63 -0
  43. package/src/shadcn/sidebar/sidebar-input.svelte.d.ts +1 -1
  44. package/src/slides/deck-controller.svelte.js +254 -0
  45. package/src/work/kanban-board-state.svelte.js +205 -0
  46. package/src/work/kanban-rail-state.svelte.js +85 -0
  47. package/src/work/use-kanban-dnd.svelte.js +129 -0
@@ -1,9 +1,19 @@
1
1
  <script lang="ts">
2
2
  import { Calendar as CalendarPrimitive } from "bits-ui";
3
- // The root component composes its own sibling sub-components through the barrel it
4
- // is re-exported from; standard shadcn-svelte shape, not an initialization hazard.
5
- // fallow-ignore-next-line circular-dependency
6
- import * as Calendar from "./index.js";
3
+ import CalendarCaption from "./calendar-caption.svelte";
4
+ import CalendarCell from "./calendar-cell.svelte";
5
+ import CalendarDay from "./calendar-day.svelte";
6
+ import CalendarGridBody from "./calendar-grid-body.svelte";
7
+ import CalendarGridHead from "./calendar-grid-head.svelte";
8
+ import CalendarGridRow from "./calendar-grid-row.svelte";
9
+ import CalendarGrid from "./calendar-grid.svelte";
10
+ import CalendarHeadCell from "./calendar-head-cell.svelte";
11
+ import CalendarHeader from "./calendar-header.svelte";
12
+ import CalendarMonth from "./calendar-month.svelte";
13
+ import CalendarMonths from "./calendar-months.svelte";
14
+ import CalendarNav from "./calendar-nav.svelte";
15
+ import CalendarNextButton from "./calendar-next-button.svelte";
16
+ import CalendarPrevButton from "./calendar-prev-button.svelte";
7
17
  import { cn, type WithoutChildrenOrChild } from "../../lib/utils.js";
8
18
  import type { ButtonVariant } from "../button/button.svelte";
9
19
  import { isEqualMonth, type DateValue } from "@internationalized/date";
@@ -68,15 +78,15 @@ get along, so we shut typescript up by casting `value` to `never`.
68
78
  {...restProps}
69
79
  >
70
80
  {#snippet children({ months, weekdays })}
71
- <Calendar.Months>
72
- <Calendar.Nav>
73
- <Calendar.PrevButton variant={buttonVariant} />
74
- <Calendar.NextButton variant={buttonVariant} />
75
- </Calendar.Nav>
81
+ <CalendarMonths>
82
+ <CalendarNav>
83
+ <CalendarPrevButton variant={buttonVariant} />
84
+ <CalendarNextButton variant={buttonVariant} />
85
+ </CalendarNav>
76
86
  {#each months as month, monthIndex (month)}
77
- <Calendar.Month>
78
- <Calendar.Header>
79
- <Calendar.Caption
87
+ <CalendarMonth>
88
+ <CalendarHeader>
89
+ <CalendarCaption
80
90
  {captionLayout}
81
91
  months={monthsProp}
82
92
  {monthFormat}
@@ -87,38 +97,38 @@ get along, so we shut typescript up by casting `value` to `never`.
87
97
  {locale}
88
98
  {monthIndex}
89
99
  />
90
- </Calendar.Header>
91
- <Calendar.Grid>
92
- <Calendar.GridHead>
93
- <Calendar.GridRow class="select-none">
100
+ </CalendarHeader>
101
+ <CalendarGrid>
102
+ <CalendarGridHead>
103
+ <CalendarGridRow class="select-none">
94
104
  {#each weekdays as weekday (weekday)}
95
- <Calendar.HeadCell>
105
+ <CalendarHeadCell>
96
106
  {weekday.slice(0, 2)}
97
- </Calendar.HeadCell>
107
+ </CalendarHeadCell>
98
108
  {/each}
99
- </Calendar.GridRow>
100
- </Calendar.GridHead>
101
- <Calendar.GridBody>
109
+ </CalendarGridRow>
110
+ </CalendarGridHead>
111
+ <CalendarGridBody>
102
112
  {#each month.weeks as weekDates (weekDates)}
103
- <Calendar.GridRow class="mt-2 w-full">
113
+ <CalendarGridRow class="mt-2 w-full">
104
114
  {#each weekDates as date (date)}
105
- <Calendar.Cell {date} month={month.value}>
115
+ <CalendarCell {date} month={month.value}>
106
116
  {#if day}
107
117
  {@render day({
108
118
  day: date,
109
119
  outsideMonth: !isEqualMonth(date, month.value),
110
120
  })}
111
121
  {:else}
112
- <Calendar.Day />
122
+ <CalendarDay />
113
123
  {/if}
114
- </Calendar.Cell>
124
+ </CalendarCell>
115
125
  {/each}
116
- </Calendar.GridRow>
126
+ </CalendarGridRow>
117
127
  {/each}
118
- </Calendar.GridBody>
119
- </Calendar.Grid>
120
- </Calendar.Month>
128
+ </CalendarGridBody>
129
+ </CalendarGrid>
130
+ </CalendarMonth>
121
131
  {/each}
122
- </Calendar.Months>
132
+ </CalendarMonths>
123
133
  {/snippet}
124
134
  </CalendarPrimitive.Root>
@@ -1,5 +1,4 @@
1
1
  import { Calendar as CalendarPrimitive } from "bits-ui";
2
- import * as Calendar from "./index.js";
3
2
  import { type WithoutChildrenOrChild } from "../../lib/utils.js";
4
3
  import type { ButtonVariant } from "../button/button.svelte";
5
4
  import { type DateValue } from "@internationalized/date";
@@ -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
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  type Props = WithElementRef<
8
8
  Omit<HTMLInputAttributes, "type"> &
9
- ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
9
+ ({ type: "file"; files?: FileList | null } | { type?: InputType; files?: undefined })
10
10
  >;
11
11
 
12
12
  let {
@@ -3,7 +3,7 @@ import { type WithElementRef } from "../../lib/utils.js";
3
3
  type InputType = Exclude<HTMLInputTypeAttribute, "file">;
4
4
  type Props = WithElementRef<Omit<HTMLInputAttributes, "type"> & ({
5
5
  type: "file";
6
- files?: FileList;
6
+ files?: FileList | null;
7
7
  } | {
8
8
  type?: InputType;
9
9
  files?: undefined;
@@ -1,6 +1,6 @@
1
1
  declare const $$Component: import("svelte").Component<(Omit<import("svelte/elements").HTMLInputAttributes, "type"> & ({
2
2
  type: "file";
3
- files?: FileList;
3
+ files?: FileList | null;
4
4
  } | {
5
5
  type?: "number" | "hidden" | "color" | "text" | "search" | "submit" | "reset" | "button" | "checkbox" | "radio" | (string & {}) | "tel" | "url" | "email" | "date" | "image" | "time" | "month" | "datetime-local" | "password" | "range" | "week";
6
6
  files?: undefined;
@@ -13,6 +13,7 @@
13
13
  <div
14
14
  bind:this={ref}
15
15
  data-slot="input-otp-group"
16
+ aria-hidden="true"
16
17
  class={cn("flex items-center", className)}
17
18
  {...restProps}
18
19
  >
@@ -22,5 +22,9 @@
22
22
  )}
23
23
  {...restProps}
24
24
  >
25
- {cell.char}
25
+ {#snippet child({ props })}
26
+ <div {...props} data-active={cell.isActive}>
27
+ {cell.char}
28
+ </div>
29
+ {/snippet}
26
30
  </InputOTPPrimitive.Cell>
@@ -4,6 +4,7 @@
4
4
 
5
5
  let {
6
6
  ref = $bindable(null),
7
+ inputRef = $bindable(null),
7
8
  class: className,
8
9
  value = $bindable(""),
9
10
  ...restProps
@@ -12,6 +13,7 @@
12
13
 
13
14
  <InputOTPPrimitive.Root
14
15
  bind:ref
16
+ bind:inputRef
15
17
  bind:value
16
18
  data-slot="input-otp"
17
19
  class={cn(
@@ -1,4 +1,4 @@
1
1
  import { PinInput as InputOTPPrimitive } from "bits-ui";
2
- declare const $$Component: import("svelte").Component<InputOTPPrimitive.RootProps, {}, "value" | "ref">;
2
+ declare const $$Component: import("svelte").Component<InputOTPPrimitive.RootProps, {}, "value" | "ref" | "inputRef">;
3
3
  type $$Component = ReturnType<typeof $$Component>;
4
4
  export default $$Component;
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  declare const $$Component: import("svelte").Component<(Omit<import("svelte/elements").HTMLInputAttributes, "type"> & ({
2
2
  type: "file";
3
- files?: FileList;
3
+ files?: FileList | null;
4
4
  } | {
5
5
  type?: "number" | "hidden" | "color" | "text" | "search" | "submit" | "reset" | "button" | "checkbox" | "radio" | (string & {}) | "tel" | "url" | "email" | "date" | "image" | "time" | "month" | "datetime-local" | "password" | "range" | "week";
6
6
  files?: undefined;
@@ -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
+ }