@open-slide/core 1.6.0 → 1.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 (45) hide show
  1. package/dist/{build-tLrkKUHr.js → build-CCZDC8eF.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-PwUHqZ_X.js → config-C7sZtiY2.js} +45 -18
  4. package/dist/{config-CfMThYN9.d.ts → config-D1bANimZ.d.ts} +1 -1
  5. package/dist/{dev-DpCIRbhT.js → dev-kLS_4CAI.js} +1 -1
  6. package/dist/{en-BDnM5zKJ.js → en-hyGpmL1O.js} +1 -4
  7. package/dist/index.d.ts +22 -4
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +4 -13
  11. package/dist/{preview-BSGlM6Se.js → preview-DUkOjOx8.js} +1 -1
  12. package/dist/{types-B-KrjgX8.d.ts → types-Bvk1pM70.d.ts} +1 -4
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/create-theme/SKILL.md +1 -1
  17. package/skills/slide-authoring/SKILL.md +169 -0
  18. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  19. package/src/app/components/inspector/comment-widget.tsx +16 -2
  20. package/src/app/components/inspector/inspect-overlay.tsx +132 -35
  21. package/src/app/components/inspector/inspector-panel.tsx +19 -256
  22. package/src/app/components/inspector/inspector-provider.tsx +102 -1
  23. package/src/app/components/panel/save-card.tsx +4 -4
  24. package/src/app/components/player.tsx +25 -25
  25. package/src/app/components/sidebar/folder-item.tsx +7 -2
  26. package/src/app/components/sidebar/sidebar.tsx +87 -16
  27. package/src/app/components/slide-transition-layer.tsx +154 -0
  28. package/src/app/components/style-panel/style-panel.tsx +3 -0
  29. package/src/app/lib/folders.ts +28 -0
  30. package/src/app/lib/inspector/fiber.test.ts +154 -0
  31. package/src/app/lib/inspector/fiber.ts +12 -1
  32. package/src/app/lib/sdk.ts +3 -1
  33. package/src/app/lib/transition.ts +23 -0
  34. package/src/app/lib/use-click-page-navigation.ts +52 -0
  35. package/src/app/lib/use-is-mobile.ts +21 -0
  36. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  37. package/src/app/routes/home-shell.tsx +8 -0
  38. package/src/app/routes/home.tsx +1 -1
  39. package/src/app/routes/slide.tsx +92 -60
  40. package/src/locale/en.ts +1 -5
  41. package/src/locale/ja.ts +1 -5
  42. package/src/locale/types.ts +1 -5
  43. package/src/locale/zh-cn.ts +1 -5
  44. package/src/locale/zh-tw.ts +1 -5
  45. package/src/app/components/click-nav-zones.tsx +0 -36
@@ -5,6 +5,7 @@ import { ThemeToggle } from '@/components/theme-toggle';
5
5
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
6
6
  import type { Folder, FolderIcon } from '@/lib/sdk';
7
7
  import { format, useLocale } from '@/lib/use-locale';
8
+ import { cn } from '@/lib/utils';
8
9
  import { FolderIconChip, FolderItem } from './folder-item';
9
10
  import { IconPicker, PRESET_COLORS } from './icon-picker';
10
11
 
@@ -12,6 +13,8 @@ export const DRAFT_ID = 'draft';
12
13
  export const THEMES_ID = '__themes__';
13
14
  export const ASSETS_ID = '__assets__';
14
15
 
16
+ export const FOLDER_DND_MIME = 'application/x-folder-id';
17
+
15
18
  export function Sidebar({
16
19
  folders,
17
20
  countFor,
@@ -25,6 +28,7 @@ export function Sidebar({
25
28
  onDelete,
26
29
  onDropToFolder,
27
30
  onDropToDraft,
31
+ onReorder,
28
32
  }: {
29
33
  folders: Folder[];
30
34
  countFor: (folderId: string | null) => number;
@@ -38,7 +42,23 @@ export function Sidebar({
38
42
  onDelete: (id: string) => void;
39
43
  onDropToFolder: (folderId: string, slideId: string) => void;
40
44
  onDropToDraft: (slideId: string) => void;
45
+ onReorder: (ids: string[]) => void;
41
46
  }) {
47
+ const [dragId, setDragId] = useState<string | null>(null);
48
+ const [dropTarget, setDropTarget] = useState<{ id: string; before: boolean } | null>(null);
49
+
50
+ const finishReorder = (toId: string, before: boolean) => {
51
+ const fromId = dragId;
52
+ setDragId(null);
53
+ setDropTarget(null);
54
+ if (!fromId || fromId === toId) return;
55
+ const ids = folders.map((f) => f.id);
56
+ if (!ids.includes(fromId) || !ids.includes(toId)) return;
57
+ const next = ids.filter((id) => id !== fromId);
58
+ next.splice(next.indexOf(toId) + (before ? 0 : 1), 0, fromId);
59
+ if (next.every((id, i) => id === ids[i])) return;
60
+ onReorder(next);
61
+ };
42
62
  const [creating, setCreating] = useState(false);
43
63
  const [newName, setNewName] = useState('');
44
64
  const [newIcon, setNewIcon] = useState<FolderIcon>(() => ({
@@ -139,22 +159,73 @@ export function Sidebar({
139
159
  </div>
140
160
 
141
161
  <div className="flex-1 overflow-y-auto px-2 pb-2">
142
- {folders.map((folder) => (
143
- <FolderItem
144
- key={folder.id}
145
- row={{
146
- kind: 'folder',
147
- folder,
148
- onRename: (name) => onRename(folder.id, name),
149
- onChangeIcon: (icon) => onChangeIcon(folder.id, icon),
150
- onDelete: () => onDelete(folder.id),
151
- }}
152
- count={countFor(folder.id)}
153
- selected={selectedId === folder.id}
154
- onSelect={() => onSelect(folder.id)}
155
- onDropSlide={(slideId) => onDropToFolder(folder.id, slideId)}
156
- />
157
- ))}
162
+ {folders.map((folder) => {
163
+ const isDropTarget = dropTarget?.id === folder.id;
164
+ const before = isDropTarget && dropTarget.before;
165
+ const after = isDropTarget && !dropTarget.before;
166
+ return (
167
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop handle wraps the row
168
+ <div
169
+ key={folder.id}
170
+ className={cn(
171
+ 'relative',
172
+ before &&
173
+ 'before:absolute before:inset-x-2 before:-top-px before:h-[2px] before:rounded-full before:bg-brand',
174
+ after &&
175
+ 'after:absolute after:inset-x-2 after:-bottom-px after:h-[2px] after:rounded-full after:bg-brand',
176
+ dragId === folder.id && 'opacity-50',
177
+ )}
178
+ draggable={import.meta.env.DEV}
179
+ onDragStart={(e) => {
180
+ if (!import.meta.env.DEV) return;
181
+ e.dataTransfer.setData(FOLDER_DND_MIME, folder.id);
182
+ e.dataTransfer.effectAllowed = 'move';
183
+ setDragId(folder.id);
184
+ }}
185
+ onDragEnd={() => {
186
+ setDragId(null);
187
+ setDropTarget(null);
188
+ }}
189
+ onDragOver={(e) => {
190
+ if (!e.dataTransfer.types.includes(FOLDER_DND_MIME)) return;
191
+ e.preventDefault();
192
+ e.dataTransfer.dropEffect = 'move';
193
+ const rect = e.currentTarget.getBoundingClientRect();
194
+ const isBefore = e.clientY < rect.top + rect.height / 2;
195
+ if (!dropTarget || dropTarget.id !== folder.id || dropTarget.before !== isBefore) {
196
+ setDropTarget({ id: folder.id, before: isBefore });
197
+ }
198
+ }}
199
+ onDragLeave={(e) => {
200
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
201
+ if (dropTarget?.id === folder.id) setDropTarget(null);
202
+ }}
203
+ onDrop={(e) => {
204
+ const fromId = e.dataTransfer.getData(FOLDER_DND_MIME);
205
+ if (!fromId) return;
206
+ e.preventDefault();
207
+ e.stopPropagation();
208
+ const rect = e.currentTarget.getBoundingClientRect();
209
+ const isBefore = e.clientY < rect.top + rect.height / 2;
210
+ finishReorder(folder.id, isBefore);
211
+ }}
212
+ >
213
+ <FolderItem
214
+ row={{
215
+ kind: 'folder',
216
+ folder,
217
+ onRename: (name) => onRename(folder.id, name),
218
+ onChangeIcon: (icon) => onChangeIcon(folder.id, icon),
219
+ onDelete: () => onDelete(folder.id),
220
+ }}
221
+ count={countFor(folder.id)}
222
+ selected={selectedId === folder.id}
223
+ onSelect={() => onSelect(folder.id)}
224
+ onDropSlide={(slideId) => onDropToFolder(folder.id, slideId)}
225
+ />
226
+ </div>
227
+ );
228
+ })}
158
229
 
159
230
  {import.meta.env.DEV &&
160
231
  (creating ? (
@@ -0,0 +1,154 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { SlidePageProvider } from '../lib/page-context';
3
+ import type { Page } from '../lib/sdk';
4
+ import { resolveTransition, type SlideTransition, type TransitionPhase } from '../lib/transition';
5
+
6
+ type Props = {
7
+ pages: Page[];
8
+ index: number;
9
+ total: number;
10
+ moduleTransition?: SlideTransition;
11
+ disabled?: boolean;
12
+ };
13
+
14
+ type Direction = 'forward' | 'backward';
15
+
16
+ const DEFAULT_EASING = 'cubic-bezier(.4, 0, .2, 1)';
17
+
18
+ function runPhase(
19
+ el: HTMLElement,
20
+ phase: TransitionPhase | undefined,
21
+ fallbackDuration: number,
22
+ fallbackEasing: string,
23
+ ): Animation | null {
24
+ if (!phase) return null;
25
+ return el.animate(phase.keyframes, {
26
+ duration: phase.duration ?? fallbackDuration,
27
+ easing: phase.easing ?? fallbackEasing,
28
+ delay: phase.delay ?? 0,
29
+ fill: 'both',
30
+ });
31
+ }
32
+
33
+ export function SlideTransitionLayer({ pages, index, total, moduleTransition, disabled }: Props) {
34
+ const [current, setCurrent] = useState(index);
35
+ const [outgoing, setOutgoing] = useState<number | null>(null);
36
+ const [direction, setDirection] = useState<Direction>('forward');
37
+
38
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
39
+ const outgoingLayerRef = useRef<HTMLDivElement | null>(null);
40
+ const incomingLayerRef = useRef<HTMLDivElement | null>(null);
41
+ const animsRef = useRef<Animation[]>([]);
42
+ const currentRef = useRef(current);
43
+ currentRef.current = current;
44
+
45
+ useEffect(() => {
46
+ if (index === currentRef.current) return;
47
+
48
+ const prev = currentRef.current;
49
+ const next = index;
50
+
51
+ // Interrupt: cancel in-flight animations. The previously-incoming page
52
+ // (currentRef) becomes the new outgoing; React reuses its DOM slot.
53
+ for (const a of animsRef.current) {
54
+ try {
55
+ a.cancel();
56
+ } catch {}
57
+ }
58
+ animsRef.current = [];
59
+
60
+ const transition = resolveTransition(pages, next, moduleTransition);
61
+ if (disabled || !transition) {
62
+ setCurrent(next);
63
+ setOutgoing(null);
64
+ return;
65
+ }
66
+
67
+ setDirection(next > prev ? 'forward' : 'backward');
68
+ setOutgoing(prev);
69
+ setCurrent(next);
70
+ }, [index, pages, moduleTransition, disabled]);
71
+
72
+ useEffect(() => {
73
+ if (outgoing === null) return;
74
+
75
+ const transition = resolveTransition(pages, current, moduleTransition);
76
+ const wrapper = wrapperRef.current;
77
+ const out = outgoingLayerRef.current;
78
+ const inc = incomingLayerRef.current;
79
+ if (!transition || !wrapper || !out || !inc) {
80
+ setOutgoing(null);
81
+ return;
82
+ }
83
+
84
+ wrapper.dataset.osdDir = direction;
85
+ wrapper.style.setProperty('--osd-dir', direction === 'forward' ? '1' : '-1');
86
+
87
+ const easing = transition.easing ?? DEFAULT_EASING;
88
+ const duration = transition.duration;
89
+
90
+ const anims: Animation[] = [];
91
+ const exitAnim = runPhase(out, transition.exit, duration, easing);
92
+ const enterAnim = runPhase(inc, transition.enter, duration, easing);
93
+ if (exitAnim) anims.push(exitAnim);
94
+ if (enterAnim) anims.push(enterAnim);
95
+ animsRef.current = anims;
96
+
97
+ if (anims.length === 0) {
98
+ setOutgoing(null);
99
+ return;
100
+ }
101
+
102
+ let cancelled = false;
103
+ Promise.all(anims.map((a) => a.finished))
104
+ .then(() => {
105
+ if (cancelled) return;
106
+ animsRef.current = [];
107
+ setOutgoing(null);
108
+ })
109
+ .catch(() => {
110
+ // AbortError fires when we cancel mid-flight on an interrupt.
111
+ });
112
+
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, [outgoing, current, direction, pages, moduleTransition]);
117
+
118
+ useEffect(() => {
119
+ return () => {
120
+ for (const a of animsRef.current) {
121
+ try {
122
+ a.cancel();
123
+ } catch {}
124
+ }
125
+ animsRef.current = [];
126
+ };
127
+ }, []);
128
+
129
+ const CurrentPage = pages[current];
130
+ const OutgoingPage = outgoing !== null ? pages[outgoing] : null;
131
+
132
+ return (
133
+ <div
134
+ ref={wrapperRef}
135
+ className="relative h-full w-full"
136
+ style={{ background: 'var(--osd-bg)' }}
137
+ >
138
+ {OutgoingPage && outgoing !== null ? (
139
+ <div ref={outgoingLayerRef} className="absolute inset-0">
140
+ <SlidePageProvider index={outgoing} total={total}>
141
+ <OutgoingPage />
142
+ </SlidePageProvider>
143
+ </div>
144
+ ) : null}
145
+ {CurrentPage ? (
146
+ <div ref={incomingLayerRef} className="absolute inset-0">
147
+ <SlidePageProvider index={current} total={total}>
148
+ <CurrentPage />
149
+ </SlidePageProvider>
150
+ </div>
151
+ ) : null}
152
+ </div>
153
+ );
154
+ }
@@ -211,6 +211,9 @@ export function DesignToggleButton({
211
211
  >
212
212
  <Palette className="size-3.5" />
213
213
  <span className="hidden md:inline">{t.stylePanel.designToggle}</span>
214
+ <kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
215
+ D
216
+ </kbd>
214
217
  </Button>
215
218
  );
216
219
  }
@@ -88,12 +88,22 @@ async function putAssign(slideId: string, folderId: string | null): Promise<void
88
88
  if (!res.ok) throw new Error(`PUT /__folders/assign ${res.status}`);
89
89
  }
90
90
 
91
+ async function putReorder(ids: string[]): Promise<void> {
92
+ const res = await fetch('/__folders/reorder', {
93
+ method: 'PUT',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({ ids }),
96
+ });
97
+ if (!res.ok) throw new Error(`PUT /__folders/reorder ${res.status}`);
98
+ }
99
+
91
100
  export type UseFoldersResult = {
92
101
  manifest: FoldersManifest;
93
102
  loading: boolean;
94
103
  create: (name: string, icon: FolderIcon) => Promise<Folder>;
95
104
  update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
96
105
  remove: (id: string) => Promise<void>;
106
+ reorder: (ids: string[]) => Promise<void>;
97
107
  assign: (slideId: string, folderId: string | null) => Promise<void>;
98
108
  renameSlide: (slideId: string, name: string) => Promise<void>;
99
109
  duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
@@ -163,6 +173,23 @@ export function useFolders(): UseFoldersResult {
163
173
  [refresh],
164
174
  );
165
175
 
176
+ const reorder = useCallback(
177
+ async (ids: string[]) => {
178
+ const prev = manifest;
179
+ const byId = new Map(prev.folders.map((f) => [f.id, f]));
180
+ const next = ids.map((id) => byId.get(id)).filter((f): f is Folder => Boolean(f));
181
+ if (next.length !== prev.folders.length) return;
182
+ setManifest({ ...prev, folders: next });
183
+ try {
184
+ await putReorder(ids);
185
+ } catch (err) {
186
+ setManifest(prev);
187
+ throw err;
188
+ }
189
+ },
190
+ [manifest],
191
+ );
192
+
166
193
  const assign = useCallback(
167
194
  async (slideId: string, folderId: string | null) => {
168
195
  await putAssign(slideId, folderId);
@@ -202,6 +229,7 @@ export function useFolders(): UseFoldersResult {
202
229
  create,
203
230
  update,
204
231
  remove,
232
+ reorder,
205
233
  assign,
206
234
  renameSlide,
207
235
  duplicateSlide,
@@ -0,0 +1,154 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { findSlideSource } from './fiber.ts';
3
+
4
+ class FakeHTMLElement {
5
+ dataset: Record<string, string> = {};
6
+ private closestSelf: FakeHTMLElement | null = null;
7
+ setClosestSelfForSlideLoc() {
8
+ this.closestSelf = this;
9
+ }
10
+ closest(selector: string): FakeHTMLElement | null {
11
+ if (selector === '[data-slide-loc]') return this.closestSelf;
12
+ return null;
13
+ }
14
+ }
15
+
16
+ type DebugSource = { fileName?: string; lineNumber?: number; columnNumber?: number };
17
+ type FakeFiber = {
18
+ return: FakeFiber | null;
19
+ stateNode?: unknown;
20
+ _debugSource?: DebugSource;
21
+ };
22
+
23
+ function makeEl(opts: { slideLoc?: string; fiber?: FakeFiber } = {}): FakeHTMLElement {
24
+ const el = new FakeHTMLElement();
25
+ if (opts.slideLoc) {
26
+ el.dataset.slideLoc = opts.slideLoc;
27
+ el.setClosestSelfForSlideLoc();
28
+ }
29
+ if (opts.fiber) {
30
+ (el as unknown as Record<string, FakeFiber>).__reactFiber$test = opts.fiber;
31
+ }
32
+ return el;
33
+ }
34
+
35
+ function makeFiber(opts: {
36
+ fileName?: string;
37
+ line?: number;
38
+ column?: number;
39
+ host?: boolean;
40
+ parent?: FakeFiber | null;
41
+ }): FakeFiber {
42
+ const source: DebugSource | undefined =
43
+ opts.fileName !== undefined
44
+ ? { fileName: opts.fileName, lineNumber: opts.line, columnNumber: opts.column }
45
+ : undefined;
46
+ return {
47
+ return: opts.parent ?? null,
48
+ stateNode: opts.host ? new FakeHTMLElement() : undefined,
49
+ _debugSource: source,
50
+ };
51
+ }
52
+
53
+ beforeAll(() => {
54
+ vi.stubGlobal('HTMLElement', FakeHTMLElement);
55
+ });
56
+
57
+ afterAll(() => {
58
+ vi.unstubAllGlobals();
59
+ });
60
+
61
+ describe('findSlideSource primary path', () => {
62
+ it('reads line:column from data-slide-loc', () => {
63
+ const el = makeEl({ slideLoc: '42:7' });
64
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
65
+ expect(hit).not.toBeNull();
66
+ expect(hit?.line).toBe(42);
67
+ expect(hit?.column).toBe(7);
68
+ expect(hit?.anchor).toBe(el as unknown as HTMLElement);
69
+ });
70
+ });
71
+
72
+ describe('findSlideSource fallback', () => {
73
+ it('matches a POSIX fileName', () => {
74
+ const fiber = makeFiber({
75
+ fileName: '/repo/slides/cover/index.tsx',
76
+ line: 10,
77
+ column: 4,
78
+ host: true,
79
+ });
80
+ const el = makeEl({ fiber });
81
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
82
+ expect(hit).not.toBeNull();
83
+ expect(hit?.line).toBe(10);
84
+ expect(hit?.column).toBe(4);
85
+ });
86
+
87
+ it('matches a Windows-backslash fileName', () => {
88
+ const fiber = makeFiber({
89
+ fileName: 'C:\\repo\\slides\\cover\\index.tsx',
90
+ line: 11,
91
+ column: 2,
92
+ host: true,
93
+ });
94
+ const el = makeEl({ fiber });
95
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
96
+ expect(hit).not.toBeNull();
97
+ expect(hit?.line).toBe(11);
98
+ expect(hit?.column).toBe(2);
99
+ });
100
+
101
+ it('matches a fileName carrying an HMR ?t= query', () => {
102
+ const fiber = makeFiber({
103
+ fileName: '/repo/slides/cover/index.tsx?t=1700000000000',
104
+ line: 12,
105
+ column: 0,
106
+ host: true,
107
+ });
108
+ const el = makeEl({ fiber });
109
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
110
+ expect(hit).not.toBeNull();
111
+ expect(hit?.line).toBe(12);
112
+ });
113
+
114
+ it('matches a Windows fileName with an HMR query', () => {
115
+ const fiber = makeFiber({
116
+ fileName: 'C:\\repo\\slides\\cover\\index.tsx?t=1700000000000',
117
+ line: 13,
118
+ column: 1,
119
+ host: true,
120
+ });
121
+ const el = makeEl({ fiber });
122
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
123
+ expect(hit).not.toBeNull();
124
+ expect(hit?.line).toBe(13);
125
+ expect(hit?.column).toBe(1);
126
+ });
127
+
128
+ it('returns null when the fiber fileName points at a different slideId', () => {
129
+ const fiber = makeFiber({
130
+ fileName: '/repo/slides/other/index.tsx',
131
+ line: 10,
132
+ column: 4,
133
+ host: true,
134
+ });
135
+ const el = makeEl({ fiber });
136
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
137
+ expect(hit).toBeNull();
138
+ });
139
+
140
+ it('walks up the fiber chain until it finds a matching source', () => {
141
+ const parent = makeFiber({
142
+ fileName: '/repo/slides/cover/index.tsx',
143
+ line: 99,
144
+ column: 3,
145
+ host: true,
146
+ });
147
+ const leaf = makeFiber({ parent, host: true });
148
+ const el = makeEl({ fiber: leaf });
149
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
150
+ expect(hit).not.toBeNull();
151
+ expect(hit?.line).toBe(99);
152
+ expect(hit?.column).toBe(3);
153
+ });
154
+ });
@@ -28,6 +28,12 @@ function getSource(fiber: FiberLike) {
28
28
  return fiber._debugSource ?? fiber.memoizedProps?.__source;
29
29
  }
30
30
 
31
+ // `_debugSource.fileName` may carry Vite's HMR query (`?t=…`) and, on
32
+ // Windows, backslash separators. Both break the naive `endsWith` match.
33
+ function normalizeDebugFileName(fileName: string): string {
34
+ return fileName.split(/[?#]/)[0].replace(/\\/g, '/');
35
+ }
36
+
31
37
  export function findSlideSource(
32
38
  el: HTMLElement,
33
39
  slideId: string,
@@ -58,7 +64,12 @@ export function findSlideSource(
58
64
  while (fiber) {
59
65
  const src = getSource(fiber);
60
66
  const isHost = fiber.stateNode instanceof HTMLElement;
61
- if (src?.fileName?.endsWith(needle) && src.lineNumber && (!opts?.hostOnly || isHost)) {
67
+ if (
68
+ src?.fileName &&
69
+ normalizeDebugFileName(src.fileName).endsWith(needle) &&
70
+ src.lineNumber &&
71
+ (!opts?.hostOnly || isHost)
72
+ ) {
62
73
  return {
63
74
  line: src.lineNumber,
64
75
  column: src.columnNumber ?? 0,
@@ -1,7 +1,8 @@
1
1
  import type { ComponentType } from 'react';
2
2
  import type { DesignSystem } from './design.ts';
3
+ import type { SlideTransition } from './transition.ts';
3
4
 
4
- export type Page = ComponentType;
5
+ export type Page = ComponentType & { transition?: SlideTransition };
5
6
 
6
7
  export type SlideMeta = {
7
8
  title?: string;
@@ -16,6 +17,7 @@ export type SlideModule = {
16
17
  design?: DesignSystem;
17
18
  // Index-aligned with `default`.
18
19
  notes?: (string | undefined)[];
20
+ transition?: SlideTransition;
19
21
  };
20
22
 
21
23
  export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
@@ -0,0 +1,23 @@
1
+ import type { Page } from './sdk';
2
+
3
+ export type TransitionPhase = {
4
+ keyframes: Keyframe[] | PropertyIndexedKeyframes;
5
+ easing?: string;
6
+ duration?: number;
7
+ delay?: number;
8
+ };
9
+
10
+ export type SlideTransition = {
11
+ duration: number;
12
+ easing?: string;
13
+ enter?: TransitionPhase;
14
+ exit?: TransitionPhase;
15
+ };
16
+
17
+ export function resolveTransition(
18
+ pages: Page[],
19
+ index: number,
20
+ moduleDefault?: SlideTransition,
21
+ ): SlideTransition | undefined {
22
+ return pages[index]?.transition ?? moduleDefault;
23
+ }
@@ -0,0 +1,52 @@
1
+ import { type RefObject, useEffect } from 'react';
2
+
3
+ // Clicks that land on (or inside) these never navigate — interactive slide
4
+ // content keeps its click, and present chrome is excluded via data-osd-chrome.
5
+ // Authors can opt any element out with a data-osd-interactive attribute.
6
+ const NAV_PASSTHROUGH =
7
+ 'a, button, input, textarea, select, label, summary, iframe, video, audio, embed, object, [role="button"], [role="link"], [contenteditable="true"], [data-osd-interactive], [data-osd-chrome]';
8
+
9
+ type UseClickPageNavigationOptions<T extends HTMLElement> = {
10
+ ref: RefObject<T>;
11
+ enabled?: boolean;
12
+ /** Fraction of the width on each side that navigates; the center is inert. */
13
+ edgeRatio?: number;
14
+ canPrev: boolean;
15
+ canNext: boolean;
16
+ onPrev: () => void;
17
+ onNext: () => void;
18
+ };
19
+
20
+ export function useClickPageNavigation<T extends HTMLElement>({
21
+ ref,
22
+ enabled = true,
23
+ edgeRatio = 0.3,
24
+ canPrev,
25
+ canNext,
26
+ onPrev,
27
+ onNext,
28
+ }: UseClickPageNavigationOptions<T>) {
29
+ useEffect(() => {
30
+ const el = ref.current;
31
+ if (!el || !enabled) return;
32
+
33
+ const onClick = (event: MouseEvent) => {
34
+ if (event.button !== 0 || event.defaultPrevented) return;
35
+ const target = event.target;
36
+ if (target instanceof HTMLElement && target.closest(NAV_PASSTHROUGH)) return;
37
+ if (window.getSelection()?.toString()) return;
38
+
39
+ const rect = el.getBoundingClientRect();
40
+ if (rect.width === 0) return;
41
+ const x = (event.clientX - rect.left) / rect.width;
42
+ if (x < edgeRatio) {
43
+ if (canPrev) onPrev();
44
+ } else if (x > 1 - edgeRatio) {
45
+ if (canNext) onNext();
46
+ }
47
+ };
48
+
49
+ el.addEventListener('click', onClick);
50
+ return () => el.removeEventListener('click', onClick);
51
+ }, [ref, enabled, edgeRatio, canPrev, canNext, onPrev, onNext]);
52
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ // Matches Tailwind's `md` breakpoint — below it the slide viewer hides desktop
4
+ // navigation chrome and relies on tap-to-navigate instead.
5
+ const QUERY = '(max-width: 767.98px)';
6
+
7
+ export function useIsMobile(): boolean {
8
+ const [mobile, setMobile] = useState(() => {
9
+ if (typeof window === 'undefined') return false;
10
+ return window.matchMedia(QUERY).matches;
11
+ });
12
+
13
+ useEffect(() => {
14
+ const mql = window.matchMedia(QUERY);
15
+ const onChange = (e: MediaQueryListEvent) => setMobile(e.matches);
16
+ mql.addEventListener('change', onChange);
17
+ return () => mql.removeEventListener('change', onChange);
18
+ }, []);
19
+
20
+ return mobile;
21
+ }
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ const QUERY = '(prefers-reduced-motion: reduce)';
4
+
5
+ export function usePrefersReducedMotion(): boolean {
6
+ const [reduce, setReduce] = useState(() => {
7
+ if (typeof window === 'undefined') return false;
8
+ return window.matchMedia(QUERY).matches;
9
+ });
10
+
11
+ useEffect(() => {
12
+ const mql = window.matchMedia(QUERY);
13
+ const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
14
+ mql.addEventListener('change', onChange);
15
+ return () => mql.removeEventListener('change', onChange);
16
+ }, []);
17
+
18
+ return reduce;
19
+ }