@open-slide/core 0.0.8 → 0.0.10

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 (34) hide show
  1. package/dist/{build-CXY2DSzy.js → build-DHiRlpjn.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BYTf0qVz.js → config-LZM903FE.js} +742 -44
  4. package/dist/{dev-BxCKugi3.js → dev-B3JzCYn7.js} +1 -1
  5. package/dist/{preview-C1F-rHfx.js → preview-UikovHEt.js} +1 -1
  6. package/dist/vite/index.js +1 -1
  7. package/package.json +3 -1
  8. package/src/app/App.tsx +2 -0
  9. package/src/app/components/AssetView.tsx +846 -0
  10. package/src/app/components/ClickNavZones.tsx +2 -2
  11. package/src/app/components/PdfProgressToast.tsx +23 -0
  12. package/src/app/components/ThumbnailRail.tsx +2 -2
  13. package/src/app/components/inspector/CommentWidget.tsx +1 -1
  14. package/src/app/components/inspector/InspectOverlay.tsx +81 -41
  15. package/src/app/components/inspector/InspectorPanel.tsx +948 -0
  16. package/src/app/components/inspector/InspectorProvider.tsx +229 -13
  17. package/src/app/components/inspector/SaveBar.tsx +77 -0
  18. package/src/app/components/ui/input.tsx +21 -0
  19. package/src/app/components/ui/label.tsx +24 -0
  20. package/src/app/components/ui/progress.tsx +31 -0
  21. package/src/app/components/ui/select.tsx +190 -0
  22. package/src/app/components/ui/slider.tsx +61 -0
  23. package/src/app/components/ui/sonner.tsx +38 -0
  24. package/src/app/components/ui/textarea.tsx +18 -0
  25. package/src/app/components/ui/toggle-group.tsx +83 -0
  26. package/src/app/components/ui/toggle.tsx +45 -0
  27. package/src/app/components/ui/tooltip.tsx +55 -0
  28. package/src/app/lib/assets.ts +166 -0
  29. package/src/app/lib/export-pdf.ts +194 -0
  30. package/src/app/lib/inspector/fiber.ts +40 -5
  31. package/src/app/lib/inspector/useEditor.ts +62 -0
  32. package/src/app/lib/print-ready.ts +58 -0
  33. package/src/app/routes/Slide.tsx +140 -51
  34. package/src/app/components/inspector/CommentPopover.tsx +0 -94
@@ -0,0 +1,62 @@
1
+ import { useCallback } from 'react';
2
+
3
+ export type EditOp =
4
+ | { kind: 'set-style'; key: string; value: string | null }
5
+ | { kind: 'set-text'; value: string }
6
+ | { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string };
7
+
8
+ export type Edit = { line: number; column: number; ops: EditOp[] };
9
+
10
+ export class NoOpEditError extends Error {
11
+ constructor() {
12
+ super(
13
+ 'Edit completed but the source file did not change — the target JSX may already match, or the target element may not be directly editable here.',
14
+ );
15
+ this.name = 'NoOpEditError';
16
+ }
17
+ }
18
+
19
+ export function useEditor(slideId: string) {
20
+ const applyEdit = useCallback(
21
+ async (line: number, column: number, ops: EditOp[]) => {
22
+ const res = await fetch('/__edit', {
23
+ method: 'POST',
24
+ headers: { 'content-type': 'application/json' },
25
+ body: JSON.stringify({ slideId, line, column, ops }),
26
+ });
27
+ const body = (await res.json().catch(() => ({}))) as { error?: string; changed?: boolean };
28
+ if (!res.ok) {
29
+ throw new Error(body.error ?? `POST /__edit → ${res.status}`);
30
+ }
31
+ if (body.changed === false) {
32
+ throw new NoOpEditError();
33
+ }
34
+ },
35
+ [slideId],
36
+ );
37
+
38
+ // Batch many element edits into one file write and one HMR tick.
39
+ const applyEdits = useCallback(
40
+ async (edits: Edit[]) => {
41
+ if (edits.length === 0) return;
42
+ const res = await fetch('/__edit/batch', {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json' },
45
+ body: JSON.stringify({ slideId, edits }),
46
+ });
47
+ const body = (await res.json().catch(() => ({}))) as {
48
+ error?: string;
49
+ changed?: boolean;
50
+ results?: Array<{ ok: boolean; error?: string }>;
51
+ };
52
+ if (!res.ok) {
53
+ throw new Error(body.error ?? `POST /__edit/batch → ${res.status}`);
54
+ }
55
+ const failed = body.results?.find((r) => !r.ok);
56
+ if (failed?.error) throw new Error(failed.error);
57
+ },
58
+ [slideId],
59
+ );
60
+
61
+ return { applyEdit, applyEdits };
62
+ }
@@ -0,0 +1,58 @@
1
+ // Helpers used by the PDF export flow to wait for the page to settle before
2
+ // invoking window.print(). Browser-only — no Node / headless dependency.
3
+
4
+ const DEFAULT_WAITFOR_TIMEOUT_MS = 10_000;
5
+
6
+ export async function waitForFonts(): Promise<void> {
7
+ if (!('fonts' in document)) return;
8
+ await document.fonts.ready;
9
+ const pending: Promise<unknown>[] = [];
10
+ for (const face of document.fonts) {
11
+ if (face.status !== 'loaded') pending.push(face.load());
12
+ }
13
+ if (pending.length) {
14
+ await Promise.all(pending.map((p) => p.catch(() => undefined)));
15
+ }
16
+ }
17
+
18
+ export async function waitForDataWaitfor(
19
+ root: HTMLElement,
20
+ timeoutMs = DEFAULT_WAITFOR_TIMEOUT_MS,
21
+ ): Promise<void> {
22
+ const targets = Array.from(root.querySelectorAll<HTMLElement>('[data-waitfor]'));
23
+ if (targets.length === 0) return;
24
+ const deadline = performance.now() + timeoutMs;
25
+ await Promise.all(
26
+ targets.map(async (el) => {
27
+ const selector = el.getAttribute('data-waitfor');
28
+ if (!selector) return;
29
+ while (performance.now() < deadline) {
30
+ try {
31
+ if (el.querySelector(selector)) return;
32
+ } catch {
33
+ return; // invalid selector — skip rather than hang
34
+ }
35
+ await nextFrame();
36
+ }
37
+ }),
38
+ );
39
+ }
40
+
41
+ /** Returns true if `frame` has no running finite-iteration animations. */
42
+ export function isFrameAnimationSettled(frame: Element): boolean {
43
+ if (typeof document.getAnimations !== 'function') return true;
44
+ for (const anim of document.getAnimations()) {
45
+ const effect = anim.effect as KeyframeEffect | null;
46
+ if (!effect) continue;
47
+ const target = effect.target;
48
+ if (!target || !frame.contains(target)) continue;
49
+ const timing = effect.getComputedTiming();
50
+ if (timing.iterations === Infinity) continue;
51
+ if (anim.playState !== 'finished') return false;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ function nextFrame(): Promise<void> {
57
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
58
+ }
@@ -1,14 +1,18 @@
1
1
  import config from 'virtual:open-slide/config';
2
- import { ChevronLeft, Download, FileCode2, Loader2, Pencil, Play } from 'lucide-react';
2
+ import { ChevronLeft, Download, FileCode2, FileText, Loader2, Pencil, Play } from 'lucide-react';
3
3
  import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, useParams, useSearchParams } from 'react-router-dom';
5
+ import { toast } from 'sonner';
6
+ import { AssetView } from '@/components/AssetView';
5
7
  import { CommentWidget } from '@/components/inspector/CommentWidget';
6
8
  import { InspectOverlay } from '@/components/inspector/InspectOverlay';
9
+ import { InspectorPanel } from '@/components/inspector/InspectorPanel';
7
10
  import {
8
11
  InspectorProvider,
9
12
  InspectToggleButton,
10
13
  useInspector,
11
14
  } from '@/components/inspector/InspectorProvider';
15
+ import { SaveBar } from '@/components/inspector/SaveBar';
12
16
  import { Button, buttonVariants } from '@/components/ui/button';
13
17
  import {
14
18
  DropdownMenu,
@@ -16,15 +20,17 @@ import {
16
20
  DropdownMenuItem,
17
21
  DropdownMenuTrigger,
18
22
  } from '@/components/ui/dropdown-menu';
19
- import { Separator } from '@/components/ui/separator';
23
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
20
24
  import { useFolders } from '@/lib/folders';
21
25
  import { useWheelPageNavigation } from '@/lib/useWheelPageNavigation';
22
26
  import { cn } from '@/lib/utils';
23
27
  import { ClickNavZones } from '../components/ClickNavZones';
28
+ import { PdfProgressToast } from '../components/PdfProgressToast';
24
29
  import { Player } from '../components/Player';
25
30
  import { SlideCanvas } from '../components/SlideCanvas';
26
31
  import { ThumbnailRail } from '../components/ThumbnailRail';
27
32
  import { exportSlideAsHtml } from '../lib/export-html';
33
+ import { exportSlideAsPdf } from '../lib/export-pdf';
28
34
  import type { SlideModule } from '../lib/sdk';
29
35
  import { loadSlide } from '../lib/slides';
30
36
 
@@ -60,6 +66,7 @@ export function Slide() {
60
66
  const pageCount = pages.length;
61
67
  const rawIndex = Number(searchParams.get('p') ?? '1') - 1;
62
68
  const index = Number.isFinite(rawIndex) ? Math.max(0, Math.min(pageCount - 1, rawIndex)) : 0;
69
+ const view = searchParams.get('view') === 'assets' ? 'assets' : 'slides';
63
70
 
64
71
  const goTo = useCallback(
65
72
  (i: number) => {
@@ -166,15 +173,50 @@ export function Slide() {
166
173
  <header className="relative flex shrink-0 items-center justify-between border-b bg-card px-3 py-2 md:px-5 md:py-3">
167
174
  <div className="flex items-center gap-2 md:gap-3">
168
175
  {showSlideBrowser && (
169
- <>
170
- <Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
171
- <Link to="/">
172
- <ChevronLeft className="size-4" />
173
- <span className="hidden md:inline">Home</span>
174
- </Link>
175
- </Button>
176
- <Separator orientation="vertical" className="hidden h-5 md:block" />
177
- </>
176
+ <Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
177
+ <Link to="/">
178
+ <ChevronLeft className="size-4" />
179
+ <span className="hidden md:inline">Home</span>
180
+ </Link>
181
+ </Button>
182
+ )}
183
+ {import.meta.env.DEV && (
184
+ <Tabs
185
+ value={view}
186
+ onValueChange={(next) => {
187
+ setSearchParams(
188
+ (prev) => {
189
+ const params = new URLSearchParams(prev);
190
+ if (next === 'assets') params.set('view', 'assets');
191
+ else params.delete('view');
192
+ return params;
193
+ },
194
+ { replace: true },
195
+ );
196
+ }}
197
+ >
198
+ <TabsList className="relative h-7 rounded-md p-0.5 group-data-[orientation=horizontal]/tabs:h-7">
199
+ <div
200
+ aria-hidden
201
+ className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-2px)] rounded-[5px] bg-background shadow-sm transition-transform duration-200 ease-out"
202
+ style={{
203
+ transform: view === 'assets' ? 'translateX(100%)' : 'translateX(0)',
204
+ }}
205
+ />
206
+ <TabsTrigger
207
+ value="slides"
208
+ className="relative z-10 h-6 px-3 text-xs data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:bg-transparent"
209
+ >
210
+ Slides
211
+ </TabsTrigger>
212
+ <TabsTrigger
213
+ value="assets"
214
+ className="relative z-10 h-6 px-3 text-xs data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:bg-transparent"
215
+ >
216
+ Assets
217
+ </TabsTrigger>
218
+ </TabsList>
219
+ </Tabs>
178
220
  )}
179
221
  </div>
180
222
 
@@ -185,7 +227,7 @@ export function Slide() {
185
227
  </div>
186
228
 
187
229
  <div className="flex items-center gap-1.5">
188
- {allowHtmlDownload && (
230
+ {view === 'slides' && allowHtmlDownload && (
189
231
  <DropdownMenu>
190
232
  <DropdownMenuTrigger
191
233
  type="button"
@@ -218,53 +260,100 @@ export function Slide() {
218
260
  <FileCode2 />
219
261
  Download HTML
220
262
  </DropdownMenuItem>
263
+ <DropdownMenuItem
264
+ disabled={exporting}
265
+ onSelect={async () => {
266
+ if (!slide || exporting) return;
267
+ setExporting(true);
268
+ const toastId = `pdf-export-${slideId}`;
269
+ toast.custom(
270
+ () => (
271
+ <PdfProgressToast
272
+ progress={{
273
+ phase: 'processing',
274
+ current: 0,
275
+ total: pages.length,
276
+ percent: 0,
277
+ }}
278
+ />
279
+ ),
280
+ { id: toastId, duration: Infinity },
281
+ );
282
+ try {
283
+ await exportSlideAsPdf(slide, slideId, (p) => {
284
+ toast.custom(() => <PdfProgressToast progress={p} />, {
285
+ id: toastId,
286
+ duration: Infinity,
287
+ });
288
+ });
289
+ } catch (err) {
290
+ console.error('[open-slide] pdf export failed', err);
291
+ toast.error('PDF export failed', { id: toastId, duration: 4000 });
292
+ } finally {
293
+ setExporting(false);
294
+ toast.dismiss(toastId);
295
+ }
296
+ }}
297
+ >
298
+ <FileText />
299
+ Download PDF
300
+ </DropdownMenuItem>
221
301
  </DropdownMenuContent>
222
302
  </DropdownMenu>
223
303
  )}
224
- <InspectToggleButton />
225
- <Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
226
- <Play className="size-4" />
227
- <span className="hidden md:inline">Play</span>
228
- <kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
229
- F
230
- </kbd>
231
- </Button>
304
+ {view === 'slides' && <InspectToggleButton />}
305
+ {view === 'slides' && (
306
+ <Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
307
+ <Play className="size-4" />
308
+ <span className="hidden md:inline">Play</span>
309
+ <kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
310
+ F
311
+ </kbd>
312
+ </Button>
313
+ )}
232
314
  </div>
233
315
  </header>
234
316
 
235
- <div className="flex min-h-0 flex-1">
236
- <div className="hidden w-[17rem] shrink-0 md:block">
237
- <ThumbnailRail pages={pages} current={index} onSelect={goTo} />
317
+ {view === 'assets' ? (
318
+ <div className="min-h-0 flex-1">
319
+ <AssetView slideId={slideId} />
238
320
  </div>
239
- <main
240
- ref={slideViewportRef}
241
- data-inspector-root
242
- className="relative min-h-0 min-w-0 flex-1 bg-background p-2 md:p-8"
243
- >
244
- <SlideWheelNavigation
245
- targetRef={slideViewportRef}
246
- onPrev={() => goTo(index - 1)}
247
- onNext={() => goTo(index + 1)}
248
- canPrev={index > 0}
249
- canNext={index < pageCount - 1}
250
- />
251
- <SlideCanvas>
252
- <CurrentPage />
253
- </SlideCanvas>
254
- <ClickNavZones
255
- onPrev={() => goTo(index - 1)}
256
- onNext={() => goTo(index + 1)}
257
- canPrev={index > 0}
258
- canNext={index < pageCount - 1}
259
- />
260
- <InspectOverlay />
261
- <div className="pointer-events-none absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-0.5 text-[11px] font-medium tabular-nums text-white backdrop-blur md:hidden">
262
- {index + 1} / {pageCount}
321
+ ) : (
322
+ <div className="flex min-h-0 flex-1">
323
+ <div className="hidden w-[17rem] shrink-0 md:block">
324
+ <ThumbnailRail pages={pages} current={index} onSelect={goTo} />
263
325
  </div>
264
- </main>
265
- </div>
266
-
267
- <CommentWidget />
326
+ <main
327
+ ref={slideViewportRef}
328
+ data-inspector-root
329
+ className="relative min-h-0 min-w-0 flex-1 bg-background p-2 md:p-8"
330
+ >
331
+ <SlideWheelNavigation
332
+ targetRef={slideViewportRef}
333
+ onPrev={() => goTo(index - 1)}
334
+ onNext={() => goTo(index + 1)}
335
+ canPrev={index > 0}
336
+ canNext={index < pageCount - 1}
337
+ />
338
+ <SlideCanvas>
339
+ <CurrentPage />
340
+ </SlideCanvas>
341
+ <ClickNavZones
342
+ onPrev={() => goTo(index - 1)}
343
+ onNext={() => goTo(index + 1)}
344
+ canPrev={index > 0}
345
+ canNext={index < pageCount - 1}
346
+ />
347
+ <InspectOverlay />
348
+ <SaveBar />
349
+ <CommentWidget />
350
+ <div className="pointer-events-none absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-0.5 text-[11px] font-medium tabular-nums text-white backdrop-blur md:hidden">
351
+ {index + 1} / {pageCount}
352
+ </div>
353
+ </main>
354
+ <InspectorPanel />
355
+ </div>
356
+ )}
268
357
  </div>
269
358
  </InspectorProvider>
270
359
  );
@@ -1,94 +0,0 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import { createPortal } from 'react-dom';
3
- import { useInspector } from './InspectorProvider';
4
-
5
- const POPOVER_W = 320;
6
- const POPOVER_H = 180;
7
-
8
- export function CommentPopover() {
9
- const { pending, setPending, add, cancel } = useInspector();
10
- const [text, setText] = useState('');
11
- const [submitting, setSubmitting] = useState(false);
12
- const [error, setError] = useState<string | null>(null);
13
- const taRef = useRef<HTMLTextAreaElement>(null);
14
-
15
- useEffect(() => {
16
- taRef.current?.focus();
17
- }, []);
18
-
19
- if (!pending) return null;
20
-
21
- const left = clamp(pending.clickX + 12, 8, window.innerWidth - POPOVER_W - 8);
22
- const top = clamp(pending.clickY + 12, 8, window.innerHeight - POPOVER_H - 8);
23
-
24
- const onSubmit = async () => {
25
- const trimmed = text.trim();
26
- if (!trimmed) return;
27
- setSubmitting(true);
28
- try {
29
- await add(pending.line, pending.column, trimmed);
30
- setPending(null);
31
- } catch (e) {
32
- setError(String((e as Error).message ?? e));
33
- setSubmitting(false);
34
- }
35
- };
36
-
37
- return createPortal(
38
- <div
39
- data-inspector-ui
40
- className="fixed z-50 rounded-md border bg-card p-3 shadow-xl"
41
- style={{ left, top, width: POPOVER_W }}
42
- >
43
- <div className="mb-2 flex items-center justify-between">
44
- <span className="text-xs font-medium text-muted-foreground">
45
- Line {pending.line} · Comment
46
- </span>
47
- <button
48
- type="button"
49
- className="text-xs text-muted-foreground hover:text-foreground"
50
- onClick={cancel}
51
- >
52
-
53
- </button>
54
- </div>
55
- <textarea
56
- ref={taRef}
57
- value={text}
58
- onChange={(e) => setText(e.target.value)}
59
- placeholder="Describe the change…"
60
- className="h-20 w-full resize-none rounded border bg-background p-2 text-sm outline-none focus:ring-2 focus:ring-primary/40"
61
- onKeyDown={(e) => {
62
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
63
- e.preventDefault();
64
- onSubmit();
65
- }
66
- }}
67
- />
68
- {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
69
- <div className="mt-2 flex items-center justify-end gap-2">
70
- <button
71
- type="button"
72
- onClick={cancel}
73
- className="rounded border px-2 py-1 text-xs hover:bg-muted"
74
- >
75
- Cancel
76
- </button>
77
- <button
78
- type="button"
79
- disabled={submitting || !text.trim()}
80
- onClick={onSubmit}
81
- className="rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50"
82
- >
83
- {submitting ? 'Saving…' : 'Submit'}
84
- </button>
85
- </div>
86
- <p className="mt-2 text-[10px] text-muted-foreground">⌘/Ctrl + Enter to submit</p>
87
- </div>,
88
- document.body,
89
- );
90
- }
91
-
92
- function clamp(n: number, lo: number, hi: number): number {
93
- return Math.max(lo, Math.min(hi, n));
94
- }