Drag-N-Drop assets into Artcraft to upload them (#1231)

This commit is contained in:
Tanish Manku
2026-03-31 21:05:38 -04:00
committed by GitHub
parent 400bccc155
commit b09a0fd854
13 changed files with 207 additions and 13 deletions
Generated
+7
View File
@@ -5163,6 +5163,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-types"
version = "2.12.0"
@@ -11846,6 +11852,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.3.1",
"http-range",
"jni 0.21.1",
"libc",
"log",
+1 -1
View File
@@ -327,7 +327,7 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
@@ -10,6 +10,7 @@
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-toggle-maximize",
"core:window:default",
"dialog:default",
@@ -34,10 +34,6 @@ pub async fn setup_main_window(
let win_builder = win_builder
.decorations(false); // NB: This breaks Mac! (And breaks resize on Linux)
#[cfg(target_os = "windows")]
let win_builder = win_builder
.drag_and_drop(false); // TODO: Is this necessary on Windows?
let _window = win_builder.build()?;
Ok(())
+5
View File
@@ -14,6 +14,10 @@
"windows": [
],
"security": {
"assetProtocol": {
"enable": true,
"scope": ["**"]
},
"csp": {
"default-src": [
"'self'",
@@ -32,6 +36,7 @@
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"asset:",
"blob:",
"data:",
"http://ipc.localhost",
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from "react";
import { faCube, faImages, faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UploadModal3D } from "../reusable/UploadModal3D/UploadModal3D";
import { UploadModalImage } from "../reusable/UploadModalImage/UploadModalImage";
import { UploadModalSplat } from "../reusable/UploadModalSplat/UploadModalSplat";
import { FilterEngineCategories } from "../../enums";
type ModalType = "3d" | "image" | "splat" | null;
function getModalTypeForFileName(name: string): ModalType {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (ext === "glb") return "3d";
if (ext === "png" || ext === "jpg" || ext === "jpeg") return "image";
if (ext === "spz") return "splat";
return null;
}
function isAnyModalOpen(): boolean {
return !!document.querySelector("[data-radix-dialog-content]");
}
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
export function GlobalFileDropHandler() {
const dragCounter = useRef(0);
const [isDragging, setIsDragging] = useState(false);
const [modalType, setModalType] = useState<ModalType>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const closeModal = () => {
setModalType(null);
setPendingFile(null);
};
useEffect(() => {
if (isTauri) {
const unlisteners: Array<() => void> = [];
const setup = async () => {
try {
const { getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const { convertFileSrc } = await import("@tauri-apps/api/core");
const appWindow = getCurrentWebviewWindow();
const unlisten = await appWindow.onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "enter") {
if (!isAnyModalOpen()) setIsDragging(true);
} else if (payload.type === "over") {
// overlay stays visible
} else if (payload.type === "drop") {
setIsDragging(false);
if (isAnyModalOpen()) return;
const path = payload.paths[0];
if (!path) return;
const fileName = path.split(/[/\\]/).pop() ?? "file";
const type = getModalTypeForFileName(fileName);
if (type === null) return;
try {
const assetUrl = convertFileSrc(path);
const response = await fetch(assetUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const file = new File([blob], fileName);
await appWindow.setFocus();
setModalType(type);
setPendingFile(file);
} catch (err) {
console.error("[DragDrop] file read failed:", err);
}
} else {
setIsDragging(false);
}
});
unlisteners.push(unlisten);
} catch (err) {
console.error("[DragDrop] setup failed:", err);
}
};
setup();
return () => { unlisteners.forEach((fn) => fn()); };
} else {
// HTML5 path (browser dev mode)
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer?.types.includes("Files")) return;
if (isAnyModalOpen()) return;
dragCounter.current++;
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes("Files")) return;
dragCounter.current--;
if (dragCounter.current <= 0) { dragCounter.current = 0; setIsDragging(false); }
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes("Files")) return;
e.preventDefault();
setIsDragging(false);
dragCounter.current = 0;
if (isAnyModalOpen()) return;
const file = e.dataTransfer?.files[0];
if (!file) return;
const type = getModalTypeForFileName(file.name);
if (type === null) return;
setModalType(type);
setPendingFile(file);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("dragover", handleDragOver);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragover", handleDragOver);
window.removeEventListener("drop", handleDrop);
};
}
}, []);
return (
<>
{isDragging && modalType === null && (
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/40">
<div className="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-white/60 bg-black/30 px-16 py-12 text-white backdrop-blur-sm">
<FontAwesomeIcon icon={faUpRightAndDownLeftFromCenter} className="text-4xl opacity-80" />
<div className="text-xl font-semibold">Drop to Upload</div>
<div className="text-sm opacity-60">GLB, PNG, JPG, JPEG, SPZ</div>
</div>
</div>
)}
<UploadModal3D
isOpen={modalType === "3d"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={(_category: FilterEngineCategories) => closeModal()}
title="Upload a 3D Model"
titleIcon={faCube}
/>
<UploadModalImage
isOpen={modalType === "image"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={() => closeModal()}
title="Upload an Image"
titleIcon={faImages}
/>
<UploadModalSplat
isOpen={modalType === "splat"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={() => {}}
title="Upload a Splat"
titleIcon={faCube}
/>
</>
);
}
@@ -13,6 +13,7 @@ interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -25,6 +26,7 @@ interface Props {
export const UploadFiles3D = ({
fileTypes,
engineCategory,
initialFile,
options,
onClose,
onUploadProgress,
@@ -49,7 +51,7 @@ export const UploadFiles3D = ({
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
@@ -143,7 +145,7 @@ export const UploadFiles3D = ({
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
className="pointer-events-none h-full !w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
@@ -22,6 +22,7 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -32,7 +33,7 @@ interface Props {
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
export function UploadModal3D(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [isCharacter, setIsCharacter] = useState(false);
@@ -101,6 +102,7 @@ export function UploadModal3D(props: Props) {
title={title}
engineCategory={selectedCategory}
fileTypes={objectFileTypes}
initialFile={initialFile}
options={{
...(options ?? {}),
fileSubtypes: characterAnimationOptions,
@@ -7,19 +7,21 @@ import { UploaderState } from "../../../models";
interface Props {
title: string;
fileTypes: string[];
initialFile?: File;
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
}
export const UploadFilesImage = ({
fileTypes,
initialFile,
onClose,
onUploadProgress,
}: Props) => {
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const handleSubmit = () => {
if (!assetFile.value) {
@@ -21,12 +21,13 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
}
const imageFileTypes = Object.values(IMAGEPLANE_FILE_TYPE);
export function UploadModalImage(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
@@ -67,6 +68,7 @@ export function UploadModalImage(props: Props) {
<UploadFilesImage
title={title}
fileTypes={imageFileTypes}
initialFile={initialFile}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
@@ -14,6 +14,7 @@ interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -27,6 +28,7 @@ interface Props {
export const UploadFilesSplat = ({
fileTypes,
engineCategory,
initialFile,
options,
onClose,
onUploadProgress,
@@ -52,7 +54,7 @@ export const UploadFilesSplat = ({
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
@@ -166,7 +168,7 @@ export const UploadFilesSplat = ({
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
className="pointer-events-none h-full !w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
@@ -22,6 +22,7 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -33,7 +34,7 @@ const splatFileTypes = Object.values(SPLAT_FILE_TYPE);
export function UploadModalSplat(props: Props) {
const selectedCategory = FilterEngineCategories.SPLAT;
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
@@ -72,6 +73,7 @@ export function UploadModalSplat(props: Props) {
title={title}
engineCategory={selectedCategory}
fileTypes={splatFileTypes}
initialFile={initialFile}
options={{
...(options ?? {}),
}}
+2
View File
@@ -2,6 +2,7 @@ import { StrictMode, useEffect } from "react";
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import { BrowserRouter } from "react-router-dom";
import { PageEnigma } from "./pages/PageEnigma/PageEnigma";
import { GlobalFileDropHandler } from "./components/GlobalFileDropHandler/GlobalFileDropHandler";
import { createRoot } from "react-dom/client";
import "./styles/normalize.css";
import "./styles/tailwind.css";
@@ -94,6 +95,7 @@ createRoot(document.getElementById("root")!).render(
<GlobalSettingsManager env={ENV} />
<div className="topbar-spacer" data-tauri-drag-region={true} />
<PageEnigma />
<GlobalFileDropHandler />
</BrowserRouter>
</StrictMode>
</>,