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:
1 parent
1bb31f42d7
commit
7654e95636
9 files changed
+742
-32
No files matched your search
@@ -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.
|
||||
|
||||
+17
-5
@@ -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"] : []),
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user