Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/inspector-image-quick-actions.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use present-tense descriptive wording in the changeset line.

Line 5 reads like an imperative (“Show ...”) rather than a present-tense description. Please switch to descriptive present tense.

Suggested wording
-Show a floating action panel below the inspector selection box when an image is selected, with quick-access Replace and Crop icons.
+Displays a floating action panel below the inspector selection box for selected images with quick-access Replace and Crop icons.

As per coding guidelines, "Changeset descriptions must be short and direct: one line, present-tense, describing what changed from a user's perspective. No paragraphs, no rationale, no 'this PR…'."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Show a floating action panel below the inspector selection box when an image is selected, with quick-access Replace and Crop icons.
Displays a floating action panel below the inspector selection box for selected images with quick-access Replace and Crop icons.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/inspector-image-quick-actions.md at line 5, Update the changeset
line that currently reads "Show a floating action panel below the inspector
selection box when an image is selected, with quick-access Replace and Crop
icons." to a present-tense descriptive sentence (e.g., "Displays a floating
action panel below the inspector selection box when an image is selected, with
quick-access Replace and Crop icons.") so the one-line changeset uses
present-tense description; edit the line containing the original sentence to the
new phrasing using the same wording scope.

196 changes: 196 additions & 0 deletions packages/core/src/app/components/inspector/asset-picker-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<PickerScope>('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 (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t.inspector.replaceImageDialogTitle}</DialogTitle>
<DialogDescription>
{descPrefix}
<span className="font-mono">{path}</span>
{descSuffix}
</DialogDescription>
</DialogHeader>
<Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
<TabsList>
<TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
<TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
</TabsList>
</Tabs>
<label
htmlFor={inputId}
className={cn(
'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',
'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
uploading && 'pointer-events-none opacity-60',
)}
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Upload className="size-3.5" />
)}
<span>{t.asset.upload}</span>
</label>
<input
id={inputId}
type="file"
accept="image/*"
className="sr-only"
disabled={uploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) handleFile(file).catch(() => {});
}}
/>
<section
aria-label={t.inspector.replaceImageDialogTitle}
className="relative max-h-[60vh] overflow-y-auto"
onDragEnter={(e) => {
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 ? (
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
{t.inspector.pickerLoading}
</p>
) : images.length === 0 ? (
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
{t.inspector.pickerEmpty}
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
{images.map((asset) => (
<button
key={asset.name}
type="button"
onClick={() => onPick(asset, scope)}
className={cn(
'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
)}
>
<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]">
<img
src={asset.url}
alt=""
className="size-full object-contain"
draggable={false}
/>
</div>
<div className="border-t px-2 py-1.5">
<div className="truncate text-[11px] font-medium" title={asset.name}>
{asset.name}
</div>
</div>
</button>
))}
</div>
)}
{dragActive && (
<div
className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
aria-hidden
>
<div className="absolute inset-0 bg-brand/5" />
<div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
<div className="absolute inset-x-0 bottom-4 flex justify-center">
<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">
<ArrowDownToLine className="size-3.5 text-brand" />
<span>{t.asset.dropToUpload}</span>
</div>
</div>
</div>
)}
</section>
</DialogContent>
</Dialog>
);
}

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;
}
119 changes: 106 additions & 13 deletions packages/core/src/app/components/inspector/inspect-overlay.tsx
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -82,7 +89,7 @@ export function InspectOverlay() {
if (!active) return null;
return (
<div ref={overlayRef} data-inspector-ui className="pointer-events-none absolute inset-0 z-30">
<Frame anchor={selectedAnchor} overlayRef={overlayRef} variant="selected" />
<Frame anchor={selectedAnchor} overlayRef={overlayRef} variant="selected" showImageActions />
<Frame anchor={dedupedHover} overlayRef={overlayRef} variant="hover" />
</div>
);
Expand All @@ -99,10 +106,12 @@ function Frame({
anchor,
overlayRef,
variant,
showImageActions = false,
}: {
anchor: HTMLElement | null;
overlayRef: React.RefObject<HTMLDivElement>;
variant: FrameVariant;
showImageActions?: boolean;
}) {
const [rect, setRect] = useState<RelRect | null>(null);
const [hasTarget, setHasTarget] = useState(false);
Expand Down Expand Up @@ -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 (
<>
<div
className="absolute"
style={{
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
opacity: visible ? 1 : 0,
transition,
...FRAME_STYLES[variant],
}}
/>
{showImageActions && imageAnchor && (
<ImageActionPanel
anchor={imageAnchor}
rect={rect}
visible={actionsVisible}
transition={transition}
/>
)}
</>
);
}

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 (
<div
className="absolute"
style={{
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
opacity: visible ? 1 : 0,
transition,
...FRAME_STYLES[variant],
}}
/>
<TooltipProvider delayDuration={200}>
<div
className={cn(
'absolute flex items-center gap-0.5 rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
visible ? 'pointer-events-auto' : 'pointer-events-none',
)}
style={{
left: rect.left + rect.width / 2,
top: rect.top + rect.height + FLOATING_PANEL_GAP,
transform: 'translateX(-50%)',
opacity: visible ? 1 : 0,
transition,
}}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t.inspector.replace}
onClick={(e) => {
e.stopPropagation();
openReplace(anchor);
}}
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"
>
<ImageIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" data-inspector-ui>
{t.inspector.replace}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t.inspector.crop}
onClick={(e) => {
e.stopPropagation();
openCrop(anchor as HTMLImageElement);
}}
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"
>
<Crop className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" data-inspector-ui>
{t.inspector.crop}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
}

Expand Down
Loading
Loading