mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-26 03:58:11 +08:00
Make the dropdown widget's file drop a callback and keep stray file drops from navigating the browser away (#4553)
* Make the dropdown widget's file drop a callback and keep stray file drops from navigating the browser away * Look up the dropped file's widget before reading the file
This commit is contained in:
@@ -30,4 +30,9 @@ pub enum LayoutMessage {
|
|||||||
layout_target: LayoutTarget,
|
layout_target: LayoutTarget,
|
||||||
widget_id: WidgetId,
|
widget_id: WidgetId,
|
||||||
},
|
},
|
||||||
|
WidgetValueFileDrop {
|
||||||
|
layout_target: LayoutTarget,
|
||||||
|
widget_id: WidgetId,
|
||||||
|
file: DroppedFile,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,21 @@ impl MessageHandler<LayoutMessage, LayoutMessageContext<'_>> for LayoutMessageHa
|
|||||||
responses.add((icon_button.on_drag_drop.callback)(icon_button));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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;
|
||||||
@@ -84,9 +83,10 @@ 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
|
// Set along with the `on_file_drop` callback
|
||||||
#[serde(rename = "fileDropAction")]
|
#[serde(rename = "takesFileDrop")]
|
||||||
pub file_drop_action: Option<IngestAction>,
|
#[widget_builder(skip)]
|
||||||
|
pub takes_file_drop: bool,
|
||||||
|
|
||||||
// Sizing
|
// Sizing
|
||||||
#[serde(rename = "minWidth")]
|
#[serde(rename = "minWidth")]
|
||||||
@@ -101,8 +101,28 @@ pub struct DropdownInput {
|
|||||||
pub tooltip_description: String,
|
pub tooltip_description: String,
|
||||||
#[serde(rename = "tooltipShortcut")]
|
#[serde(rename = "tooltipShortcut")]
|
||||||
pub tooltip_shortcut: Option<ActionShortcut>,
|
pub tooltip_shortcut: Option<ActionShortcut>,
|
||||||
//
|
|
||||||
// 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<DroppedFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type MenuListEntrySections = Vec<Vec<MenuListEntry>>;
|
pub type MenuListEntrySections = Vec<Vec<MenuListEntry>>;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ use graphene_std::vector::style::{
|
|||||||
};
|
};
|
||||||
use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification};
|
use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification};
|
||||||
use graphene_std::{NodeParameter, ParameterRef};
|
use graphene_std::{NodeParameter, ParameterRef};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> {
|
pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> {
|
||||||
let widget = TextLabel::new(text).widget_instance();
|
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(),
|
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||||
DropdownInput::new(vec![vec![none, browse], file_entries])
|
DropdownInput::new(vec![vec![none, browse], file_entries])
|
||||||
.selected_index(selected_index)
|
.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(),
|
.widget_instance(),
|
||||||
]);
|
]);
|
||||||
widgets
|
widgets
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ mod tests {
|
|||||||
let png = Image::new(8, 8, Color::WHITE).to_png();
|
let png = Image::new(8, 8, Color::WHITE).to_png();
|
||||||
let refusal = |data: &[u8]| {
|
let refusal = |data: &[u8]| {
|
||||||
let responses = ingest(data, resource_input(TypeFilter::raster().types), true);
|
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] {
|
match &responses[0] {
|
||||||
Message::Dialog(DialogMessage::DisplayDialogError { description, .. }) => description.clone(),
|
Message::Dialog(DialogMessage::DisplayDialogError { description, .. }) => description.clone(),
|
||||||
_ => panic!("the user should be told why"),
|
_ => panic!("the user should be told why"),
|
||||||
|
|||||||
@@ -70,6 +70,15 @@
|
|||||||
editor.widgetValueDragDrop(layoutTarget, widgets[widgetIndex].widgetId);
|
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.
|
// 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.
|
// The overload declares the precise correlated return type while the implementation uses broader types.
|
||||||
function unwrapWidget(widgetInstance: WidgetInstance): UnwrappedWidget | undefined;
|
function unwrapWidget(widgetInstance: WidgetInstance): UnwrappedWidget | undefined;
|
||||||
@@ -165,9 +174,7 @@
|
|||||||
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>) => {
|
fileDrop: (e: CustomEvent<File>) => widgetValueFileDrop(index, e.detail),
|
||||||
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, IngestAction } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { MenuListEntry, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
const DASH_ENTRY: MenuListEntry = {
|
const DASH_ENTRY: MenuListEntry = {
|
||||||
value: "",
|
value: "",
|
||||||
@@ -35,7 +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;
|
export let takesFileDrop = false;
|
||||||
// Sizing
|
// Sizing
|
||||||
export let minWidth = 0;
|
export let minWidth = 0;
|
||||||
export let maxWidth = 0;
|
export let maxWidth = 0;
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function takesDraggedFile(e: DragEvent): boolean {
|
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) {
|
function fileDragOverWidget(e: DragEvent) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
onModifyInputField,
|
onModifyInputField,
|
||||||
onFocusOut,
|
onFocusOut,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
onPaste,
|
onPaste,
|
||||||
onPointerLockChange,
|
onPointerLockChange,
|
||||||
updateDirectInput,
|
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: "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: "modifyinputfield", action: (e: CustomEvent) => editorWrapper && onModifyInputField(e, editorWrapper) },
|
||||||
{ target: window, eventName: "focusout", action: () => onFocusOut() },
|
{ 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: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) },
|
||||||
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() },
|
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() },
|
||||||
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorWrapper && onPaste(e, editorWrapper) },
|
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorWrapper && onPaste(e, editorWrapper) },
|
||||||
|
|||||||
@@ -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() {
|
export function onFocusOut() {
|
||||||
canvasFocused = false;
|
canvasFocused = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ mod editor_commands {
|
|||||||
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
|
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
|
||||||
use editor::messages::portfolio::utility_types::PanelGroupId;
|
use editor::messages::portfolio::utility_types::PanelGroupId;
|
||||||
use editor::messages::prelude::*;
|
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 graph_craft::document::NodeId;
|
||||||
use graphene_std::raster::color::Color;
|
use graphene_std::raster::color::Color;
|
||||||
use graphene_std::vector::style::FillChoice;
|
use graphene_std::vector::style::FillChoice;
|
||||||
@@ -176,6 +176,13 @@ mod editor_commands {
|
|||||||
LayoutMessage::WidgetValueDragDrop { layout_target, widget_id }.into()
|
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<u8>) -> 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
|
/// 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 {
|
fn end_transaction() -> Message {
|
||||||
DocumentMessage::EndTransaction.into()
|
DocumentMessage::EndTransaction.into()
|
||||||
@@ -597,7 +604,7 @@ mod editor_commands {
|
|||||||
ClipboardMessage::ReadSelection { content, cut }.into()
|
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<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