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 6463cf4f3c..76cbc59365 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -3,6 +3,7 @@ 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; @@ -83,6 +84,9 @@ 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, // Sizing #[serde(rename = "minWidth")] 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 88e7b7512d..0eeda6fbcf 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -7,7 +7,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface}; use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle; -use crate::messages::portfolio::ingest::utility_types::TypeFilter; +use crate::messages::portfolio::ingest::utility_types::{IngestAction, TypeFilter}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use choice::enum_choice; @@ -1290,7 +1290,7 @@ pub fn font_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::>().into() } -/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file matching the given filters. +/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file matching the given filters, as dropping a file onto it also does. pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Vec) -> Vec { let mut widgets = start_widgets(¶meter_widgets_info); @@ -1351,6 +1351,8 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Ve ]), } }; + let file_drop_action = IngestAction::resource_input(document_id, node_id, index, &filters); + let none = MenuListEntry::new("none") .label("None") .tooltip_description("No resource assigned to this input.") @@ -1386,7 +1388,10 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Ve widgets.extend_from_slice(&[ Separator::new(SeparatorStyle::Unrelated).widget_instance(), - DropdownInput::new(vec![vec![none, browse], file_entries]).selected_index(selected_index).widget_instance(), + DropdownInput::new(vec![vec![none, browse], file_entries]) + .selected_index(selected_index) + .file_drop_action(Some(file_drop_action)) + .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 1ad47de07c..f6e09f5bc0 100644 --- a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs +++ b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs @@ -33,18 +33,9 @@ impl MessageHandler for IngestMessageHandle input_index, filters, } => { - let accepted_types = filters.iter().flat_map(|filter| filter.types.iter().copied()).collect(); + let action = IngestAction::resource_input(document_id, node_id, input_index, &filters); - responses.add(IngestMessage::Browse { - filters, - multiple: false, - action: IngestAction::ResourceInput { - document_id, - node_id, - input_index: input_index as u32, - accepted_types, - }, - }); + responses.add(IngestMessage::Browse { filters, multiple: false, action }); } IngestMessage::Browse { filters, multiple, action } => responses.add(FrontendMessage::TriggerBrowse { options: FileDialogOptions { @@ -282,6 +273,24 @@ mod tests { } } + #[test] + fn browsing_for_a_resource_input_carries_the_action_a_dropped_file_uses() { + let filters = vec![TypeFilter::documents(), TypeFilter::raster()]; + let accepted_types = [TypeFilter::documents().types, TypeFilter::raster().types].concat(); + let dropped = IngestAction::resource_input(REQUESTING_DOCUMENT, NodeId(7), 1, &filters); + assert_eq!(dropped, resource_input(accepted_types)); + + let mut responses = VecDeque::new(); + let message = IngestMessage::SetResourceInput { + document_id: REQUESTING_DOCUMENT, + node_id: NodeId(7), + input_index: 1, + filters, + }; + IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open: true }); + assert!(matches!(&responses[0], Message::Portfolio(PortfolioMessage::Ingest(IngestMessage::Browse { action, .. })) if *action == dropped)); + } + #[test] fn truncated_image_only_shows_a_dialog() { let png = Image::new(8, 8, Color::WHITE).to_png(); diff --git a/editor/src/messages/portfolio/ingest/utility_types.rs b/editor/src/messages/portfolio/ingest/utility_types.rs index f2a896035e..50e2e83f7d 100644 --- a/editor/src/messages/portfolio/ingest/utility_types.rs +++ b/editor/src/messages/portfolio/ingest/utility_types.rs @@ -39,6 +39,18 @@ pub enum IngestAction { }, } +impl IngestAction { + /// Sends a file to a node's resource input, which takes only the types that its filters list. + pub fn resource_input(document_id: DocumentId, node_id: NodeId, input_index: usize, filters: &[TypeFilter]) -> Self { + Self::ResourceInput { + document_id, + node_id, + input_index: input_index as u32, + accepted_types: filters.iter().flat_map(|filter| filter.types.iter().copied()).collect(), + } + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DataType { GraphiteLegacy, diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 6e2385640f..495fc598ff 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -165,6 +165,9 @@ 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); + }, }, }), }, diff --git a/frontend/src/components/widgets/inputs/DropdownInput.svelte b/frontend/src/components/widgets/inputs/DropdownInput.svelte index cb9d327787..ef7fbaecd4 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 } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { MenuListEntry, ActionShortcut, IngestAction } from "/wrapper/pkg/graphite_wasm_wrapper"; const DASH_ENTRY: MenuListEntry = { value: "", @@ -19,7 +19,7 @@ tooltipShortcut: undefined, }; - const dispatch = createEventDispatcher<{ selectedIndex: number; hoverInEntry: number; hoverOutEntry: number }>(); + const dispatch = createEventDispatcher<{ selectedIndex: number; hoverInEntry: number; hoverOutEntry: number; fileDrop: File }>(); let self: LayoutRow | undefined; @@ -35,6 +35,7 @@ // Behavior export let virtualScrolling = false; export let interactive = true; + export let fileDropAction: IngestAction | undefined = undefined; // Sizing export let minWidth = 0; export let maxWidth = 0; @@ -47,6 +48,7 @@ let activeEntrySkipWatcher = false; let initialSelectedIndex: number | undefined = undefined; let open = false; + let fileDragOver = false; $: watchSelectedIndex(selectedIndex); $: watchEntries(entries); @@ -113,15 +115,49 @@ const blurTarget = (e.target instanceof Element ? e.target.closest("[data-dropdown-input]") : undefined) || undefined; if (blurTarget !== self?.div?.()) open = false; } + + function takesDraggedFile(e: DragEvent): boolean { + return Boolean(fileDropAction) && !disabled && Boolean(e.dataTransfer?.types.includes("Files")); + } + + function fileDragOverWidget(e: DragEvent) { + if (!takesDraggedFile(e)) return; + + // The browser refuses the drop unless the dragover is canceled + e.preventDefault(); + fileDragOver = true; + } + + function fileDragLeaveWidget(e: DragEvent) { + // Moving between the widget's own children is not leaving it + if (e.relatedTarget instanceof Node && self?.div?.()?.contains(e.relatedTarget)) return; + + fileDragOver = false; + } + + function fileDropOnWidget(e: DragEvent) { + if (!takesDraggedFile(e)) return; + + // The drop is kept from also reaching a panel that imports dropped files + e.preventDefault(); + e.stopPropagation(); + fileDragOver = false; + + const file = e.dataTransfer?.files[0]; + if (file) dispatch("fileDrop", file); + } 0 ? { "min-width": `${minWidth}px` } : {}), ...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}), }} + on:dragover={fileDragOverWidget} + on:dragleave={fileDragLeaveWidget} + on:drop={fileDropOnWidget} bind:this={self} data-dropdown-input > @@ -169,6 +205,15 @@ --widget-height: 20px; } + &.file-drag-over::after { + content: ""; + position: absolute; + inset: -4px; + border: 1px dashed var(--color-e-nearwhite); + border-radius: 4px; + pointer-events: none; + } + .dropdown-box { align-items: center; white-space: nowrap; diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 60d4883fb7..203c26d1de 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -597,7 +597,7 @@ mod editor_commands { ClipboardMessage::ReadSelection { content, cut }.into() } - /// A file picked in the dialog that `TriggerBrowse` opened + /// A file headed for a known action, either picked in the dialog that `TriggerBrowse` opened or dropped onto a widget that takes files fn ingest_picked(name: String, mime_type: String, data: Vec, action: IngestAction) -> Message { IngestMessage::Ingest { data,