@open-slide/core 1.6.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/index.d.ts +20 -2
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +169 -0
- 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 -8
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -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/routes/slide.tsx +19 -11
package/dist/index.d.ts
CHANGED
|
@@ -52,9 +52,26 @@ declare function useSlidePageNumber(): {
|
|
|
52
52
|
total: number;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/app/lib/transition.d.ts
|
|
57
|
+
type TransitionPhase = {
|
|
58
|
+
keyframes: Keyframe[] | PropertyIndexedKeyframes;
|
|
59
|
+
easing?: string;
|
|
60
|
+
duration?: number;
|
|
61
|
+
delay?: number;
|
|
62
|
+
};
|
|
63
|
+
type SlideTransition = {
|
|
64
|
+
duration: number;
|
|
65
|
+
easing?: string;
|
|
66
|
+
enter?: TransitionPhase;
|
|
67
|
+
exit?: TransitionPhase;
|
|
68
|
+
};
|
|
69
|
+
|
|
55
70
|
//#endregion
|
|
56
71
|
//#region src/app/lib/sdk.d.ts
|
|
57
|
-
type Page = ComponentType
|
|
72
|
+
type Page = ComponentType & {
|
|
73
|
+
transition?: SlideTransition;
|
|
74
|
+
};
|
|
58
75
|
type SlideMeta = {
|
|
59
76
|
title?: string;
|
|
60
77
|
theme?: string;
|
|
@@ -66,9 +83,10 @@ type SlideModule = {
|
|
|
66
83
|
meta?: SlideMeta;
|
|
67
84
|
design?: DesignSystem;
|
|
68
85
|
notes?: (string | undefined)[];
|
|
86
|
+
transition?: SlideTransition;
|
|
69
87
|
};
|
|
70
88
|
declare const CANVAS_WIDTH = 1920;
|
|
71
89
|
declare const CANVAS_HEIGHT = 1080;
|
|
72
90
|
|
|
73
91
|
//#endregion
|
|
74
|
-
export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
|
|
92
|
+
export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, SlideTransition, TransitionPhase, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
|
package/package.json
CHANGED
|
@@ -308,6 +308,174 @@ const Footer = () => {
|
|
|
308
308
|
|
|
309
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
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
|
+
|
|
311
479
|
## Repeated elements: component, not `map`
|
|
312
480
|
|
|
313
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.
|
|
@@ -373,6 +541,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
|
|
|
373
541
|
- [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
|
|
374
542
|
- [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
|
|
375
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.
|
|
376
545
|
- [ ] Nothing outside `slides/<id>/` was edited.
|
|
377
546
|
|
|
378
547
|
## Anti-patterns
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { Crop, ImageIcon } from 'lucide-react';
|
|
1
2
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
3
|
import { PANEL_TRANSITION_MS } from '@/components/panel/panel-shell';
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
3
5
|
import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber';
|
|
6
|
+
import { useLocale } from '@/lib/use-locale';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
4
8
|
import { useInspector } from './inspector-provider';
|
|
5
9
|
|
|
6
10
|
type Highlight = { hit: SlideSourceHit };
|
|
@@ -31,6 +35,9 @@ export function InspectOverlay() {
|
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
const onMove = (e: PointerEvent) => {
|
|
38
|
+
if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) {
|
|
39
|
+
return setHover(null);
|
|
40
|
+
}
|
|
34
41
|
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
|
|
35
42
|
if (!el) return setHover(null);
|
|
36
43
|
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
@@ -75,37 +82,48 @@ export function InspectOverlay() {
|
|
|
75
82
|
};
|
|
76
83
|
}, [active, slideId, setSelected, cancel, openCrop]);
|
|
77
84
|
|
|
85
|
+
const hoverAnchor = hover?.hit.anchor.isConnected ? hover.hit.anchor : null;
|
|
86
|
+
const selectedAnchor = selected?.anchor.isConnected ? selected.anchor : null;
|
|
87
|
+
const dedupedHover = hoverAnchor && hoverAnchor !== selectedAnchor ? hoverAnchor : null;
|
|
88
|
+
|
|
89
|
+
if (!active) return null;
|
|
78
90
|
return (
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
overlayRef={overlayRef}
|
|
82
|
-
|
|
83
|
-
// is editing even after the cursor moves away.
|
|
84
|
-
targetAnchor={selected?.anchor ?? hover?.hit.anchor ?? null}
|
|
85
|
-
/>
|
|
91
|
+
<div ref={overlayRef} data-inspector-ui className="pointer-events-none absolute inset-0 z-30">
|
|
92
|
+
<Frame anchor={selectedAnchor} overlayRef={overlayRef} variant="selected" showImageActions />
|
|
93
|
+
<Frame anchor={dedupedHover} overlayRef={overlayRef} variant="hover" />
|
|
94
|
+
</div>
|
|
86
95
|
);
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
type FrameVariant = 'selected' | 'hover';
|
|
99
|
+
|
|
100
|
+
const FRAME_STYLES: Record<FrameVariant, React.CSSProperties> = {
|
|
101
|
+
selected: { outline: '2px solid #3b82f6', background: 'rgba(59,130,246,0.1)' },
|
|
102
|
+
hover: { outline: '1.5px dashed #3b82f6', background: 'rgba(59,130,246,0.05)' },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function Frame({
|
|
106
|
+
anchor,
|
|
91
107
|
overlayRef,
|
|
92
|
-
|
|
108
|
+
variant,
|
|
109
|
+
showImageActions = false,
|
|
93
110
|
}: {
|
|
94
|
-
|
|
111
|
+
anchor: HTMLElement | null;
|
|
95
112
|
overlayRef: React.RefObject<HTMLDivElement>;
|
|
96
|
-
|
|
113
|
+
variant: FrameVariant;
|
|
114
|
+
showImageActions?: boolean;
|
|
97
115
|
}) {
|
|
98
116
|
const [rect, setRect] = useState<RelRect | null>(null);
|
|
99
117
|
const [hasTarget, setHasTarget] = useState(false);
|
|
100
118
|
|
|
101
119
|
const measure = useCallback(() => {
|
|
102
120
|
const overlay = overlayRef.current;
|
|
103
|
-
if (!
|
|
121
|
+
if (!anchor?.isConnected || !overlay) {
|
|
104
122
|
setHasTarget(false);
|
|
105
123
|
return;
|
|
106
124
|
}
|
|
107
125
|
|
|
108
|
-
const targetRect =
|
|
126
|
+
const targetRect = anchor.getBoundingClientRect();
|
|
109
127
|
const overlayRect = overlay.getBoundingClientRect();
|
|
110
128
|
const next = {
|
|
111
129
|
left: targetRect.left - overlayRect.left,
|
|
@@ -116,14 +134,14 @@ function FrameOverlay({
|
|
|
116
134
|
|
|
117
135
|
setHasTarget(true);
|
|
118
136
|
setRect((prev) => (sameRect(prev, next) ? prev : next));
|
|
119
|
-
}, [
|
|
137
|
+
}, [overlayRef, anchor]);
|
|
120
138
|
|
|
121
139
|
useLayoutEffect(() => {
|
|
122
140
|
measure();
|
|
123
141
|
}, [measure]);
|
|
124
142
|
|
|
125
143
|
useEffect(() => {
|
|
126
|
-
if (!
|
|
144
|
+
if (!anchor) {
|
|
127
145
|
setHasTarget(false);
|
|
128
146
|
return;
|
|
129
147
|
}
|
|
@@ -139,7 +157,7 @@ function FrameOverlay({
|
|
|
139
157
|
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
140
158
|
if (root) resizeObserver.observe(root);
|
|
141
159
|
if (overlayRef.current) resizeObserver.observe(overlayRef.current);
|
|
142
|
-
|
|
160
|
+
resizeObserver.observe(anchor);
|
|
143
161
|
|
|
144
162
|
const stopAt = performance.now() + LAYOUT_TRACK_MS;
|
|
145
163
|
const trackPanelTransition = () => {
|
|
@@ -157,9 +175,9 @@ function FrameOverlay({
|
|
|
157
175
|
window.removeEventListener('resize', scheduleMeasure, true);
|
|
158
176
|
window.removeEventListener('scroll', scheduleMeasure, true);
|
|
159
177
|
};
|
|
160
|
-
}, [
|
|
178
|
+
}, [measure, overlayRef, anchor]);
|
|
161
179
|
|
|
162
|
-
const visible = !!(
|
|
180
|
+
const visible = !!(hasTarget && rect);
|
|
163
181
|
|
|
164
182
|
// First render after appearing: snap to the new rect (no transition).
|
|
165
183
|
// Subsequent rect changes in the same visible session: animate.
|
|
@@ -173,31 +191,110 @@ function FrameOverlay({
|
|
|
173
191
|
return () => clearTimeout(t);
|
|
174
192
|
}, [visible]);
|
|
175
193
|
|
|
176
|
-
if (!
|
|
194
|
+
if (!rect) return null;
|
|
177
195
|
const transition = morph
|
|
178
196
|
? `left ${FRAME_MORPH_MS}ms ease-out, top ${FRAME_MORPH_MS}ms ease-out, ` +
|
|
179
197
|
`width ${FRAME_MORPH_MS}ms ease-out, height ${FRAME_MORPH_MS}ms ease-out, ` +
|
|
180
198
|
`opacity ${FRAME_FADE_MS}ms ease-out`
|
|
181
199
|
: `opacity ${FRAME_FADE_MS}ms ease-out`;
|
|
182
200
|
|
|
201
|
+
const imageAnchor = anchor instanceof HTMLImageElement ? anchor : null;
|
|
202
|
+
const actionsVisible = showImageActions && visible && !!imageAnchor;
|
|
203
|
+
|
|
183
204
|
return (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
<>
|
|
206
|
+
<div
|
|
207
|
+
className="absolute"
|
|
208
|
+
style={{
|
|
209
|
+
left: rect.left,
|
|
210
|
+
top: rect.top,
|
|
211
|
+
width: rect.width,
|
|
212
|
+
height: rect.height,
|
|
213
|
+
opacity: visible ? 1 : 0,
|
|
214
|
+
transition,
|
|
215
|
+
...FRAME_STYLES[variant],
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
{showImageActions && imageAnchor && (
|
|
219
|
+
<ImageActionPanel
|
|
220
|
+
anchor={imageAnchor}
|
|
221
|
+
rect={rect}
|
|
222
|
+
visible={actionsVisible}
|
|
223
|
+
transition={transition}
|
|
198
224
|
/>
|
|
199
225
|
)}
|
|
200
|
-
|
|
226
|
+
</>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const FLOATING_PANEL_GAP = 8;
|
|
231
|
+
|
|
232
|
+
function ImageActionPanel({
|
|
233
|
+
anchor,
|
|
234
|
+
rect,
|
|
235
|
+
visible,
|
|
236
|
+
transition,
|
|
237
|
+
}: {
|
|
238
|
+
anchor: HTMLElement;
|
|
239
|
+
rect: RelRect;
|
|
240
|
+
visible: boolean;
|
|
241
|
+
transition: string;
|
|
242
|
+
}) {
|
|
243
|
+
const { openCrop, openReplace } = useInspector();
|
|
244
|
+
const t = useLocale();
|
|
245
|
+
return (
|
|
246
|
+
<TooltipProvider delayDuration={200}>
|
|
247
|
+
<div
|
|
248
|
+
className={cn(
|
|
249
|
+
'absolute flex items-center gap-0.5 rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
|
|
250
|
+
visible ? 'pointer-events-auto' : 'pointer-events-none',
|
|
251
|
+
)}
|
|
252
|
+
style={{
|
|
253
|
+
left: rect.left + rect.width / 2,
|
|
254
|
+
top: rect.top + rect.height + FLOATING_PANEL_GAP,
|
|
255
|
+
transform: 'translateX(-50%)',
|
|
256
|
+
opacity: visible ? 1 : 0,
|
|
257
|
+
transition,
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<Tooltip>
|
|
261
|
+
<TooltipTrigger asChild>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
aria-label={t.inspector.replace}
|
|
265
|
+
onClick={(e) => {
|
|
266
|
+
e.stopPropagation();
|
|
267
|
+
openReplace(anchor);
|
|
268
|
+
}}
|
|
269
|
+
className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
270
|
+
>
|
|
271
|
+
<ImageIcon className="size-3.5" />
|
|
272
|
+
</button>
|
|
273
|
+
</TooltipTrigger>
|
|
274
|
+
<TooltipContent side="bottom" data-inspector-ui>
|
|
275
|
+
{t.inspector.replace}
|
|
276
|
+
</TooltipContent>
|
|
277
|
+
</Tooltip>
|
|
278
|
+
<Tooltip>
|
|
279
|
+
<TooltipTrigger asChild>
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
aria-label={t.inspector.crop}
|
|
283
|
+
onClick={(e) => {
|
|
284
|
+
e.stopPropagation();
|
|
285
|
+
openCrop(anchor as HTMLImageElement);
|
|
286
|
+
}}
|
|
287
|
+
className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
288
|
+
>
|
|
289
|
+
<Crop className="size-3.5" />
|
|
290
|
+
</button>
|
|
291
|
+
</TooltipTrigger>
|
|
292
|
+
<TooltipContent side="bottom" data-inspector-ui>
|
|
293
|
+
{t.inspector.crop}
|
|
294
|
+
</TooltipContent>
|
|
295
|
+
</Tooltip>
|
|
296
|
+
</div>
|
|
297
|
+
</TooltipProvider>
|
|
201
298
|
);
|
|
202
299
|
}
|
|
203
300
|
|