From 2fcbecf05b406ac56b036303e6a7b0f55a2d0545 Mon Sep 17 00:00:00 2001 From: Yiwei Ho Date: Wed, 20 May 2026 01:25:32 +0800 Subject: [PATCH 1/2] feat(core): floating image action panel in inspector Show a small floating toolbar below the inspector selection box when an image is selected, with quick-access Replace and Crop icons. Extract AssetPickerDialog into its own module and add an openReplace callback to the inspector context so the floating panel and the side panel share the same flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/inspector-image-quick-actions.md | 5 + .../inspector/asset-picker-dialog.tsx | 196 ++++++++++++++ .../components/inspector/inspect-overlay.tsx | 88 ++++++ .../components/inspector/inspector-panel.tsx | 254 +----------------- .../inspector/inspector-provider.tsx | 50 ++++ 5 files changed, 346 insertions(+), 247 deletions(-) create mode 100644 .changeset/inspector-image-quick-actions.md create mode 100644 packages/core/src/app/components/inspector/asset-picker-dialog.tsx diff --git a/.changeset/inspector-image-quick-actions.md b/.changeset/inspector-image-quick-actions.md new file mode 100644 index 00000000..929a65ac --- /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 00000000..00e368e5 --- /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 06f9e499..58049487 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 }; @@ -82,6 +86,7 @@ export function InspectOverlay() { // Pin to the selection so the highlight tracks what the panel // is editing even after the cursor moves away. targetAnchor={selected?.anchor ?? hover?.hit.anchor ?? null} + selectedAnchor={selected?.anchor ?? null} /> ); } @@ -90,10 +95,12 @@ function FrameOverlay({ active, overlayRef, targetAnchor, + selectedAnchor, }: { active: boolean; overlayRef: React.RefObject; targetAnchor: HTMLElement | null; + selectedAnchor: HTMLElement | null; }) { const [rect, setRect] = useState(null); const [hasTarget, setHasTarget] = useState(false); @@ -180,6 +187,12 @@ function FrameOverlay({ `opacity ${FRAME_FADE_MS}ms ease-out` : `opacity ${FRAME_FADE_MS}ms ease-out`; + const showImageActions = + visible && + !!rect && + selectedAnchor instanceof HTMLImageElement && + selectedAnchor === targetAnchor; + return (
{rect && ( @@ -197,10 +210,85 @@ function FrameOverlay({ }} /> )} + {rect && selectedAnchor && ( + + )}
); } +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} + +
+
+ ); +} + function sameRect(a: RelRect | null, b: RelRect): boolean { return ( !!a && diff --git a/packages/core/src/app/components/inspector/inspector-panel.tsx b/packages/core/src/app/components/inspector/inspector-panel.tsx index f5444305..c614549a 100644 --- a/packages/core/src/app/components/inspector/inspector-panel.tsx +++ b/packages/core/src/app/components/inspector/inspector-panel.tsx @@ -3,27 +3,16 @@ import { AlignJustify, AlignLeft, AlignRight, - ArrowDownToLine, Bold, Crop, 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, @@ -34,18 +23,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 = { @@ -317,12 +304,7 @@ export function InspectorPanel() { <>
- +
)} @@ -747,20 +729,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 (
@@ -782,7 +753,7 @@ function ImageField({ variant="outline" size="sm" className="flex-1" - onClick={() => setOpen(true)} + onClick={() => openReplace(anchor)} > {t.inspector.replace} @@ -801,36 +772,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); - }} - /> - )} ); } @@ -894,187 +835,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 45dd35dc..63934f4b 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); @@ -296,6 +298,11 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil 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 => { @@ -883,6 +890,16 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil 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 }); + }, []); + const openCrop = useCallback((anchor: HTMLImageElement) => { const loc = anchor.dataset.slideLoc; if (!loc) return; @@ -925,6 +942,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil cancelEdits, committing, openCrop, + openReplace, }), [ slideId, @@ -945,12 +963,44 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil 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 && ( Date: Sun, 24 May 2026 19:54:02 +0800 Subject: [PATCH 2/2] fix(core): stop inspector hover from tracking through floating UI Tag tooltip content with data-inspector-ui (Radix portals it outside the overlay) and clear hover when the pointer enters inspector UI, matching the existing click/dblclick early-returns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/components/inspector/inspect-overlay.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/app/components/inspector/inspect-overlay.tsx b/packages/core/src/app/components/inspector/inspect-overlay.tsx index 20766f44..4a446b18 100644 --- a/packages/core/src/app/components/inspector/inspect-overlay.tsx +++ b/packages/core/src/app/components/inspector/inspect-overlay.tsx @@ -35,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 }); @@ -268,7 +271,9 @@ function ImageActionPanel({ - {t.inspector.replace} + + {t.inspector.replace} + @@ -284,7 +289,9 @@ function ImageActionPanel({ - {t.inspector.crop} + + {t.inspector.crop} +