@open-slide/core 1.5.0 → 1.7.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/dist/{build-DZhbjQpQ.js → build-tLrkKUHr.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BQdTMho4.d.ts → config-CfMThYN9.d.ts} +1 -1
- package/dist/{config-iKjqaX08.js → config-PwUHqZ_X.js} +246 -2
- package/dist/{dev-BjLGk5nN.js → dev-DpCIRbhT.js} +1 -1
- package/dist/{en-DDGqyNaW.js → en-BDnM5zKJ.js} +4 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +20 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +13 -1
- package/dist/{preview-jwLWHWkQ.js → preview-BSGlM6Se.js} +1 -1
- package/dist/{types-Dpr8nbih.d.ts → types-B-KrjgX8.d.ts} +5 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +30 -22
- package/skills/slide-authoring/SKILL.md +186 -0
- package/src/app/components/asset-view.tsx +8 -1
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/inspect-overlay.tsx +132 -35
- package/src/app/components/inspector/inspector-panel.tsx +19 -256
- package/src/app/components/inspector/inspector-provider.tsx +102 -1
- package/src/app/components/panel/save-card.tsx +4 -4
- package/src/app/components/player.tsx +13 -3
- package/src/app/components/present/overview-grid.tsx +4 -1
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -0
- package/src/app/components/themes/theme-detail.tsx +7 -2
- package/src/app/components/themes/themes-gallery.tsx +4 -1
- package/src/app/components/thumbnail-rail.tsx +10 -2
- package/src/app/lib/assets.ts +2 -0
- package/src/app/lib/export-html.ts +7 -2
- package/src/app/lib/export-pdf.ts +34 -2
- package/src/app/lib/folders.ts +35 -1
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/sdk.ts +3 -1
- package/src/app/lib/transition.ts +23 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-wheel-page-navigation.ts +7 -0
- package/src/app/routes/home-shell.tsx +13 -2
- package/src/app/routes/home.tsx +28 -2
- package/src/app/routes/presenter.tsx +7 -2
- package/src/app/routes/slide.tsx +19 -8
- package/src/locale/en.ts +4 -0
- package/src/locale/ja.ts +4 -0
- package/src/locale/types.ts +5 -0
- package/src/locale/zh-cn.ts +4 -0
- package/src/locale/zh-tw.ts +4 -0
|
@@ -105,24 +105,31 @@ const Title = ({ children }: { children: React.ReactNode }) => (
|
|
|
105
105
|
|
|
106
106
|
### Footer
|
|
107
107
|
|
|
108
|
+
Pull the page number from `useSlidePageNumber()` — never hardcode `pageNum` / `total` props.
|
|
109
|
+
|
|
108
110
|
```tsx
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
import { useSlidePageNumber } from '@open-slide/core';
|
|
112
|
+
|
|
113
|
+
const Footer = () => {
|
|
114
|
+
const { current, total } = useSlidePageNumber();
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
style={{
|
|
118
|
+
position: 'absolute',
|
|
119
|
+
left: 120,
|
|
120
|
+
right: 120,
|
|
121
|
+
bottom: 60,
|
|
122
|
+
display: 'flex',
|
|
123
|
+
justifyContent: 'space-between',
|
|
124
|
+
fontSize: 24,
|
|
125
|
+
color: '#94a3b8',
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<span>EDITORIAL NOIR · 2026</span>
|
|
129
|
+
<span>{current} / {total}</span>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
126
133
|
```
|
|
127
134
|
|
|
128
135
|
### Eyebrow / accents (optional)
|
|
@@ -161,7 +168,7 @@ const Cover: Page = () => (
|
|
|
161
168
|
<p style={{ fontSize: 36, color: '#94a3b8', maxWidth: 1200, marginTop: 32 }}>
|
|
162
169
|
A short subtitle that explains what this slide is about.
|
|
163
170
|
</p>
|
|
164
|
-
<Footer
|
|
171
|
+
<Footer />
|
|
165
172
|
</div>
|
|
166
173
|
);
|
|
167
174
|
```
|
|
@@ -173,7 +180,7 @@ The demo is a normal slide module — same shape as `slides/<id>/index.tsx`, jus
|
|
|
173
180
|
|
|
174
181
|
Contract:
|
|
175
182
|
|
|
176
|
-
- `import type
|
|
183
|
+
- `import { type Page, useSlidePageNumber } from '@open-slide/core';`
|
|
177
184
|
- Inline the **same** `Title`, `Footer`, `Eyebrow` components defined in the theme markdown — verbatim, no abstractions, no imports from elsewhere. The demo and the markdown must stay in lockstep so what the user sees in the panel matches what `create-slide` will paste into a real slide.
|
|
178
185
|
- Export 2–3 `Page` components and a default array. Aim for: a Cover (Eyebrow + Title + subtitle), one Content page exercising body type + accent, and a Closer or "End" card. The "Example usage" block at the bottom of the markdown is a good starting point — extend it.
|
|
179
186
|
- If the theme has runtime-tweakable tokens worth surfacing in the Design panel later, also `export const design: DesignSystem = {...}`.
|
|
@@ -182,14 +189,15 @@ Contract:
|
|
|
182
189
|
Skeleton:
|
|
183
190
|
|
|
184
191
|
```tsx
|
|
185
|
-
import type
|
|
192
|
+
import { type Page, useSlidePageNumber } from '@open-slide/core';
|
|
186
193
|
|
|
187
194
|
const Title = ({ children }: { children: React.ReactNode }) => (
|
|
188
195
|
// …same JSX as in themes/<id>.md
|
|
189
196
|
);
|
|
190
|
-
const Footer = (
|
|
197
|
+
const Footer = () => {
|
|
198
|
+
const { current, total } = useSlidePageNumber();
|
|
191
199
|
// …
|
|
192
|
-
|
|
200
|
+
};
|
|
193
201
|
const Eyebrow = ({ children }: { children: React.ReactNode }) => (
|
|
194
202
|
// …
|
|
195
203
|
);
|
|
@@ -291,6 +291,191 @@ The user uploads the real file via the Assets panel, then clicks the placeholder
|
|
|
291
291
|
|
|
292
292
|
Size the placeholder to the slot it occupies. Pass `width`/`height` when the layout has a fixed image box; omit them when the placeholder fills a flex/grid cell. The `hint` should describe the *content* the user needs ("Q3 revenue chart") not the *role* ("hero image").
|
|
293
293
|
|
|
294
|
+
## Page numbers
|
|
295
|
+
|
|
296
|
+
If a footer shows the current page (`03 / 12`, `Page 3`, etc.), read it from `useSlidePageNumber()` — **never hardcode** `n` / `TOTAL`. Inserting, reordering, or deleting a page would otherwise force you to retouch every footer.
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
import { useSlidePageNumber } from '@open-slide/core';
|
|
300
|
+
|
|
301
|
+
const Footer = () => {
|
|
302
|
+
const { current, total } = useSlidePageNumber();
|
|
303
|
+
return (
|
|
304
|
+
<span>{String(current).padStart(2, '0')} / {String(total).padStart(2, '0')}</span>
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`current` is 1-indexed (matches what readers see) and `total` is the slide's page count. The hook works in every render context (main viewer, thumbnails, overview grid, present mode, presenter window, HTML/PDF export) — the same `<Footer />` JSX is correct everywhere. Call the hook inside a component that's used **per page**; don't try to call it at module top level.
|
|
310
|
+
|
|
311
|
+
## Page transitions
|
|
312
|
+
|
|
313
|
+
The framework can run an enter/exit animation between every slide change. There's **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap is a perfectly tasteful default; only opt in when motion adds something.
|
|
314
|
+
|
|
315
|
+
`prefers-reduced-motion: reduce` is honored automatically. You don't write a fallback.
|
|
316
|
+
|
|
317
|
+
### Contract
|
|
318
|
+
|
|
319
|
+
Module-level for the whole deck; per-page to override. The **incoming page wins**: navigating A → B uses `pages[B].transition ?? module.transition`. Its `exit` plays on A, its `enter` plays on B. Going back B → A uses A's transition.
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
import type { Page, SlideTransition } from '@open-slide/core';
|
|
323
|
+
|
|
324
|
+
const Cover: Page = () => <section>…</section>;
|
|
325
|
+
const Body: Page = () => <section>…</section>;
|
|
326
|
+
|
|
327
|
+
// Module-level default — every page inherits unless it overrides.
|
|
328
|
+
export const transition: SlideTransition = { /* … */ };
|
|
329
|
+
|
|
330
|
+
// Per-page override.
|
|
331
|
+
Cover.transition = { /* … */ };
|
|
332
|
+
|
|
333
|
+
export default [Cover, Body];
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
type TransitionPhase = {
|
|
338
|
+
keyframes: Keyframe[] | PropertyIndexedKeyframes; // WAAPI keyframes
|
|
339
|
+
duration?: number; // ms (falls back to top-level duration)
|
|
340
|
+
easing?: string; // CSS easing
|
|
341
|
+
delay?: number; // ms — use to overlap exit + enter
|
|
342
|
+
};
|
|
343
|
+
type SlideTransition = {
|
|
344
|
+
duration: number; // top-level fallback
|
|
345
|
+
easing?: string; // top-level fallback
|
|
346
|
+
enter?: TransitionPhase; // runs on incoming page
|
|
347
|
+
exit?: TransitionPhase; // runs on outgoing page
|
|
348
|
+
};
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The framework also exposes `--osd-dir` (`1` forward, `-1` backward) and `data-osd-dir` on the wrapper, so a single keyframe can mirror direction without a JS callback.
|
|
352
|
+
|
|
353
|
+
### Design principles (hold the line)
|
|
354
|
+
|
|
355
|
+
The single loudest signal of "made in PowerPoint" is six different transitions in one deck. Restraint is the rhythm.
|
|
356
|
+
|
|
357
|
+
- **Pick one DNA, hold it across the deck.** Same duration band, same easing pair, same out-then-in stagger. Variation lives only in *which property* gets the small nudge — Y, X, opacity, scale, blur.
|
|
358
|
+
- **Duration: 140–280 ms.** Exit 140–180 ms, enter 200–280 ms, enter delayed ~80 ms so they overlap but don't fight. Past 350 ms is video-editor territory; reserve for genuine state changes.
|
|
359
|
+
- **Magnitude ceiling: 12 px or 3% scale.** A 6 px Y-rise reads as "next thought." A 1920 px translateX reads as "different document." Premium tools move barely enough to register.
|
|
360
|
+
- **Opacity is always part of it.** Pure-transform transitions look stiff; pure-opacity transitions are the safest possible default.
|
|
361
|
+
- **Easing: ease-in for exit, ease-out for enter.** `cubic-bezier(0.4, 0, 1, 1)` going out, `cubic-bezier(0, 0, 0.2, 1)` coming in. Never `linear` (feels like a slideshow). Reserve symmetric `ease-in-out` for state-anchored morphs only.
|
|
362
|
+
|
|
363
|
+
### Tasteful family — six members, one DNA
|
|
364
|
+
|
|
365
|
+
Use this set as a starting point. Pick one as the deck's house transition; optionally reserve a second for hero/cover slides and a third for genuine section breaks. The CSS-`calc` + `--osd-dir` trick lets a single definition mirror itself on backward navigation when needed.
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)';
|
|
369
|
+
const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)';
|
|
370
|
+
|
|
371
|
+
// RISE — house quiet. 6 px Y. Use as module default.
|
|
372
|
+
export const transition: SlideTransition = {
|
|
373
|
+
duration: 200,
|
|
374
|
+
exit: { duration: 140, easing: EASE_IN,
|
|
375
|
+
keyframes: [
|
|
376
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
377
|
+
{ opacity: 0, transform: 'translateY(-4px)' },
|
|
378
|
+
] },
|
|
379
|
+
enter: { duration: 200, delay: 80, easing: EASE_OUT,
|
|
380
|
+
keyframes: [
|
|
381
|
+
{ opacity: 0, transform: 'translateY(6px)' },
|
|
382
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
383
|
+
] },
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// DISSOLVE — pure opacity. The quietest possible.
|
|
387
|
+
const dissolve: SlideTransition = {
|
|
388
|
+
duration: 240,
|
|
389
|
+
exit: { duration: 200, easing: EASE_IN,
|
|
390
|
+
keyframes: [{ opacity: 1 }, { opacity: 0 }] },
|
|
391
|
+
enter: { duration: 240, delay: 40, easing: EASE_OUT,
|
|
392
|
+
keyframes: [{ opacity: 0 }, { opacity: 1 }] },
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// SETTLE — cover-grade. Rise + a hair of blur on enter only.
|
|
396
|
+
Cover.transition = {
|
|
397
|
+
duration: 280,
|
|
398
|
+
exit: { duration: 160, easing: EASE_IN,
|
|
399
|
+
keyframes: [
|
|
400
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
401
|
+
{ opacity: 0, transform: 'translateY(-6px)' },
|
|
402
|
+
] },
|
|
403
|
+
enter: { duration: 280, delay: 100, easing: EASE_OUT,
|
|
404
|
+
keyframes: [
|
|
405
|
+
{ opacity: 0, transform: 'translateY(12px)', filter: 'blur(4px)' },
|
|
406
|
+
{ opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
|
|
407
|
+
] },
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// BLOOM — scale 0.97 → 1, no translate. Materializes in place.
|
|
411
|
+
const bloom: SlideTransition = {
|
|
412
|
+
duration: 240,
|
|
413
|
+
exit: { duration: 160, easing: EASE_IN,
|
|
414
|
+
keyframes: [
|
|
415
|
+
{ opacity: 1, transform: 'scale(1)' },
|
|
416
|
+
{ opacity: 0, transform: 'scale(1.01)' },
|
|
417
|
+
] },
|
|
418
|
+
enter: { duration: 240, delay: 80, easing: EASE_OUT,
|
|
419
|
+
keyframes: [
|
|
420
|
+
{ opacity: 0, transform: 'scale(0.97)' },
|
|
421
|
+
{ opacity: 1, transform: 'scale(1)' },
|
|
422
|
+
] },
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// FALL — mirrored Rise. Incoming page comes down from above.
|
|
426
|
+
const fall: SlideTransition = {
|
|
427
|
+
duration: 200,
|
|
428
|
+
exit: { duration: 140, easing: EASE_IN,
|
|
429
|
+
keyframes: [
|
|
430
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
431
|
+
{ opacity: 0, transform: 'translateY(4px)' },
|
|
432
|
+
] },
|
|
433
|
+
enter: { duration: 200, delay: 80, easing: EASE_OUT,
|
|
434
|
+
keyframes: [
|
|
435
|
+
{ opacity: 0, transform: 'translateY(-6px)' },
|
|
436
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
437
|
+
] },
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// BREATH — section break. Exit fully, hold 120 ms, then enter.
|
|
441
|
+
// Reserve for genuine chapter dividers; use at most 1–2× per deck.
|
|
442
|
+
const breath: SlideTransition = {
|
|
443
|
+
duration: 460,
|
|
444
|
+
exit: { duration: 180, easing: EASE_IN,
|
|
445
|
+
keyframes: [{ opacity: 1 }, { opacity: 0 }] },
|
|
446
|
+
enter: { duration: 240, delay: 300, easing: EASE_OUT,
|
|
447
|
+
keyframes: [
|
|
448
|
+
{ opacity: 0, transform: 'translateY(8px)' },
|
|
449
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
450
|
+
] },
|
|
451
|
+
};
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
All six share the same DNA — they only differ in which property carries the small nudge. The reader perceives variety; the eye still reads one consistent hand.
|
|
455
|
+
|
|
456
|
+
### Direction-aware keyframes (use sparingly)
|
|
457
|
+
|
|
458
|
+
Most tasteful tools don't mirror on backward navigation. When you genuinely need to — e.g. a horizontal slide that should reverse — use `--osd-dir` inside `calc()`:
|
|
459
|
+
|
|
460
|
+
```tsx
|
|
461
|
+
{ transform: 'translateX(calc(var(--osd-dir, 1) * 8px))' },
|
|
462
|
+
{ transform: 'translateX(0)' },
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
If you find yourself reaching for this on every transition, you're probably over-designing. Forward = backward is the more refined default.
|
|
466
|
+
|
|
467
|
+
### Transition anti-patterns
|
|
468
|
+
|
|
469
|
+
- ❌ Six different transitions across six pages — the single loudest "made in PowerPoint" tell.
|
|
470
|
+
- ❌ `translateX(100%)` slide-from-side — iOS modal / PowerPoint Push; not a slide change.
|
|
471
|
+
- ❌ Aggressive scale-pop (e.g. `0.85 → 1`) + blur — lightbox / photo-viewer vocabulary; implies zooming *into* something.
|
|
472
|
+
- ❌ `clip-path: inset(…)` reveals — After Effects vocabulary; theatrical.
|
|
473
|
+
- ❌ Parallel blur on both layers at once — visual mush; the eye can't fixate.
|
|
474
|
+
- ❌ Duration > 350 ms for a standard slide change — drags.
|
|
475
|
+
- ❌ Translate > 12 px or scale > 3% — reads as rupture, not continuity.
|
|
476
|
+
- ❌ `linear` easing — feels like a slideshow, not a product.
|
|
477
|
+
- ❌ Declaring a transition on every deck. **If you don't have a clear reason, omit it.** Snap-swap is fine.
|
|
478
|
+
|
|
294
479
|
## Repeated elements: component, not `map`
|
|
295
480
|
|
|
296
481
|
When a page has visually repeated items — cards, logo rows, gallery tiles, list rows, step indicators — **define a small component and instantiate it once per item**. Do **not** render the group with `array.map` over a data array.
|
|
@@ -356,6 +541,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
|
|
|
356
541
|
- [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
|
|
357
542
|
- [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
|
|
358
543
|
- [ ] Every `<ImagePlaceholder>` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
|
|
544
|
+
- [ ] If a `SlideTransition` is declared, every page sits in one family — same duration band (140–280 ms), same easing pair, same out-then-in stagger, magnitude under 12 px / 3%. No six-different-vocabularies decks. When in doubt, omit transitions entirely.
|
|
359
545
|
- [ ] Nothing outside `slides/<id>/` was edited.
|
|
360
546
|
|
|
361
547
|
## Anti-patterns
|
|
@@ -414,7 +414,14 @@ function AssetCard({
|
|
|
414
414
|
<div className="truncate text-[12.5px] font-medium" title={asset.name}>
|
|
415
415
|
{asset.name}
|
|
416
416
|
</div>
|
|
417
|
-
<div className="folio
|
|
417
|
+
<div className="folio flex items-center gap-1.5">
|
|
418
|
+
<span className="truncate">{formatSize(asset.size)}</span>
|
|
419
|
+
{asset.unused ? (
|
|
420
|
+
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[10px] font-medium text-muted-foreground leading-none">
|
|
421
|
+
{t.asset.usageUnused}
|
|
422
|
+
</span>
|
|
423
|
+
) : null}
|
|
424
|
+
</div>
|
|
418
425
|
</div>
|
|
419
426
|
<DropdownMenu>
|
|
420
427
|
<DropdownMenuTrigger
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { ArrowDownToLine, Loader2, Upload } from 'lucide-react';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
import { useCallback, useId, useRef, useState } from 'react';
|
|
4
|
+
import { toast } from 'sonner';
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '@/components/ui/dialog';
|
|
12
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
13
|
+
import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
|
|
14
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
15
|
+
import { cn } from '@/lib/utils';
|
|
16
|
+
|
|
17
|
+
export type PickerScope = 'slide' | 'global';
|
|
18
|
+
const GLOBAL_PICKER_SLIDE_ID = '@global';
|
|
19
|
+
|
|
20
|
+
export function AssetPickerDialog({
|
|
21
|
+
slideId,
|
|
22
|
+
onClose,
|
|
23
|
+
onPick,
|
|
24
|
+
}: {
|
|
25
|
+
slideId: string;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
onPick: (asset: AssetEntry, scope: PickerScope) => void;
|
|
28
|
+
}) {
|
|
29
|
+
const [scope, setScope] = useState<PickerScope>('slide');
|
|
30
|
+
const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId;
|
|
31
|
+
const { assets, loading, refresh } = useAssets(effectiveSlideId);
|
|
32
|
+
const images = assets.filter((a) => a.mime.startsWith('image/'));
|
|
33
|
+
const t = useLocale();
|
|
34
|
+
const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
|
|
35
|
+
const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
|
|
36
|
+
const [uploading, setUploading] = useState(false);
|
|
37
|
+
const [dragActive, setDragActive] = useState(false);
|
|
38
|
+
const dragDepth = useRef(0);
|
|
39
|
+
const inputId = useId();
|
|
40
|
+
|
|
41
|
+
const handleFile = useCallback(
|
|
42
|
+
async (file: File) => {
|
|
43
|
+
if (!file.type.startsWith('image/')) return;
|
|
44
|
+
setUploading(true);
|
|
45
|
+
try {
|
|
46
|
+
const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
|
|
47
|
+
if (!ok || !entry) {
|
|
48
|
+
toast.error(format(t.asset.toastUploadFailed, { status }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await refresh().catch(() => {});
|
|
52
|
+
onPick(entry, scope);
|
|
53
|
+
} finally {
|
|
54
|
+
setUploading(false);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
[effectiveSlideId, scope, refresh, onPick, t],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
62
|
+
<DialogContent className="sm:max-w-xl">
|
|
63
|
+
<DialogHeader>
|
|
64
|
+
<DialogTitle>{t.inspector.replaceImageDialogTitle}</DialogTitle>
|
|
65
|
+
<DialogDescription>
|
|
66
|
+
{descPrefix}
|
|
67
|
+
<span className="font-mono">{path}</span>
|
|
68
|
+
{descSuffix}
|
|
69
|
+
</DialogDescription>
|
|
70
|
+
</DialogHeader>
|
|
71
|
+
<Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
|
|
72
|
+
<TabsList>
|
|
73
|
+
<TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
|
|
74
|
+
<TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
|
|
75
|
+
</TabsList>
|
|
76
|
+
</Tabs>
|
|
77
|
+
<label
|
|
78
|
+
htmlFor={inputId}
|
|
79
|
+
className={cn(
|
|
80
|
+
'absolute right-12 top-3.5 inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[5px] border border-border bg-card px-2 text-[12px] font-medium transition-colors',
|
|
81
|
+
'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
|
|
82
|
+
uploading && 'pointer-events-none opacity-60',
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{uploading ? (
|
|
86
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
87
|
+
) : (
|
|
88
|
+
<Upload className="size-3.5" />
|
|
89
|
+
)}
|
|
90
|
+
<span>{t.asset.upload}</span>
|
|
91
|
+
</label>
|
|
92
|
+
<input
|
|
93
|
+
id={inputId}
|
|
94
|
+
type="file"
|
|
95
|
+
accept="image/*"
|
|
96
|
+
className="sr-only"
|
|
97
|
+
disabled={uploading}
|
|
98
|
+
onChange={(e) => {
|
|
99
|
+
const file = e.target.files?.[0];
|
|
100
|
+
e.target.value = '';
|
|
101
|
+
if (file) handleFile(file).catch(() => {});
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
<section
|
|
105
|
+
aria-label={t.inspector.replaceImageDialogTitle}
|
|
106
|
+
className="relative max-h-[60vh] overflow-y-auto"
|
|
107
|
+
onDragEnter={(e) => {
|
|
108
|
+
if (uploading || !hasFiles(e)) return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
dragDepth.current += 1;
|
|
111
|
+
setDragActive(true);
|
|
112
|
+
}}
|
|
113
|
+
onDragOver={(e) => {
|
|
114
|
+
if (uploading || !hasFiles(e)) return;
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
117
|
+
}}
|
|
118
|
+
onDragLeave={() => {
|
|
119
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
120
|
+
if (dragDepth.current === 0) setDragActive(false);
|
|
121
|
+
}}
|
|
122
|
+
onDrop={(e) => {
|
|
123
|
+
if (uploading || !hasFiles(e)) return;
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
dragDepth.current = 0;
|
|
126
|
+
setDragActive(false);
|
|
127
|
+
const file = e.dataTransfer.files?.[0];
|
|
128
|
+
if (file) handleFile(file).catch(() => {});
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{loading ? (
|
|
132
|
+
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
|
133
|
+
{t.inspector.pickerLoading}
|
|
134
|
+
</p>
|
|
135
|
+
) : images.length === 0 ? (
|
|
136
|
+
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
|
137
|
+
{t.inspector.pickerEmpty}
|
|
138
|
+
</p>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
|
|
141
|
+
{images.map((asset) => (
|
|
142
|
+
<button
|
|
143
|
+
key={asset.name}
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={() => onPick(asset, scope)}
|
|
146
|
+
className={cn(
|
|
147
|
+
'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
|
|
148
|
+
'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
<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]">
|
|
152
|
+
<img
|
|
153
|
+
src={asset.url}
|
|
154
|
+
alt=""
|
|
155
|
+
className="size-full object-contain"
|
|
156
|
+
draggable={false}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="border-t px-2 py-1.5">
|
|
160
|
+
<div className="truncate text-[11px] font-medium" title={asset.name}>
|
|
161
|
+
{asset.name}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</button>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
{dragActive && (
|
|
169
|
+
<div
|
|
170
|
+
className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
|
|
171
|
+
aria-hidden
|
|
172
|
+
>
|
|
173
|
+
<div className="absolute inset-0 bg-brand/5" />
|
|
174
|
+
<div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
|
|
175
|
+
<div className="absolute inset-x-0 bottom-4 flex justify-center">
|
|
176
|
+
<div className="flex items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating">
|
|
177
|
+
<ArrowDownToLine className="size-3.5 text-brand" />
|
|
178
|
+
<span>{t.asset.dropToUpload}</span>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</section>
|
|
184
|
+
</DialogContent>
|
|
185
|
+
</Dialog>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasFiles(e: React.DragEvent): boolean {
|
|
190
|
+
const types = e.dataTransfer?.types;
|
|
191
|
+
if (!types) return false;
|
|
192
|
+
for (let i = 0; i < types.length; i++) {
|
|
193
|
+
if (types[i] === 'Files') return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|