mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-03 21:58:11 +08:00
Add drag-and-drop support to the file picker dropdown widget (#4551)
Add drag-and-drop file ingest into file picker dropdowns
This commit is contained in:
@@ -3,6 +3,7 @@ use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
|
|||||||
use crate::messages::layout::utility_types::widget_prelude::*;
|
use crate::messages::layout::utility_types::widget_prelude::*;
|
||||||
use crate::messages::message::Message;
|
use crate::messages::message::Message;
|
||||||
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
|
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
|
||||||
|
use crate::messages::portfolio::ingest::utility_types::IngestAction;
|
||||||
use derivative::*;
|
use derivative::*;
|
||||||
use graphene_std::Color;
|
use graphene_std::Color;
|
||||||
use graphene_std::color::SRGBA8;
|
use graphene_std::color::SRGBA8;
|
||||||
@@ -83,6 +84,9 @@ pub struct DropdownInput {
|
|||||||
pub virtual_scrolling: bool,
|
pub virtual_scrolling: bool,
|
||||||
#[derivative(Default(value = "true"))]
|
#[derivative(Default(value = "true"))]
|
||||||
pub interactive: bool,
|
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<IngestAction>,
|
||||||
|
|
||||||
// Sizing
|
// Sizing
|
||||||
#[serde(rename = "minWidth")]
|
#[serde(rename = "minWidth")]
|
||||||
|
|||||||
@@ -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::document_metadata::LayerNodeIdentifier;
|
||||||
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface};
|
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface};
|
||||||
use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle;
|
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::prelude::*;
|
||||||
use crate::messages::tool::common_functionality::graph_modification_utils;
|
use crate::messages::tool::common_functionality::graph_modification_utils;
|
||||||
use choice::enum_choice;
|
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::<Vec<_>>().into()
|
font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::<Vec<_>>().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<TypeFilter>) -> Vec<WidgetInstance> {
|
pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Vec<TypeFilter>) -> Vec<WidgetInstance> {
|
||||||
let mut widgets = start_widgets(¶meter_widgets_info);
|
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")
|
let none = MenuListEntry::new("none")
|
||||||
.label("None")
|
.label("None")
|
||||||
.tooltip_description("No resource assigned to this input.")
|
.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(&[
|
widgets.extend_from_slice(&[
|
||||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
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
|
widgets
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,18 +33,9 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
|
|||||||
input_index,
|
input_index,
|
||||||
filters,
|
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 {
|
responses.add(IngestMessage::Browse { filters, multiple: false, action });
|
||||||
filters,
|
|
||||||
multiple: false,
|
|
||||||
action: IngestAction::ResourceInput {
|
|
||||||
document_id,
|
|
||||||
node_id,
|
|
||||||
input_index: input_index as u32,
|
|
||||||
accepted_types,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
IngestMessage::Browse { filters, multiple, action } => responses.add(FrontendMessage::TriggerBrowse {
|
IngestMessage::Browse { filters, multiple, action } => responses.add(FrontendMessage::TriggerBrowse {
|
||||||
options: FileDialogOptions {
|
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]
|
#[test]
|
||||||
fn truncated_image_only_shows_a_dialog() {
|
fn truncated_image_only_shows_a_dialog() {
|
||||||
let png = Image::new(8, 8, Color::WHITE).to_png();
|
let png = Image::new(8, 8, Color::WHITE).to_png();
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum DataType {
|
pub enum DataType {
|
||||||
GraphiteLegacy,
|
GraphiteLegacy,
|
||||||
|
|||||||
@@ -165,6 +165,9 @@
|
|||||||
hoverInEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
hoverInEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||||
hoverOutEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
hoverOutEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||||
selectedIndex: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true),
|
selectedIndex: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true),
|
||||||
|
fileDrop: async (e: CustomEvent<File>) => {
|
||||||
|
if (props.fileDropAction) editor.ingestPicked(e.detail.name, e.detail.type, await e.detail.bytes(), props.fileDropAction);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||||
import TextLabel from "/src/components/widgets/labels/TextLabel.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 = {
|
const DASH_ENTRY: MenuListEntry = {
|
||||||
value: "",
|
value: "",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
tooltipShortcut: undefined,
|
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;
|
let self: LayoutRow | undefined;
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
// Behavior
|
// Behavior
|
||||||
export let virtualScrolling = false;
|
export let virtualScrolling = false;
|
||||||
export let interactive = true;
|
export let interactive = true;
|
||||||
|
export let fileDropAction: IngestAction | undefined = undefined;
|
||||||
// Sizing
|
// Sizing
|
||||||
export let minWidth = 0;
|
export let minWidth = 0;
|
||||||
export let maxWidth = 0;
|
export let maxWidth = 0;
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
let activeEntrySkipWatcher = false;
|
let activeEntrySkipWatcher = false;
|
||||||
let initialSelectedIndex: number | undefined = undefined;
|
let initialSelectedIndex: number | undefined = undefined;
|
||||||
let open = false;
|
let open = false;
|
||||||
|
let fileDragOver = false;
|
||||||
|
|
||||||
$: watchSelectedIndex(selectedIndex);
|
$: watchSelectedIndex(selectedIndex);
|
||||||
$: watchEntries(entries);
|
$: watchEntries(entries);
|
||||||
@@ -113,15 +115,49 @@
|
|||||||
const blurTarget = (e.target instanceof Element ? e.target.closest("[data-dropdown-input]") : undefined) || undefined;
|
const blurTarget = (e.target instanceof Element ? e.target.closest("[data-dropdown-input]") : undefined) || undefined;
|
||||||
if (blurTarget !== self?.div?.()) open = false;
|
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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LayoutRow
|
<LayoutRow
|
||||||
class="dropdown-input"
|
class="dropdown-input"
|
||||||
classes={{ narrow }}
|
classes={{ narrow, "file-drag-over": fileDragOver }}
|
||||||
styles={{
|
styles={{
|
||||||
...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}),
|
...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}),
|
||||||
...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}),
|
...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}),
|
||||||
}}
|
}}
|
||||||
|
on:dragover={fileDragOverWidget}
|
||||||
|
on:dragleave={fileDragLeaveWidget}
|
||||||
|
on:drop={fileDropOnWidget}
|
||||||
bind:this={self}
|
bind:this={self}
|
||||||
data-dropdown-input
|
data-dropdown-input
|
||||||
>
|
>
|
||||||
@@ -169,6 +205,15 @@
|
|||||||
--widget-height: 20px;
|
--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 {
|
.dropdown-box {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ mod editor_commands {
|
|||||||
ClipboardMessage::ReadSelection { content, cut }.into()
|
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<u8>, action: IngestAction) -> Message {
|
fn ingest_picked(name: String, mime_type: String, data: Vec<u8>, action: IngestAction) -> Message {
|
||||||
IngestMessage::Ingest {
|
IngestMessage::Ingest {
|
||||||
data,
|
data,
|
||||||
|
|||||||
Reference in New Issue
Block a user