diff --git a/Cargo.lock b/Cargo.lock index 0f1efd693f..62354153ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 2b29b4d128..c44fbda594 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/desktop/artcraft/capabilities/default.json b/crates/desktop/artcraft/capabilities/default.json index e0a5a74fd1..9e286eff53 100644 --- a/crates/desktop/artcraft/capabilities/default.json +++ b/crates/desktop/artcraft/capabilities/default.json @@ -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", diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/setup_main_window.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/setup_main_window.rs index ffbbbe4b05..22b9f463c6 100644 --- a/crates/desktop/artcraft/src/core/lifecycle/startup/setup_main_window.rs +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/setup_main_window.rs @@ -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(()) diff --git a/crates/desktop/artcraft/tauri.conf.json b/crates/desktop/artcraft/tauri.conf.json index ec5c2ab981..d610ec511b 100644 --- a/crates/desktop/artcraft/tauri.conf.json +++ b/crates/desktop/artcraft/tauri.conf.json @@ -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", diff --git a/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx b/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx new file mode 100644 index 0000000000..c818031099 --- /dev/null +++ b/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx @@ -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(null); + const [pendingFile, setPendingFile] = useState(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 && ( +
+
+ +
Drop to Upload
+
GLB, PNG, JPG, JPEG, SPZ
+
+
+ )} + closeModal()} + title="Upload a 3D Model" + titleIcon={faCube} + /> + closeModal()} + title="Upload an Image" + titleIcon={faImages} + /> + {}} + title="Upload a Splat" + titleIcon={faCube} + /> + + ); +} diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx index a4dd7dc55d..71fd250692 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx @@ -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 = ({
{!assetFile.value && ( diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx index 5d7750bc5f..3f3358052f 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx @@ -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(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, diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx index 90f49cc3cf..72ff7353ab 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx @@ -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) { diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx index 15c1d06c72..d035612c8d 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx @@ -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(initialUploaderState); @@ -67,6 +68,7 @@ export function UploadModalImage(props: Props) { diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx index 7a2f879153..dda59ad79e 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx @@ -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 = ({
{!assetFile.value && ( diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx index e8f7ab7ee1..80fb1ad659 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx @@ -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(initialUploaderState); @@ -72,6 +73,7 @@ export function UploadModalSplat(props: Props) { title={title} engineCategory={selectedCategory} fileTypes={splatFileTypes} + initialFile={initialFile} options={{ ...(options ?? {}), }} diff --git a/frontend/apps/artcraft/app/src/index.tsx b/frontend/apps/artcraft/app/src/index.tsx index 4b67e29c4f..c13e2c0a7c 100644 --- a/frontend/apps/artcraft/app/src/index.tsx +++ b/frontend/apps/artcraft/app/src/index.tsx @@ -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(
+ ,