diff --git a/.gitignore b/.gitignore index 0ab454d67..b3c1f8452 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ /wpt/*.json /apps/wpt/output /sites -.blitz-cache \ No newline at end of file +.blitz-cache +/.zed diff --git a/Cargo.lock b/Cargo.lock index fed7872c4..ba7e429b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1055,11 +1055,13 @@ dependencies = [ "blitz-dom", "blitz-paint", "blitz-traits", + "bytes", "data-url", "futures-util", "keyboard-types 0.7.0", "rfd", "tracing", + "url", "wasm-bindgen", "web-sys", "web-time", @@ -1110,6 +1112,7 @@ dependencies = [ "bitflags 2.13.1", "bytes", "cursor-icon", + "futures-core", "http", "keyboard-types 0.7.0", "serde", diff --git a/Cargo.toml b/Cargo.toml index 45bddd3e4..d5e3ad526 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,7 +181,8 @@ bytes = "1.7.1" slotmap = "1.0.7" tracing = "0.1.40" tracing-subscriber = "0.3" -futures-util = "0.3.30" +futures-core = "0.3.32" +futures-util = "0.3.32" futures-intrusive = "0.5.0" pollster = "0.4" smol_str = "0.3" diff --git a/examples/dnd.rs b/examples/dnd.rs new file mode 100644 index 000000000..bdfddc902 --- /dev/null +++ b/examples/dnd.rs @@ -0,0 +1,133 @@ +use dioxus_native::prelude::*; + +fn main() { + dioxus_native::launch(app); +} + +fn app() -> Element { + let mut drag_active = use_signal(|| false); + let mut files = use_store(Vec::new); + let mut texts = use_store(Vec::new); + + rsx! { + div { + width: "200px", + height: "50px", + border: "1px solid black", + draggable: "true", + ondragstart: move |e| { + println!("dragstart"); + if let Err(err) = e.data_transfer().set_data("text/plain", "Some text") { + dbg!(err); + } + }, + ondrag: move |_| { + //println!("drag"); + }, + ondragend: move |_| { + println!("dragend"); + }, + "Drag me!" + } + div { + width: "400px", + height: "400px", + border: if drag_active() { "2px solid black" } else { + "2px solid gray" + }, + ondragenter: move |_| { + drag_active.set(true); + println!("enter"); + }, + ondragover: move |evt| { + evt.prevent_default(); + //println!("over"); + }, + ondragleave: move |_| { + drag_active.set(false); + println!("leave"); + }, + ondrop: move |evt| { + evt.prevent_default(); + drag_active.set(false); + println!("drop"); + for file in evt.data_transfer().files() { + files.push( file.name()); + } + if let Some(text) = evt.data_transfer().get_as_text() { + texts.push(text); + } + if let Some(text) = evt.data_transfer().get_data("text/html") { + texts.push(text); + } + if let Some(text) = evt.data_transfer().get_data("text/rtf") { + texts.push(text); + } + }, + "Files" + ul { + for name in files.iter() { + li {{name}} + } + } + "Text" + ul { + for text in texts.iter() { + li {{text}} + } + } + DragArea{ + width: "200px", + height: "200px", + padding: "4px", + color: "green", + DragArea{ + width: "100px", + height: "100px", + color: "blue" + } + DragArea{ + width: "100px", + height: "100px", + color: "red" + } + } + } + } +} + +#[component] +fn DragArea( + color: String, + #[props(extends=GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + let mut drag_active = use_signal(|| false); + let color = use_signal(|| color); + + rsx!(div { + border: if drag_active() { + "2px solid black" + } else { "2px solid {color}"}, + ondragenter: move |evt| { + evt.stop_propagation(); + drag_active.set(true); + println!("enter {}", color); + }, + ondragover: move |evt| { + evt.prevent_default(); + //println!("over {}", color); + }, + ondragleave: move |evt| { + evt.stop_propagation(); + drag_active.set(false); + println!("leave {}", color); + }, + ondrop: move |_| { + drag_active.set(false); + println!("drop {}", color); + }, + ..attributes, + {children} + }) +} diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 313cb9c76..ac852d24e 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -2458,6 +2458,22 @@ impl BaseDocument { Some((inline_root.id, byte_offset)) } + /// Checks if a point has landed on selection + pub fn is_text_selected_at_position(&self, x: f32, y: f32) -> bool { + if let Some((node, offset)) = self.find_text_position(x, y) { + self.is_selected_text(node, offset) + } else { + false + } + } + + /// Checks if found node and offset are in selection + pub fn is_selected_text(&self, node: NodeId, offset: usize) -> bool { + let (parent, idx) = self.anonymous_block_location(node); + let parent = parent.unwrap_or(node); + self.text_selection.is_selected(parent, idx, offset) + } + /// Set the text selection range (creates a new selection from anchor to focus) pub fn set_text_selection( &mut self, diff --git a/packages/blitz-dom/src/events/dnd.rs b/packages/blitz-dom/src/events/dnd.rs new file mode 100644 index 000000000..1f4196447 --- /dev/null +++ b/packages/blitz-dom/src/events/dnd.rs @@ -0,0 +1,60 @@ +use std::any::Any; + +use blitz_traits::events::BlitzDataTransferItemsTrait; + +#[derive(Debug, Clone)] +pub struct BlitzInternalDataTransferEntry { + pub format: String, + pub data: String, +} + +#[derive(Debug, Default)] +pub struct BlitzInternalDataTransferItems { + entries: Vec, + // todo when dragging images file is set + _files: Vec, +} + +impl BlitzDataTransferItemsTrait for BlitzInternalDataTransferItems { + fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + fn get_data(&self, format: &str) -> Option { + self.entries + .iter() + .find(|entry| entry.format == format) + .map(|entry| entry.data.clone()) + } + + fn set_data(&mut self, format: &str, data: &str) -> Result<(), String> { + if let Some(entry) = self.entries.iter_mut().find(|e| e.format == format) { + entry.data = data.to_string(); + } else { + self.entries.push(BlitzInternalDataTransferEntry { + format: format.to_string(), + data: data.to_string(), + }); + } + Ok(()) + } + + fn clear_all(&mut self) { + self.entries.clear(); + } + + fn clear_format(&mut self, format: &str) { + self.entries.retain(|entry| entry.format != format); + } + + fn types(&self) -> Vec { + self.entries + .iter() + .map(|entry| entry.format.clone()) + .collect() + } + + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} diff --git a/packages/blitz-dom/src/events/driver.rs b/packages/blitz-dom/src/events/driver.rs index 34bb985a6..3c6fccf6d 100644 --- a/packages/blitz-dom/src/events/driver.rs +++ b/packages/blitz-dom/src/events/driver.rs @@ -1,7 +1,7 @@ use crate::Document; use blitz_traits::events::{ - BlitzPointerEvent, BlitzPointerId, DomEvent, DomEventData, EventState, Point, PointerCoords, - UiEvent, + BlitzDragEvent, BlitzPointerEvent, BlitzPointerId, DomEvent, DomEventData, EventState, Point, + PointerCoords, UiEvent, }; use blitz_traits::node_id::NodeId; use std::collections::VecDeque; @@ -145,6 +145,37 @@ impl<'doc, Handler: EventHandler> EventDriver<'doc, Handler> { hover_node_id } + pub fn handle_drag_move(&mut self, event: &BlitzDragEvent) -> Option { + let mut doc = self.doc.inner_mut(); + + let prev_hover_node_id = doc.hover_node_id; + let changed = doc.set_hover_to(event.page_x(), event.page_y()); + let hover_node_id = doc.hover_node_id; + + drop(doc); + + if !changed { + return prev_hover_node_id; + } + + // DragLeave, DragEnter already bubbles + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#dragging_over_elements_and_specifying_drop_targets + if let Some(target) = hover_node_id { + self.handle_dom_event(DomEvent::new( + target, + DomEventData::DragEnter(event.clone()), + )); + } + if let Some(target) = prev_hover_node_id { + self.handle_dom_event(DomEvent::new( + target, + DomEventData::DragLeave(event.clone()), + )); + } + + hover_node_id + } + pub fn handle_ui_event(&mut self, event: UiEvent) { let doc = self.doc.inner(); @@ -182,6 +213,15 @@ impl<'doc, Handler: EventHandler> EventDriver<'doc, Handler> { should_clear_hover = true; } } + UiEvent::DragEnter(event) => { + let mut doc = self.doc.inner_mut(); + doc.set_hover_to(event.page_x(), event.page_y()); + hover_node_id = doc.hover_node_id; + drop(doc); + } + UiEvent::DragOver(event) => { + hover_node_id = self.handle_drag_move(event); + } _ => {} }; @@ -195,6 +235,12 @@ impl<'doc, Handler: EventHandler> EventDriver<'doc, Handler> { UiEvent::KeyDown(_) => focussed_node_id, UiEvent::Ime(_) => focussed_node_id, UiEvent::AppleStandardKeybinding(_) => focussed_node_id, + UiEvent::DragEnter(_) => hover_node_id, + UiEvent::DragOver(_) => hover_node_id, + UiEvent::DragLeave(_) => hover_node_id, + UiEvent::Drop(_) => hover_node_id, + // uses event source node as target + UiEvent::OutgoingDrop(_) | UiEvent::OutgoingCancel(_) | UiEvent::DragStart(_) => None, }; // Fall back to the root element. A document without a root element (e.g. an // empty iframe sub-document) has no event target, so there is nothing to do. @@ -260,6 +306,42 @@ impl<'doc, Handler: EventHandler> EventDriver<'doc, Handler> { self.run_default_action(&mut dom_event); self.process_queue(); } + UiEvent::DragEnter(data) => { + self.handle_dom_event(DomEvent::new(target, DomEventData::DragEnter(data))) + } + UiEvent::DragOver(data) => { + if let Some(source_id) = data.source.get_node_id() { + self.handle_dom_event(DomEvent::new( + source_id, + DomEventData::Drag(data.clone()), + )); + } + self.handle_dom_event(DomEvent::new(target, DomEventData::DragOver(data))) + } + UiEvent::DragLeave(data) => { + self.handle_dom_event(DomEvent::new(target, DomEventData::DragLeave(data))) + } + UiEvent::Drop(data) => { + if let Some(source_id) = data.source.get_node_id() { + self.handle_dom_event(DomEvent::new(target, DomEventData::Drop(data.clone()))); + // dragend always fires last on the source + self.handle_dom_event(DomEvent::new(source_id, DomEventData::DragEnd(data))); + } else { + self.handle_dom_event(DomEvent::new(target, DomEventData::Drop(data))); + } + } + // html doesn't differenciate between outgoing cancel and outgoing drop, it sends drag end in both cases + UiEvent::OutgoingDrop(data) | UiEvent::OutgoingCancel(data) => { + if let Some(source_id) = data.source.get_node_id() { + self.handle_dom_event(DomEvent::new(source_id, DomEventData::DragEnd(data))); + } + } + UiEvent::DragStart(data) => { + self.doc.inner_mut().set_mousedown_node_id(None); + if let Some(source_id) = data.source.get_node_id() { + self.handle_dom_event(DomEvent::new(source_id, DomEventData::DragStart(data))); + } + } }; // Update document input state (hover, focus, active, etc) @@ -360,8 +442,8 @@ impl<'doc, Handler: EventHandler> EventDriver<'doc, Handler> { | DomEventData::MouseOver(data) | DomEventData::MouseOut(data) | DomEventData::TouchStart(data) - | DomEventData::TouchEnd(data) | DomEventData::TouchMove(data) + | DomEventData::TouchEnd(data) | DomEventData::TouchCancel(data) | DomEventData::Click(data) | DomEventData::ContextMenu(data) @@ -371,6 +453,18 @@ impl<'doc, Handler: EventHandler> EventDriver<'doc, Handler> { DomEventData::Wheel(data) => { self.adjust_element_coords(event.target, &data.coords, &mut data.element) } + DomEventData::DragStart(data) + | DomEventData::Drag(data) + | DomEventData::DragEnd(data) + | DomEventData::DragEnter(data) + | DomEventData::DragOver(data) + | DomEventData::DragLeave(data) => { + self.adjust_element_coords(event.target, &data.coords, &mut data.element) + } + DomEventData::Drop(data) => { + data.data_transfer_mut().readable(); + self.adjust_element_coords(event.target, &data.coords, &mut data.element) + } _ => {} } diff --git a/packages/blitz-dom/src/events/mod.rs b/packages/blitz-dom/src/events/mod.rs index d73c45e2b..a68473501 100644 --- a/packages/blitz-dom/src/events/mod.rs +++ b/packages/blitz-dom/src/events/mod.rs @@ -1,3 +1,4 @@ +mod dnd; mod driver; mod focus; mod ime; @@ -90,6 +91,26 @@ fn map_dom_event_to_ui_event( DomEventData::Blur(_) => None, DomEventData::FocusIn(_) => None, DomEventData::FocusOut(_) => None, + + // Thse events can only trigger on a specific node in document where a drag started + // so they don't need to be adjusted? + DomEventData::DragStart(_) => None, + DomEventData::Drag(_) => None, + DomEventData::DragEnd(_) => None, + + // Enter/leave events will be recreated by sub-document's event driver + // based dragover events + DomEventData::DragEnter(_) => None, + DomEventData::DragLeave(_) => None, + + DomEventData::DragOver(mut event) => { + adjust_coords_for_subdocument(&mut event.coords, node_offset, viewport_scroll); + Some(UiEvent::DragOver(event)) + } + DomEventData::Drop(mut event) => { + adjust_coords_for_subdocument(&mut event.coords, node_offset, viewport_scroll); + Some(UiEvent::Drop(event)) + } } } @@ -296,6 +317,31 @@ pub(crate) fn handle_dom_event( DomEventData::FocusOut(_) => { // Do nothing (no default action) } + DomEventData::DragStart(_) => { + // Do nothing (no default action) + } + DomEventData::Drag(_) => { + // Do nothing (no default action) + } + DomEventData::DragEnd(_) => { + // Do nothing (no default action) + } + DomEventData::DragEnter(_) => { + // Do nothing (no default action) + } + DomEventData::DragOver(_event) => { + + // todo set drop effect based on node type or if it has drop zone + // this needs to be set here and not in dragenter as keyboard modifiers can affect the effect + // use blitz_traits::events::BlitzDragOperation; + // event.data_transfer_mut().drop_effect = BlitzDragOperation::Copy; + } + DomEventData::DragLeave(_) => { + // Do nothing (no default action) + } + DomEventData::Drop(_) => { + // Do nothing (no default action) + } } // Keep the focused text input scrolled so that its caret stays visible. Keyboard/IME events diff --git a/packages/blitz-dom/src/events/pointer.rs b/packages/blitz-dom/src/events/pointer.rs index 4eca26658..85f5e0f1a 100644 --- a/packages/blitz-dom/src/events/pointer.rs +++ b/packages/blitz-dom/src/events/pointer.rs @@ -5,8 +5,9 @@ use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use blitz_traits::{ events::{ - BlitzInputEvent, BlitzPointerEvent, BlitzPointerId, BlitzWheelDelta, BlitzWheelEvent, - DomEvent, DomEventData, MouseEventButton, MouseEventButtons, + BlitzDataTransferItemsTrait, BlitzInputEvent, BlitzPointerEvent, BlitzPointerId, + BlitzWheelDelta, BlitzWheelEvent, DomEvent, DomEventData, MouseEventButton, + MouseEventButtons, }, navigation::NavigationOptions, }; @@ -18,6 +19,7 @@ use taffy::AbsoluteAxis; use crate::{ BaseDocument, + events::dnd::BlitzInternalDataTransferItems, node::{ScrollbarRef, SpecialElementData}, scrolling::{FlingState, ScrollAnimationState}, }; @@ -224,7 +226,37 @@ pub(crate) fn handle_pointermove( BlitzPointerId::Mouse | BlitzPointerId::Pen => { if let Some(mousedown_node_id) = doc.mousedown_node_id { let node = &doc.nodes[mousedown_node_id]; - if let Some(style) = node.primary_styles() { + // todo: nearest ancestor with draggable=true + let draggable = node + .data + .attr(local_name!("draggable")) + .map(|v| v == "true") + .unwrap_or_default(); + + // todo: images, links, inputs, textbox, text formatted in html + let auto_text_drag = !draggable + && doc.is_text_selected_at_position( + doc.mousedown_position.x, + doc.mousedown_position.y, + ); + + if draggable || auto_text_drag { + let mut items = BlitzInternalDataTransferItems::default(); + + if auto_text_drag && let Some(text) = doc.get_selected_text() { + let _ = items.set_data("text/plain", &text); + } + + doc.drag_mode = DragMode::None; + doc.shell_provider.set_datatransfer( + Box::new(items), + None, + Default::default(), + Some(mousedown_node_id), + ); + + return changed; + } else if let Some(style) = node.primary_styles() { let user_select = style.clone_user_select(); if user_select == UserSelect::None { // Do nothing. Continue with rest of function @@ -500,8 +532,15 @@ pub(crate) fn handle_pointerdown( ClickTarget::SelectableText => { // Handle text selection for non-input elements if let Some((inline_root_id, byte_offset)) = doc.find_text_position(x, y) { - doc.set_text_selection(inline_root_id, byte_offset, inline_root_id, byte_offset); - doc.shell_provider.request_redraw(); + if !doc.is_selected_text(inline_root_id, byte_offset) { + doc.set_text_selection( + inline_root_id, + byte_offset, + inline_root_id, + byte_offset, + ); + doc.shell_provider.request_redraw(); + } } else { doc.clear_text_selection(); } @@ -599,6 +638,7 @@ pub(crate) fn handle_pointerup( // Dispatch a click event if do_click && event.button == MouseEventButton::Main { + doc.clear_text_selection(); dispatch_event(DomEvent::new(target, DomEventData::Click(event.clone()))); } diff --git a/packages/blitz-dom/src/selection.rs b/packages/blitz-dom/src/selection.rs index 91fa272b7..f04220704 100644 --- a/packages/blitz-dom/src/selection.rs +++ b/packages/blitz-dom/src/selection.rs @@ -119,4 +119,30 @@ impl TextSelection { pub fn set_focus(&mut self, node: NodeId, offset: usize) { self.focus.set_node(node, offset); } + + pub fn is_selected(&self, parent: NodeId, sibling_index: Option, offset: usize) -> bool { + if !self.is_active() { + return false; + } + + let anchor_key = ( + self.anchor.node_or_parent, + self.anchor.sibling_index, + self.anchor.offset, + ); + let focus_key = ( + self.focus.node_or_parent, + self.focus.sibling_index, + self.focus.offset, + ); + let this_key = (Some(parent), sibling_index, offset); + + let (start, end) = if anchor_key <= focus_key { + (anchor_key, focus_key) + } else { + (focus_key, anchor_key) + }; + + start <= this_key && this_key <= end + } } diff --git a/packages/blitz-shell/Cargo.toml b/packages/blitz-shell/Cargo.toml index 07cdca74c..bac29882a 100644 --- a/packages/blitz-shell/Cargo.toml +++ b/packages/blitz-shell/Cargo.toml @@ -42,6 +42,8 @@ atomic_refcell = { workspace = true } tracing = { workspace = true, optional = true } futures-util = { workspace = true } data-url = { workspace = true, optional = true } +url = { workspace = true } +bytes = { workspace = true } # WASM web-time = { workspace = true } diff --git a/packages/blitz-shell/src/application.rs b/packages/blitz-shell/src/application.rs index 293dad2e3..013571f6d 100644 --- a/packages/blitz-shell/src/application.rs +++ b/packages/blitz-shell/src/application.rs @@ -105,6 +105,18 @@ impl BlitzApplication { window.apply_pending_resize_if_settled(); } } + BlitzShellEvent::StartDataTransfer { + window_id, + data, + node, + } => { + if let Some(view) = self.windows.get_mut(&window_id) + && let Err(err) = view.start_internal_drag(data, node, event_loop) + { + #[cfg(feature = "tracing")] + tracing::error!("{}", err); + } + } } } } @@ -160,7 +172,7 @@ impl ApplicationHandler for BlitzApplication { } if let Some(window) = self.windows.get_mut(&window_id) { - window.handle_winit_event(event); + window.handle_winit_event(event, event_loop); } self.proxy.send_event(BlitzShellEvent::Poll { window_id }); } diff --git a/packages/blitz-shell/src/dnd.rs b/packages/blitz-shell/src/dnd.rs new file mode 100644 index 000000000..44bbbe1d6 --- /dev/null +++ b/packages/blitz-shell/src/dnd.rs @@ -0,0 +1,388 @@ +use std::any::Any; +use std::collections::HashSet; +use std::ops::ControlFlow; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use blitz_traits::events::{ + BlitzDataTransferArc, BlitzDataTransferItemsTrait, BlitzDragOperation, BlitzDragOperations, + BlitzFileError, BlitzFileTrait, BlitzFileType, +}; + +use bytes::Bytes; +use futures_util::Stream; +use winit::data_transfer::{ + DataTransfer, DataTransferSend, SendData, TransferType, TypeHint, TypedData, +}; + +use winit::event_loop::DndAction; + +pub(crate) fn blitz_drag_operation_to_winit_dnd_action( + data: &BlitzDragOperation, +) -> Option { + match data { + BlitzDragOperation::None => None, + BlitzDragOperation::Copy => Some(DndAction::Copy), + BlitzDragOperation::Move => Some(DndAction::Move), + BlitzDragOperation::Link => Some(DndAction::Link), + } +} + +pub(crate) fn blitz_drag_operations_to_winit_dnd_action( + data: &Option, +) -> Vec { + match data { + Some(ops) => { + let mut actions = Vec::new(); + if ops.contains(BlitzDragOperations::COPY) { + actions.push(DndAction::Copy); + } + if ops.contains(BlitzDragOperations::MOVE) { + actions.push(DndAction::Move); + } + if ops.contains(BlitzDragOperations::LINK) { + actions.push(DndAction::Link); + } + actions + } + + None => vec![ + DndAction::Move, + DndAction::Copy, + DndAction::Link, + DndAction::Ask, + DndAction::Private, + ], + } +} + +pub(crate) fn winit_dnd_action_to_blitz_dnd_operation( + action: Option, +) -> BlitzDragOperation { + match action { + Some(DndAction::Move) => BlitzDragOperation::Move, + Some(DndAction::Copy) => BlitzDragOperation::Copy, + Some(DndAction::Link) => BlitzDragOperation::Link, + Some(DndAction::Ask) => BlitzDragOperation::None, + Some(DndAction::Private) => BlitzDragOperation::None, + Some(action) => { + #[cfg(feature = "tracing")] + tracing::error!("unsupported DndAction variant: {action:?}"); + BlitzDragOperation::None + } + None => BlitzDragOperation::None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct BlitzTransferType(String); + +impl TransferType for BlitzTransferType { + fn hint(&self) -> Option { + match self.0.as_str() { + "text/plain" => Some(TypeHint::Plaintext), + "text/uri-list" => Some(TypeHint::UriList), + "text/html" => Some(TypeHint::Html), + "text/rtf" | "application/rtf" => Some(TypeHint::Rtf), + ct if ct.starts_with("audio/") => Some(TypeHint::Audio { + extension_hint: audio_mime_to_static_ext(&ct[6..]), + }), + ct if ct.starts_with("image/") => Some(TypeHint::Image { + extension_hint: image_mime_to_static_ext(&ct[6..]), + }), + _ => None, + } + } + + fn matches(&self, other: &dyn TransferType) -> bool { + other.cast_ref::().is_some_and(|o| o.0 == self.0) + } +} + +fn audio_mime_to_static_ext(ext: &str) -> Option<&'static str> { + if ext.is_empty() { + return None; + } + + let normalized = match ext { + "mpeg" => "mp3", + "x-flac" => "flac", + "x-wav" | "wave" | "vnd.wave" => "wav", + "x-aiff" => "aif", + "x-m4a" => "m4a", + "3gpp" => "3ga", + "vnd.dlna.adts" => "aac", + "basic" => "au", + "x-midi" => "mid", + "vorbis" => "ogg", + other => other, + }; + + Some(Box::leak(normalized.to_string().into_boxed_str())) +} + +fn image_mime_to_static_ext(ext: &str) -> Option<&'static str> { + if ext.is_empty() { + return None; + } + + let normalized = match ext { + "jpeg" => "jpg", + "svg+xml" => "svg", + "tiff" => "tif", + "heif" => "heic", + "x-portable-bitmap" => "pbm", + "x-portable-graymap" => "pgm", + "x-portable-pixmap" => "ppm", + "x-portable-anymap" => "pnm", + other => other, + }; + + Some(Box::leak(normalized.to_string().into_boxed_str())) +} + +#[derive(Debug)] +pub(crate) struct WinitDataTransfer { + data: BlitzDataTransferArc, + types: Vec, +} + +impl WinitDataTransfer { + pub fn new(data: BlitzDataTransferArc) -> Self { + let types = data + .borrow() + .items + .types() + .into_iter() + .map(BlitzTransferType) + .collect(); + + Self { data, types } + } +} + +impl DataTransfer for WinitDataTransfer { + fn for_each_available_type<'this>( + &'this self, + func: &'_ mut dyn FnMut(&'this dyn TransferType) -> ControlFlow<()>, + ) { + for hint in &self.types { + if let ControlFlow::Break(()) = func(hint) { + break; + } + } + } +} + +impl DataTransferSend for WinitDataTransfer { + fn data_for_type(&self, type_: &dyn TransferType) -> Option { + let format = type_ + .hint() + .map(|hint| winit_hint_to_mimetype(&hint)) + .or_else(|| type_.cast_ref::().map(|t| t.0.clone()))?; + let transfer = self.data.borrow(); + let data = transfer.get_data_unchecked(&format)?; + + if format == "text/uri-list" { + let uris = data + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(String::from) + .collect(); + Some(SendData::Uris(uris)) + } else { + Some(SendData::String(data)) + } + } +} + +#[derive(Debug)] +pub struct WinitDataTransferItems { + items: Vec>, +} + +impl WinitDataTransferItems { + pub(crate) fn new(items: Vec>) -> Self { + Self { items } + } + pub(crate) fn push(&mut self, item: Arc) { + self.items.push(item); + } + pub fn items(&self) -> &Vec> { + &self.items + } +} + +impl BlitzDataTransferItemsTrait for WinitDataTransferItems { + fn is_empty(&self) -> bool { + self.items.is_empty() + } + + fn files(&self) -> Box + '_> { + Box::new( + self.items + .iter() + .flat_map(|item| { + let hint = item.type_().hint(); + item.try_as_file_paths() + .into_iter() + .flatten() + .map(move |p| (p, hint)) + }) + .scan(HashSet::new(), |seen, (p, hint)| { + Some(seen.insert(p.clone()).then_some((p, hint))) + }) + .flatten() + .map(|(p, hint)| Arc::new(WinitBlitzFile::new(p, hint)) as BlitzFileType), + ) + } + + fn get_data(&self, format: &str) -> Option { + self.items.iter().find_map(|item| { + let hint = item.type_().hint()?; + let mime = winit_hint_to_mimetype(&hint); + (mime == format).then(|| item.try_as_string().ok())? + }) + } + + fn types(&self) -> Vec { + let mut seen = std::collections::HashSet::new(); + self.items + .iter() + .filter_map(|item| { + item.type_() + .hint() + .map(|hint| winit_hint_to_mimetype(&hint)) + }) + .filter(|mime| seen.insert(mime.clone())) + .collect() + } + + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} + +#[derive(Debug)] +struct WinitBlitzFile { + path: PathBuf, + content_type: Option, +} + +fn winit_hint_to_mimetype(hint: &TypeHint) -> String { + match hint { + TypeHint::Plaintext => "text/plain".to_string(), + TypeHint::UriList => "text/uri-list".to_string(), + TypeHint::Html => "text/html".to_string(), + TypeHint::Rtf => "application/rtf".to_string(), + TypeHint::Audio { extension_hint } => extension_hint + .map(|ext| format!("audio/{ext}")) + .unwrap_or_else(|| "audio".to_string()), + TypeHint::Image { extension_hint } => extension_hint + .map(|ext| format!("image/{ext}")) + .unwrap_or_else(|| "image".to_string()), + _ => { + #[cfg(feature = "tracing")] + tracing::error!("unsupported TypeHint variant: {hint:?}"); + "unknown".to_string() + } + } +} +impl WinitBlitzFile { + fn new(path: PathBuf, hint: Option) -> Self { + Self { + path, + content_type: hint.as_ref().map(winit_hint_to_mimetype), + } + } +} + +impl BlitzFileTrait for WinitBlitzFile { + fn name(&self) -> String { + self.path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + } + + fn size(&self) -> u64 { + std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0) + } + + fn last_modified(&self) -> u64 { + std::fs::metadata(&self.path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) + } + + fn path(&self) -> PathBuf { + self.path.clone() + } + + fn content_type(&self) -> Option { + self.content_type.clone() + } + + fn read_bytes( + &self, + ) -> Pin> + Send + 'static>> { + let path = self.path.clone(); + Box::pin(async move { + std::fs::read(&path) + .map(Bytes::from) + .map_err(|e| Box::new(e) as BlitzFileError) + }) + } + + fn byte_stream( + &self, + ) -> Pin> + Send + 'static>> { + use futures_util::StreamExt; + use std::io::Read; + + let path = self.path.clone(); + + let stream = futures_util::stream::unfold(None::, move |file_opt| { + let path = path.clone(); + async move { + let mut file = match file_opt { + Some(f) => f, + None => match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => return Some((Err(Box::new(e) as BlitzFileError), None)), + }, + }; + + let mut buf = vec![0u8; 64 * 1024]; + match file.read(&mut buf) { + Ok(0) => None, + Ok(n) => { + buf.truncate(n); + Some((Ok(Bytes::from(buf)), Some(file))) + } + Err(e) => Some((Err(Box::new(e) as BlitzFileError), None)), + } + } + }); + + stream.boxed() + } + + fn read_string( + &self, + ) -> Pin> + Send + 'static>> { + let path = self.path.clone(); + Box::pin(async move { + std::fs::read_to_string(&path).map_err(|e| Box::new(e) as BlitzFileError) + }) + } + + fn inner(&self) -> &dyn std::any::Any { + self + } +} diff --git a/packages/blitz-shell/src/event.rs b/packages/blitz-shell/src/event.rs index 7df030d6e..c26754c4b 100644 --- a/packages/blitz-shell/src/event.rs +++ b/packages/blitz-shell/src/event.rs @@ -1,3 +1,5 @@ +use blitz_traits::NodeId; +use blitz_traits::events::BlitzDataTransferArc; use blitz_traits::navigation::NavigationOptions; use blitz_traits::net::NetWaker; use futures_util::task::ArcWake; @@ -52,6 +54,12 @@ pub enum BlitzShellEvent { is_md: bool, }, + StartDataTransfer { + window_id: WindowId, + data: BlitzDataTransferArc, + node: Option, + }, + /// Delivered after the WASM resize-debounce window expires. Route to /// `View::apply_pending_resize_if_settled`, which applies the pending /// size iff motion has actually settled. diff --git a/packages/blitz-shell/src/lib.rs b/packages/blitz-shell/src/lib.rs index a89e9cd94..a137c3742 100644 --- a/packages/blitz-shell/src/lib.rs +++ b/packages/blitz-shell/src/lib.rs @@ -10,10 +10,14 @@ mod application; mod convert_events; +mod dnd; mod event; mod net; mod window; +/// exported for downcasting so you can use platform specific event types +pub use dnd::WinitDataTransferItems; + #[cfg(feature = "accessibility")] mod accessibility; @@ -152,6 +156,30 @@ impl ShellProvider for BlitzShellProvider { let _ = self.window.drag_window(); } + fn set_datatransfer( + &self, + items: Box, + effect_allowed: Option, + drop_effect: blitz_traits::events::BlitzDragOperation, + node: Option, + ) { + use atomic_refcell::AtomicRefCell; + use blitz_traits::events::{BlitzDataTransfer, BlitzDragDataStoreMode}; + + let data = Arc::new(AtomicRefCell::new(BlitzDataTransfer { + items, + effect_allowed, + drop_effect, + mode: BlitzDragDataStoreMode::ReadWrite, + })); + + self.proxy.send_event(BlitzShellEvent::StartDataTransfer { + window_id: self.window.id(), + data, + node, + }); + } + #[cfg(all( feature = "clipboard", any( diff --git a/packages/blitz-shell/src/window.rs b/packages/blitz-shell/src/window.rs index 903be23f8..a40675483 100644 --- a/packages/blitz-shell/src/window.rs +++ b/packages/blitz-shell/src/window.rs @@ -4,25 +4,34 @@ use crate::convert_events::{ pointer_source_to_blitz_details, theme_to_color_scheme, winit_ime_to_blitz, winit_key_event_to_blitz, winit_modifiers_to_kbt_modifiers, }; +use crate::dnd::{ + WinitDataTransfer, WinitDataTransferItems, blitz_drag_operation_to_winit_dnd_action, + blitz_drag_operations_to_winit_dnd_action, winit_dnd_action_to_blitz_dnd_operation, +}; use crate::event::{BlitzShellEvent, BlitzShellProxy, create_waker}; use anyrender::WindowRenderer; -use blitz_dom::Document; +use blitz_dom::{Document, NodeId}; use blitz_paint::paint_scene; use blitz_traits::events::{ + BlitzDataTransfer, BlitzDataTransferArc, BlitzDragEvent, BlitzDragId, BlitzDragOperation, BlitzPointerEvent, BlitzPointerId, BlitzWheelDelta, BlitzWheelEvent, MouseEventButton, MouseEventButtons, PointerCoords, PointerDetails, UiEvent, }; use blitz_traits::shell::Viewport; +use winit::data_transfer::DataTransferId; use winit::dpi::{LogicalPosition, PhysicalInsets, PhysicalPosition}; +use winit::error::RequestError; use winit::keyboard::PhysicalKey; use atomic_refcell::AtomicRefCell; use std::any::Any; +use std::ops::ControlFlow; use std::sync::Arc; use std::task::Waker; +use std::time::Duration; use web_time::Instant; use winit::event::{ButtonSource, ElementState, MouseButton}; -use winit::event_loop::ActiveEventLoop; +use winit::event_loop::{ActiveEventLoop, AsyncRequestSerial, DndAction}; use winit::window::{Theme, WindowAttributes, WindowId}; use winit::{event::Modifiers, event::WindowEvent, keyboard::KeyCode, window::Window}; @@ -64,6 +73,7 @@ impl WindowConfig { } } +const INTERNAL_DROP_THRESHOLD: Duration = Duration::from_millis(50); pub struct View { pub doc: Box, @@ -98,6 +108,13 @@ pub struct View { pub is_visible: bool, pub safe_area_insets: PhysicalInsets, + /// As far as I can tell, multiple drags are not possible + pub active_drag: Arc>>, + /// used to retrive data from drag event, it might be better if it's changed to a vector? + pending_data_transfers: Option<(BlitzDragEvent, Vec)>, + /// used to track if internal drag was dropped inside our outside + internal_drag: Instant, + #[cfg(target_arch = "wasm32")] pending_resize: Option>, #[cfg(target_arch = "wasm32")] @@ -145,8 +162,8 @@ impl View { // until a layout pass fires. let requested_surface_size = config.attributes.surface_size; let attrs = config.attributes.with_visible(false); - let winit_window: Arc = Arc::from(event_loop.create_window(attrs).unwrap()); + #[cfg(feature = "accessibility")] let accessibility = AccessibilityState::new(&*winit_window, proxy.clone()); @@ -218,6 +235,9 @@ impl View { theme_override: None, buttons: MouseEventButtons::None, active_events: Arc::new(AtomicRefCell::new(Vec::new())), + active_drag: Arc::new(AtomicRefCell::new(None)), + pending_data_transfers: None, + internal_drag: Instant::now(), safe_area_insets, #[cfg(target_arch = "wasm32")] pending_resize: None, @@ -500,6 +520,29 @@ impl View { active.len() != len_before } + fn set_active_drag(&self, event: BlitzDragEvent) { + *self.active_drag.borrow_mut() = Some(event); + } + + fn update_active_drag( + &self, + id: BlitzDragId, + f: impl FnOnce(&mut BlitzDragEvent), + ) -> Option { + let mut d = self.active_drag.borrow_mut(); + let drag = d.as_mut()?; + if drag.id != id { + return None; + } + f(drag); + Some(drag.clone()) + } + + fn remove_active_drag(&self, id: BlitzDragId) -> Option { + let mut d = self.active_drag.borrow_mut(); + if d.as_ref()?.id == id { d.take() } else { None } + } + #[inline] pub fn with_viewport(&mut self, cb: impl FnOnce(&mut Viewport)) { let mut inner = self.doc.inner_mut(); @@ -582,7 +625,67 @@ impl View { self.doc.handle_ui_event(event); } - pub fn handle_winit_event(&mut self, event: WindowEvent) { + pub fn start_internal_drag( + &mut self, + data_transfer: BlitzDataTransferArc, + node: Option, + event_loop: &dyn ActiveEventLoop, + ) -> Result<(), RequestError> { + let mut event = BlitzDragEvent { + id: 0, + coords: self.pointer_coords(self.pointer_pos), + element: Default::default(), + mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()), + button: MouseEventButton::Main, + buttons: self.buttons, + data_transfer: data_transfer.clone(), + source: node.into(), + }; + + // this is done this way so that implementation of for_each_available_type + // for WinitDataTransfer actually works for outgoing drag events without + // issues with borrow checker or unsafe code, otherwise they will not have + // any data types within them + self.doc.handle_ui_event(UiEvent::DragStart(event.clone())); + + let actions = + blitz_drag_operations_to_winit_dnd_action(&data_transfer.borrow().effect_allowed); + match event_loop.start_drag( + self.window_id(), + Box::from(WinitDataTransfer::new(data_transfer)), + &actions, + None, + ) { + Ok(id) => { + event.id = id.into_raw() as u64; + self.set_active_drag(event); + Ok(()) + } + Err(err) => { + self.doc.handle_ui_event(UiEvent::OutgoingCancel(event)); + Err(err) + } + } + } + + pub fn set_dnd_action( + &self, + id: DataTransferId, + event_loop: &dyn ActiveEventLoop, + drop_effect: &BlitzDragOperation, + ) { + let action = blitz_drag_operation_to_winit_dnd_action(drop_effect); + let actions = action + .as_ref() + .map_or(&[] as &[DndAction], std::slice::from_ref); + + if let Err(err) = event_loop.set_valid_dnd_actions(id, actions) { + #[cfg(feature = "tracing")] + tracing::error!("{}", err) + } + } + + pub fn handle_winit_event(&mut self, event: WindowEvent, event_loop: &dyn ActiveEventLoop) { // Update accessibility focus and window size state in response to a Winit WindowEvent #[cfg(feature = "accessibility")] self.accessibility @@ -854,9 +957,150 @@ impl View { WindowEvent::PanGesture { .. } => {}, WindowEvent::DoubleTapGesture { .. } => {}, WindowEvent::RotationGesture { .. } => {}, - WindowEvent::DragEntered { .. } => {}, - WindowEvent::DragDropped { .. } => {}, - WindowEvent::DragLeft { .. } => {}, + WindowEvent::HoldGesture { .. } => {}, + + WindowEvent::OutgoingDragDropped { id, .. } => { + if let Some(event) = self.remove_active_drag(id.into_raw() as u64) { + // drags started with event_loop.start_drag could be clasified as outgoing + // even if they are dropped within the same window + // `INTERNAL_DROP_THRESHOLD` is here to make sure that it's not clasified as + // outgoung drop while actually being a regular drop + if self.internal_drag.elapsed() < INTERNAL_DROP_THRESHOLD { + self.doc.handle_ui_event(UiEvent::Drop(event)); + } else { + self.doc.handle_ui_event(UiEvent::OutgoingDrop(event)); + } + } + }, + WindowEvent::OutgoingDragCanceled { id } => { + if let Some( event) = self.remove_active_drag(id.into_raw() as u64) { + self.doc.handle_ui_event(UiEvent::OutgoingCancel(event)); + } + }, + WindowEvent::DataTransferReceived { serial, value, .. } => { + if let Some((event, serials)) = &mut self.pending_data_transfers && let Some(pos) = serials.iter().position(|s| *s == serial) { + serials.swap_remove(pos); + let is_empty = serials.is_empty(); + + let mut dt = event.data_transfer.borrow_mut(); + if let Some(items) = (&mut *dt.items as &mut dyn std::any::Any) + .downcast_mut::() + { + items.push(value); + } + + drop(dt); + + if is_empty && let Some((event, _)) = self.pending_data_transfers.take() { + // since drop is async it might come later and the mouse could be moved in the meantime + // if this becomes a problem save drop node id in the event or something simmilar + self.doc.handle_ui_event(UiEvent::Drop(event)); + } + } + }, + WindowEvent::DragEntered { id, position } => { + let drag_id = id.into_raw() as u64; + + // Internal drags + if let Some(event) = self.update_active_drag(drag_id, |drag| { + drag.mods = winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()); + if let Some(pos) = position { + drag.coords = self.pointer_coords(pos); + } + self.set_dnd_action(id, event_loop, &drag.data_transfer().drop_effect); + }) { + if let Some(pos) = position { + self.pointer_pos = pos; + } + self.doc.handle_ui_event(UiEvent::DragEnter(event)); + return; + } + + let data_transfer = Arc::new(AtomicRefCell::new(BlitzDataTransfer { + items: Box::new(WinitDataTransferItems::new(Vec::new())), + effect_allowed: None, + drop_effect: Default::default(), + mode: Default::default(), + })); + + if let Some(pos) = position { + self.pointer_pos = pos; + } + + let event = BlitzDragEvent { + id: id.into_raw() as u64, + coords: self.pointer_coords(self.pointer_pos), + element: Default::default(), + mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()), + data_transfer, + // currently not possible to detect + button: Default::default(), + buttons: Default::default(), + source: blitz_traits::events::BlitzDataTransferSource::External + }; + + self.set_active_drag(event.clone()); + self.doc.handle_ui_event(UiEvent::DragEnter(event)); + }, + WindowEvent::DragPosition { id, position, .. } => { + if let Some(event) = self.update_active_drag(id.into_raw() as u64, |drag| { + drag.mods = winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()); + drag.coords = self.pointer_coords(position); + self.set_dnd_action(id, event_loop, &drag.data_transfer().drop_effect); + }) { + self.doc.handle_ui_event(UiEvent::DragOver(event)); + } + }, + WindowEvent::DragDropped { id, proposed_action } => { + + if let Some(mut event) = self.remove_active_drag(id.into_raw() as u64) { + event.mods = winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()); + let mut dt = event.data_transfer_mut(); + dt.drop_effect = winit_dnd_action_to_blitz_dnd_operation(proposed_action); + drop(dt); + let dt = match event_loop.data_transfer(id) { + Ok(dt) => dt, + Err(err) => { + #[cfg(feature = "tracing")] + tracing::error!("{}", err); + return; + }, + }; + let mut serial_ids= Vec::new(); + + dt.for_each_available_type(&mut |ty| { + // todo: an option to filter types based on defined hint? + match event_loop.fetch_data_transfer(id, ty) { + Ok(serial) => { + serial_ids.push(serial); + }, + Err(err) => { + #[cfg(feature = "tracing")] + tracing::error!("{}", err); + }, + } + ControlFlow::Continue(()) + }); + + if serial_ids.is_empty() { + self.doc.handle_ui_event(UiEvent::Drop(event)); + } else { + self.pending_data_transfers = Some((event, serial_ids)); + } + } + }, + WindowEvent::DragLeft { id } => { + self.internal_drag = Instant::now() ; + let drag_id = id.into_raw() as u64; + + let Some(event) = self.active_drag.borrow().as_ref() + .filter(|e| e.id == drag_id) + .cloned() else { + return; + }; + + self.doc.handle_ui_event(UiEvent::DragLeave(event)); + }, _ => {} } } diff --git a/packages/blitz-traits/Cargo.toml b/packages/blitz-traits/Cargo.toml index 628bc7d1d..e545ef1c5 100644 --- a/packages/blitz-traits/Cargo.toml +++ b/packages/blitz-traits/Cargo.toml @@ -20,3 +20,4 @@ smol_str = { workspace = true } bitflags = { workspace = true } cursor-icon = { workspace = true } serde = { workspace = true } +futures-core = { workspace = true } diff --git a/packages/blitz-traits/src/events.rs b/packages/blitz-traits/src/events.rs index 360f2987b..e23c86934 100644 --- a/packages/blitz-traits/src/events.rs +++ b/packages/blitz-traits/src/events.rs @@ -3,12 +3,14 @@ use std::str::FromStr; use std::sync::Arc; -use atomic_refcell::AtomicRefCell; +use atomic_refcell::{AtomicRef, AtomicRefCell, AtomicRefMut}; use bitflags::bitflags; use keyboard_types::{Code, Key, Location, Modifiers}; use smol_str::SmolStr; use crate::NodeId; +mod datatransfer; +pub use datatransfer::*; #[derive(Default)] pub struct EventState { @@ -69,6 +71,13 @@ pub enum UiEvent { KeyDown(BlitzKeyEvent), Ime(BlitzImeEvent), AppleStandardKeybinding(SmolStr), + DragStart(BlitzDragEvent), + DragEnter(BlitzDragEvent), + DragOver(BlitzDragEvent), + DragLeave(BlitzDragEvent), + Drop(BlitzDragEvent), + OutgoingDrop(BlitzDragEvent), + OutgoingCancel(BlitzDragEvent), } impl UiEvent { pub fn discriminant(&self) -> u8 { @@ -152,6 +161,14 @@ pub enum DomEventKind { FocusIn, FocusOut, + DragStart, + Drag, + DragEnd, + DragEnter, + DragOver, + DragLeave, + Drop, + AppleStandardKeybinding, } impl DomEventKind { @@ -202,6 +219,15 @@ impl FromStr for DomEventKind { "blur" => Ok(Self::Blur), "focusin" => Ok(Self::FocusIn), "focusout" => Ok(Self::FocusOut), + + "dragstart" => Ok(Self::DragStart), + "drag" => Ok(Self::Drag), + "dragend" => Ok(Self::DragEnd), + "dragenter" => Ok(Self::DragEnter), + "dragover" => Ok(Self::DragOver), + "dragleave" => Ok(Self::DragLeave), + "drop" => Ok(Self::Drop), + _ => Err(()), } } @@ -250,6 +276,14 @@ pub enum DomEventData { FocusIn(BlitzFocusEvent), FocusOut(BlitzFocusEvent), + DragStart(BlitzDragEvent), + Drag(BlitzDragEvent), + DragEnd(BlitzDragEvent), + DragEnter(BlitzDragEvent), + DragOver(BlitzDragEvent), + DragLeave(BlitzDragEvent), + Drop(BlitzDragEvent), + AppleStandardKeybinding(SmolStr), } impl DomEventData { @@ -305,6 +339,14 @@ impl DomEventData { Self::FocusIn { .. } => "focusin", Self::FocusOut { .. } => "focusout", + Self::DragStart { .. } => "dragstart", + Self::Drag { .. } => "drag", + Self::DragEnd { .. } => "dragend", + Self::DragEnter { .. } => "dragenter", + Self::DragOver { .. } => "dragover", + Self::DragLeave { .. } => "dragleave", + Self::Drop { .. } => "drop", + Self::AppleStandardKeybinding { .. } => "applekeybinding", } } @@ -351,6 +393,14 @@ impl DomEventData { Self::FocusIn { .. } => DomEventKind::FocusIn, Self::FocusOut { .. } => DomEventKind::FocusOut, + Self::DragStart { .. } => DomEventKind::DragStart, + Self::Drag { .. } => DomEventKind::Drag, + Self::DragEnd { .. } => DomEventKind::DragEnd, + Self::DragEnter { .. } => DomEventKind::DragEnter, + Self::DragOver { .. } => DomEventKind::DragOver, + Self::DragLeave { .. } => DomEventKind::DragLeave, + Self::Drop { .. } => DomEventKind::Drop, + Self::AppleStandardKeybinding { .. } => DomEventKind::AppleStandardKeybinding, } } @@ -397,6 +447,14 @@ impl DomEventData { Self::FocusIn { .. } => false, Self::FocusOut { .. } => false, + Self::DragStart { .. } => true, + Self::Drag { .. } => true, + Self::DragEnd { .. } => false, + Self::DragEnter { .. } => true, + Self::DragOver { .. } => true, + Self::DragLeave { .. } => false, + Self::Drop { .. } => true, + Self::AppleStandardKeybinding { .. } => true, } } @@ -443,11 +501,77 @@ impl DomEventData { Self::FocusIn { .. } => true, Self::FocusOut { .. } => true, + Self::DragStart { .. } => true, + Self::Drag { .. } => true, + Self::DragEnd { .. } => true, + Self::DragEnter { .. } => true, + Self::DragOver { .. } => true, + Self::DragLeave { .. } => true, + Self::Drop { .. } => true, + Self::AppleStandardKeybinding { .. } => false, } } } +pub type BlitzDragId = u64; +pub type BlitzDataTransferArc = Arc>; + +#[derive(Clone, Debug)] +pub struct BlitzDragEvent { + pub id: BlitzDragId, + pub coords: PointerCoords, + pub element: Point, + pub mods: Modifiers, + pub button: MouseEventButton, + pub buttons: MouseEventButtons, + pub data_transfer: BlitzDataTransferArc, + pub source: BlitzDataTransferSource, +} + +impl BlitzDragEvent { + #[inline(always)] + pub fn page_x(&self) -> f32 { + self.coords.page_x + } + #[inline(always)] + pub fn page_y(&self) -> f32 { + self.coords.page_y + } + #[inline(always)] + pub fn client_x(&self) -> f32 { + self.coords.client_x + } + #[inline(always)] + pub fn client_y(&self) -> f32 { + self.coords.client_y + } + #[inline(always)] + pub fn screen_x(&self) -> f32 { + self.coords.screen_x + } + #[inline(always)] + pub fn screen_y(&self) -> f32 { + self.coords.screen_y + } + #[inline(always)] + pub fn element_x(&self) -> f32 { + self.element.x + } + #[inline(always)] + pub fn element_y(&self) -> f32 { + self.element.y + } + #[inline(always)] + pub fn data_transfer<'a>(&'a self) -> AtomicRef<'a, BlitzDataTransfer> { + self.data_transfer.borrow() + } + #[inline(always)] + pub fn data_transfer_mut<'a>(&'a self) -> AtomicRefMut<'a, BlitzDataTransfer> { + self.data_transfer.borrow_mut() + } +} + #[derive(Debug, Clone, Copy)] pub struct HitResult { /// The node_id of the node identified as the hit target diff --git a/packages/blitz-traits/src/events/datatransfer.rs b/packages/blitz-traits/src/events/datatransfer.rs new file mode 100644 index 000000000..2b78ac9c6 --- /dev/null +++ b/packages/blitz-traits/src/events/datatransfer.rs @@ -0,0 +1,308 @@ +use std::any::Any; +use std::pin::Pin; +use std::sync::Arc; +use std::{path::PathBuf, str::FromStr}; + +use bitflags::bitflags; +use bytes::Bytes; + +use crate::NodeId; + +bitflags! { + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct BlitzDragOperations: u8 { + const NONE = 0; + const COPY = 1 << 0; + const MOVE = 1 << 1; + const LINK = 1 << 2; + } +} + +impl BlitzDragOperations { + pub fn as_str(self) -> &'static str { + match self { + Self::NONE => "none", + Self::COPY => "copy", + Self::MOVE => "move", + Self::LINK => "link", + x if x == (Self::COPY | Self::MOVE) => "copyMove", + x if x == (Self::COPY | Self::LINK) => "copyLink", + x if x == (Self::MOVE | Self::LINK) => "linkMove", + x if x == (Self::COPY | Self::MOVE | Self::LINK) => "all", + _ => "uninitialized", + } + } + pub fn from_str_opt(s: &str) -> Result, ParseDragOperationsError> { + match s.trim() { + "none" => Ok(Some(Self::NONE)), + "copy" => Ok(Some(Self::COPY)), + "move" => Ok(Some(Self::MOVE)), + "link" => Ok(Some(Self::LINK)), + "copyMove" => Ok(Some(Self::COPY | Self::MOVE)), + "copyLink" => Ok(Some(Self::COPY | Self::LINK)), + "linkMove" => Ok(Some(Self::MOVE | Self::LINK)), + "all" => Ok(Some(Self::COPY | Self::MOVE | Self::LINK)), + "uninitialized" => Ok(None), + _ => Err(ParseDragOperationsError), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseDragOperationsError; + +impl std::fmt::Display for ParseDragOperationsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid drag effect string") + } +} + +impl std::error::Error for ParseDragOperationsError {} + +impl std::fmt::Display for BlitzDragOperations { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum BlitzDragOperation { + #[default] + None, + Copy, + Move, + Link, +} + +impl BlitzDragOperation { + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Copy => "copy", + Self::Move => "move", + Self::Link => "link", + } + } +} + +impl std::fmt::Display for BlitzDragOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for BlitzDragOperation { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.trim() { + "none" => Ok(Self::None), + "copy" => Ok(Self::Copy), + "move" => Ok(Self::Move), + "link" => Ok(Self::Link), + _ => Err(()), + } + } +} + +// https://html.spec.whatwg.org/multipage/dnd.html#drag-data-store-mode +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BlitzDragDataStoreMode { + /// getData/setData/clearData forbidden (dragenter/dragover/dragleave/drag/dragend) + #[default] + Protected, + /// getData allowed, setData/clearData forbidden (drop) + ReadOnly, + /// getData/setData/clearData allowed (dragstart) + ReadWrite, +} + +impl BlitzDragDataStoreMode { + pub fn can_write(&self) -> bool { + matches!(self, Self::ReadWrite) + } + + pub fn is_protected(&self) -> bool { + matches!(self, Self::Protected) + } +} + +pub type BlitzFileType = Arc; + +pub type BlitzFileError = Box; + +pub trait BlitzFileTrait: Any + Send + Sync + std::fmt::Debug { + fn name(&self) -> String; + fn size(&self) -> u64; + fn last_modified(&self) -> u64; + fn path(&self) -> PathBuf; + fn content_type(&self) -> Option; + + fn read_bytes( + &self, + ) -> Pin> + Send + 'static>>; + + fn byte_stream( + &self, + ) -> Pin> + Send + 'static>>; + + fn read_string( + &self, + ) -> Pin> + Send + 'static>>; + + fn inner(&self) -> &dyn std::any::Any; +} + +pub trait BlitzDataTransferItemsTrait: Any + Send + Sync + std::fmt::Debug { + fn is_empty(&self) -> bool; + fn files(&self) -> Box + '_> { + Box::new(std::iter::empty()) + } + fn get_data(&self, format: &str) -> Option { + let _ = format; + None + } + fn set_data(&mut self, format: &str, data: &str) -> Result<(), String> { + let _ = format; + let _ = data; + Err("setData is not supported on received data transfer".into()) + } + fn clear_all(&mut self) {} + fn clear_format(&mut self, format: &str) { + let _ = format; + } + fn types(&self) -> Vec { + Vec::new() + } + fn as_any(&self) -> &dyn std::any::Any; +} + +#[derive(Debug)] +pub struct BlitzDataTransfer { + pub items: Box, + pub effect_allowed: Option, + pub drop_effect: BlitzDragOperation, + pub mode: BlitzDragDataStoreMode, +} + +impl PartialEq for BlitzDataTransfer { + fn eq(&self, other: &Self) -> bool { + self.effect_allowed == other.effect_allowed + && self.drop_effect == other.drop_effect + && self.mode == other.mode + } +} + +/// legacy +fn normalize_format(format: &str) -> String { + match format.to_ascii_lowercase().as_str() { + "text" => "text/plain".to_string(), + "url" => "text/uri-list".to_string(), + other => other.to_string(), + } +} + +impl BlitzDataTransfer { + pub fn with_writable(mut self) -> Self { + self.mode = BlitzDragDataStoreMode::ReadWrite; + self + } + + pub fn protected(&mut self) { + self.mode = BlitzDragDataStoreMode::Protected; + } + + pub fn readable(&mut self) { + self.mode = BlitzDragDataStoreMode::ReadOnly; + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn files(&self) -> impl Iterator { + self.items.files() + } + + pub fn effect_allowed(&self) -> String { + self.effect_allowed + .map(|v| v.to_string()) + .unwrap_or("uninitialized".to_string()) + } + + pub fn set_effect_allowed(&mut self, effect: &str) { + if self.mode.can_write() + && let Ok(op) = BlitzDragOperations::from_str_opt(effect) + { + self.effect_allowed = op; + } + } + + pub fn drop_effect(&self) -> String { + self.drop_effect.to_string() + } + + pub fn set_drop_effect(&mut self, effect: &str) { + if let Ok(op) = effect.parse() { + self.drop_effect = op; + } + } + + pub fn get_data_unchecked(&self, format: &str) -> Option { + let format = normalize_format(format); + self.items.get_data(&format) + } + + pub fn get_data(&self, format: &str) -> Option { + if self.mode == BlitzDragDataStoreMode::Protected { + return None; + } + let format = normalize_format(format); + self.items.get_data(&format) + } + + pub fn set_data(&mut self, format: &str, data: &str) -> Result<(), String> { + if !self.mode.can_write() { + return Err("setData is only allowed during dragstart".into()); + } + let format = normalize_format(format); + self.items.set_data(&format, data) + } + + pub fn clear_data(&mut self, format: Option<&str>) -> Result<(), String> { + if !self.mode.can_write() { + return Err("clearData is only allowed during dragstart".into()); + } + + match format { + Some(fmt) => { + let fmt = normalize_format(fmt); + self.items.clear_format(&fmt) + } + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/clearData + None => self.items.clear_all(), + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub enum BlitzDataTransferSource { + Internal(Option), + External, +} + +impl BlitzDataTransferSource { + pub fn get_node_id(&self) -> Option { + match self { + BlitzDataTransferSource::Internal(node) => *node, + BlitzDataTransferSource::External => None, + } + } +} + +impl From> for BlitzDataTransferSource { + fn from(value: Option) -> Self { + Self::Internal(value) + } +} diff --git a/packages/blitz-traits/src/shell.rs b/packages/blitz-traits/src/shell.rs index ba17e5dab..2e53b903f 100644 --- a/packages/blitz-traits/src/shell.rs +++ b/packages/blitz-traits/src/shell.rs @@ -2,6 +2,11 @@ use cursor_icon::CursorIcon; +use crate::{ + NodeId, + events::{BlitzDataTransferItemsTrait, BlitzDragOperation, BlitzDragOperations}, +}; + /// Type representing an error performing a clipboard operation // TODO: fill out with meaningful errors pub struct ClipboardError; @@ -32,6 +37,18 @@ pub trait ShellProvider: Send + Sync + 'static { let _ = text; Err(ClipboardError) } + fn set_datatransfer( + &self, + items: Box, + effect_allowed: Option, + drop_effect: BlitzDragOperation, + node: Option, + ) { + let _ = items; + let _ = effect_allowed; + let _ = drop_effect; + let _ = node; + } fn open_file_dialog( &self, multiple: bool, diff --git a/packages/dioxus-native-dom/src/dioxus_document.rs b/packages/dioxus-native-dom/src/dioxus_document.rs index 7f4c5d70a..a55317e2e 100644 --- a/packages/dioxus-native-dom/src/dioxus_document.rs +++ b/packages/dioxus-native-dom/src/dioxus_document.rs @@ -1,8 +1,8 @@ //! Integration between Dioxus and Blitz use crate::NodeId; use crate::events::{ - BlitzKeyboardData, NativeConverter, NativeFocusData, NativeFormData, NativePointerData, - NativeScrollData, NativeTouchData, NativeWheelData, NodeHandle, + BlitzKeyboardData, NativeConverter, NativeDragData, NativeFocusData, NativeFormData, + NativePointerData, NativeScrollData, NativeTouchData, NativeWheelData, NodeHandle, }; use crate::mutation_writer::{DioxusState, MutationWriter}; use crate::qual_name; @@ -333,6 +333,14 @@ impl EventHandler for DioxusEventHandler<'_> { // AppleStandardKeybinding events are not exposed to script DomEventData::AppleStandardKeybinding(_) => None, + + DomEventData::DragStart(devent) + | DomEventData::Drag(devent) + | DomEventData::DragEnd(devent) + | DomEventData::DragEnter(devent) + | DomEventData::DragOver(devent) + | DomEventData::DragLeave(devent) + | DomEventData::Drop(devent) => Some(wrap_event_data(NativeDragData(devent.clone()))), }; let Some(event_data) = event_data else { diff --git a/packages/dioxus-native-dom/src/events.rs b/packages/dioxus-native-dom/src/events.rs index e20ce059a..57c64e14a 100644 --- a/packages/dioxus-native-dom/src/events.rs +++ b/packages/dioxus-native-dom/src/events.rs @@ -3,17 +3,20 @@ use blitz_dom::{ ScrollLogicalPosition as BlitzScrollLogicalPosition, }; use blitz_traits::events::{ - BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, BlitzScrollEvent, BlitzWheelDelta, - BlitzWheelEvent, MouseEventButton, + BlitzDragEvent, BlitzFileError, BlitzFileType, BlitzKeyEvent, BlitzPointerEvent, + BlitzPointerId, BlitzScrollEvent, BlitzWheelDelta, BlitzWheelEvent, MouseEventButton, }; +use blitz_traits::net::Bytes; +use dioxus_core::CapturedError; use dioxus_html::{ - AnimationData, CancelData, ClipboardData, CompositionData, DragData, FocusData, FormData, - FormValue, HasFileData, HasFocusData, HasFormData, HasKeyboardData, HasMouseData, - HasPointerData, HasScrollData, HasTouchData, HasTouchPointData, HasWheelData, - HtmlEventConverter, ImageData, KeyboardData, MediaData, MountedData, MountedError, - MountedResult, MouseData, PlatformEventData, PointerData, RenderedElementBacking, ResizeData, - ScrollBehavior, ScrollData, ScrollLogicalPosition, ScrollToOptions, SelectionData, ToggleData, - TouchData, TouchPoint, TransitionData, VisibleData, WheelData, + AnimationData, CancelData, ClipboardData, CompositionData, DataTransfer, DragData, FileData, + FocusData, FormData, FormValue, HasDataTransferData, HasDragData, HasFileData, HasFocusData, + HasFormData, HasKeyboardData, HasMouseData, HasPointerData, HasScrollData, HasTouchData, + HasTouchPointData, HasWheelData, HtmlEventConverter, ImageData, KeyboardData, MediaData, + MountedData, MountedError, MountedResult, MouseData, NativeDataTransfer, NativeFileData, + PlatformEventData, PointerData, RenderedElementBacking, ResizeData, ScrollBehavior, ScrollData, + ScrollLogicalPosition, ScrollToOptions, SelectionData, ToggleData, TouchData, TouchPoint, + TransitionData, VisibleData, WheelData, geometry::{ ClientPoint, ElementPoint, PagePoint, PixelsRect, PixelsSize, PixelsVector2D, ScreenPoint, WheelDelta, @@ -24,6 +27,7 @@ use dioxus_html::{ InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction, }, }; +use futures_util::{Stream, TryStreamExt}; use keyboard_types::{Code, Key, Location, Modifiers}; use std::{ any::Any, @@ -79,8 +83,8 @@ impl HtmlEventConverter for NativeConverter { unimplemented!("todo: convert_composition_data in dioxus-native. requires support in blitz") } - fn convert_drag_data(&self, _event: &PlatformEventData) -> DragData { - unimplemented!("todo: convert_drag_data in dioxus-native. requires support in blitz") + fn convert_drag_data(&self, event: &PlatformEventData) -> DragData { + event.downcast::().unwrap().clone().into() } fn convert_image_data(&self, _event: &PlatformEventData) -> ImageData { @@ -389,15 +393,19 @@ impl ModifiersInteraction for NativePointerData { } } +fn blitz_mouse_button_to_dioxus_mouse_button(button: &MouseEventButton) -> MouseButton { + match button { + MouseEventButton::Main => MouseButton::Primary, + MouseEventButton::Auxiliary => MouseButton::Auxiliary, + MouseEventButton::Secondary => MouseButton::Secondary, + MouseEventButton::Fourth => MouseButton::Fourth, + MouseEventButton::Fifth => MouseButton::Fifth, + } +} + impl PointerInteraction for NativePointerData { fn trigger_button(&self) -> Option { - Some(match self.0.button { - MouseEventButton::Main => MouseButton::Primary, - MouseEventButton::Auxiliary => MouseButton::Auxiliary, - MouseEventButton::Secondary => MouseButton::Secondary, - MouseEventButton::Fourth => MouseButton::Fourth, - MouseEventButton::Fifth => MouseButton::Fifth, - }) + Some(blitz_mouse_button_to_dioxus_mouse_button(&self.0.button)) } fn held_buttons(&self) -> MouseButtonSet { @@ -652,6 +660,160 @@ pub fn synthetic_click_event(node: &Node, modifiers: Modifiers) -> Box node.synthetic_click_event_data(modifiers), )) } +#[derive(Clone)] +pub struct NativeDragData(pub(crate) BlitzDragEvent); + +#[derive(Clone)] +pub struct BlitzNativeFileData(BlitzFileType); + +pub fn blitz_file_error_to_captured_error(err: BlitzFileError) -> CapturedError { + CapturedError::from_boxed(err) +} + +impl NativeFileData for BlitzNativeFileData { + fn name(&self) -> String { + self.0.name() + } + + fn size(&self) -> u64 { + self.0.size() + } + + fn last_modified(&self) -> u64 { + self.0.last_modified() + } + + fn content_type(&self) -> Option { + self.0.content_type() + } + + fn path(&self) -> std::path::PathBuf { + self.0.path() + } + + fn inner(&self) -> &dyn Any { + self.0.inner() + } + + fn read_bytes(&self) -> Pin> + 'static>> { + let fut = self.0.read_bytes(); + Box::pin(async move { fut.await.map_err(blitz_file_error_to_captured_error) }) + } + + fn read_string( + &self, + ) -> Pin> + 'static>> { + let fut = self.0.read_string(); + Box::pin(async move { fut.await.map_err(blitz_file_error_to_captured_error) }) + } + + fn byte_stream( + &self, + ) -> Pin> + 'static + Send>> { + Box::pin( + self.0 + .byte_stream() + .map_err(blitz_file_error_to_captured_error), + ) + } +} + +impl NativeDataTransfer for NativeDragData { + fn get_data(&self, format: &str) -> Option { + self.0.data_transfer().get_data(format) + } + + fn set_data(&self, format: &str, data: &str) -> Result<(), String> { + self.0.data_transfer_mut().set_data(format, data) + } + + fn clear_data(&self, format: Option<&str>) -> Result<(), String> { + self.0.data_transfer_mut().clear_data(format) + } + + fn effect_allowed(&self) -> String { + self.0.data_transfer().effect_allowed() + } + + fn set_effect_allowed(&self, effect: &str) { + self.0.data_transfer_mut().set_effect_allowed(effect); + } + fn drop_effect(&self) -> String { + self.0.data_transfer().drop_effect() + } + + fn set_drop_effect(&self, effect: &str) { + self.0.data_transfer_mut().set_drop_effect(effect); + } + + fn files(&self) -> Vec { + self.0 + .data_transfer() + .files() + .map(|file| FileData::new(BlitzNativeFileData(file))) + .collect() + } +} + +impl HasFileData for NativeDragData { + fn files(&self) -> Vec { + NativeDataTransfer::files(self) + } +} + +impl HasDataTransferData for NativeDragData { + fn data_transfer(&self) -> DataTransfer { + DataTransfer::new(self.clone()) + } +} + +impl HasDragData for NativeDragData { + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} + +impl ModifiersInteraction for NativeDragData { + fn modifiers(&self) -> Modifiers { + self.0.mods + } +} + +impl InteractionLocation for NativeDragData { + fn client_coordinates(&self) -> ClientPoint { + ClientPoint::new(self.0.client_x() as f64, self.0.client_y() as f64) + } + + fn screen_coordinates(&self) -> ScreenPoint { + ScreenPoint::new(self.0.screen_x() as f64, self.0.screen_y() as f64) + } + + fn page_coordinates(&self) -> PagePoint { + PagePoint::new(self.0.page_x() as f64, self.0.page_y() as f64) + } +} + +impl InteractionElementOffset for NativeDragData { + fn element_coordinates(&self) -> ElementPoint { + ElementPoint::new(self.0.element_x() as f64, self.0.element_y() as f64) + } +} + +impl PointerInteraction for NativeDragData { + fn trigger_button(&self) -> Option { + Some(blitz_mouse_button_to_dioxus_mouse_button(&self.0.button)) + } + + fn held_buttons(&self) -> MouseButtonSet { + dioxus_html::input_data::decode_mouse_button_set(self.0.buttons.bits() as u16) + } +} + +impl HasMouseData for NativeDragData { + fn as_any(&self) -> &dyn Any { + self as &dyn Any + } +} #[cfg(test)] mod tests {