@lemonriver/core 0.1.0 → 0.2.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/LICENSE +21 -0
- package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
- package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
- package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -3
- package/dist/locale/index.d.ts +5 -2
- package/dist/locale/index.js +2 -2
- package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
- package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
- package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
- package/package.json +21 -15
- package/skills/apply-comments/SKILL.md +10 -6
- package/skills/create-slide/SKILL.md +2 -0
- package/skills/current-slide/SKILL.md +1 -0
- package/skills/slide-authoring/SKILL.md +31 -1
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/app.tsx +2 -0
- package/src/app/assets/open-slide.svg +39 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/board/board-canvas.tsx +143 -12
- package/src/app/components/board/board.css +51 -0
- package/src/app/components/board/layout.test.ts +53 -1
- package/src/app/components/board/layout.ts +50 -2
- package/src/app/components/board/page-node.tsx +287 -13
- package/src/app/components/board/use-board-cards.ts +210 -0
- package/src/app/components/board/use-page-order.ts +17 -9
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/icon-switcher-indicator.tsx +1 -1
- package/src/app/components/inspector/comment-layer.tsx +1011 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspect-overlay.tsx +2 -39
- package/src/app/components/inspector/inspector-panel.tsx +10 -93
- package/src/app/components/inspector/inspector-provider.tsx +121 -47
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +0 -1
- package/src/app/components/preview-step-host.tsx +16 -0
- package/src/app/components/sidebar/sidebar.tsx +2 -2
- package/src/app/components/style-panel/style-panel.tsx +3 -2
- package/src/app/components/thumbnail-rail.tsx +76 -1
- package/src/app/favicon.ico +0 -0
- package/src/app/favicon.svg +39 -0
- package/src/app/index.html +2 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/inspector/freeze-motion.ts +61 -0
- package/src/app/lib/inspector/use-comments.ts +32 -3
- package/src/app/lib/locale-store.ts +6 -1
- package/src/app/lib/print-ready.ts +9 -6
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/board.tsx +48 -11
- package/src/app/routes/home.tsx +43 -7
- package/src/app/routes/presenter.tsx +1 -16
- package/src/app/routes/preview.tsx +169 -0
- package/src/app/routes/slide.tsx +221 -173
- package/src/app/styles.css +234 -80
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +127 -5
- package/src/locale/es.ts +692 -0
- package/src/locale/index.ts +1 -0
- package/src/locale/ja.ts +120 -6
- package/src/locale/types.ts +132 -5
- package/src/locale/zh-cn.ts +117 -5
- package/src/locale/zh-tw.ts +117 -5
- package/src/app/assets/open-slide.png +0 -0
- package/src/app/components/inspector/comment-widget.tsx +0 -119
|
@@ -11,27 +11,30 @@ export async function waitForFonts(): Promise<void> {
|
|
|
11
11
|
await document.fonts.ready;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Resolves false when any target was still missing at the deadline.
|
|
14
15
|
export async function waitForDataWaitfor(
|
|
15
16
|
root: HTMLElement,
|
|
16
17
|
timeoutMs = DEFAULT_WAITFOR_TIMEOUT_MS,
|
|
17
|
-
): Promise<
|
|
18
|
+
): Promise<boolean> {
|
|
18
19
|
const targets = Array.from(root.querySelectorAll<HTMLElement>('[data-waitfor]'));
|
|
19
|
-
if (targets.length === 0) return;
|
|
20
|
+
if (targets.length === 0) return true;
|
|
20
21
|
const deadline = performance.now() + timeoutMs;
|
|
21
|
-
await Promise.all(
|
|
22
|
+
const results = await Promise.all(
|
|
22
23
|
targets.map(async (el) => {
|
|
23
24
|
const selector = el.getAttribute('data-waitfor');
|
|
24
|
-
if (!selector) return;
|
|
25
|
+
if (!selector) return true;
|
|
25
26
|
while (performance.now() < deadline) {
|
|
26
27
|
try {
|
|
27
|
-
if (el.querySelector(selector)) return;
|
|
28
|
+
if (el.querySelector(selector)) return true;
|
|
28
29
|
} catch {
|
|
29
|
-
return; // invalid selector — skip rather than hang
|
|
30
|
+
return true; // invalid selector — skip rather than hang
|
|
30
31
|
}
|
|
31
32
|
await nextFrame();
|
|
32
33
|
}
|
|
34
|
+
return false;
|
|
33
35
|
}),
|
|
34
36
|
);
|
|
37
|
+
return results.every(Boolean);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function isFrameAnimationSettled(frame: Element): boolean {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useAutoGrow(
|
|
4
|
+
ref: RefObject<HTMLTextAreaElement | null>,
|
|
5
|
+
value: string,
|
|
6
|
+
max: number,
|
|
7
|
+
): void {
|
|
8
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-measure whenever the text changes
|
|
9
|
+
useLayoutEffect(() => {
|
|
10
|
+
const node = ref.current;
|
|
11
|
+
if (!node) return;
|
|
12
|
+
node.style.height = '0px';
|
|
13
|
+
node.style.height = `${Math.min(max, Math.max(40, node.scrollHeight))}px`;
|
|
14
|
+
}, [ref, value, max]);
|
|
15
|
+
}
|
package/src/app/lib/utils.ts
CHANGED
|
@@ -9,6 +9,10 @@ export function round2(n: number): number {
|
|
|
9
9
|
return Math.round(n * 100) / 100;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
13
|
+
return Math.min(max, Math.max(min, value));
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
export function pad2(n: number): string {
|
|
13
17
|
return n.toString().padStart(2, '0');
|
|
14
18
|
}
|
package/src/app/routes/board.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ChevronLeft } from 'lucide-react';
|
|
2
|
-
import { lazy, Suspense, useCallback } from 'react';
|
|
2
|
+
import { lazy, Suspense, useCallback, useEffect, useState } from 'react';
|
|
3
3
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
5
|
import { useDocumentTitle } from '@/lib/use-document-title';
|
|
6
6
|
import { format, useLocale } from '@/lib/use-locale';
|
|
7
7
|
import { pad2 } from '@/lib/utils';
|
|
8
8
|
import { columnStarts } from '../../files/board-structure';
|
|
9
|
+
import { useBoardCards } from '../components/board/use-board-cards';
|
|
9
10
|
import { useBoardColumns } from '../components/board/use-board-columns';
|
|
10
11
|
import { usePageOrder } from '../components/board/use-page-order';
|
|
11
12
|
import type { Page } from '../lib/sdk';
|
|
@@ -17,6 +18,20 @@ const BoardCanvas = lazy(() => import('../components/board/board-canvas'));
|
|
|
17
18
|
// the deck module is still loading.
|
|
18
19
|
const NO_PAGES: Page[] = [];
|
|
19
20
|
|
|
21
|
+
const LINKS_KEY = 'open-slide:board-links-visible';
|
|
22
|
+
|
|
23
|
+
function useLinksVisible() {
|
|
24
|
+
const [visible, setVisible] = useState(() => {
|
|
25
|
+
if (typeof window === 'undefined') return true;
|
|
26
|
+
return window.localStorage.getItem(LINKS_KEY) !== '0';
|
|
27
|
+
});
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
window.localStorage.setItem(LINKS_KEY, visible ? '1' : '0');
|
|
30
|
+
}, [visible]);
|
|
31
|
+
const toggle = useCallback(() => setVisible((v) => !v), []);
|
|
32
|
+
return [visible, toggle] as const;
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
export function Board() {
|
|
21
36
|
const { slideId = '' } = useParams();
|
|
22
37
|
const navigate = useNavigate();
|
|
@@ -36,15 +51,24 @@ export function Board() {
|
|
|
36
51
|
pages,
|
|
37
52
|
columnTitle,
|
|
38
53
|
);
|
|
54
|
+
const { ready, cards, pairs, patch, link, unlink, beginOp } = useBoardCards(
|
|
55
|
+
slideId,
|
|
56
|
+
modulePages,
|
|
57
|
+
pages.length,
|
|
58
|
+
);
|
|
59
|
+
const [showLinks, toggleLinks] = useLinksVisible();
|
|
39
60
|
|
|
40
|
-
// Order and
|
|
41
|
-
//
|
|
61
|
+
// Order, structure and the board's own card data move together: every path
|
|
62
|
+
// that changes the deck's pages goes through here, so none of them can drift
|
|
63
|
+
// out of step with the pages.
|
|
42
64
|
const movePage = useCallback(
|
|
43
65
|
(from: number, to: number) => {
|
|
66
|
+
if (from === to) return;
|
|
67
|
+
const settle = beginOp({ kind: 'move', from, to });
|
|
44
68
|
moveBetweenColumns(from, to);
|
|
45
|
-
void reorder(from, to);
|
|
69
|
+
void reorder(from, to).then(settle);
|
|
46
70
|
},
|
|
47
|
-
[moveBetweenColumns, reorder],
|
|
71
|
+
[beginOp, moveBetweenColumns, reorder],
|
|
48
72
|
);
|
|
49
73
|
|
|
50
74
|
const openPage = useCallback(
|
|
@@ -56,10 +80,11 @@ export function Board() {
|
|
|
56
80
|
|
|
57
81
|
const duplicatePage = useCallback(
|
|
58
82
|
(index: number) => {
|
|
83
|
+
const settle = beginOp({ kind: 'duplicate', index });
|
|
59
84
|
resizeColumnAt(index, 1);
|
|
60
|
-
void duplicate(index);
|
|
85
|
+
void duplicate(index).then(settle);
|
|
61
86
|
},
|
|
62
|
-
[duplicate, resizeColumnAt],
|
|
87
|
+
[beginOp, duplicate, resizeColumnAt],
|
|
63
88
|
);
|
|
64
89
|
|
|
65
90
|
// A new page lands at the end of the column whose + was clicked.
|
|
@@ -69,10 +94,12 @@ export function Board() {
|
|
|
69
94
|
const starts = columnStarts(columns);
|
|
70
95
|
const start = starts[columnIndex];
|
|
71
96
|
const size = columns[columnIndex].size;
|
|
97
|
+
const at = size > 0 ? start + size : start;
|
|
98
|
+
const settle = beginOp({ kind: 'insert', index: at });
|
|
72
99
|
growColumn(columnIndex, 1);
|
|
73
|
-
void insert(
|
|
100
|
+
void insert(at).then(settle);
|
|
74
101
|
},
|
|
75
|
-
[columns, growColumn, insert],
|
|
102
|
+
[beginOp, columns, growColumn, insert],
|
|
76
103
|
);
|
|
77
104
|
|
|
78
105
|
const moveToEnd = useCallback(
|
|
@@ -82,10 +109,12 @@ export function Board() {
|
|
|
82
109
|
|
|
83
110
|
const deletePage = useCallback(
|
|
84
111
|
(index: number) => {
|
|
112
|
+
if (pages.length <= 1) return;
|
|
113
|
+
const settle = beginOp({ kind: 'delete', index });
|
|
85
114
|
resizeColumnAt(index, -1);
|
|
86
|
-
void remove(index);
|
|
115
|
+
void remove(index).then(settle);
|
|
87
116
|
},
|
|
88
|
-
[remove, resizeColumnAt],
|
|
117
|
+
[beginOp, pages.length, remove, resizeColumnAt],
|
|
89
118
|
);
|
|
90
119
|
|
|
91
120
|
return (
|
|
@@ -123,6 +152,10 @@ export function Board() {
|
|
|
123
152
|
slideId={slideId}
|
|
124
153
|
pages={pages}
|
|
125
154
|
notes={slide?.notes}
|
|
155
|
+
cards={cards}
|
|
156
|
+
cardsReady={ready}
|
|
157
|
+
links={pairs}
|
|
158
|
+
showLinks={showLinks}
|
|
126
159
|
columns={columns}
|
|
127
160
|
design={slide?.design}
|
|
128
161
|
onOpenPage={openPage}
|
|
@@ -132,6 +165,10 @@ export function Board() {
|
|
|
132
165
|
onDuplicate={duplicatePage}
|
|
133
166
|
onDelete={deletePage}
|
|
134
167
|
onMoveToEnd={moveToEnd}
|
|
168
|
+
onPatchCard={patch}
|
|
169
|
+
onLink={link}
|
|
170
|
+
onUnlink={unlink}
|
|
171
|
+
onToggleLinks={toggleLinks}
|
|
135
172
|
/>
|
|
136
173
|
</Suspense>
|
|
137
174
|
)}
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
MoreHorizontal,
|
|
9
9
|
Palette,
|
|
10
10
|
Pencil,
|
|
11
|
+
Plus,
|
|
11
12
|
Search,
|
|
12
13
|
Trash2,
|
|
13
14
|
X,
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
DropdownMenuItem,
|
|
31
32
|
DropdownMenuTrigger,
|
|
32
33
|
} from '@/components/ui/dropdown-menu';
|
|
34
|
+
import { useAgentStatus, useCreateDeck } from '@/lib/agent/use-agent';
|
|
33
35
|
import { format, useLocale } from '@/lib/use-locale';
|
|
34
36
|
import { cn, pad2 } from '@/lib/utils';
|
|
37
|
+
import { ProviderLogo } from '../components/agent/provider-logo';
|
|
35
38
|
import { FolderIconChip, SLIDE_DND_MIME, SystemViewIcon } from '../components/sidebar/folder-item';
|
|
36
39
|
import { ALL_SLIDES_ID, DRAFT_ID } from '../components/sidebar/sidebar';
|
|
37
40
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
@@ -193,6 +196,7 @@ export function Home() {
|
|
|
193
196
|
<div className="ml-auto flex w-full items-center gap-2 md:w-auto">
|
|
194
197
|
<SortControl value={sortKey} onChange={setSortKey} />
|
|
195
198
|
<SearchInput value={query} onChange={setQuery} />
|
|
199
|
+
{import.meta.env.DEV && <NewDeckButton />}
|
|
196
200
|
</div>
|
|
197
201
|
</div>
|
|
198
202
|
</header>
|
|
@@ -319,6 +323,30 @@ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: Sor
|
|
|
319
323
|
);
|
|
320
324
|
}
|
|
321
325
|
|
|
326
|
+
function useCreateDeckWithAgent() {
|
|
327
|
+
const { provider } = useAgentStatus();
|
|
328
|
+
const start = useCreateDeck();
|
|
329
|
+
if (!provider) return null;
|
|
330
|
+
return { provider, start };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function NewDeckButton() {
|
|
334
|
+
const t = useLocale();
|
|
335
|
+
const agent = useCreateDeckWithAgent();
|
|
336
|
+
if (!agent) return null;
|
|
337
|
+
return (
|
|
338
|
+
<Button
|
|
339
|
+
size="sm"
|
|
340
|
+
className="h-8 shrink-0"
|
|
341
|
+
onClick={agent.start}
|
|
342
|
+
title={format(t.agent.createDeckWith, { provider: agent.provider.name })}
|
|
343
|
+
>
|
|
344
|
+
<Plus />
|
|
345
|
+
{t.agent.createDeck}
|
|
346
|
+
</Button>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
322
350
|
function HomeLoading() {
|
|
323
351
|
const t = useLocale();
|
|
324
352
|
return (
|
|
@@ -360,6 +388,7 @@ function NoResultsState({ query, onClear }: { query: string; onClear: () => void
|
|
|
360
388
|
|
|
361
389
|
function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
|
|
362
390
|
const t = useLocale();
|
|
391
|
+
const agent = useCreateDeckWithAgent();
|
|
363
392
|
const folderEmptyTitle = t.home.folderEmptyTitle.replace(
|
|
364
393
|
'{name}',
|
|
365
394
|
folderName ?? t.home.folderEmptyTitle,
|
|
@@ -373,13 +402,20 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
|
|
|
373
402
|
<p className="mt-4 font-heading text-[14px] font-semibold tracking-tight">
|
|
374
403
|
{t.home.noSlidesYet}
|
|
375
404
|
</p>
|
|
376
|
-
|
|
377
|
-
{
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
</
|
|
381
|
-
|
|
382
|
-
|
|
405
|
+
{agent ? (
|
|
406
|
+
<Button size="sm" className="mt-4" onClick={agent.start}>
|
|
407
|
+
<ProviderLogo provider={agent.provider.id} />
|
|
408
|
+
{format(t.agent.createDeckWith, { provider: agent.provider.name })}
|
|
409
|
+
</Button>
|
|
410
|
+
) : (
|
|
411
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
412
|
+
{t.home.createSlideHintPrefix}
|
|
413
|
+
<code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
|
|
414
|
+
/create-slide
|
|
415
|
+
</code>
|
|
416
|
+
{t.home.createSlideHintSuffix}
|
|
417
|
+
</p>
|
|
418
|
+
)}
|
|
383
419
|
</>
|
|
384
420
|
) : (
|
|
385
421
|
<>
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from 'lucide-react';
|
|
12
12
|
import {
|
|
13
13
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
14
|
-
type ReactNode,
|
|
15
14
|
useCallback,
|
|
16
15
|
useEffect,
|
|
17
16
|
useRef,
|
|
@@ -29,12 +28,12 @@ import {
|
|
|
29
28
|
type PresenterState,
|
|
30
29
|
usePresenterChannel,
|
|
31
30
|
} from '../components/present/use-presenter-channel';
|
|
31
|
+
import { PreviewStepHost } from '../components/preview-step-host';
|
|
32
32
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
33
33
|
import { isDeckWarmed, markDeckWarmed, SlidePreloadLayer } from '../components/slide-preload-layer';
|
|
34
34
|
import { SlidePageProvider } from '../lib/page-context';
|
|
35
35
|
import { CANVAS_HEIGHT, CANVAS_WIDTH, type SlideModule } from '../lib/sdk';
|
|
36
36
|
import { loadSlide, slideIds } from '../lib/slides';
|
|
37
|
-
import { type StepController, StepHost } from '../lib/step-context';
|
|
38
37
|
import { useSlideModule } from '../lib/use-slide-module';
|
|
39
38
|
|
|
40
39
|
export function Presenter() {
|
|
@@ -653,20 +652,6 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
|
653
652
|
return <span className="eyebrow">{children}</span>;
|
|
654
653
|
}
|
|
655
654
|
|
|
656
|
-
function PreviewStepHost({ revealed, children }: { revealed: number; children: ReactNode }) {
|
|
657
|
-
const noopControllerRef = useRef<StepController | null>(null);
|
|
658
|
-
return (
|
|
659
|
-
<StepHost
|
|
660
|
-
isActivePage={false}
|
|
661
|
-
entryDirection="jump"
|
|
662
|
-
controllerRef={noopControllerRef}
|
|
663
|
-
controlledRevealed={revealed}
|
|
664
|
-
>
|
|
665
|
-
{children}
|
|
666
|
-
</StepHost>
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
655
|
function Clock() {
|
|
671
656
|
const [now, setNow] = useState(() => new Date());
|
|
672
657
|
const t = useLocale();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Component, type ReactNode, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useParams, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { useDocumentTitle } from '@/lib/use-document-title';
|
|
4
|
+
import { PreviewStepHost } from '../components/preview-step-host';
|
|
5
|
+
import { SlideCanvas } from '../components/slide-canvas';
|
|
6
|
+
import { nextPaint, sleep } from '../lib/dom';
|
|
7
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
8
|
+
import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from '../lib/print-ready';
|
|
9
|
+
import { useSlideModule } from '../lib/use-slide-module';
|
|
10
|
+
|
|
11
|
+
const SETTLE_TIMEOUT_MS = 10_000;
|
|
12
|
+
const POLL_INTERVAL_MS = 50;
|
|
13
|
+
|
|
14
|
+
type Settle = { status: 'loading' } | { status: 'ready' } | { status: 'error'; message: string };
|
|
15
|
+
|
|
16
|
+
function parseIntParam(raw: string | null): number | null {
|
|
17
|
+
if (raw === null || raw.trim() === '') return null;
|
|
18
|
+
const n = Number(raw);
|
|
19
|
+
return Number.isInteger(n) ? n : Number.NaN;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
|
|
23
|
+
return Promise.race([promise.then(() => true), sleep(ms).then(() => false)]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function decodeImages(root: HTMLElement): Promise<unknown> {
|
|
27
|
+
const images = Array.from(root.querySelectorAll('img'));
|
|
28
|
+
return Promise.all(images.map((img) => img.decode().catch(() => {})));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type PageErrorBoundaryProps = {
|
|
32
|
+
resetKey: unknown;
|
|
33
|
+
onError: (error: unknown) => void;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
class PageErrorBoundary extends Component<PageErrorBoundaryProps, { failed: boolean }> {
|
|
38
|
+
state = { failed: false };
|
|
39
|
+
|
|
40
|
+
static getDerivedStateFromError() {
|
|
41
|
+
return { failed: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
componentDidCatch(error: unknown) {
|
|
45
|
+
this.props.onError(error);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
componentDidUpdate(prev: PageErrorBoundaryProps) {
|
|
49
|
+
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
|
|
50
|
+
this.setState({ failed: false });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
render() {
|
|
55
|
+
return this.state.failed ? null : this.props.children;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function Preview() {
|
|
60
|
+
const { slideId = '' } = useParams();
|
|
61
|
+
const [searchParams] = useSearchParams();
|
|
62
|
+
const { slide, error: loadError } = useSlideModule(slideId);
|
|
63
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
64
|
+
const [settle, setSettle] = useState<Settle>({ status: 'loading' });
|
|
65
|
+
const [renderError, setRenderError] = useState<{ page: unknown; message: string } | null>(null);
|
|
66
|
+
|
|
67
|
+
const pageParam = parseIntParam(searchParams.get('p'));
|
|
68
|
+
const stepParam = parseIntParam(searchParams.get('step'));
|
|
69
|
+
const pages = slide?.default ?? [];
|
|
70
|
+
const pageNumber = pageParam ?? 1;
|
|
71
|
+
const Page = pages[pageNumber - 1];
|
|
72
|
+
|
|
73
|
+
let inputError = loadError;
|
|
74
|
+
if (!inputError && slide) {
|
|
75
|
+
if (pages.length === 0) inputError = `Slide "${slideId}" has no pages.`;
|
|
76
|
+
else if (!Page) {
|
|
77
|
+
inputError = `Page ${searchParams.get('p')} is out of range (1–${pages.length}).`;
|
|
78
|
+
} else if (stepParam !== null && (Number.isNaN(stepParam) || stepParam < 0)) {
|
|
79
|
+
inputError = `Invalid step "${searchParams.get('step')}" — expected a non-negative integer.`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
useDocumentTitle(slide ? `${slide.meta?.title ?? slideId} · ${pageNumber}` : undefined);
|
|
84
|
+
|
|
85
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a new step reveals content that has to settle again
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
setSettle({ status: 'loading' });
|
|
88
|
+
if (!Page || inputError) return;
|
|
89
|
+
let cancelled = false;
|
|
90
|
+
(async () => {
|
|
91
|
+
await nextPaint();
|
|
92
|
+
const frame = frameRef.current;
|
|
93
|
+
if (!frame) return;
|
|
94
|
+
const deadline = performance.now() + SETTLE_TIMEOUT_MS;
|
|
95
|
+
const remaining = () => Math.max(0, deadline - performance.now());
|
|
96
|
+
const fontsLoaded = await settlesWithin(waitForFonts(), remaining());
|
|
97
|
+
const targetsFound = await waitForDataWaitfor(frame, remaining());
|
|
98
|
+
const imagesDecoded = await settlesWithin(decodeImages(frame), remaining());
|
|
99
|
+
while (!cancelled && !isFrameAnimationSettled(frame) && remaining() > 0) {
|
|
100
|
+
await sleep(POLL_INTERVAL_MS);
|
|
101
|
+
}
|
|
102
|
+
const animationsSettled = isFrameAnimationSettled(frame);
|
|
103
|
+
await nextPaint();
|
|
104
|
+
if (cancelled) return;
|
|
105
|
+
const pending = [
|
|
106
|
+
!fontsLoaded && 'fonts to load',
|
|
107
|
+
!targetsFound && 'a [data-waitfor] target to appear',
|
|
108
|
+
!imagesDecoded && 'images to decode',
|
|
109
|
+
!animationsSettled && 'animations to finish',
|
|
110
|
+
].filter(Boolean);
|
|
111
|
+
setSettle(
|
|
112
|
+
pending.length > 0
|
|
113
|
+
? { status: 'error', message: `Timed out waiting for ${pending.join(', ')}.` }
|
|
114
|
+
: { status: 'ready' },
|
|
115
|
+
);
|
|
116
|
+
})();
|
|
117
|
+
return () => {
|
|
118
|
+
cancelled = true;
|
|
119
|
+
};
|
|
120
|
+
}, [Page, inputError, stepParam]);
|
|
121
|
+
|
|
122
|
+
const pageError = renderError && renderError.page === Page ? renderError.message : null;
|
|
123
|
+
const error = inputError ?? pageError ?? (settle.status === 'error' ? settle.message : null);
|
|
124
|
+
const state = error ? 'error' : settle.status;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
ref={frameRef}
|
|
129
|
+
data-osd-preview={state}
|
|
130
|
+
data-osd-preview-page={Page ? pageNumber : undefined}
|
|
131
|
+
data-osd-preview-total={slide ? pages.length : undefined}
|
|
132
|
+
className="h-screen w-screen overflow-hidden bg-black"
|
|
133
|
+
>
|
|
134
|
+
{error ? (
|
|
135
|
+
<pre
|
|
136
|
+
data-osd-preview-error
|
|
137
|
+
className="m-0 p-6 font-mono text-[14px] whitespace-pre-wrap text-red-400"
|
|
138
|
+
>
|
|
139
|
+
{error}
|
|
140
|
+
</pre>
|
|
141
|
+
) : (
|
|
142
|
+
slide &&
|
|
143
|
+
Page && (
|
|
144
|
+
<SlideCanvas flat freezeMotion design={slide.design}>
|
|
145
|
+
<SlidePageProvider index={pageNumber - 1} total={pages.length}>
|
|
146
|
+
<PageErrorBoundary
|
|
147
|
+
resetKey={Page}
|
|
148
|
+
onError={(e) =>
|
|
149
|
+
setRenderError({
|
|
150
|
+
page: Page,
|
|
151
|
+
message: `Page ${pageNumber} threw while rendering:\n${e instanceof Error ? (e.stack ?? e.message) : String(e)}`,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
>
|
|
155
|
+
{stepParam === null ? (
|
|
156
|
+
<Page />
|
|
157
|
+
) : (
|
|
158
|
+
<PreviewStepHost revealed={stepParam}>
|
|
159
|
+
<Page />
|
|
160
|
+
</PreviewStepHost>
|
|
161
|
+
)}
|
|
162
|
+
</PageErrorBoundary>
|
|
163
|
+
</SlidePageProvider>
|
|
164
|
+
</SlideCanvas>
|
|
165
|
+
)
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|