diff --git a/.changeset/inspector-image-quick-actions.md b/.changeset/inspector-image-quick-actions.md new file mode 100644 index 0000000..929a65a --- /dev/null +++ b/.changeset/inspector-image-quick-actions.md @@ -0,0 +1,5 @@ +--- +"@open-slide/core": minor +--- + +Show a floating action panel below the inspector selection box when an image is selected, with quick-access Replace and Crop icons. diff --git a/packages/core/src/app/components/inspector/asset-picker-dialog.tsx b/packages/core/src/app/components/inspector/asset-picker-dialog.tsx new file mode 100644 index 0000000..00e368e --- /dev/null +++ b/packages/core/src/app/components/inspector/asset-picker-dialog.tsx @@ -0,0 +1,196 @@ +import { ArrowDownToLine, Loader2, Upload } from 'lucide-react'; +import type React from 'react'; +import { useCallback, useId, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets'; +import { format, useLocale } from '@/lib/use-locale'; +import { cn } from '@/lib/utils'; + +export type PickerScope = 'slide' | 'global'; +const GLOBAL_PICKER_SLIDE_ID = '@global'; + +export function AssetPickerDialog({ + slideId, + onClose, + onPick, +}: { + slideId: string; + onClose: () => void; + onPick: (asset: AssetEntry, scope: PickerScope) => void; +}) { + const [scope, setScope] = useState('slide'); + const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId; + const { assets, loading, refresh } = useAssets(effectiveSlideId); + const images = assets.filter((a) => a.mime.startsWith('image/')); + const t = useLocale(); + const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`; + const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}'); + const [uploading, setUploading] = useState(false); + const [dragActive, setDragActive] = useState(false); + const dragDepth = useRef(0); + const inputId = useId(); + + const handleFile = useCallback( + async (file: File) => { + if (!file.type.startsWith('image/')) return; + setUploading(true); + try { + const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file); + if (!ok || !entry) { + toast.error(format(t.asset.toastUploadFailed, { status })); + return; + } + await refresh().catch(() => {}); + onPick(entry, scope); + } finally { + setUploading(false); + } + }, + [effectiveSlideId, scope, refresh, onPick, t], + ); + + return ( + !o && onClose()}> + + + {t.inspector.replaceImageDialogTitle} + + {descPrefix} + {path} + {descSuffix} + + + setScope(next as PickerScope)}> + + {t.asset.scopeSlide} + {t.asset.scopeGlobal} + + + + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) handleFile(file).catch(() => {}); + }} + /> +
{ + if (uploading || !hasFiles(e)) return; + e.preventDefault(); + dragDepth.current += 1; + setDragActive(true); + }} + onDragOver={(e) => { + if (uploading || !hasFiles(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }} + onDragLeave={() => { + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setDragActive(false); + }} + onDrop={(e) => { + if (uploading || !hasFiles(e)) return; + e.preventDefault(); + dragDepth.current = 0; + setDragActive(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFile(file).catch(() => {}); + }} + > + {loading ? ( +

+ {t.inspector.pickerLoading} +

+ ) : images.length === 0 ? ( +

+ {t.inspector.pickerEmpty} +

+ ) : ( +
+ {images.map((asset) => ( + + ))} +
+ )} + {dragActive && ( +
+
+
+
+
+ + {t.asset.dropToUpload} +
+
+
+ )} +
+
+
+ ); +} + +function hasFiles(e: React.DragEvent): boolean { + const types = e.dataTransfer?.types; + if (!types) return false; + for (let i = 0; i < types.length; i++) { + if (types[i] === 'Files') return true; + } + return false; +} diff --git a/packages/core/src/app/components/inspector/inspect-overlay.tsx b/packages/core/src/app/components/inspector/inspect-overlay.tsx index b047e85..4a446b1 100644 --- a/packages/core/src/app/components/inspector/inspect-overlay.tsx +++ b/packages/core/src/app/components/inspector/inspect-overlay.tsx @@ -1,6 +1,10 @@ +import { Crop, ImageIcon } from 'lucide-react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { PANEL_TRANSITION_MS } from '@/components/panel/panel-shell'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber'; +import { useLocale } from '@/lib/use-locale'; +import { cn } from '@/lib/utils'; import { useInspector } from './inspector-provider'; type Highlight = { hit: SlideSourceHit }; @@ -31,6 +35,9 @@ export function InspectOverlay() { }; const onMove = (e: PointerEvent) => { + if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) { + return setHover(null); + } const el = pickInspectorTarget(pickElement(e.clientX, e.clientY)); if (!el) return setHover(null); const hit = findSlideSource(el, slideId, { hostOnly: true }); @@ -82,7 +89,7 @@ export function InspectOverlay() { if (!active) return null; return (
- +
); @@ -99,10 +106,12 @@ function Frame({ anchor, overlayRef, variant, + showImageActions = false, }: { anchor: HTMLElement | null; overlayRef: React.RefObject; variant: FrameVariant; + showImageActions?: boolean; }) { const [rect, setRect] = useState(null); const [hasTarget, setHasTarget] = useState(false); @@ -189,19 +198,103 @@ function Frame({ `opacity ${FRAME_FADE_MS}ms ease-out` : `opacity ${FRAME_FADE_MS}ms ease-out`; + const imageAnchor = anchor instanceof HTMLImageElement ? anchor : null; + const actionsVisible = showImageActions && visible && !!imageAnchor; + + return ( + <> +
+ {showImageActions && imageAnchor && ( + + )} + + ); +} + +const FLOATING_PANEL_GAP = 8; + +function ImageActionPanel({ + anchor, + rect, + visible, + transition, +}: { + anchor: HTMLElement; + rect: RelRect; + visible: boolean; + transition: string; +}) { + const { openCrop, openReplace } = useInspector(); + const t = useLocale(); return ( -
+ +
+ + + + + + {t.inspector.replace} + + + + + + + + {t.inspector.crop} + + +
+
); } diff --git a/packages/core/src/app/components/inspector/inspector-panel.tsx b/packages/core/src/app/components/inspector/inspector-panel.tsx index 1d18fb7..e649abe 100644 --- a/packages/core/src/app/components/inspector/inspector-panel.tsx +++ b/packages/core/src/app/components/inspector/inspector-panel.tsx @@ -3,28 +3,17 @@ import { AlignJustify, AlignLeft, AlignRight, - ArrowDownToLine, Bold, Crop, Crosshair, ImageIcon, Italic, - Loader2, - Upload, X, } from 'lucide-react'; -import { useCallback, useEffect, useId, useRef, useState } from 'react'; -import { toast } from 'sonner'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Field, NumberField, Section } from '@/components/panel/panel-fields'; import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Select, @@ -35,18 +24,16 @@ import { } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Slider } from '@/components/ui/slider'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { Toggle } from '@/components/ui/toggle'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets'; import { findSlideSource } from '@/lib/inspector/fiber'; import type { EditOp } from '@/lib/inspector/use-editor'; import { useAgentSocketConnected } from '@/lib/use-agent-socket'; -import { format, useLocale } from '@/lib/use-locale'; -import { cn } from '@/lib/utils'; +import { useLocale } from '@/lib/use-locale'; import type { Locale } from '../../../locale/types'; +import { AssetPickerDialog } from './asset-picker-dialog'; import { type SelectedTarget, useInspector } from './inspector-provider'; type ElementSnapshot = { @@ -320,12 +307,7 @@ export function InspectorPanel() { <>
- +
)} @@ -750,20 +732,9 @@ function ColorField({ ); } -function ImageField({ - slideId, - src, - anchor, - apply, -}: { - slideId: string; - src: string; - anchor: HTMLElement; - apply: (ops: EditOp[]) => void; -}) { - const [open, setOpen] = useState(false); +function ImageField({ src, anchor }: { src: string; anchor: HTMLElement }) { const t = useLocale(); - const { openCrop } = useInspector(); + const { openCrop, openReplace } = useInspector(); const isImage = anchor.tagName === 'IMG'; return (
@@ -785,7 +756,7 @@ function ImageField({ variant="outline" size="sm" className="flex-1" - onClick={() => setOpen(true)} + onClick={() => openReplace(anchor)} > {t.inspector.replace} @@ -804,36 +775,6 @@ function ImageField({ )}
- {open && ( - setOpen(false)} - onPick={(asset, scope) => { - setOpen(false); - const assetPath = - scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`; - const ops: EditOp[] = [ - { - kind: 'set-attr-asset', - attr: 'src', - assetPath, - previewUrl: asset.url, - }, - ]; - if (isImage) { - const cs = window.getComputedStyle(anchor); - if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') { - ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' }); - } - const op = cs.objectPosition.trim(); - if (!op || op === '0% 0%' || op === 'auto') { - ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' }); - } - } - apply(ops); - }} - /> - )}
); } @@ -897,187 +838,6 @@ function PlaceholderField({ ); } -type PickerScope = 'slide' | 'global'; -const GLOBAL_PICKER_SLIDE_ID = '@global'; - -function AssetPickerDialog({ - slideId, - onClose, - onPick, -}: { - slideId: string; - onClose: () => void; - onPick: (asset: AssetEntry, scope: PickerScope) => void; -}) { - const [scope, setScope] = useState('slide'); - const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId; - const { assets, loading, refresh } = useAssets(effectiveSlideId); - const images = assets.filter((a) => a.mime.startsWith('image/')); - const t = useLocale(); - const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`; - const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}'); - const [uploading, setUploading] = useState(false); - const [dragActive, setDragActive] = useState(false); - const dragDepth = useRef(0); - const inputId = useId(); - - const handleFile = useCallback( - async (file: File) => { - if (!file.type.startsWith('image/')) return; - setUploading(true); - try { - const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file); - if (!ok || !entry) { - toast.error(format(t.asset.toastUploadFailed, { status })); - return; - } - await refresh().catch(() => {}); - onPick(entry, scope); - } finally { - setUploading(false); - } - }, - [effectiveSlideId, scope, refresh, onPick, t], - ); - - return ( - !o && onClose()}> - - - {t.inspector.replaceImageDialogTitle} - - {descPrefix} - {path} - {descSuffix} - - - setScope(next as PickerScope)}> - - {t.asset.scopeSlide} - {t.asset.scopeGlobal} - - - - { - const file = e.target.files?.[0]; - e.target.value = ''; - if (file) handleFile(file).catch(() => {}); - }} - /> -
{ - if (uploading || !hasFiles(e)) return; - e.preventDefault(); - dragDepth.current += 1; - setDragActive(true); - }} - onDragOver={(e) => { - if (uploading || !hasFiles(e)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - }} - onDragLeave={() => { - dragDepth.current = Math.max(0, dragDepth.current - 1); - if (dragDepth.current === 0) setDragActive(false); - }} - onDrop={(e) => { - if (uploading || !hasFiles(e)) return; - e.preventDefault(); - dragDepth.current = 0; - setDragActive(false); - const file = e.dataTransfer.files?.[0]; - if (file) handleFile(file).catch(() => {}); - }} - > - {loading ? ( -

- {t.inspector.pickerLoading} -

- ) : images.length === 0 ? ( -

- {t.inspector.pickerEmpty} -

- ) : ( -
- {images.map((asset) => ( - - ))} -
- )} - {dragActive && ( -
-
-
-
-
- - {t.asset.dropToUpload} -
-
-
- )} -
-
-
- ); -} - -function hasFiles(e: React.DragEvent): boolean { - const types = e.dataTransfer?.types; - if (!types) return false; - for (let i = 0; i < types.length; i++) { - if (types[i] === 'Files') return true; - } - return false; -} - function AgentWatchingBadge() { const t = useLocale(); const connected = useAgentSocketConnected(); diff --git a/packages/core/src/app/components/inspector/inspector-provider.tsx b/packages/core/src/app/components/inspector/inspector-provider.tsx index 44cc857..8c7384a 100644 --- a/packages/core/src/app/components/inspector/inspector-provider.tsx +++ b/packages/core/src/app/components/inspector/inspector-provider.tsx @@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'; import { type SlideComment, useComments } from '@/lib/inspector/use-comments'; import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor'; import { useLocale } from '@/lib/use-locale'; +import { AssetPickerDialog } from './asset-picker-dialog'; import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog'; export type SelectedTarget = { @@ -263,6 +264,7 @@ type InspectorCtx = { cancelEdits: () => void; committing: boolean; openCrop: (anchor: HTMLImageElement) => void; + openReplace: (anchor: HTMLElement) => void; }; const Ctx = createContext(null); @@ -304,6 +306,11 @@ export function InspectorProvider({ initialPosition: { x: number; y: number }; initialRect: ImageCropRect | null; } | null>(null); + const [replaceTarget, setReplaceTarget] = useState<{ + line: number; + column: number; + anchor: HTMLElement; + } | null>(null); const t = useLocale(); const ensureInstanceId = useCallback((el: HTMLElement): string => { @@ -920,6 +927,16 @@ export function InspectorProvider({ setSelected(null); }, []); + const openReplace = useCallback((anchor: HTMLElement) => { + const loc = anchor.dataset.slideLoc; + if (!loc) return; + const [lineStr, columnStr] = loc.split(':'); + const line = Number(lineStr); + const column = Number(columnStr); + if (!Number.isFinite(line) || !Number.isFinite(column)) return; + setReplaceTarget({ line, column, anchor }); + }, []); + useEffect(() => { if (import.meta.env.PROD) return; const onKey = (e: KeyboardEvent) => { @@ -973,6 +990,7 @@ export function InspectorProvider({ cancelEdits, committing, openCrop, + openReplace, }), [ slideId, @@ -993,12 +1011,44 @@ export function InspectorProvider({ cancelEdits, committing, openCrop, + openReplace, ], ); return ( {children} + {replaceTarget && ( + setReplaceTarget(null)} + onPick={(asset, scope) => { + const { line, column, anchor } = replaceTarget; + const assetPath = + scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`; + const ops: EditOp[] = [ + { + kind: 'set-attr-asset', + attr: 'src', + assetPath, + previewUrl: asset.url, + }, + ]; + if (anchor.tagName === 'IMG' && anchor.isConnected) { + const cs = window.getComputedStyle(anchor); + if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') { + ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' }); + } + const op = cs.objectPosition.trim(); + if (!op || op === '0% 0%' || op === 'auto') { + ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' }); + } + } + bufferOps(line, column, anchor, ops); + setReplaceTarget(null); + }} + /> + )} {cropTarget && (