Desktop: drag & drop files onto the promptbox on create pages (#1821)

### What
- Drag files onto the promptbox on the Image, Video, and Audio create
pages to add them as references - same look and behavior as the webapp
- On those pages the promptbox is now the only drop target; dropping
elsewhere does nothing
- Every other page keeps the existing upload-modal behavior

### Notes
- The webapp's drop code doesn't work as-is on desktop - Tauri
intercepts file drags before the page sees them, so desktop needed its
own path
- Both now share one component, so the two apps stay in sync going
forward
- Model limits still apply: each box only accepts the reference types
its model supports, and existing slot/duration caps are unchanged
This commit is contained in:
BFlat authored and GitHub committed 2026-07-30 22:37:34 +07:00
1 parent 1bb31f42d7
commit 7654e95636
9 files changed
+742 -32

No files matched your search

+11 -1
View File
@@ -6,6 +6,7 @@ import App from "./app/app";
import { StorytellerApiHostStore, UsersApi } from "@storyteller/api";
import { captureLandingContext, getReferrer } from "@storyteller/common";
import { setOmniGenErrorNotifier } from "@storyteller/omni-gen";
import { setToastDelegate } from "@storyteller/ui-toaster";
import { toast } from "./components/toast/toast";
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
@@ -24,7 +25,7 @@ if (import.meta.env.DEV) {
// (disabled — it overrides the origin above and points at localhost:12345,
// which breaks dev unless a local backend is running. Re-enable only when
// testing against a local storyteller-web.)
StorytellerApiHostStore.getInstance().setDevelopment();
//StorytellerApiHostStore.getInstance().setDevelopment();
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -33,6 +34,15 @@ if (import.meta.env.DEV) {
// Surface omni model/generation outages through this app's toast component.
setOmniGenErrorNotifier((message) => toast.error(message));
// Shared libs (promptbox deck, gallery modal, …) fire react-hot-toast toasts,
// but this app renders its own ToastContainer and never mounts the
// react-hot-toast container — route those toasts here so limit/validation
// errors (e.g. "audio too long") actually show up.
setToastDelegate({
success: (message) => toast.success(message),
error: (message) => toast.error(message),
});
// Persist landing context (referral username, landing URL, referrer) to apex-
// domain cookies so attribution survives the getartcraft.com →
// app.getartcraft.com hop. First visit wins.
@@ -7,6 +7,7 @@ import {
UploadModalImage,
UploadModalSplat,
} from "@storyteller/ui-upload-modal";
import { isPromptBoxDropZoneActive } from "@storyteller/ui-promptbox";
import { FilterEngineCategories } from "../../enums";
type ModalType = "3d" | "image" | "splat" | null;
@@ -23,6 +24,15 @@ function isAnyModalOpen(): boolean {
return !!document.querySelector("[data-radix-dialog-content]");
}
/**
* Whole-app drops stand down while a prompt box owns them. On the create
* pages the prompt box is the only drop target (matching the webapp), so a
* drop anywhere else must do nothing rather than open the upload modal.
*/
function isGlobalDropSuppressed(): boolean {
return isAnyModalOpen() || isPromptBoxDropZoneActive();
}
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
export function GlobalFileDropHandler() {
@@ -50,12 +60,14 @@ export function GlobalFileDropHandler() {
const payload = event.payload;
if (payload.type === "enter") {
if (!isAnyModalOpen()) setIsDragging(true);
if (!isGlobalDropSuppressed()) setIsDragging(true);
} else if (payload.type === "over") {
// overlay stays visible
// The prompt box can mount mid-drag (or the pointer can move
// onto it), so keep re-checking rather than trusting "enter".
if (isGlobalDropSuppressed()) setIsDragging(false);
} else if (payload.type === "drop") {
setIsDragging(false);
if (isAnyModalOpen()) return;
if (isGlobalDropSuppressed()) return;
if (payload.paths.length === 0) return;
@@ -117,7 +129,7 @@ export function GlobalFileDropHandler() {
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer?.types.includes("Files")) return;
if (isAnyModalOpen()) return;
if (isGlobalDropSuppressed()) return;
dragCounter.current++;
setIsDragging(true);
};
@@ -135,7 +147,7 @@ export function GlobalFileDropHandler() {
e.preventDefault();
setIsDragging(false);
dragCounter.current = 0;
if (isAnyModalOpen()) return;
if (isGlobalDropSuppressed()) return;
const allFiles = Array.from(e.dataTransfer.files);
if (allFiles.length === 0) return;
@@ -697,3 +697,36 @@ img {
background-size: 200% 100%;
animation: generation-shimmer 1.5s ease-in-out infinite;
}
/* Prompt box drag & drop overlay (mirrors the webapp's styles.css) */
@keyframes promptbox-drop-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.promptbox-drop-overlay {
animation: promptbox-drop-overlay-in 0.15s ease-out both;
}
@keyframes promptbox-drop-chip-in {
from {
opacity: 0;
translate: 0 6px;
scale: 0.9;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
}
.promptbox-drop-chip {
animation: promptbox-drop-chip-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -23,3 +23,4 @@ export * from "./lib/deck/DeckCard";
export * from "./lib/deck/ReferenceDeck";
export * from "./lib/deck/KeyframeCards";
export * from "./lib/deck/useDeckMedia";
export * from "./lib/deck/usePromptBoxDrop";
@@ -41,7 +41,15 @@ import { PromptFullscreenButton } from "./PromptFullscreenButton";
import { PromptClearAllButton } from "./PromptClearAllButton";
import { gtagEvent } from "@storyteller/google-analytics";
import { twMerge } from "tailwind-merge";
import { AudioReferenceRow } from "./common/AudioReferenceRow";
import {
AudioReferenceRow,
type AudioReferenceRowHandle,
} from "./common/AudioReferenceRow";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./deck/usePromptBoxDrop";
import { AudioTuningPopover } from "./common/AudioTuningPopover";
import { SoundsSettingsPopover } from "./common/SoundsSettingsPopover";
import { StylePromptRow } from "./common/StylePromptRow";
@@ -282,6 +290,29 @@ export const PromptBoxAudio = ({
setReferenceImages(images);
};
// Drag & drop / paste onto the box bounds. Uploads run through the
// reference row's own plumbing so limits, duration caps, and the
// audio/image mutual exclusion all behave exactly as they do for the
// row's own file pickers.
const referenceRowRef = useRef<AudioReferenceRowHandle>(null);
const handleDroppedFiles = ({ images, audios }: DroppedFiles) => {
if (audios.length > 0) {
void referenceRowRef.current?.addAudioFiles(audios);
} else if (images.length > 0) {
// One image ref max, and it can't coexist with audio — so a mixed drop
// resolves to the audio above and the image is ignored.
void referenceRowRef.current?.addImageFile(images[0]!);
}
};
const drop = usePromptBoxDrop({
acceptsImages: imageRefsSupported,
acceptsVideos: false,
acceptsAudio: audioRefsSupported,
onDropFiles: handleDroppedFiles,
});
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
};
@@ -486,6 +517,7 @@ export const PromptBoxAudio = ({
const referenceRow: ReactNode =
audioRefsSupported || imageRefsSupported ? (
<AudioReferenceRow
ref={referenceRowRef}
referenceAudios={referenceAudios}
onReferenceAudiosChange={handleReferenceAudiosChange}
maxAudioCount={audioRefsSupported ? maxAudioRefs : 0}
@@ -512,7 +544,14 @@ export const PromptBoxAudio = ({
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={imageRefsSupported}
acceptsVideos={false}
acceptsAudio={audioRefsSupported}
/>
{referenceRow}
<div className="flex justify-center gap-2">
@@ -34,6 +34,11 @@ import { ResolutionPicker } from "./common/ResolutionPicker";
import { QualityPicker } from "./common/QualityPicker";
import { ReferenceDeck } from "./deck/ReferenceDeck";
import { useDeckMedia } from "./deck/useDeckMedia";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./deck/usePromptBoxDrop";
import { DeckAddAction, DeckItem } from "./deck/deckTypes";
interface PromptBoxImageProps {
@@ -132,6 +137,28 @@ export const PromptBoxImage = ({
ownGalleryModal: true,
});
// Drag & drop / paste onto the box bounds. Images are the only reference
// kind this box takes, and only when the model can use them at all.
const dropAcceptsImages = !!selectedModel?.canUseImagePrompt;
const handleDroppedFiles = ({ images }: DroppedFiles) => {
if (images.length === 0) return;
if (deck.availableImageSlots <= 0) {
toast.error(
`Max ${maxImagePromptCount} image reference${maxImagePromptCount === 1 ? "" : "s"}`,
);
return;
}
deck.processImageFiles(images, "start");
};
const drop = usePromptBoxDrop({
acceptsImages: dropAcceptsImages,
acceptsVideos: false,
acceptsAudio: false,
onDropFiles: handleDroppedFiles,
});
const deckItems: DeckItem[] = useMemo(
() => [
...referenceImages.map((img, i) => ({
@@ -437,7 +464,14 @@ export const PromptBoxImage = ({
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={dropAcceptsImages}
acceptsVideos={false}
acceptsAudio={false}
/>
<div className="flex justify-center gap-2">
{renderReferenceDeck()}
@@ -35,6 +35,11 @@ import type { UploadImageFn } from "./ImagePromptRow";
import { ReferenceDeck } from "./deck/ReferenceDeck";
import { KeyframeCards } from "./deck/KeyframeCards";
import { useDeckMedia } from "./deck/useDeckMedia";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./deck/usePromptBoxDrop";
import { DeckAddAction, DeckItem } from "./deck/deckTypes";
import { AspectRatioIcon } from "./common/AspectRatioIcon";
import { VideoGenerationCountPicker } from "./common/VideoGenerationCountPicker";
@@ -506,6 +511,52 @@ export const PromptBoxVideo = ({
ownGalleryModal: true,
});
// Drag & drop / paste onto the box bounds: files route to the reference
// kind their MIME matches, gated on what the model supports. Keyframe mode
// renders no video/audio deck, so those kinds only land in reference mode
// where the user can see (and remove) them.
const dropAcceptsVideos = isReferenceMode && maxVideoCount > 0;
const dropAcceptsAudio = isReferenceMode && maxAudioCount > 0;
const handleDroppedFiles = ({ images, videos, audios }: DroppedFiles) => {
if (images.length > 0) {
if (!isReferenceMode) {
// Fill the empty keyframe slots in order: first frame, then last.
const queue = [...images];
const firstOpen =
referenceImages.length === 0 && deck.uploadingImages.length === 0;
const lastOpen =
!!selectedModel?.endFrame && !endFrameImage && !deck.uploadingEnd;
if (firstOpen) deck.processImageFiles([queue.shift()!], "start");
if (lastOpen && queue.length > 0) {
deck.processImageFiles([queue.shift()!], "end");
}
if (!firstOpen && !lastOpen) {
toast.error(
selectedModel?.endFrame
? "First and last frames are already set"
: "The first frame is already set",
);
}
} else if (deck.availableImageSlots <= 0) {
toast.error(
`Max ${maxImageCount} image reference${maxImageCount === 1 ? "" : "s"}`,
);
} else {
deck.processImageFiles(images, "start");
}
}
if (videos.length > 0) void deck.processVideoFiles(videos);
if (audios.length > 0) void deck.processAudioFiles(audios);
};
const drop = usePromptBoxDrop({
acceptsImages: maxImageCount > 0,
acceptsVideos: dropAcceptsVideos,
acceptsAudio: dropAcceptsAudio,
onDropFiles: handleDroppedFiles,
});
// Mixed deck items ordered images → videos → audios: the @ImageN/@VideoN/
// @AudioN mention labels are index-derived per type, so this ordering (and
// image-only reordering) is load-bearing.
@@ -1291,7 +1342,15 @@ export const PromptBoxVideo = ({
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={maxImageCount > 0}
acceptsVideos={dropAcceptsVideos}
acceptsAudio={dropAcceptsAudio}
keyframeMode={!isReferenceMode}
/>
{selectedModel?.textToVideoSupported === false && (
<div className="mb-2 flex items-center gap-1.5 rounded-md bg-ui-controls/60 px-2.5 py-1.5 text-xs text-base-fg/70">
<FontAwesomeIcon
@@ -1,4 +1,10 @@
import { useCallback, useRef, useState } from "react";
import {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
} from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faFolderOpen,
@@ -19,6 +25,13 @@ import {
isAudioFile,
} from "./audioFiles";
/** Lets the prompt box push dropped/pasted files through the same upload
* path the row's own file inputs use. */
export interface AudioReferenceRowHandle {
addAudioFiles: (files: File[]) => Promise<void>;
addImageFile: (file: File) => Promise<void>;
}
export interface AudioReferenceRowProps {
referenceAudios: RefAudio[];
onReferenceAudiosChange: (audios: RefAudio[]) => void;
@@ -41,20 +54,26 @@ export interface AudioReferenceRowProps {
// remix/sample source or Seed Audio refs), plus an optional image reference
// for models that support one. Rendered inside the glass card, above the
// prompt textarea.
export function AudioReferenceRow({
referenceAudios,
onReferenceAudiosChange,
maxAudioCount,
maxAudioRefDuration,
uploadAudio,
onPickAudioFromLibrary,
audioRequired = false,
imageSupported = false,
referenceImages = [],
onReferenceImagesChange,
uploadImage,
className = "",
}: AudioReferenceRowProps) {
export const AudioReferenceRow = forwardRef<
AudioReferenceRowHandle,
AudioReferenceRowProps
>(function AudioReferenceRow(
{
referenceAudios,
onReferenceAudiosChange,
maxAudioCount,
maxAudioRefDuration,
uploadAudio,
onPickAudioFromLibrary,
audioRequired = false,
imageSupported = false,
referenceImages = [],
onReferenceImagesChange,
uploadImage,
className = "",
},
ref,
) {
const audioInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
@@ -66,10 +85,7 @@ export function AudioReferenceRow({
const referenceImagesRef = useRef(referenceImages);
referenceImagesRef.current = referenceImages;
const handleAudioFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(event.target.files || []);
const processAudioFiles = async (files: File[]) => {
if (files.length === 0) return;
const audioFiles = files.filter(isAudioFile);
@@ -123,15 +139,17 @@ export function AudioReferenceRow({
});
}
}
};
const handleAudioFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
await processAudioFiles(Array.from(event.target.files || []));
if (audioInputRef.current) audioInputRef.current.value = "";
};
const handleImageFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = (event.target.files || [])[0];
if (!file || !uploadImage) return;
const processImageFile = async (file: File) => {
if (!uploadImage) return;
setIsUploadingImage(true);
await uploadImage({
@@ -156,10 +174,26 @@ export function AudioReferenceRow({
}
},
});
};
const handleImageFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = (event.target.files || [])[0];
if (file) await processImageFile(file);
if (imageInputRef.current) imageInputRef.current.value = "";
};
useImperativeHandle(
ref,
() => ({
addAudioFiles: processAudioFiles,
addImageFile: processImageFile,
}),
// Recreated each render so the handle always closes over fresh props
// (slot counts, current refs) rather than mount-time values.
);
const removeAudio = (id: string) => {
onReferenceAudiosChange(referenceAudios.filter((a) => a.id !== id));
if (audioInputRef.current) audioInputRef.current.value = "";
@@ -280,7 +314,7 @@ export function AudioReferenceRow({
</div>
</div>
);
}
});
function AudioRefTile({
audio,
@@ -0,0 +1,488 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faImages, faMusic, faVideo } from "@fortawesome/pro-solid-svg-icons";
import { toast } from "@storyteller/ui-toaster";
import { IsDesktopApp } from "@storyteller/tauri-utils";
// ── Types ───────────────────────────────────────────────────────────────
type DropKind = "image" | "video" | "audio";
/** "accept" / "reject" reflect whether the hovered payload contains at
* least one file the current model can take as a reference. */
export type DropDragState = "idle" | "accept" | "reject";
export interface DroppedFiles {
images: File[];
videos: File[];
audios: File[];
}
export interface UsePromptBoxDropOptions {
acceptsImages: boolean;
acceptsVideos: boolean;
acceptsAudio: boolean;
onDropFiles: (files: DroppedFiles) => void;
}
interface PromptBoxDropOverlayProps {
dragState: DropDragState;
acceptsImages: boolean;
acceptsVideos: boolean;
acceptsAudio: boolean;
/** Video keyframe mode: images fill the start/end frame slots. */
keyframeMode?: boolean;
}
/**
* Live count of mounted prompt boxes that own file drops. The desktop's
* global drop handler reads this to stand down: on a create page the prompt
* box is the only drop target, so a drop anywhere else does nothing.
*/
let activeDropZoneCount = 0;
const dropZoneCountListeners = new Set<(count: number) => void>();
const adjustActiveDropZoneCount = (delta: number) => {
activeDropZoneCount = Math.max(0, activeDropZoneCount + delta);
dropZoneCountListeners.forEach((listener) => listener(activeDropZoneCount));
};
export const isPromptBoxDropZoneActive = () => activeDropZoneCount > 0;
export const subscribeToPromptBoxDropZones = (
listener: (count: number) => void,
) => {
dropZoneCountListeners.add(listener);
return () => {
dropZoneCountListeners.delete(listener);
};
};
// Tauri hands us OS paths, not File objects. Extensions are all we get to
// route by, so they mirror the MIME buckets the browser path uses.
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif", "bmp", "avif"];
const VIDEO_EXTENSIONS = ["mp4", "mov", "webm", "mkv", "avi", "m4v"];
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"];
const MIME_BY_EXTENSION: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
avif: "image/avif",
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
m4v: "video/x-m4v",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
aac: "audio/aac",
m4a: "audio/mp4",
opus: "audio/opus",
};
const kindOfMime = (mime: string): DropKind | null =>
mime.startsWith("image/")
? "image"
: mime.startsWith("video/")
? "video"
: mime.startsWith("audio/")
? "audio"
: null;
const extensionOf = (path: string) =>
path.split(/[/\\]/).pop()?.split(".").pop()?.toLowerCase() ?? "";
const kindOfPath = (path: string): DropKind | null => {
const ext = extensionOf(path);
if (IMAGE_EXTENSIONS.includes(ext)) return "image";
if (VIDEO_EXTENSIONS.includes(ext)) return "video";
if (AUDIO_EXTENSIONS.includes(ext)) return "audio";
return null;
};
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const listKinds = (labels: string[]) =>
labels.length <= 1
? (labels[0] ?? "")
: `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
/**
* Makes the prompt box a drop target for reference media, and catches
* clipboard pastes of files anywhere on the page (focus-independent). Files
* are routed by MIME type; kinds the current model doesn't take are
* rejected with a toast instead of silently vanishing.
*
* Two transports, one behaviour. In the browser this is plain HTML5 drag
* events on the returned `dropZoneProps`. Under Tauri the webview never sees
* those events (the OS drag is captured natively), so the same states are
* driven from `onDragDropEvent` and the cursor position is hit-tested
* against the element `dropZoneProps.ref` is attached to.
*/
export function usePromptBoxDrop({
acceptsImages,
acceptsVideos,
acceptsAudio,
onDropFiles,
}: UsePromptBoxDropOptions) {
const [dragState, setDragState] = useState<DropDragState>("idle");
const zoneRef = useRef<HTMLDivElement>(null);
const enabled = acceptsImages || acceptsVideos || acceptsAudio;
const isDesktop = IsDesktopApp();
const acceptsKind = useCallback(
(kind: DropKind | null) =>
kind === "image"
? acceptsImages
: kind === "video"
? acceptsVideos
: kind === "audio"
? acceptsAudio
: false,
[acceptsImages, acceptsVideos, acceptsAudio],
);
// Announce ownership of drops for as long as an enabled box is mounted, so
// the desktop's global drop handler stays out of the way on this page.
useEffect(() => {
if (!enabled) return;
adjustActiveDropZoneCount(1);
return () => adjustActiveDropZoneCount(-1);
}, [enabled]);
const routeFiles = (files: File[]) => {
const accepted: DroppedFiles = { images: [], videos: [], audios: [] };
const rejectedKinds = new Set<DropKind>();
let unknownCount = 0;
for (const file of files) {
const kind = kindOfMime(file.type) ?? kindOfPath(file.name);
if (kind === null) {
unknownCount++;
} else if (!acceptsKind(kind)) {
rejectedKinds.add(kind);
} else if (kind === "image") {
accepted.images.push(file);
} else if (kind === "video") {
accepted.videos.push(file);
} else {
accepted.audios.push(file);
}
}
const anyAccepted =
accepted.images.length > 0 ||
accepted.videos.length > 0 ||
accepted.audios.length > 0;
if (anyAccepted) onDropFiles(accepted);
// Mode-neutral wording: a kind can be off because the model lacks it or
// because the current input mode (e.g. keyframes) doesn't show it.
if (rejectedKinds.size > 0) {
toast.error(
`${capitalize(listKinds([...rejectedKinds]))} references aren't available here`,
);
} else if (unknownCount > 0 && !anyAccepted) {
toast.error(
`Only ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files can be added here`,
);
}
};
// Async listeners (Tauri, paste) re-read the freshest router through a ref
// so they can stay subscribed once instead of resubscribing per render.
const routeFilesRef = useRef(routeFiles);
useEffect(() => {
routeFilesRef.current = routeFiles;
});
const acceptsKindRef = useRef(acceptsKind);
useEffect(() => {
acceptsKindRef.current = acceptsKind;
}, [acceptsKind]);
// ── Browser transport ────────────────────────────────────────────────
// While the box is on screen, a stray drop outside it must not navigate
// the browser to the file (which would blow away the user's session).
useEffect(() => {
if (!enabled || isDesktop) return;
const preventNavigation = (e: DragEvent) => {
if (e.dataTransfer?.types.includes("Files")) e.preventDefault();
};
// Drops that land outside the box still need the overlay dismissed.
const clearDragState = () => setDragState("idle");
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
window.addEventListener("drop", clearDragState);
window.addEventListener("dragend", clearDragState);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
window.removeEventListener("drop", clearDragState);
window.removeEventListener("dragend", clearDragState);
};
}, [enabled, isDesktop]);
// MIME types are unreliable mid-drag (browsers may report empty strings),
// so unknown types count as acceptable until the drop resolves them.
const inspectDrag = useCallback(
(dt: DataTransfer): DropDragState => {
const fileItems = Array.from(dt.items).filter((i) => i.kind === "file");
if (fileItems.length === 0) return "accept";
const ok = fileItems.some(
(i) => i.type === "" || acceptsKind(kindOfMime(i.type)),
);
return ok ? "accept" : "reject";
},
[acceptsKind],
);
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
setDragState(inspectDrag(e.dataTransfer));
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
if (dragState === "idle") return;
// Child elements fire enter/leave pairs constantly; only reset when the
// pointer actually exits the drop target's bounds.
const rect = e.currentTarget.getBoundingClientRect();
if (
e.clientX < rect.left ||
e.clientX >= rect.right ||
e.clientY < rect.top ||
e.clientY >= rect.bottom
) {
setDragState("idle");
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
setDragState("idle");
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) routeFiles(files);
};
// ── Tauri transport ──────────────────────────────────────────────────
useEffect(() => {
if (!enabled || !isDesktop) return;
let unlisten: (() => void) | undefined;
let disposed = false;
// Tauri reports positions in physical pixels; CSS coordinates need the
// display's scale factor divided back out.
const isInsideZone = (position: { x: number; y: number }) => {
const zone = zoneRef.current;
if (!zone) return false;
const scale = window.devicePixelRatio || 1;
const x = position.x / scale;
const y = position.y / scale;
const rect = zone.getBoundingClientRect();
return (
x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom
);
};
const setup = async () => {
try {
const { getCurrentWebviewWindow } = await import(
"@tauri-apps/api/webviewWindow"
);
const { convertFileSrc } = await import("@tauri-apps/api/core");
const appWindow = getCurrentWebviewWindow();
const stop = await appWindow.onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "enter" || payload.type === "over") {
// Paths aren't provided while hovering, so the overlay can only
// promise "accept" until the drop resolves the real files.
setDragState(isInsideZone(payload.position) ? "accept" : "idle");
return;
}
if (payload.type !== "drop") {
setDragState("idle");
return;
}
setDragState("idle");
if (!isInsideZone(payload.position)) return;
if (payload.paths.length === 0) return;
// Only read the files this box can actually take; unsupported ones
// still reach routeFiles (as zero-byte stand-ins) so it can raise
// the same "not available here" toast the browser path does.
const readable = payload.paths.filter(
(path) => kindOfPath(path) !== null,
);
const unsupported = payload.paths.filter(
(path) => kindOfPath(path) === null,
);
const files = await Promise.all(
readable.map(async (path) => {
const fileName = path.split(/[/\\]/).pop() ?? "file";
const type = MIME_BY_EXTENSION[extensionOf(path)] ?? "";
try {
const response = await fetch(convertFileSrc(path));
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
return new File([blob], fileName, { type });
} catch (err) {
console.error("[PromptBoxDrop] file read failed:", err);
return null;
}
}),
);
if (disposed) return;
const readFiles = files.filter((file): file is File => file !== null);
const failedCount = readable.length - readFiles.length;
if (failedCount > 0) {
toast.error(
`Couldn't read ${failedCount} file${failedCount > 1 ? "s" : ""}`,
);
}
const placeholders = unsupported.map(
(path) => new File([], path.split(/[/\\]/).pop() ?? "file"),
);
const allFiles = [...readFiles, ...placeholders];
if (allFiles.length > 0) routeFilesRef.current(allFiles);
});
if (disposed) stop();
else unlisten = stop;
} catch (err) {
console.error("[PromptBoxDrop] setup failed:", err);
}
};
void setup();
return () => {
disposed = true;
unlisten?.();
};
}, [enabled, isDesktop]);
// ── Paste-to-add ─────────────────────────────────────────────────────
// A copied image (or media file) pasted anywhere on the page lands in the
// deck, whether or not the textarea is focused.
useEffect(() => {
if (!enabled) return;
const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
// Plain text pastes carry no files — leave them entirely alone.
if (files.length === 0) return;
// Guard against double-adding if more than one box ever mounts.
const marked = e as ClipboardEvent & { promptBoxHandled?: boolean };
if (marked.promptBoxHandled) return;
marked.promptBoxHandled = true;
// No preventDefault: any text alongside the file still pastes into
// whichever field is focused.
routeFilesRef.current(files);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled]);
return {
dragState,
dropZoneProps: {
ref: zoneRef,
// Under Tauri these never fire, but leaving them attached keeps a
// single code path and costs nothing.
onDragEnter: handleDragOver,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
/**
* Full-bleed dashed overlay shown while files hover the prompt box, listing
* the reference kinds the current model takes (red when none match).
*/
export function PromptBoxDropOverlay({
dragState,
acceptsImages,
acceptsVideos,
acceptsAudio,
keyframeMode,
}: PromptBoxDropOverlayProps) {
if (dragState === "idle") return null;
const kinds = [
...(acceptsImages
? [{ icon: faImages, label: keyframeMode ? "Frames" : "Images" }]
: []),
...(acceptsVideos ? [{ icon: faVideo, label: "Video" }] : []),
...(acceptsAudio ? [{ icon: faMusic, label: "Audio" }] : []),
];
const rejected = dragState === "reject";
return (
<div
className={twMerge(
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-[#161618]/85 backdrop-blur-sm",
rejected ? "border-red-400/80" : "border-primary",
)}
>
<div className="flex items-center gap-2">
{kinds.map((kind, i) => (
<div
key={kind.label}
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-base-fg/90"
style={{ animationDelay: `${i * 50}ms` }}
>
<FontAwesomeIcon icon={kind.icon} className="text-sm" />
</div>
))}
</div>
<div className="text-sm font-semibold text-base-fg">
{rejected
? "That file type isn't supported"
: keyframeMode && !acceptsVideos && !acceptsAudio
? "Drop to set your frames"
: "Drop to add references"}
</div>
<div className="-mt-1 text-xs text-base-fg/60">
{rejected
? `Accepts ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files`
: kinds.map((kind) => kind.label).join(" · ")}
</div>
</div>
);
}
function acceptedKindLabels(
acceptsImages: boolean,
acceptsVideos: boolean,
acceptsAudio: boolean,
): string[] {
return [
...(acceptsImages ? ["image"] : []),
...(acceptsVideos ? ["video"] : []),
...(acceptsAudio ? ["audio"] : []),
];
}