@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.
- package/dist/{build-tLrkKUHr.js → build-CCZDC8eF.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-PwUHqZ_X.js → config-C7sZtiY2.js} +45 -18
- package/dist/{config-CfMThYN9.d.ts → config-D1bANimZ.d.ts} +1 -1
- package/dist/{dev-DpCIRbhT.js → dev-kLS_4CAI.js} +1 -1
- package/dist/{en-BDnM5zKJ.js → en-hyGpmL1O.js} +1 -4
- package/dist/index.d.ts +22 -4
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +4 -13
- package/dist/{preview-BSGlM6Se.js → preview-DUkOjOx8.js} +1 -1
- package/dist/{types-B-KrjgX8.d.ts → types-Bvk1pM70.d.ts} +1 -4
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +1 -1
- package/skills/slide-authoring/SKILL.md +169 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +16 -2
- package/src/app/components/inspector/inspect-overlay.tsx +132 -35
- package/src/app/components/inspector/inspector-panel.tsx +19 -256
- package/src/app/components/inspector/inspector-provider.tsx +102 -1
- package/src/app/components/panel/save-card.tsx +4 -4
- package/src/app/components/player.tsx +25 -25
- package/src/app/components/sidebar/folder-item.tsx +7 -2
- package/src/app/components/sidebar/sidebar.tsx +87 -16
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -0
- package/src/app/lib/folders.ts +28 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +12 -1
- package/src/app/lib/sdk.ts +3 -1
- package/src/app/lib/transition.ts +23 -0
- package/src/app/lib/use-click-page-navigation.ts +52 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/routes/home-shell.tsx +8 -0
- package/src/app/routes/home.tsx +1 -1
- package/src/app/routes/slide.tsx +92 -60
- package/src/locale/en.ts +1 -5
- package/src/locale/ja.ts +1 -5
- package/src/locale/types.ts +1 -5
- package/src/locale/zh-cn.ts +1 -5
- package/src/locale/zh-tw.ts +1 -5
- 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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
}
|
package/src/app/lib/folders.ts
CHANGED
|
@@ -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 (
|
|
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,
|
package/src/app/lib/sdk.ts
CHANGED
|
@@ -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
|
+
}
|