mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Drag-N-Drop assets into Artcraft to upload them (#1231)
This commit is contained in:
Generated
+7
@@ -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
@@ -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(())
|
||||
|
||||
@@ -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",
|
||||
|
||||
+171
@@ -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,
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+4
-2
@@ -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 && (
|
||||
|
||||
+3
-1
@@ -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,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>
|
||||
</>,
|
||||
|
||||
Reference in New Issue
Block a user