diff --git a/editor/src/messages/layout/layout_message.rs b/editor/src/messages/layout/layout_message.rs index 61d2d54549..f0106e53d0 100644 --- a/editor/src/messages/layout/layout_message.rs +++ b/editor/src/messages/layout/layout_message.rs @@ -30,4 +30,9 @@ pub enum LayoutMessage { layout_target: LayoutTarget, widget_id: WidgetId, }, + WidgetValueFileDrop { + layout_target: LayoutTarget, + widget_id: WidgetId, + file: DroppedFile, + }, } diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 7760ee61ec..7c8cb241e0 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -76,6 +76,21 @@ impl MessageHandler> for LayoutMessageHa responses.add((icon_button.on_drag_drop.callback)(icon_button)); } } + LayoutMessage::WidgetValueFileDrop { layout_target, widget_id, file } => { + let Some(layout) = self.layouts.get_mut(layout_target as usize) else { + warn!("WidgetValueFileDrop referenced an invalid layout. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`"); + return; + }; + let Some(widget_instance) = layout.iter_mut().find(|widget| widget.widget_id == widget_id) else { + warn!("WidgetValueFileDrop referenced an invalid widget ID. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`"); + return; + }; + if let Widget::DropdownInput(dropdown_input) = &*widget_instance.widget + && dropdown_input.takes_file_drop + { + responses.add((dropdown_input.on_file_drop.callback)(&file)); + } + } } } diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 76cbc59365..88adf9c330 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -3,7 +3,6 @@ use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::message::Message; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; -use crate::messages::portfolio::ingest::utility_types::IngestAction; use derivative::*; use graphene_std::Color; use graphene_std::color::SRGBA8; @@ -84,9 +83,10 @@ pub struct DropdownInput { pub virtual_scrolling: bool, #[derivative(Default(value = "true"))] pub interactive: bool, - // Where a file dropped on the widget is sent, which also makes the widget take dropped files - #[serde(rename = "fileDropAction")] - pub file_drop_action: Option, + // Set along with the `on_file_drop` callback + #[serde(rename = "takesFileDrop")] + #[widget_builder(skip)] + pub takes_file_drop: bool, // Sizing #[serde(rename = "minWidth")] @@ -101,8 +101,28 @@ pub struct DropdownInput { pub tooltip_description: String, #[serde(rename = "tooltipShortcut")] pub tooltip_shortcut: Option, - // - // Callbacks exists on the `MenuListEntry` children, not this parent `DropdownInput` + + // Callbacks + #[serde(skip)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + #[widget_builder(skip)] + pub on_file_drop: WidgetCallback, +} + +impl DropdownInput { + /// Makes the widget take a file dropped on it, which is handed to the callback. + pub fn on_file_drop(mut self, callback: impl Fn(&DroppedFile) -> Message + 'static + Send + Sync) -> Self { + self.takes_file_drop = true; + self.on_file_drop = WidgetCallback::new(callback); + self + } +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct DroppedFile { + pub name: String, + pub mime_type: String, + pub data: Vec, } pub type MenuListEntrySections = Vec>; diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 0919b06e9d..4a94995786 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -39,6 +39,7 @@ use graphene_std::vector::style::{ }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::{NodeParameter, ParameterRef}; +use std::path::PathBuf; pub(crate) fn string_properties(text: &str) -> Vec { let widget = TextLabel::new(text).widget_instance(); @@ -1390,7 +1391,15 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Ve Separator::new(SeparatorStyle::Unrelated).widget_instance(), DropdownInput::new(vec![vec![none, browse], file_entries]) .selected_index(selected_index) - .file_drop_action(Some(file_drop_action)) + .on_file_drop(move |file| { + IngestMessage::Ingest { + data: file.data.clone(), + action: file_drop_action.clone(), + mime_type: file.mime_type.clone(), + path: Some(PathBuf::from(&file.name)), + } + .into() + }) .widget_instance(), ]); widgets diff --git a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs index 76f85d62cd..e7ca8c9679 100644 --- a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs +++ b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs @@ -342,6 +342,7 @@ mod tests { let png = Image::new(8, 8, Color::WHITE).to_png(); let refusal = |data: &[u8]| { let responses = ingest(data, resource_input(TypeFilter::raster().types), true); + assert_eq!(responses.len(), 1, "a refused file should only show a dialog"); match &responses[0] { Message::Dialog(DialogMessage::DisplayDialogError { description, .. }) => description.clone(), _ => panic!("the user should be told why"), diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 495fc598ff..18a46529c3 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -70,6 +70,15 @@ editor.widgetValueDragDrop(layoutTarget, widgets[widgetIndex].widgetId); } + async function widgetValueFileDrop(widgetIndex: number, file: File) { + // The layout may change while the file is read, so the widget is looked up first + const target = layoutTarget; + const widgetId = widgets[widgetIndex].widgetId; + + const data = await file.bytes(); + editor.widgetValueFileDrop(target, widgetId, file.name, file.type, data); + } + // Extracts the kind and props from a Widget tagged enum, validated against the widget registry. // The overload declares the precise correlated return type while the implementation uses broader types. function unwrapWidget(widgetInstance: WidgetInstance): UnwrappedWidget | undefined; @@ -165,9 +174,7 @@ hoverInEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), hoverOutEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), selectedIndex: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true), - fileDrop: async (e: CustomEvent) => { - if (props.fileDropAction) editor.ingestPicked(e.detail.name, e.detail.type, await e.detail.bytes(), props.fileDropAction); - }, + fileDrop: (e: CustomEvent) => widgetValueFileDrop(index, e.detail), }, }), }, diff --git a/frontend/src/components/widgets/inputs/DropdownInput.svelte b/frontend/src/components/widgets/inputs/DropdownInput.svelte index ef7fbaecd4..28d83d00c9 100644 --- a/frontend/src/components/widgets/inputs/DropdownInput.svelte +++ b/frontend/src/components/widgets/inputs/DropdownInput.svelte @@ -4,7 +4,7 @@ import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import IconLabel from "/src/components/widgets/labels/IconLabel.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; - import type { MenuListEntry, ActionShortcut, IngestAction } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { MenuListEntry, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper"; const DASH_ENTRY: MenuListEntry = { value: "", @@ -35,7 +35,7 @@ // Behavior export let virtualScrolling = false; export let interactive = true; - export let fileDropAction: IngestAction | undefined = undefined; + export let takesFileDrop = false; // Sizing export let minWidth = 0; export let maxWidth = 0; @@ -117,7 +117,7 @@ } function takesDraggedFile(e: DragEvent): boolean { - return Boolean(fileDropAction) && !disabled && Boolean(e.dataTransfer?.types.includes("Files")); + return takesFileDrop && !disabled && Boolean(e.dataTransfer?.types.includes("Files")); } function fileDragOverWidget(e: DragEvent) { diff --git a/frontend/src/managers/input.ts b/frontend/src/managers/input.ts index f0fc26119c..44a0c5ba19 100644 --- a/frontend/src/managers/input.ts +++ b/frontend/src/managers/input.ts @@ -17,6 +17,8 @@ import { onModifyInputField, onFocusOut, onContextMenu, + onDragOver, + onDrop, onPaste, onPointerLockChange, updateDirectInput, @@ -45,6 +47,8 @@ const listeners: Listener[] = [ { target: window, eventName: "wheel", action: (e: WheelEvent) => editorWrapper && onWheelScroll(e, editorWrapper), options: { passive: false } }, { target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => editorWrapper && onModifyInputField(e, editorWrapper) }, { target: window, eventName: "focusout", action: () => onFocusOut() }, + { target: window, eventName: "dragover", action: (e: DragEvent) => onDragOver(e) }, + { target: window, eventName: "drop", action: (e: DragEvent) => onDrop(e) }, { target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) }, { target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() }, { target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorWrapper && onPaste(e, editorWrapper) }, diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index 463002c3b0..7a5902a5c1 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -293,6 +293,23 @@ export function onPaste(e: ClipboardEvent, editor: EditorWrapper) { }); } +// A spot that takes dropped files has canceled the event by the time it bubbles up to the window +function isUnclaimedFileDrag(e: DragEvent): boolean { + return !e.defaultPrevented && Boolean(e.dataTransfer?.types.includes("Files")); +} + +export function onDragOver(e: DragEvent) { + if (!isUnclaimedFileDrag(e)) return; + + // Refusing the drop here keeps the browser from navigating away to open the file + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "none"; +} + +export function onDrop(e: DragEvent) { + if (isUnclaimedFileDrag(e)) e.preventDefault(); +} + export function onFocusOut() { canvasFocused = false; } diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 203c26d1de..863d4f9264 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -22,7 +22,7 @@ mod editor_commands { use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; use editor::messages::portfolio::utility_types::PanelGroupId; use editor::messages::prelude::*; - use editor::messages::tool::tool_messages::tool_prelude::WidgetId; + use editor::messages::tool::tool_messages::tool_prelude::{DroppedFile, WidgetId}; use graph_craft::document::NodeId; use graphene_std::raster::color::Color; use graphene_std::vector::style::FillChoice; @@ -176,6 +176,13 @@ mod editor_commands { LayoutMessage::WidgetValueDragDrop { layout_target, widget_id }.into() } + /// Hand a file dropped on a UI widget to the widget's file drop callback + fn widget_value_file_drop(layout_target: LayoutTarget, widget_id: u64, name: String, mime_type: String, data: Vec) -> Message { + let widget_id = WidgetId(widget_id); + let file = DroppedFile { name, mime_type, data }; + LayoutMessage::WidgetValueFileDrop { layout_target, widget_id, file }.into() + } + /// Closes out the current transaction (drag-end / text-commit end), so emits during a slider drag collapse into one history step instead of N fn end_transaction() -> Message { DocumentMessage::EndTransaction.into() @@ -597,7 +604,7 @@ mod editor_commands { ClipboardMessage::ReadSelection { content, cut }.into() } - /// A file headed for a known action, either picked in the dialog that `TriggerBrowse` opened or dropped onto a widget that takes files + /// A file headed for a known action, picked in the dialog that `TriggerBrowse` opened fn ingest_picked(name: String, mime_type: String, data: Vec, action: IngestAction) -> Message { IngestMessage::Ingest { data,