@open-aippt/core 1.13.2
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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin.js +2 -0
- package/dist/build-DxTqmvsO.js +17 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +86 -0
- package/dist/config-CjzqjrEA.js +4280 -0
- package/dist/config-DIC-yVPp.d.ts +23 -0
- package/dist/design-cpzS8aud.js +35 -0
- package/dist/dev-BYuTeJbA.js +20 -0
- package/dist/format-BCeKbTOM.js +1605 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +467 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +3 -0
- package/dist/preview-DlQvnJPq.js +18 -0
- package/dist/sync-BPZ0m27m.js +139 -0
- package/dist/sync-EsYusbbL.js +3 -0
- package/dist/types-CHmFPIG_.d.ts +430 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +4 -0
- package/env.d.ts +59 -0
- package/package.json +103 -0
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +91 -0
- package/skills/create-theme/SKILL.md +250 -0
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +625 -0
- package/src/app/app.tsx +47 -0
- package/src/app/components/asset-view.tsx +966 -0
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +243 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +93 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +387 -0
- package/src/app/components/inspector/inspector-panel.tsx +1115 -0
- package/src/app/components/inspector/inspector-provider.tsx +1218 -0
- package/src/app/components/inspector/save-bar.tsx +48 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/notes-drawer.tsx +120 -0
- package/src/app/components/overview-grid.tsx +363 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +80 -0
- package/src/app/components/panel/save-card.tsx +142 -0
- package/src/app/components/pdf-progress-toast.tsx +32 -0
- package/src/app/components/player.tsx +466 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +315 -0
- package/src/app/components/present/help-overlay.tsx +57 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +39 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +46 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +66 -0
- package/src/app/components/present/use-touch-swipe.ts +66 -0
- package/src/app/components/shared-element.tsx +48 -0
- package/src/app/components/sidebar/folder-item.tsx +258 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
- package/src/app/components/sidebar/sidebar.tsx +284 -0
- package/src/app/components/slide-canvas.tsx +102 -0
- package/src/app/components/slide-transition-layer.tsx +844 -0
- package/src/app/components/style-panel/design-provider.tsx +148 -0
- package/src/app/components/style-panel/style-panel.tsx +349 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +59 -0
- package/src/app/components/themes/theme-detail.tsx +305 -0
- package/src/app/components/themes/themes-gallery.tsx +149 -0
- package/src/app/components/thumbnail-rail.tsx +805 -0
- package/src/app/components/ui/badge.tsx +45 -0
- package/src/app/components/ui/button.tsx +99 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/components/ui/dialog.tsx +157 -0
- package/src/app/components/ui/dropdown-menu.tsx +245 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/popover.tsx +75 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +196 -0
- package/src/app/components/ui/separator.tsx +28 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +58 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/assets.ts +242 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/export-html.ts +326 -0
- package/src/app/lib/export-pdf.ts +298 -0
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +85 -0
- package/src/app/lib/inspector/use-comments.ts +74 -0
- package/src/app/lib/inspector/use-editor.ts +73 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/print-ready.test.ts +32 -0
- package/src/app/lib/print-ready.ts +51 -0
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +37 -0
- package/src/app/lib/slides.ts +26 -0
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/transition.ts +30 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-click-page-navigation.ts +60 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/lib/use-wheel-page-navigation.ts +99 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +213 -0
- package/src/app/routes/home.tsx +807 -0
- package/src/app/routes/presenter.tsx +418 -0
- package/src/app/routes/slide.tsx +1108 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +429 -0
- package/src/app/virtual.d.ts +51 -0
- package/src/locale/en.ts +416 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +422 -0
- package/src/locale/types.ts +443 -0
- package/src/locale/zh-cn.ts +414 -0
- package/src/locale/zh-tw.ts +414 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export type NoteSaveStatus =
|
|
4
|
+
| { kind: 'idle' }
|
|
5
|
+
| { kind: 'saving' }
|
|
6
|
+
| { kind: 'saved' }
|
|
7
|
+
| { kind: 'error'; message: string };
|
|
8
|
+
|
|
9
|
+
const DEBOUNCE_MS = 600;
|
|
10
|
+
|
|
11
|
+
type Target = { slideId: string; index: number };
|
|
12
|
+
|
|
13
|
+
// HMR is suppressed for our writes, so the cached slide module's `notes`
|
|
14
|
+
// stays stale across navigation. Cache last-saved text per target so
|
|
15
|
+
// switching slides and back doesn't surface the old value.
|
|
16
|
+
const sessionCache = new Map<string, string>();
|
|
17
|
+
const cacheKey = (slideId: string, index: number) => `${slideId}:${index}`;
|
|
18
|
+
|
|
19
|
+
// Remap the per-target cache after a reorder. `order[i]` is the original
|
|
20
|
+
// page index that lands at new position `i`, matching the contract used by
|
|
21
|
+
// the `/__slides/:id/reorder` endpoint.
|
|
22
|
+
export function remapNotesSessionCacheAfterReorder(slideId: string, order: number[]): void {
|
|
23
|
+
const prev = new Map<number, string>();
|
|
24
|
+
for (let i = 0; i < order.length; i++) {
|
|
25
|
+
const cached = sessionCache.get(cacheKey(slideId, i));
|
|
26
|
+
if (cached !== undefined) prev.set(i, cached);
|
|
27
|
+
sessionCache.delete(cacheKey(slideId, i));
|
|
28
|
+
}
|
|
29
|
+
for (let newIdx = 0; newIdx < order.length; newIdx++) {
|
|
30
|
+
const oldIdx = order[newIdx];
|
|
31
|
+
const text = prev.get(oldIdx);
|
|
32
|
+
if (text !== undefined) sessionCache.set(cacheKey(slideId, newIdx), text);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useNotes(slideId: string, index: number, initial: string | undefined) {
|
|
37
|
+
const initialText = sessionCache.get(cacheKey(slideId, index)) ?? initial ?? '';
|
|
38
|
+
const [value, setValueState] = useState(initialText);
|
|
39
|
+
const [status, setStatus] = useState<NoteSaveStatus>({ kind: 'idle' });
|
|
40
|
+
|
|
41
|
+
const lastSavedRef = useRef(initialText);
|
|
42
|
+
const dirtyRef = useRef(false);
|
|
43
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const inflightRef = useRef<AbortController | null>(null);
|
|
45
|
+
const targetRef = useRef<Target>({ slideId, index });
|
|
46
|
+
const valueRef = useRef(value);
|
|
47
|
+
valueRef.current = value;
|
|
48
|
+
|
|
49
|
+
const cancelTimer = useCallback(() => {
|
|
50
|
+
if (timerRef.current != null) {
|
|
51
|
+
clearTimeout(timerRef.current);
|
|
52
|
+
timerRef.current = null;
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const persist = useCallback(async (target: Target, text: string) => {
|
|
57
|
+
inflightRef.current?.abort();
|
|
58
|
+
const ctl = new AbortController();
|
|
59
|
+
inflightRef.current = ctl;
|
|
60
|
+
setStatus({ kind: 'saving' });
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch('/__notes', {
|
|
63
|
+
method: 'PUT',
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ slideId: target.slideId, index: target.index, text }),
|
|
66
|
+
signal: ctl.signal,
|
|
67
|
+
});
|
|
68
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
69
|
+
if (!res.ok) throw new Error(body.error ?? `PUT /__notes → ${res.status}`);
|
|
70
|
+
sessionCache.set(cacheKey(target.slideId, target.index), text);
|
|
71
|
+
if (inflightRef.current !== ctl) return;
|
|
72
|
+
lastSavedRef.current = text;
|
|
73
|
+
dirtyRef.current = false;
|
|
74
|
+
setStatus({ kind: 'saved' });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if ((err as { name?: string }).name === 'AbortError') return;
|
|
77
|
+
setStatus({ kind: 'error', message: String((err as Error).message ?? err) });
|
|
78
|
+
} finally {
|
|
79
|
+
if (inflightRef.current === ctl) inflightRef.current = null;
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const flush = useCallback(async () => {
|
|
84
|
+
cancelTimer();
|
|
85
|
+
if (!dirtyRef.current) return;
|
|
86
|
+
const target = targetRef.current;
|
|
87
|
+
await persist(target, valueRef.current);
|
|
88
|
+
}, [cancelTimer, persist]);
|
|
89
|
+
|
|
90
|
+
// When the (slideId, index) target changes, flush pending edits for the
|
|
91
|
+
// previous target before adopting the new initial text.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const prev = targetRef.current;
|
|
94
|
+
const targetChanged = prev.slideId !== slideId || prev.index !== index;
|
|
95
|
+
if (targetChanged && dirtyRef.current) {
|
|
96
|
+
cancelTimer();
|
|
97
|
+
const pending = valueRef.current;
|
|
98
|
+
if (lastSavedRef.current !== pending) void persist(prev, pending);
|
|
99
|
+
}
|
|
100
|
+
targetRef.current = { slideId, index };
|
|
101
|
+
cancelTimer();
|
|
102
|
+
setValueState(initialText);
|
|
103
|
+
lastSavedRef.current = initialText;
|
|
104
|
+
dirtyRef.current = false;
|
|
105
|
+
setStatus({ kind: 'idle' });
|
|
106
|
+
}, [slideId, index, initialText, persist, cancelTimer]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
return () => {
|
|
110
|
+
cancelTimer();
|
|
111
|
+
inflightRef.current?.abort();
|
|
112
|
+
};
|
|
113
|
+
}, [cancelTimer]);
|
|
114
|
+
|
|
115
|
+
const setValue = useCallback(
|
|
116
|
+
(next: string) => {
|
|
117
|
+
setValueState(next);
|
|
118
|
+
dirtyRef.current = next !== lastSavedRef.current;
|
|
119
|
+
cancelTimer();
|
|
120
|
+
if (!dirtyRef.current) {
|
|
121
|
+
setStatus({ kind: 'idle' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const target = targetRef.current;
|
|
125
|
+
timerRef.current = setTimeout(() => {
|
|
126
|
+
timerRef.current = null;
|
|
127
|
+
void persist(target, next);
|
|
128
|
+
}, DEBOUNCE_MS);
|
|
129
|
+
},
|
|
130
|
+
[persist, cancelTimer],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return { value, setValue, status, flush };
|
|
134
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import config from 'virtual:open-aippt/config';
|
|
2
|
+
import { useSyncExternalStore } from 'react';
|
|
3
|
+
import { en } from '../../locale/en';
|
|
4
|
+
import { ja } from '../../locale/ja';
|
|
5
|
+
import type { Locale } from '../../locale/types';
|
|
6
|
+
import { zhCN } from '../../locale/zh-cn';
|
|
7
|
+
import { zhTW } from '../../locale/zh-tw';
|
|
8
|
+
|
|
9
|
+
export type LocaleId = Locale['id'];
|
|
10
|
+
|
|
11
|
+
const LOCALES: Record<LocaleId, Locale> = {
|
|
12
|
+
en,
|
|
13
|
+
'zh-TW': zhTW,
|
|
14
|
+
'zh-CN': zhCN,
|
|
15
|
+
ja,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const LOCALE_OPTIONS: ReadonlyArray<{ id: LocaleId; label: string }> = [
|
|
19
|
+
{ id: 'en', label: 'English' },
|
|
20
|
+
{ id: 'zh-TW', label: '繁體中文' },
|
|
21
|
+
{ id: 'zh-CN', label: '简体中文' },
|
|
22
|
+
{ id: 'ja', label: '日本語' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const STORAGE_KEY = 'open-aippt:locale';
|
|
26
|
+
const configLocale = config.locale as Locale | undefined;
|
|
27
|
+
|
|
28
|
+
function isLocaleId(value: string | null): value is LocaleId {
|
|
29
|
+
return value === 'en' || value === 'zh-TW' || value === 'zh-CN' || value === 'ja';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readStored(): Locale {
|
|
33
|
+
try {
|
|
34
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
35
|
+
if (isLocaleId(stored)) return LOCALES[stored];
|
|
36
|
+
} catch {}
|
|
37
|
+
return configLocale ?? en;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A module-level store (rather than React context) so every React root the
|
|
41
|
+
// runtime mounts — the app shell plus the standalone roots used for HTML/PDF
|
|
42
|
+
// export — shares one locale without needing a provider above each of them.
|
|
43
|
+
let current: Locale = readStored();
|
|
44
|
+
const listeners = new Set<() => void>();
|
|
45
|
+
|
|
46
|
+
function subscribe(listener: () => void): () => void {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
return () => {
|
|
49
|
+
listeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getSnapshot(): Locale {
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function setLocale(id: LocaleId): void {
|
|
58
|
+
current = LOCALES[id];
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(STORAGE_KEY, id);
|
|
61
|
+
} catch {}
|
|
62
|
+
for (const listener of listeners) listener();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useLocaleValue(): Locale {
|
|
66
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Context, createContext, type PropsWithChildren, useContext, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
type SlidePageContextValue = {
|
|
4
|
+
index: number;
|
|
5
|
+
total: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// Stored on globalThis so dev (src) and published (dist) copies of this module
|
|
9
|
+
// share one context instance — otherwise the provider writes to one context and
|
|
10
|
+
// the hook reads from another, and `useSlidePageNumber` always sees null.
|
|
11
|
+
const GLOBAL_KEY = '__open_aippt_page_context__';
|
|
12
|
+
type GlobalWithCtx = typeof globalThis & {
|
|
13
|
+
[GLOBAL_KEY]?: Context<SlidePageContextValue | null>;
|
|
14
|
+
};
|
|
15
|
+
const g = globalThis as GlobalWithCtx;
|
|
16
|
+
if (!g[GLOBAL_KEY]) {
|
|
17
|
+
g[GLOBAL_KEY] = createContext<SlidePageContextValue | null>(null);
|
|
18
|
+
}
|
|
19
|
+
const SlidePageContext = g[GLOBAL_KEY];
|
|
20
|
+
|
|
21
|
+
export function SlidePageProvider({
|
|
22
|
+
index,
|
|
23
|
+
total,
|
|
24
|
+
children,
|
|
25
|
+
}: PropsWithChildren<{ index: number; total: number }>) {
|
|
26
|
+
const value = useMemo(() => ({ index, total }), [index, total]);
|
|
27
|
+
return <SlidePageContext.Provider value={value}>{children}</SlidePageContext.Provider>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useSlidePageNumber(): { current: number; total: number } {
|
|
31
|
+
const ctx = useContext(SlidePageContext);
|
|
32
|
+
if (!ctx) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'useSlidePageNumber must be called from a slide page rendered by @open-aippt/core',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return { current: ctx.index + 1, total: ctx.total };
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { waitForFonts } from './print-ready';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('waitForFonts', () => {
|
|
9
|
+
it('awaits document.fonts.ready without force-loading any face', async () => {
|
|
10
|
+
const load = vi.fn();
|
|
11
|
+
const faces = [
|
|
12
|
+
{ status: 'loaded', load },
|
|
13
|
+
{ status: 'unloaded', load },
|
|
14
|
+
{ status: 'unloaded', load },
|
|
15
|
+
];
|
|
16
|
+
const fonts = {
|
|
17
|
+
ready: Promise.resolve(),
|
|
18
|
+
[Symbol.iterator]: () => faces[Symbol.iterator](),
|
|
19
|
+
};
|
|
20
|
+
vi.stubGlobal('document', { fonts });
|
|
21
|
+
|
|
22
|
+
await waitForFonts();
|
|
23
|
+
|
|
24
|
+
expect(load).not.toHaveBeenCalled();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('resolves when the FontFaceSet API is unavailable', async () => {
|
|
28
|
+
vi.stubGlobal('document', {});
|
|
29
|
+
|
|
30
|
+
await expect(waitForFonts()).resolves.toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const DEFAULT_WAITFOR_TIMEOUT_MS = 10_000;
|
|
2
|
+
|
|
3
|
+
// `document.fonts.ready` already waits for every in-flight face. Never call
|
|
4
|
+
// `face.load()` on the rest: unloaded faces were never requested by CSS, and
|
|
5
|
+
// `load()` ignores `unicode-range`, so a subsetted CJK family (hundreds of
|
|
6
|
+
// faces) would be force-downloaded in full and hang or crash the tab.
|
|
7
|
+
export async function waitForFonts(): Promise<void> {
|
|
8
|
+
if (!('fonts' in document)) return;
|
|
9
|
+
await document.fonts.ready;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function waitForDataWaitfor(
|
|
13
|
+
root: HTMLElement,
|
|
14
|
+
timeoutMs = DEFAULT_WAITFOR_TIMEOUT_MS,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const targets = Array.from(root.querySelectorAll<HTMLElement>('[data-waitfor]'));
|
|
17
|
+
if (targets.length === 0) return;
|
|
18
|
+
const deadline = performance.now() + timeoutMs;
|
|
19
|
+
await Promise.all(
|
|
20
|
+
targets.map(async (el) => {
|
|
21
|
+
const selector = el.getAttribute('data-waitfor');
|
|
22
|
+
if (!selector) return;
|
|
23
|
+
while (performance.now() < deadline) {
|
|
24
|
+
try {
|
|
25
|
+
if (el.querySelector(selector)) return;
|
|
26
|
+
} catch {
|
|
27
|
+
return; // invalid selector — skip rather than hang
|
|
28
|
+
}
|
|
29
|
+
await nextFrame();
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isFrameAnimationSettled(frame: Element): boolean {
|
|
36
|
+
if (typeof document.getAnimations !== 'function') return true;
|
|
37
|
+
for (const anim of document.getAnimations()) {
|
|
38
|
+
const effect = anim.effect as KeyframeEffect | null;
|
|
39
|
+
if (!effect) continue;
|
|
40
|
+
const target = effect.target;
|
|
41
|
+
if (!target || !frame.contains(target)) continue;
|
|
42
|
+
const timing = effect.getComputedTiming();
|
|
43
|
+
if (timing.iterations === Infinity) continue;
|
|
44
|
+
if (anim.playState !== 'finished') return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nextFrame(): Promise<void> {
|
|
50
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
51
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './sdk.ts';
|
|
3
|
+
|
|
4
|
+
describe('canvas constants', () => {
|
|
5
|
+
it('targets a 1920x1080 canvas', () => {
|
|
6
|
+
expect(CANVAS_WIDTH).toBe(1920);
|
|
7
|
+
expect(CANVAS_HEIGHT).toBe(1080);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('preserves a 16:9 aspect ratio', () => {
|
|
11
|
+
expect(CANVAS_WIDTH / CANVAS_HEIGHT).toBeCloseTo(16 / 9);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
import type { DesignSystem } from './design.ts';
|
|
3
|
+
import type { SlideTransition } from './transition.ts';
|
|
4
|
+
|
|
5
|
+
export type Page = ComponentType & { transition?: SlideTransition };
|
|
6
|
+
|
|
7
|
+
export type SlideMeta = {
|
|
8
|
+
title?: string;
|
|
9
|
+
theme?: string;
|
|
10
|
+
/** ISO 8601 timestamp. Set once at scaffold time; used to sort the slide list. */
|
|
11
|
+
createdAt?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SlideModule = {
|
|
15
|
+
default: Page[];
|
|
16
|
+
meta?: SlideMeta;
|
|
17
|
+
design?: DesignSystem;
|
|
18
|
+
// Index-aligned with `default`.
|
|
19
|
+
notes?: (string | undefined)[];
|
|
20
|
+
transition?: SlideTransition;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
|
|
24
|
+
|
|
25
|
+
export type Folder = {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
icon: FolderIcon;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type FoldersManifest = {
|
|
32
|
+
folders: Folder[];
|
|
33
|
+
assignments: Record<string, string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const CANVAS_WIDTH = 1920;
|
|
37
|
+
export const CANVAS_HEIGHT = 1080;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
slideCreatedAt as createdAt,
|
|
3
|
+
slideIds as ids,
|
|
4
|
+
loadSlide as load,
|
|
5
|
+
slideThemes as themes,
|
|
6
|
+
} from 'virtual:open-aippt/slides';
|
|
7
|
+
import type { SlideModule } from './sdk';
|
|
8
|
+
|
|
9
|
+
export const slideIds: string[] = ids;
|
|
10
|
+
export const slideThemes: Record<string, string> = themes;
|
|
11
|
+
export const slideCreatedAt: Record<string, number> = createdAt;
|
|
12
|
+
|
|
13
|
+
export function slidesByTheme(themeId: string): string[] {
|
|
14
|
+
return slideIds.filter((id) => slideThemes[id] === themeId);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function loadSlide(id: string): Promise<SlideModule> {
|
|
18
|
+
return load(id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function slideChangeIncludes(data: unknown, slideId: string): boolean {
|
|
22
|
+
if (!data || typeof data !== 'object') return false;
|
|
23
|
+
const payload = data as { slideId?: unknown; slideIds?: unknown };
|
|
24
|
+
if (payload.slideId === slideId) return true;
|
|
25
|
+
return Array.isArray(payload.slideIds) && payload.slideIds.includes(slideId);
|
|
26
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type Context,
|
|
4
|
+
cloneElement,
|
|
5
|
+
createContext,
|
|
6
|
+
isValidElement,
|
|
7
|
+
type MutableRefObject,
|
|
8
|
+
type PropsWithChildren,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
useCallback,
|
|
11
|
+
useContext,
|
|
12
|
+
useLayoutEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import { usePrefersReducedMotion } from './use-prefers-reduced-motion';
|
|
18
|
+
|
|
19
|
+
export type EntryDirection = 'forward' | 'backward' | 'jump';
|
|
20
|
+
|
|
21
|
+
export type StepController = {
|
|
22
|
+
advance: () => boolean;
|
|
23
|
+
retreat: () => boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type StepAggregate = {
|
|
27
|
+
revealed: number;
|
|
28
|
+
stepCount: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Registration = {
|
|
32
|
+
id: object;
|
|
33
|
+
stepCount: number;
|
|
34
|
+
initialRevealed: number;
|
|
35
|
+
controller: StepController;
|
|
36
|
+
setRevealed: (n: number) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type StepHostContextValue = {
|
|
40
|
+
register: (reg: Registration) => () => void;
|
|
41
|
+
reportRevealed: (id: object, revealed: number) => void;
|
|
42
|
+
entryDirection: EntryDirection;
|
|
43
|
+
controlled: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const GLOBAL_KEY = '__open_aippt_step_host_context__';
|
|
47
|
+
type GlobalWithCtx = typeof globalThis & {
|
|
48
|
+
[GLOBAL_KEY]?: Context<StepHostContextValue | null>;
|
|
49
|
+
};
|
|
50
|
+
const g = globalThis as GlobalWithCtx;
|
|
51
|
+
if (!g[GLOBAL_KEY]) {
|
|
52
|
+
g[GLOBAL_KEY] = createContext<StepHostContextValue | null>(null);
|
|
53
|
+
}
|
|
54
|
+
const StepHostContext = g[GLOBAL_KEY];
|
|
55
|
+
|
|
56
|
+
type StepHostProps = PropsWithChildren<{
|
|
57
|
+
isActivePage: boolean;
|
|
58
|
+
entryDirection: EntryDirection;
|
|
59
|
+
controllerRef: MutableRefObject<StepController | null>;
|
|
60
|
+
// When set, the host distributes this count across <Steps> children in
|
|
61
|
+
// mount order — first fills to its stepCount, next takes the remainder.
|
|
62
|
+
controlledRevealed?: number;
|
|
63
|
+
onAggregateChange?: (aggregate: StepAggregate) => void;
|
|
64
|
+
}>;
|
|
65
|
+
|
|
66
|
+
export function StepHost({
|
|
67
|
+
isActivePage,
|
|
68
|
+
entryDirection,
|
|
69
|
+
controllerRef,
|
|
70
|
+
controlledRevealed,
|
|
71
|
+
onAggregateChange,
|
|
72
|
+
children,
|
|
73
|
+
}: StepHostProps) {
|
|
74
|
+
type Tracked = Registration & { revealed: number };
|
|
75
|
+
const registrationsRef = useRef<Tracked[]>([]);
|
|
76
|
+
|
|
77
|
+
const onAggregateChangeRef = useRef(onAggregateChange);
|
|
78
|
+
onAggregateChangeRef.current = onAggregateChange;
|
|
79
|
+
const controlledRevealedRef = useRef(controlledRevealed);
|
|
80
|
+
controlledRevealedRef.current = controlledRevealed;
|
|
81
|
+
|
|
82
|
+
const composite = useMemo<StepController>(
|
|
83
|
+
() => ({
|
|
84
|
+
advance: () => {
|
|
85
|
+
for (const r of registrationsRef.current) {
|
|
86
|
+
if (r.controller.advance()) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
},
|
|
90
|
+
retreat: () => {
|
|
91
|
+
for (let i = registrationsRef.current.length - 1; i >= 0; i--) {
|
|
92
|
+
if (registrationsRef.current[i].controller.retreat()) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
[],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// useLayoutEffect cleanup-then-mount ordering keeps the registry slot
|
|
101
|
+
// continuous across page swaps — the outgoing host clears its composite
|
|
102
|
+
// before the next active host installs its own, with no gap and no overlap.
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
if (!isActivePage) return;
|
|
105
|
+
controllerRef.current = composite;
|
|
106
|
+
return () => {
|
|
107
|
+
if (controllerRef.current === composite) controllerRef.current = null;
|
|
108
|
+
};
|
|
109
|
+
}, [isActivePage, composite, controllerRef]);
|
|
110
|
+
|
|
111
|
+
const notifyAggregate = useCallback(() => {
|
|
112
|
+
const cb = onAggregateChangeRef.current;
|
|
113
|
+
if (!cb) return;
|
|
114
|
+
let revealed = 0;
|
|
115
|
+
let stepCount = 0;
|
|
116
|
+
for (const r of registrationsRef.current) {
|
|
117
|
+
revealed += r.revealed;
|
|
118
|
+
stepCount += r.stepCount;
|
|
119
|
+
}
|
|
120
|
+
cb({ revealed, stepCount });
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const distributeControlled = useCallback(() => {
|
|
124
|
+
const target = controlledRevealedRef.current;
|
|
125
|
+
if (target == null) return;
|
|
126
|
+
let remaining = target;
|
|
127
|
+
for (const r of registrationsRef.current) {
|
|
128
|
+
const share = Math.max(0, Math.min(r.stepCount, remaining));
|
|
129
|
+
remaining -= share;
|
|
130
|
+
if (r.revealed !== share) {
|
|
131
|
+
r.revealed = share;
|
|
132
|
+
r.setRevealed(share);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
useLayoutEffect(() => {
|
|
138
|
+
if (controlledRevealed == null) return;
|
|
139
|
+
distributeControlled();
|
|
140
|
+
notifyAggregate();
|
|
141
|
+
}, [controlledRevealed, distributeControlled, notifyAggregate]);
|
|
142
|
+
|
|
143
|
+
const value = useMemo<StepHostContextValue>(
|
|
144
|
+
() => ({
|
|
145
|
+
register: (reg) => {
|
|
146
|
+
const tracked: Tracked = { ...reg, revealed: reg.initialRevealed };
|
|
147
|
+
registrationsRef.current.push(tracked);
|
|
148
|
+
if (controlledRevealedRef.current != null) {
|
|
149
|
+
distributeControlled();
|
|
150
|
+
}
|
|
151
|
+
notifyAggregate();
|
|
152
|
+
return () => {
|
|
153
|
+
const i = registrationsRef.current.indexOf(tracked);
|
|
154
|
+
if (i !== -1) registrationsRef.current.splice(i, 1);
|
|
155
|
+
notifyAggregate();
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
reportRevealed: (id, revealed) => {
|
|
159
|
+
const r = registrationsRef.current.find((x) => x.id === id);
|
|
160
|
+
if (!r) return;
|
|
161
|
+
r.revealed = revealed;
|
|
162
|
+
notifyAggregate();
|
|
163
|
+
},
|
|
164
|
+
entryDirection,
|
|
165
|
+
controlled: controlledRevealed != null,
|
|
166
|
+
}),
|
|
167
|
+
[entryDirection, controlledRevealed, distributeControlled, notifyAggregate],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return <StepHostContext.Provider value={value}>{children}</StepHostContext.Provider>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type StepsProps = PropsWithChildren;
|
|
174
|
+
|
|
175
|
+
export function Steps({ children }: StepsProps) {
|
|
176
|
+
const host = useContext(StepHostContext);
|
|
177
|
+
const flat = Children.toArray(children);
|
|
178
|
+
const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
|
|
179
|
+
|
|
180
|
+
// Controlled mode waits for the host to assign a slice in the registration
|
|
181
|
+
// layout-effect; otherwise the entry direction picks the initial reveal.
|
|
182
|
+
const initial = host?.controlled ? 0 : host?.entryDirection === 'forward' ? 0 : stepCount;
|
|
183
|
+
const revealedRef = useRef(initial);
|
|
184
|
+
const [revealed, setRevealed] = useState(initial);
|
|
185
|
+
|
|
186
|
+
const idRef = useRef<object>({});
|
|
187
|
+
|
|
188
|
+
const applyRevealed = useCallback((n: number) => {
|
|
189
|
+
revealedRef.current = n;
|
|
190
|
+
setRevealed(n);
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
useLayoutEffect(() => {
|
|
194
|
+
if (!host) return;
|
|
195
|
+
const id = idRef.current;
|
|
196
|
+
const ctrl: StepController = {
|
|
197
|
+
advance: () => {
|
|
198
|
+
if (revealedRef.current >= stepCount) return false;
|
|
199
|
+
applyRevealed(revealedRef.current + 1);
|
|
200
|
+
host.reportRevealed(id, revealedRef.current);
|
|
201
|
+
return true;
|
|
202
|
+
},
|
|
203
|
+
retreat: () => {
|
|
204
|
+
if (revealedRef.current <= 0) return false;
|
|
205
|
+
applyRevealed(revealedRef.current - 1);
|
|
206
|
+
host.reportRevealed(id, revealedRef.current);
|
|
207
|
+
return true;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
return host.register({
|
|
211
|
+
id,
|
|
212
|
+
stepCount,
|
|
213
|
+
initialRevealed: revealedRef.current,
|
|
214
|
+
controller: ctrl,
|
|
215
|
+
setRevealed: applyRevealed,
|
|
216
|
+
});
|
|
217
|
+
}, [host, stepCount, applyRevealed]);
|
|
218
|
+
|
|
219
|
+
const effectiveRevealed = host ? revealed : stepCount;
|
|
220
|
+
|
|
221
|
+
let stepIdx = 0;
|
|
222
|
+
return (
|
|
223
|
+
<>
|
|
224
|
+
{flat.map((child, key) => {
|
|
225
|
+
if (isValidElement(child) && child.type === Step) {
|
|
226
|
+
const idx = stepIdx++;
|
|
227
|
+
return cloneElement(child as ReactElement<{ _revealed?: boolean }>, {
|
|
228
|
+
key: child.key ?? key,
|
|
229
|
+
_revealed: idx < effectiveRevealed,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return child;
|
|
233
|
+
})}
|
|
234
|
+
</>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type StepProps = PropsWithChildren<{
|
|
239
|
+
duration?: number;
|
|
240
|
+
}>;
|
|
241
|
+
|
|
242
|
+
type InternalStepProps = StepProps & { _revealed?: boolean };
|
|
243
|
+
|
|
244
|
+
export function Step({ children, duration = 180, _revealed }: InternalStepProps) {
|
|
245
|
+
const reduceMotion = usePrefersReducedMotion();
|
|
246
|
+
const revealed = _revealed ?? true;
|
|
247
|
+
const ms = reduceMotion ? 0 : duration;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
data-osd-step={revealed ? 'revealed' : 'pending'}
|
|
252
|
+
style={{
|
|
253
|
+
opacity: revealed ? 1 : 0,
|
|
254
|
+
visibility: revealed ? 'visible' : 'hidden',
|
|
255
|
+
transition: `opacity ${ms}ms cubic-bezier(0, 0, 0.2, 1)`,
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
{children}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { loadThemeDemo as load, themes as raw } from 'virtual:open-aippt/themes';
|
|
2
|
+
import type { DesignSystem } from './design';
|
|
3
|
+
import type { Page } from './sdk';
|
|
4
|
+
|
|
5
|
+
export type Theme = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
body: string;
|
|
10
|
+
hasDemo: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ThemeDemoModule = {
|
|
14
|
+
default: Page[];
|
|
15
|
+
design?: DesignSystem;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const themes: Theme[] = raw;
|
|
19
|
+
|
|
20
|
+
export async function loadThemeDemo(id: string): Promise<ThemeDemoModule> {
|
|
21
|
+
return load(id);
|
|
22
|
+
}
|