diff --git a/examples/09-reference/all_events.rs b/examples/09-reference/all_events.rs index 814a04884c..7867e2ead3 100644 --- a/examples/09-reference/all_events.rs +++ b/examples/09-reference/all_events.rs @@ -51,6 +51,28 @@ fn app() -> Element { } div { style: "padding: 50px;", + div { + style: "display: grid; gap: 12px; max-width: 520px; margin: 0 auto 24px; font-family: sans-serif;", + label { + r#for: "selection-input", + "Select text in the input to inspect the selection range and direction" + } + input { + id: "selection-input", + value: "Select part of this text to fire selection events", + style: "font: inherit; padding: 8px 10px;", + onselect: move |event: Event| log_event(Rc::new(event.data().selection())), + onselectstart: move |event: Event| log_event(Rc::new(event.data().selection())), + onselectionchange: move |event: Event| log_event(Rc::new(event.data().selection())), + } + textarea { + style: "font: inherit; padding: 8px 10px; min-height: 80px;", + onselect: move |event: Event| log_event(Rc::new(event.data().selection())), + onselectstart: move |event: Event| log_event(Rc::new(event.data().selection())), + onselectionchange: move |event: Event| log_event(Rc::new(event.data().selection())), + "Selection events also include textarea ranges and direction.", + } + } div { style: "text-align: center; padding: 20px; font-family: sans-serif; overflow: auto; height: 400px;", onscroll: move |event: Event| { diff --git a/packages/html/src/events/selection.rs b/packages/html/src/events/selection.rs index 024da78280..ffb25ffefc 100644 --- a/packages/html/src/events/selection.rs +++ b/packages/html/src/events/selection.rs @@ -1,7 +1,54 @@ use dioxus_core::Event; +use std::ops::Range; pub type SelectionEvent = Event; +/// The direction a text selection was created in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serialize", serde(rename_all = "lowercase"))] +pub enum SelectionDirection { + /// The selection direction is unknown or directionless. + #[default] + None, + /// The focus is after the anchor. + Forward, + /// The focus is before the anchor. + Backward, +} + +/// The selection inside a text control. +/// +/// The range is measured in UTF-16 code units to match the browser selection +/// APIs on `input` and `textarea`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TextSelection { + range: Range, + direction: SelectionDirection, +} + +impl TextSelection { + /// Create a new text selection from a UTF-16 range and selection direction. + pub fn new(range: Range, direction: SelectionDirection) -> Self { + Self { range, direction } + } + + /// The selected UTF-16 range. + pub fn range(&self) -> Range { + self.range.clone() + } + + /// The direction the range was selected in. + pub fn direction(&self) -> SelectionDirection { + self.direction + } + + /// Returns `true` if the selection is a caret with no selected text. + pub fn is_collapsed(&self) -> bool { + self.range.is_empty() + } +} + pub struct SelectionData { inner: Box, } @@ -14,6 +61,17 @@ impl SelectionData { } } + /// The selection inside a text control. + /// + /// This is only populated for event targets that expose the text-control + /// selection APIs, such as `input` and `textarea` on the web. Some + /// selection events, notably `selectstart`, can also fire when selecting + /// normal document text. Those document selections are exposed by browser + /// APIs like `document.getSelection()` and intentionally return `None` here. + pub fn selection(&self) -> Option { + self.inner.selection() + } + /// Downcast this event to a concrete event type #[inline(always)] pub fn downcast(&self) -> Option<&T> { @@ -29,30 +87,79 @@ impl From for SelectionData { impl std::fmt::Debug for SelectionData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SelectionData").finish() + f.debug_struct("SelectionData") + .field("selection", &self.selection()) + .finish() } } impl PartialEq for SelectionData { - fn eq(&self, _other: &Self) -> bool { - true + fn eq(&self, other: &Self) -> bool { + self.selection() == other.selection() } } #[cfg(feature = "serialize")] /// A serialized version of SelectionData #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] -pub struct SerializedSelectionData {} +pub struct SerializedSelectionData { + #[serde(default)] + pub selection_start: Option, + #[serde(default)] + pub selection_end: Option, + #[serde(default)] + pub selection_direction: Option, +} + +#[cfg(feature = "serialize")] +impl SerializedSelectionData { + /// Create a new serialized selection data object. + pub fn new( + selection_start: Option, + selection_end: Option, + selection_direction: Option, + ) -> Self { + Self { + selection_start, + selection_end, + selection_direction, + } + } +} + +#[cfg(feature = "serialize")] +impl Default for SerializedSelectionData { + fn default() -> Self { + Self::new(None, None, None) + } +} #[cfg(feature = "serialize")] impl From<&SelectionData> for SerializedSelectionData { - fn from(_: &SelectionData) -> Self { - Self {} + fn from(data: &SelectionData) -> Self { + let Some(selection) = data.selection() else { + return Self::default(); + }; + let range = selection.range(); + Self::new( + Some(range.start), + Some(range.end), + Some(selection.direction()), + ) } } #[cfg(feature = "serialize")] impl HasSelectionData for SerializedSelectionData { + fn selection(&self) -> Option { + let start = self.selection_start?; + let end = self.selection_end?; + Some(TextSelection::new( + start..end, + self.selection_direction.unwrap_or_default(), + )) + } + fn as_any(&self) -> &dyn std::any::Any { self } @@ -76,6 +183,40 @@ impl<'de> serde::Deserialize<'de> for SelectionData { } pub trait HasSelectionData: std::any::Any { + /// The selection inside a text control. + /// + /// Return `None` when the event did not originate from a text control with + /// selection offsets. Document selections should use a separate API instead + /// of being mixed into this payload. + fn selection(&self) -> Option { + None + } + /// return self as Any fn as_any(&self) -> &dyn std::any::Any; } + +#[cfg(all(test, feature = "serialize"))] +mod tests { + use super::*; + + #[test] + fn serialized_selection_data_deserializes_missing_fields() { + let data: SerializedSelectionData = serde_json::from_str("{}").unwrap(); + assert_eq!(data, SerializedSelectionData::default()); + } + + #[test] + fn selection_data_exposes_serialized_fields() { + let event = SelectionData::new(SerializedSelectionData::new( + Some(1), + Some(4), + Some(SelectionDirection::Forward), + )); + + assert_eq!( + event.selection(), + Some(TextSelection::new(1..4, SelectionDirection::Forward)) + ); + } +} diff --git a/packages/interpreter/src/js/hash.txt b/packages/interpreter/src/js/hash.txt index 1180998a50..eb337b47e6 100644 --- a/packages/interpreter/src/js/hash.txt +++ b/packages/interpreter/src/js/hash.txt @@ -1 +1 @@ -[17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 18429234726379217184, 5052021921702764563, 11493752756395680038, 11339769846046015954] \ No newline at end of file +[17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 18429234726379217184, 5052021921702764563, 15111342379266332823, 11339769846046015954] \ No newline at end of file diff --git a/packages/interpreter/src/js/native.js b/packages/interpreter/src/js/native.js index 639fb7a567..38924f5718 100644 --- a/packages/interpreter/src/js/native.js +++ b/packages/interpreter/src/js/native.js @@ -1 +1 @@ -function serializeEvent(event,target){let contents={},extend=(obj)=>contents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent<"u"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent){let files=[];if(event.dataTransfer&&event.dataTransfer.files)for(let i=0;i{if(value instanceof File){let fileData={path:value.name,size:value.size,last_modified:value.lastModified,content_type:value.type};contents.push({key,file:fileData})}else contents.push({key,text:value})}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;i{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){event.preventDefault();let contents=serializeEvent(event,target),target_name=target.getAttribute("name")||"",requestData={event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles,target_name,values:contents.values};this.fetchAgainstHost("__file_dialog",requestData).then((response)=>response.json()).then((resp)=>{let formObjects=resp.values,dataTransfer=new DataTransfer;for(let formObject of formObjects)if(formObject.key==target_name&&formObject.file!=null){let file=new File([],formObject.file.path,{type:formObject.file.content_type,lastModified:formObject.file.last_modified});dataTransfer.items.add(file)}target.files=dataTransfer.files;let body={data:contents,element:target_id,bubbles:event.bubbles};contents.values=formObjects,this.sendSerializedEvent({...body,name:"input"}),this.sendSerializedEvent({...body,name:"change"})});return}}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,event.bubbles);super.initialize(root,handler)}fetchAgainstHost(path,data){let encoded_data=new TextEncoder().encode(JSON.stringify(data)),base64data=btoa(String.fromCharCode.apply(null,Array.from(encoded_data)));return fetch(`${this.baseUri}/${path}`,{method:"GET",headers:{"x-dioxus-data":base64data}})}sendIpcMessage(method,params={}){let body=JSON.stringify({method,params});this.ipc.postMessage(body)}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;icontents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event.type==="select"||event.type==="selectstart"||event.type==="selectionchange")extend(serializeSelectionEvent(event,target));if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent<"u"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent){let files=[];if(event.dataTransfer&&event.dataTransfer.files)for(let i=0;i{if(value instanceof File){let fileData={path:value.name,size:value.size,last_modified:value.lastModified,content_type:value.type};contents.push({key,file:fileData})}else contents.push({key,text:value})}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;i{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){event.preventDefault();let contents=serializeEvent(event,target),target_name=target.getAttribute("name")||"",requestData={event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles,target_name,values:contents.values};this.fetchAgainstHost("__file_dialog",requestData).then((response)=>response.json()).then((resp)=>{let formObjects=resp.values,dataTransfer=new DataTransfer;for(let formObject of formObjects)if(formObject.key==target_name&&formObject.file!=null){let file=new File([],formObject.file.path,{type:formObject.file.content_type,lastModified:formObject.file.last_modified});dataTransfer.items.add(file)}target.files=dataTransfer.files;let body={data:contents,element:target_id,bubbles:event.bubbles};contents.values=formObjects,this.sendSerializedEvent({...body,name:"input"}),this.sendSerializedEvent({...body,name:"change"})});return}}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,event.bubbles);super.initialize(root,handler)}fetchAgainstHost(path,data){let encoded_data=new TextEncoder().encode(JSON.stringify(data)),base64data=btoa(String.fromCharCode.apply(null,Array.from(encoded_data)));return fetch(`${this.baseUri}/${path}`,{method:"GET",headers:{"x-dioxus-data":base64data}})}sendIpcMessage(method,params={}){let body=JSON.stringify({method,params});this.ipc.postMessage(body)}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;i { + fn selection(&self) -> Option { + with_text_control(&self.event, |input| { + let start = input.selection_start().ok().flatten()? as usize; + let end = input.selection_end().ok().flatten()? as usize; + let direction = input + .selection_direction() + .ok() + .flatten() + .as_deref() + .map(selection_direction_from_web) + .unwrap_or_default(); + + Some(TextSelection::new(start..end, direction)) + }) + .flatten() + } -impl HasSelectionData for Synthetic { fn as_any(&self) -> &dyn std::any::Any { &self.event } } +fn selection_direction_from_web(direction: &str) -> SelectionDirection { + match direction { + "forward" => SelectionDirection::Forward, + "backward" => SelectionDirection::Backward, + _ => SelectionDirection::None, + } +} + impl WebEventExt for dioxus_html::SelectionData { type WebEvent = web_sys::Event; @@ -15,3 +42,43 @@ impl WebEventExt for dioxus_html::SelectionData { self.downcast::().cloned() } } + +fn with_text_control(event: &Event, f: impl FnOnce(TextControl<'_>) -> T) -> Option { + event.target().and_then(|target| { + if let Some(input) = target.dyn_ref::() { + Some(f(TextControl::Input(input))) + } else { + target + .dyn_ref::() + .map(|textarea| f(TextControl::TextArea(textarea))) + } + }) +} + +enum TextControl<'a> { + Input(&'a HtmlInputElement), + TextArea(&'a HtmlTextAreaElement), +} + +impl TextControl<'_> { + fn selection_start(&self) -> Result, wasm_bindgen::JsValue> { + match self { + Self::Input(input) => input.selection_start(), + Self::TextArea(textarea) => textarea.selection_start(), + } + } + + fn selection_end(&self) -> Result, wasm_bindgen::JsValue> { + match self { + Self::Input(input) => input.selection_end(), + Self::TextArea(textarea) => textarea.selection_end(), + } + } + + fn selection_direction(&self) -> Result, wasm_bindgen::JsValue> { + match self { + Self::Input(input) => input.selection_direction(), + Self::TextArea(textarea) => textarea.selection_direction(), + } + } +}