@open-slide/core 0.0.9 → 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.
- package/dist/{build-pqF4W1Yi.js → build-DHiRlpjn.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-CtwxMYv9.js → config-LZM903FE.js} +377 -0
- package/dist/{dev-CJX97uiy.js → dev-B3JzCYn7.js} +1 -1
- package/dist/{preview-IuLPcL5y.js → preview-UikovHEt.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/src/app/components/AssetView.tsx +846 -0
- package/src/app/components/ClickNavZones.tsx +2 -2
- package/src/app/components/ThumbnailRail.tsx +2 -2
- package/src/app/components/inspector/InspectorPanel.tsx +143 -0
- package/src/app/components/inspector/InspectorProvider.tsx +33 -3
- package/src/app/lib/assets.ts +166 -0
- package/src/app/lib/export-pdf.ts +1 -4
- package/src/app/lib/inspector/useEditor.ts +2 -1
- package/src/app/routes/Slide.tsx +96 -51
|
@@ -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
|
|