@open-slide/core 0.0.9 → 0.0.11

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.
@@ -19,7 +19,7 @@ export function ClickNavZones({ onPrev, onNext, canPrev, canNext }: Props) {
19
19
  onClick={onPrev}
20
20
  disabled={!canPrev}
21
21
  data-inspector-ui
22
- className="absolute inset-y-0 left-0 z-20 w-[18%] min-w-12"
22
+ className="absolute inset-y-0 left-0 z-20 w-[18%] min-w-12 md:hidden"
23
23
  />
24
24
  <button
25
25
  type="button"
@@ -27,7 +27,7 @@ export function ClickNavZones({ onPrev, onNext, canPrev, canNext }: Props) {
27
27
  onClick={onNext}
28
28
  disabled={!canNext}
29
29
  data-inspector-ui
30
- className="absolute inset-y-0 right-0 z-20 w-[18%] min-w-12"
30
+ className="absolute inset-y-0 right-0 z-20 w-[18%] min-w-12 md:hidden"
31
31
  />
32
32
  </>
33
33
  );
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useRef } from 'react';
2
- import { cn } from '@/lib/utils';
3
2
  import { ScrollArea } from '@/components/ui/scroll-area';
3
+ import { cn } from '@/lib/utils';
4
4
  import type { Page } from '../lib/sdk';
5
+ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
5
6
  import { SlideCanvas } from './SlideCanvas';
6
- import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../lib/sdk';
7
7
 
8
8
  type Props = {
9
9
  pages: Page[];
@@ -4,12 +4,20 @@ import {
4
4
  AlignLeft,
5
5
  AlignRight,
6
6
  Bold,
7
+ ImageIcon,
7
8
  Italic,
8
9
  Trash2,
9
10
  X,
10
11
  } from 'lucide-react';
11
12
  import { useCallback, useEffect, useRef, useState } from 'react';
12
13
  import { Button } from '@/components/ui/button';
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from '@/components/ui/dialog';
13
21
  import { Input } from '@/components/ui/input';
14
22
  import { Label } from '@/components/ui/label';
15
23
  import { ScrollArea } from '@/components/ui/scroll-area';
@@ -25,9 +33,11 @@ import { Slider } from '@/components/ui/slider';
25
33
  import { Textarea } from '@/components/ui/textarea';
26
34
  import { Toggle } from '@/components/ui/toggle';
27
35
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
36
+ import { type AssetEntry, useAssets } from '@/lib/assets';
28
37
  import { findSlideSource } from '@/lib/inspector/fiber';
29
38
  import type { SlideComment } from '@/lib/inspector/useComments';
30
39
  import type { EditOp } from '@/lib/inspector/useEditor';
40
+ import { cn } from '@/lib/utils';
31
41
  import { type SelectedTarget, useInspector } from './InspectorProvider';
32
42
 
33
43
  const PANEL_W = 340;
@@ -43,6 +53,7 @@ type ElementSnapshot = {
43
53
  lineHeight: number | null;
44
54
  letterSpacing: number;
45
55
  text: string | null;
56
+ imageSrc: string | null;
46
57
  };
47
58
 
48
59
  export function InspectorPanel() {
@@ -199,6 +210,15 @@ export function InspectorPanel() {
199
210
  />
200
211
  </Section>
201
212
 
213
+ {pinSnapshot.imageSrc !== null && (
214
+ <>
215
+ <Separator />
216
+ <Section title="Image">
217
+ <ImageField slideId={slideId} src={pinSnapshot.imageSrc} apply={apply} />
218
+ </Section>
219
+ </>
220
+ )}
221
+
202
222
  <Separator />
203
223
 
204
224
  <div className="mt-auto">
@@ -618,6 +638,124 @@ function NumberField({
618
638
  );
619
639
  }
620
640
 
641
+ function ImageField({
642
+ slideId,
643
+ src,
644
+ apply,
645
+ }: {
646
+ slideId: string;
647
+ src: string;
648
+ apply: (ops: EditOp[]) => void;
649
+ }) {
650
+ const [open, setOpen] = useState(false);
651
+ return (
652
+ <div className="space-y-2">
653
+ <div className="flex items-center gap-3">
654
+ <div className="flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:8px_8px]">
655
+ <img
656
+ src={src}
657
+ alt=""
658
+ className="size-full object-contain"
659
+ draggable={false}
660
+ onError={(e) => {
661
+ e.currentTarget.style.display = 'none';
662
+ }}
663
+ />
664
+ </div>
665
+ <Button
666
+ type="button"
667
+ variant="outline"
668
+ size="sm"
669
+ className="flex-1"
670
+ onClick={() => setOpen(true)}
671
+ >
672
+ <ImageIcon className="size-3.5" />
673
+ Replace…
674
+ </Button>
675
+ </div>
676
+ {open && (
677
+ <AssetPickerDialog
678
+ slideId={slideId}
679
+ onClose={() => setOpen(false)}
680
+ onPick={(asset) => {
681
+ setOpen(false);
682
+ apply([
683
+ {
684
+ kind: 'set-attr-asset',
685
+ attr: 'src',
686
+ assetPath: `./assets/${asset.name}`,
687
+ previewUrl: asset.url,
688
+ },
689
+ ]);
690
+ }}
691
+ />
692
+ )}
693
+ </div>
694
+ );
695
+ }
696
+
697
+ function AssetPickerDialog({
698
+ slideId,
699
+ onClose,
700
+ onPick,
701
+ }: {
702
+ slideId: string;
703
+ onClose: () => void;
704
+ onPick: (asset: AssetEntry) => void;
705
+ }) {
706
+ const { assets, loading } = useAssets(slideId);
707
+ const images = assets.filter((a) => a.mime.startsWith('image/'));
708
+ return (
709
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
710
+ <DialogContent className="sm:max-w-xl">
711
+ <DialogHeader>
712
+ <DialogTitle>Replace image</DialogTitle>
713
+ <DialogDescription>
714
+ Pick an asset from <span className="font-mono">slides/{slideId}/assets/</span>.
715
+ </DialogDescription>
716
+ </DialogHeader>
717
+ <div className="max-h-[60vh] overflow-y-auto">
718
+ {loading ? (
719
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">Loading…</p>
720
+ ) : images.length === 0 ? (
721
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
722
+ No images in this slide's assets folder yet. Add some from the Assets tab.
723
+ </p>
724
+ ) : (
725
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
726
+ {images.map((asset) => (
727
+ <button
728
+ key={asset.name}
729
+ type="button"
730
+ onClick={() => onPick(asset)}
731
+ className={cn(
732
+ 'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
733
+ 'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
734
+ )}
735
+ >
736
+ <div className="flex aspect-square w-full items-center justify-center overflow-hidden bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:12px_12px]">
737
+ <img
738
+ src={asset.url}
739
+ alt=""
740
+ className="size-full object-contain"
741
+ draggable={false}
742
+ />
743
+ </div>
744
+ <div className="border-t px-2 py-1.5">
745
+ <div className="truncate text-[11px] font-medium" title={asset.name}>
746
+ {asset.name}
747
+ </div>
748
+ </div>
749
+ </button>
750
+ ))}
751
+ </div>
752
+ )}
753
+ </div>
754
+ </DialogContent>
755
+ </Dialog>
756
+ );
757
+ }
758
+
621
759
  function CommentsSection({
622
760
  comments,
623
761
  selected,
@@ -714,6 +852,10 @@ function CommentsSection({
714
852
  function readSnapshot(el: HTMLElement): ElementSnapshot {
715
853
  const cs = getComputedStyle(el);
716
854
  const text = isSimpleTextElement(el) ? (el.textContent ?? '') : null;
855
+ const imageSrc =
856
+ el.tagName === 'IMG'
857
+ ? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || null
858
+ : null;
717
859
 
718
860
  return {
719
861
  fontSize: parseFloat(cs.fontSize) || 16,
@@ -725,6 +867,7 @@ function readSnapshot(el: HTMLElement): ElementSnapshot {
725
867
  lineHeight: parseLineHeight(cs.lineHeight, parseFloat(cs.fontSize) || 16),
726
868
  letterSpacing: parseLetterSpacing(cs.letterSpacing),
727
869
  text,
870
+ imageSrc,
728
871
  };
729
872
  }
730
873
 
@@ -19,15 +19,19 @@ export type SelectedTarget = {
19
19
  anchor: HTMLElement;
20
20
  };
21
21
 
22
+ type AssetAttrOp = { assetPath: string; previewUrl: string };
23
+
22
24
  type Bucket = {
23
25
  line: number;
24
26
  column: number;
25
27
  styleOps: Map<string, string | null>;
26
28
  textOp: { value: string } | null;
29
+ attrOps: Map<string, AssetAttrOp>;
27
30
  // Pre-edit snapshot of the DOM, captured the first time we touch
28
- // each style key / text. Used by `cancelEdits` to revert.
31
+ // each style key / text / attribute. Used by `cancelEdits` to revert.
29
32
  origStyle: Map<string, string>;
30
33
  origText: { value: string } | null;
34
+ origAttrs: Map<string, string | null>;
31
35
  };
32
36
 
33
37
  type InspectorCtx = {
@@ -75,7 +79,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
75
79
  const refreshCount = useCallback(() => {
76
80
  let n = 0;
77
81
  for (const b of pendingRef.current.values()) {
78
- if (b.styleOps.size > 0 || b.textOp !== null) n++;
82
+ if (b.styleOps.size > 0 || b.textOp !== null || b.attrOps.size > 0) n++;
79
83
  }
80
84
  setPendingCount(n);
81
85
  }, []);
@@ -90,8 +94,10 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
90
94
  column,
91
95
  styleOps: new Map(),
92
96
  textOp: null,
97
+ attrOps: new Map(),
93
98
  origStyle: new Map(),
94
99
  origText: null,
100
+ origAttrs: new Map(),
95
101
  };
96
102
  pendingRef.current.set(key, bucket);
97
103
  }
@@ -109,6 +115,15 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
109
115
  }
110
116
  bucket.textOp = { value: op.value };
111
117
  if (anchor.isConnected) anchor.textContent = op.value;
118
+ } else if (op.kind === 'set-attr-asset') {
119
+ if (!bucket.origAttrs.has(op.attr)) {
120
+ bucket.origAttrs.set(
121
+ op.attr,
122
+ anchor.hasAttribute(op.attr) ? anchor.getAttribute(op.attr) : null,
123
+ );
124
+ }
125
+ bucket.attrOps.set(op.attr, { assetPath: op.assetPath, previewUrl: op.previewUrl });
126
+ if (anchor.isConnected) anchor.setAttribute(op.attr, op.previewUrl);
112
127
  }
113
128
  }
114
129
  refreshCount();
@@ -120,10 +135,18 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
120
135
  const buckets = pendingRef.current;
121
136
  if (buckets.size === 0) return;
122
137
  const edits: Edit[] = [];
123
- for (const { line, column, styleOps, textOp } of buckets.values()) {
138
+ for (const { line, column, styleOps, textOp, attrOps } of buckets.values()) {
124
139
  const list: EditOp[] = [];
125
140
  for (const [k, v] of styleOps) list.push({ kind: 'set-style', key: k, value: v });
126
141
  if (textOp !== null) list.push({ kind: 'set-text', value: textOp.value });
142
+ for (const [attr, op] of attrOps) {
143
+ list.push({
144
+ kind: 'set-attr-asset',
145
+ attr,
146
+ assetPath: op.assetPath,
147
+ previewUrl: op.previewUrl,
148
+ });
149
+ }
127
150
  if (list.length > 0) edits.push({ line, column, ops: list });
128
151
  }
129
152
  pendingRef.current = new Map();
@@ -146,6 +169,10 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
146
169
  const style = el.style as unknown as Record<string, string>;
147
170
  for (const [k, v] of b.origStyle) style[k] = v;
148
171
  if (b.origText !== null) el.textContent = b.origText.value;
172
+ for (const [attr, value] of b.origAttrs) {
173
+ if (value === null) el.removeAttribute(attr);
174
+ else el.setAttribute(attr, value);
175
+ }
149
176
  }
150
177
  pendingRef.current = new Map();
151
178
  setPendingCount(0);
@@ -186,6 +213,9 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
186
213
  if (bucket.textOp !== null && el.textContent !== bucket.textOp.value) {
187
214
  el.textContent = bucket.textOp.value;
188
215
  }
216
+ for (const [attr, op] of bucket.attrOps) {
217
+ if (el.getAttribute(attr) !== op.previewUrl) el.setAttribute(attr, op.previewUrl);
218
+ }
189
219
  };
190
220
 
191
221
  const replayAll = () => {
@@ -0,0 +1,166 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ export type AssetEntry = {
4
+ name: string;
5
+ size: number;
6
+ mtime: number;
7
+ mime: string;
8
+ url: string;
9
+ };
10
+
11
+ export type UploadOptions = { overwrite?: boolean };
12
+
13
+ async function listAssets(slideId: string): Promise<AssetEntry[]> {
14
+ const res = await fetch(`/__assets/${slideId}`);
15
+ if (!res.ok) throw new Error(`GET /__assets/${slideId} ${res.status}`);
16
+ const data = (await res.json()) as { assets?: AssetEntry[] };
17
+ return data.assets ?? [];
18
+ }
19
+
20
+ async function uploadAsset(
21
+ slideId: string,
22
+ file: File,
23
+ opts: UploadOptions = {},
24
+ ): Promise<Response> {
25
+ const qs = opts.overwrite ? '?overwrite=1' : '';
26
+ return fetch(`/__assets/${slideId}/${encodeURIComponent(file.name)}${qs}`, {
27
+ method: 'POST',
28
+ headers: {
29
+ 'content-type': file.type || 'application/octet-stream',
30
+ 'content-length': String(file.size),
31
+ },
32
+ body: file,
33
+ });
34
+ }
35
+
36
+ async function renameAsset(slideId: string, from: string, to: string): Promise<Response> {
37
+ return fetch(`/__assets/${slideId}/${encodeURIComponent(from)}`, {
38
+ method: 'PATCH',
39
+ headers: { 'content-type': 'application/json' },
40
+ body: JSON.stringify({ name: to }),
41
+ });
42
+ }
43
+
44
+ async function deleteAsset(slideId: string, name: string): Promise<Response> {
45
+ return fetch(`/__assets/${slideId}/${encodeURIComponent(name)}`, { method: 'DELETE' });
46
+ }
47
+
48
+ export type SvglItem = {
49
+ id: number;
50
+ title: string;
51
+ category: string | string[];
52
+ route: string | { light: string; dark: string };
53
+ url: string;
54
+ };
55
+
56
+ export async function searchSvgl(query: string, signal?: AbortSignal): Promise<SvglItem[]> {
57
+ const q = query.trim();
58
+ const params = new URLSearchParams();
59
+ if (q) params.set('q', q);
60
+ else params.set('limit', '24');
61
+ const res = await fetch(`/__svgl/search?${params.toString()}`, { signal });
62
+ // svgl returns 404 when a search has no matches — treat it as an empty list,
63
+ // not an error.
64
+ if (res.status === 404) return [];
65
+ if (!res.ok) throw new Error(`svgl ${res.status}`);
66
+ return (await res.json()) as SvglItem[];
67
+ }
68
+
69
+ export function svgProxyUrl(routeUrl: string): string {
70
+ return `/__svgl/svg?u=${encodeURIComponent(routeUrl)}`;
71
+ }
72
+
73
+ export async function fetchSvgAsFile(routeUrl: string, filename: string): Promise<File> {
74
+ const res = await fetch(svgProxyUrl(routeUrl));
75
+ if (!res.ok) throw new Error(`svgl route ${res.status}`);
76
+ const blob = await res.blob();
77
+ return new File([blob], filename, { type: 'image/svg+xml' });
78
+ }
79
+
80
+ export type UseAssetsResult = {
81
+ assets: AssetEntry[];
82
+ loading: boolean;
83
+ available: boolean;
84
+ upload: (file: File, opts?: UploadOptions) => Promise<{ ok: boolean; status: number }>;
85
+ rename: (from: string, to: string) => Promise<{ ok: boolean; status: number }>;
86
+ remove: (name: string) => Promise<{ ok: boolean; status: number }>;
87
+ refresh: () => Promise<void>;
88
+ };
89
+
90
+ const NOOP_RESULT = { ok: false, status: 0 } as const;
91
+
92
+ export function useAssets(slideId: string): UseAssetsResult {
93
+ const available = import.meta.env.DEV;
94
+ const [assets, setAssets] = useState<AssetEntry[]>([]);
95
+ const [loading, setLoading] = useState(available);
96
+
97
+ const refresh = useCallback(async () => {
98
+ if (!available) return;
99
+ const next = await listAssets(slideId);
100
+ setAssets(next);
101
+ }, [slideId]);
102
+
103
+ useEffect(() => {
104
+ if (!available) return;
105
+ let cancelled = false;
106
+ setLoading(true);
107
+ listAssets(slideId)
108
+ .then((next) => {
109
+ if (!cancelled) {
110
+ setAssets(next);
111
+ setLoading(false);
112
+ }
113
+ })
114
+ .catch(() => {
115
+ if (!cancelled) setLoading(false);
116
+ });
117
+ return () => {
118
+ cancelled = true;
119
+ };
120
+ }, [slideId]);
121
+
122
+ useEffect(() => {
123
+ if (!available || !import.meta.hot) return;
124
+ const handler = (data: { slideId?: string } | undefined) => {
125
+ if (!data || data.slideId === slideId) {
126
+ refresh().catch(() => {});
127
+ }
128
+ };
129
+ import.meta.hot.on('open-slide:assets-changed', handler);
130
+ return () => {
131
+ import.meta.hot?.off('open-slide:assets-changed', handler);
132
+ };
133
+ }, [slideId, refresh]);
134
+
135
+ const upload = useCallback(
136
+ async (file: File, opts?: UploadOptions) => {
137
+ if (!available) return NOOP_RESULT;
138
+ const res = await uploadAsset(slideId, file, opts);
139
+ if (res.ok) await refresh();
140
+ return { ok: res.ok, status: res.status };
141
+ },
142
+ [slideId, refresh],
143
+ );
144
+
145
+ const rename = useCallback(
146
+ async (from: string, to: string) => {
147
+ if (!available) return NOOP_RESULT;
148
+ const res = await renameAsset(slideId, from, to);
149
+ if (res.ok) await refresh();
150
+ return { ok: res.ok, status: res.status };
151
+ },
152
+ [slideId, refresh],
153
+ );
154
+
155
+ const remove = useCallback(
156
+ async (name: string) => {
157
+ if (!available) return NOOP_RESULT;
158
+ const res = await deleteAsset(slideId, name);
159
+ if (res.ok) await refresh();
160
+ return { ok: res.ok, status: res.status };
161
+ },
162
+ [slideId, refresh],
163
+ );
164
+
165
+ return { assets, loading, available, upload, rename, remove, refresh };
166
+ }
@@ -135,10 +135,7 @@ export async function exportSlideAsPdf(
135
135
  // settled, which matches "page X of N is being processed" mental model.
136
136
  const deadline = performance.now() + ANIMATION_TIMEOUT_MS;
137
137
  while (performance.now() < deadline) {
138
- const settled = frames.reduce(
139
- (n, frame) => (isFrameAnimationSettled(frame) ? n + 1 : n),
140
- 0,
141
- );
138
+ const settled = frames.reduce((n, frame) => (isFrameAnimationSettled(frame) ? n + 1 : n), 0);
142
139
  onProgress?.({
143
140
  phase: 'processing',
144
141
  current: settled,
@@ -2,7 +2,8 @@ import { useCallback } from 'react';
2
2
 
3
3
  export type EditOp =
4
4
  | { kind: 'set-style'; key: string; value: string | null }
5
- | { kind: 'set-text'; value: string };
5
+ | { kind: 'set-text'; value: string }
6
+ | { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string };
6
7
 
7
8
  export type Edit = { line: number; column: number; ops: EditOp[] };
8
9