From 1469ce0ec749d23a0b23f52750f671ab567cce19 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 19 May 2026 14:05:21 -0500 Subject: [PATCH 1/3] fill in selection event --- examples/09-reference/all_events.rs | 22 +++ packages/html/src/events/selection.rs | 242 ++++++++++++++++++++++- packages/interpreter/src/js/hash.txt | 2 +- packages/interpreter/src/js/native.js | 2 +- packages/interpreter/src/ts/serialize.ts | 51 +++++ packages/web/Cargo.toml | 1 + packages/web/src/events/selection.rs | 153 +++++++++++++- 7 files changed, 464 insertions(+), 9 deletions(-) diff --git a/examples/09-reference/all_events.rs b/examples/09-reference/all_events.rs index 814a04884c..2f12c3bfea 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 selection event data" + } + 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(event.data()), + onselectstart: move |event: Event| log_event(event.data()), + onselectionchange: move |event: Event| log_event(event.data()), + } + textarea { + style: "font: inherit; padding: 8px 10px; min-height: 80px;", + onselect: move |event: Event| log_event(event.data()), + onselectstart: move |event: Event| log_event(event.data()), + onselectionchange: move |event: Event| log_event(event.data()), + "Selection events also include textarea ranges and selected text.", + } + } 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..ca9e9b56ca 100644 --- a/packages/html/src/events/selection.rs +++ b/packages/html/src/events/selection.rs @@ -14,6 +14,55 @@ impl SelectionData { } } + /// The start offset of the selected text in a text control. + /// + /// This is measured in UTF-16 code units and is only available for text + /// controls that expose `selectionStart`. + pub fn selection_start(&self) -> Option { + self.inner.selection_start() + } + + /// The end offset of the selected text in a text control. + /// + /// This is measured in UTF-16 code units and is only available for text + /// controls that expose `selectionEnd`. + pub fn selection_end(&self) -> Option { + self.inner.selection_end() + } + + /// The direction of the selection in a text control. + /// + /// Browsers return `"forward"`, `"backward"`, or `"none"` when this data is + /// available. + pub fn selection_direction(&self) -> Option { + self.inner.selection_direction() + } + + /// The selected text, if it can be read from the event target or document. + pub fn selected_text(&self) -> String { + self.inner.selected_text() + } + + /// The anchor offset of the current document selection. + pub fn anchor_offset(&self) -> Option { + self.inner.anchor_offset() + } + + /// The focus offset of the current document selection. + pub fn focus_offset(&self) -> Option { + self.inner.focus_offset() + } + + /// Whether the current document selection is collapsed. + pub fn is_collapsed(&self) -> Option { + self.inner.is_collapsed() + } + + /// The number of ranges in the current document selection. + pub fn range_count(&self) -> Option { + self.inner.range_count() + } + /// Downcast this event to a concrete event type #[inline(always)] pub fn downcast(&self) -> Option<&T> { @@ -29,30 +78,137 @@ 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_start", &self.selection_start()) + .field("selection_end", &self.selection_end()) + .field("selection_direction", &self.selection_direction()) + .field("selected_text", &self.selected_text()) + .field("anchor_offset", &self.anchor_offset()) + .field("focus_offset", &self.focus_offset()) + .field("is_collapsed", &self.is_collapsed()) + .field("range_count", &self.range_count()) + .finish() } } impl PartialEq for SelectionData { - fn eq(&self, _other: &Self) -> bool { - true + fn eq(&self, other: &Self) -> bool { + self.selection_start() == other.selection_start() + && self.selection_end() == other.selection_end() + && self.selection_direction() == other.selection_direction() + && self.selected_text() == other.selected_text() + && self.anchor_offset() == other.anchor_offset() + && self.focus_offset() == other.focus_offset() + && self.is_collapsed() == other.is_collapsed() + && self.range_count() == other.range_count() } } #[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, + #[serde(default)] + pub selected_text: String, + #[serde(default)] + pub anchor_offset: Option, + #[serde(default)] + pub focus_offset: Option, + #[serde(default)] + pub is_collapsed: Option, + #[serde(default)] + pub range_count: 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, + selected_text: String, + anchor_offset: Option, + focus_offset: Option, + is_collapsed: Option, + range_count: Option, + ) -> Self { + Self { + selection_start, + selection_end, + selection_direction, + selected_text, + anchor_offset, + focus_offset, + is_collapsed, + range_count, + } + } +} + +#[cfg(feature = "serialize")] +impl Default for SerializedSelectionData { + fn default() -> Self { + Self::new(None, None, None, String::new(), None, None, None, None) + } +} #[cfg(feature = "serialize")] impl From<&SelectionData> for SerializedSelectionData { - fn from(_: &SelectionData) -> Self { - Self {} + fn from(data: &SelectionData) -> Self { + Self::new( + data.selection_start(), + data.selection_end(), + data.selection_direction(), + data.selected_text(), + data.anchor_offset(), + data.focus_offset(), + data.is_collapsed(), + data.range_count(), + ) } } #[cfg(feature = "serialize")] impl HasSelectionData for SerializedSelectionData { + fn selection_start(&self) -> Option { + self.selection_start + } + + fn selection_end(&self) -> Option { + self.selection_end + } + + fn selection_direction(&self) -> Option { + self.selection_direction.clone() + } + + fn selected_text(&self) -> String { + self.selected_text.clone() + } + + fn anchor_offset(&self) -> Option { + self.anchor_offset + } + + fn focus_offset(&self) -> Option { + self.focus_offset + } + + fn is_collapsed(&self) -> Option { + self.is_collapsed + } + + fn range_count(&self) -> Option { + self.range_count + } + fn as_any(&self) -> &dyn std::any::Any { self } @@ -76,6 +232,80 @@ impl<'de> serde::Deserialize<'de> for SelectionData { } pub trait HasSelectionData: std::any::Any { + /// The start offset of the selected text in a text control. + fn selection_start(&self) -> Option { + None + } + + /// The end offset of the selected text in a text control. + fn selection_end(&self) -> Option { + None + } + + /// The direction of the selection in a text control. + fn selection_direction(&self) -> Option { + None + } + + /// The selected text. + fn selected_text(&self) -> String { + String::new() + } + + /// The anchor offset of the current document selection. + fn anchor_offset(&self) -> Option { + None + } + + /// The focus offset of the current document selection. + fn focus_offset(&self) -> Option { + None + } + + /// Whether the current document selection is collapsed. + fn is_collapsed(&self) -> Option { + None + } + + /// The number of ranges in the current document selection. + fn range_count(&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("forward".to_string()), + "abc".to_string(), + Some(2), + Some(5), + Some(false), + Some(1), + )); + + assert_eq!(event.selection_start(), Some(1)); + assert_eq!(event.selection_end(), Some(4)); + assert_eq!(event.selection_direction().as_deref(), Some("forward")); + assert_eq!(event.selected_text(), "abc"); + assert_eq!(event.anchor_offset(), Some(2)); + assert_eq!(event.focus_offset(), Some(5)); + assert_eq!(event.is_collapsed(), Some(false)); + assert_eq!(event.range_count(), Some(1)); + } +} diff --git a/packages/interpreter/src/js/hash.txt b/packages/interpreter/src/js/hash.txt index 1180998a50..2b31d9f936 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, 15820664189553920704, 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..055c7bc2a4 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_start(&self) -> Option { + with_text_control(&self.event, |input| { + input + .selection_start() + .ok() + .flatten() + .map(|value| value as usize) + }) + .flatten() + } + + fn selection_end(&self) -> Option { + with_text_control(&self.event, |input| { + input + .selection_end() + .ok() + .flatten() + .map(|value| value as usize) + }) + .flatten() + } + + fn selection_direction(&self) -> Option { + with_text_control(&self.event, |input| { + input.selection_direction().ok().flatten() + }) + .flatten() + } + + fn selected_text(&self) -> String { + if let Some(text) = with_text_control(&self.event, selected_text_in_control) { + return text; + } + + web_sys::window() + .and_then(|window| window.get_selection().ok().flatten()) + .map(|selection| selection.to_string().as_string().unwrap_or_default()) + .unwrap_or_default() + } + + fn anchor_offset(&self) -> Option { + web_sys::window() + .and_then(|window| window.get_selection().ok().flatten()) + .map(|selection| selection.anchor_offset() as usize) + } + + fn focus_offset(&self) -> Option { + web_sys::window() + .and_then(|window| window.get_selection().ok().flatten()) + .map(|selection| selection.focus_offset() as usize) + } + + fn is_collapsed(&self) -> Option { + web_sys::window() + .and_then(|window| window.get_selection().ok().flatten()) + .map(|selection| selection.is_collapsed()) + } + + fn range_count(&self) -> Option { + web_sys::window() + .and_then(|window| window.get_selection().ok().flatten()) + .map(|selection| selection.range_count() as usize) + } -impl HasSelectionData for Synthetic { fn as_any(&self) -> &dyn std::any::Any { &self.event } @@ -15,3 +81,88 @@ 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(), + } + } + + fn value(&self) -> String { + match self { + Self::Input(input) => input.value(), + Self::TextArea(textarea) => textarea.value(), + } + } +} + +fn selected_text_in_control(control: TextControl<'_>) -> String { + let start = control + .selection_start() + .ok() + .flatten() + .map(|value| value as usize) + .unwrap_or_default(); + let end = control + .selection_end() + .ok() + .flatten() + .map(|value| value as usize) + .unwrap_or(start); + let value = control.value(); + let start = byte_index_for_utf16(&value, start); + let end = byte_index_for_utf16(&value, end); + + value[start.min(end)..end.max(start)].to_string() +} + +fn byte_index_for_utf16(value: &str, utf16_offset: usize) -> usize { + let mut current = 0; + for (byte_index, c) in value.char_indices() { + if current >= utf16_offset { + return byte_index; + } + + let next = current + c.len_utf16(); + if next > utf16_offset { + return byte_index; + } + + current = next; + } + + value.len() +} From dde5f17110244227904ce908afdc0cc8294dd168 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 19 May 2026 14:38:35 -0500 Subject: [PATCH 2/3] simpler api --- examples/09-reference/all_events.rs | 16 +- packages/html/src/events/selection.rs | 247 +++++++---------------- packages/interpreter/src/js/hash.txt | 2 +- packages/interpreter/src/js/native.js | 2 +- packages/interpreter/src/ts/serialize.ts | 11 - packages/web/src/events/selection.rs | 120 ++--------- 6 files changed, 102 insertions(+), 296 deletions(-) diff --git a/examples/09-reference/all_events.rs b/examples/09-reference/all_events.rs index 2f12c3bfea..7867e2ead3 100644 --- a/examples/09-reference/all_events.rs +++ b/examples/09-reference/all_events.rs @@ -55,22 +55,22 @@ fn app() -> Element { 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 selection event data" + "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(event.data()), - onselectstart: move |event: Event| log_event(event.data()), - onselectionchange: move |event: Event| log_event(event.data()), + 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(event.data()), - onselectstart: move |event: Event| log_event(event.data()), - onselectionchange: move |event: Event| log_event(event.data()), - "Selection events also include textarea ranges and selected text.", + 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 { diff --git a/packages/html/src/events/selection.rs b/packages/html/src/events/selection.rs index ca9e9b56ca..f62110982a 100644 --- a/packages/html/src/events/selection.rs +++ b/packages/html/src/events/selection.rs @@ -1,66 +1,69 @@ use dioxus_core::Event; +use std::ops::Range; pub type SelectionEvent = Event; -pub struct SelectionData { - inner: Box, +/// 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, } -impl SelectionData { - /// Create a new SelectionData - pub fn new(inner: impl HasSelectionData + 'static) -> Self { - Self { - inner: Box::new(inner), - } - } - - /// The start offset of the selected text in a text control. - /// - /// This is measured in UTF-16 code units and is only available for text - /// controls that expose `selectionStart`. - pub fn selection_start(&self) -> Option { - self.inner.selection_start() - } +/// 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, +} - /// The end offset of the selected text in a text control. - /// - /// This is measured in UTF-16 code units and is only available for text - /// controls that expose `selectionEnd`. - pub fn selection_end(&self) -> Option { - self.inner.selection_end() +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 direction of the selection in a text control. - /// - /// Browsers return `"forward"`, `"backward"`, or `"none"` when this data is - /// available. - pub fn selection_direction(&self) -> Option { - self.inner.selection_direction() + /// The selected UTF-16 range. + pub fn range(&self) -> Range { + self.range.clone() } - /// The selected text, if it can be read from the event target or document. - pub fn selected_text(&self) -> String { - self.inner.selected_text() + /// The direction the range was selected in. + pub fn direction(&self) -> SelectionDirection { + self.direction } - /// The anchor offset of the current document selection. - pub fn anchor_offset(&self) -> Option { - self.inner.anchor_offset() + /// Returns `true` if the selection is a caret with no selected text. + pub fn is_collapsed(&self) -> bool { + self.range.is_empty() } +} - /// The focus offset of the current document selection. - pub fn focus_offset(&self) -> Option { - self.inner.focus_offset() - } +pub struct SelectionData { + inner: Box, +} - /// Whether the current document selection is collapsed. - pub fn is_collapsed(&self) -> Option { - self.inner.is_collapsed() +impl SelectionData { + /// Create a new SelectionData + pub fn new(inner: impl HasSelectionData + 'static) -> Self { + Self { + inner: Box::new(inner), + } } - /// The number of ranges in the current document selection. - pub fn range_count(&self) -> Option { - self.inner.range_count() + /// The selection inside a text control. + pub fn selection(&self) -> Option { + self.inner.selection() } /// Downcast this event to a concrete event type @@ -79,28 +82,14 @@ 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") - .field("selection_start", &self.selection_start()) - .field("selection_end", &self.selection_end()) - .field("selection_direction", &self.selection_direction()) - .field("selected_text", &self.selected_text()) - .field("anchor_offset", &self.anchor_offset()) - .field("focus_offset", &self.focus_offset()) - .field("is_collapsed", &self.is_collapsed()) - .field("range_count", &self.range_count()) + .field("selection", &self.selection()) .finish() } } impl PartialEq for SelectionData { fn eq(&self, other: &Self) -> bool { - self.selection_start() == other.selection_start() - && self.selection_end() == other.selection_end() - && self.selection_direction() == other.selection_direction() - && self.selected_text() == other.selected_text() - && self.anchor_offset() == other.anchor_offset() - && self.focus_offset() == other.focus_offset() - && self.is_collapsed() == other.is_collapsed() - && self.range_count() == other.range_count() + self.selection() == other.selection() } } @@ -113,17 +102,7 @@ pub struct SerializedSelectionData { #[serde(default)] pub selection_end: Option, #[serde(default)] - pub selection_direction: Option, - #[serde(default)] - pub selected_text: String, - #[serde(default)] - pub anchor_offset: Option, - #[serde(default)] - pub focus_offset: Option, - #[serde(default)] - pub is_collapsed: Option, - #[serde(default)] - pub range_count: Option, + pub selection_direction: Option, } #[cfg(feature = "serialize")] @@ -132,22 +111,12 @@ impl SerializedSelectionData { pub fn new( selection_start: Option, selection_end: Option, - selection_direction: Option, - selected_text: String, - anchor_offset: Option, - focus_offset: Option, - is_collapsed: Option, - range_count: Option, + selection_direction: Option, ) -> Self { Self { selection_start, selection_end, selection_direction, - selected_text, - anchor_offset, - focus_offset, - is_collapsed, - range_count, } } } @@ -155,58 +124,34 @@ impl SerializedSelectionData { #[cfg(feature = "serialize")] impl Default for SerializedSelectionData { fn default() -> Self { - Self::new(None, None, None, String::new(), None, None, None, None) + Self::new(None, None, None) } } #[cfg(feature = "serialize")] impl From<&SelectionData> for SerializedSelectionData { fn from(data: &SelectionData) -> Self { + let Some(selection) = data.selection() else { + return Self::default(); + }; + let range = selection.range(); Self::new( - data.selection_start(), - data.selection_end(), - data.selection_direction(), - data.selected_text(), - data.anchor_offset(), - data.focus_offset(), - data.is_collapsed(), - data.range_count(), + Some(range.start), + Some(range.end), + Some(selection.direction()), ) } } #[cfg(feature = "serialize")] impl HasSelectionData for SerializedSelectionData { - fn selection_start(&self) -> Option { - self.selection_start - } - - fn selection_end(&self) -> Option { - self.selection_end - } - - fn selection_direction(&self) -> Option { - self.selection_direction.clone() - } - - fn selected_text(&self) -> String { - self.selected_text.clone() - } - - fn anchor_offset(&self) -> Option { - self.anchor_offset - } - - fn focus_offset(&self) -> Option { - self.focus_offset - } - - fn is_collapsed(&self) -> Option { - self.is_collapsed - } - - fn range_count(&self) -> Option { - self.range_count + 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 { @@ -232,43 +177,8 @@ impl<'de> serde::Deserialize<'de> for SelectionData { } pub trait HasSelectionData: std::any::Any { - /// The start offset of the selected text in a text control. - fn selection_start(&self) -> Option { - None - } - - /// The end offset of the selected text in a text control. - fn selection_end(&self) -> Option { - None - } - - /// The direction of the selection in a text control. - fn selection_direction(&self) -> Option { - None - } - - /// The selected text. - fn selected_text(&self) -> String { - String::new() - } - - /// The anchor offset of the current document selection. - fn anchor_offset(&self) -> Option { - None - } - - /// The focus offset of the current document selection. - fn focus_offset(&self) -> Option { - None - } - - /// Whether the current document selection is collapsed. - fn is_collapsed(&self) -> Option { - None - } - - /// The number of ranges in the current document selection. - fn range_count(&self) -> Option { + /// The selection inside a text control. + fn selection(&self) -> Option { None } @@ -291,21 +201,12 @@ mod tests { let event = SelectionData::new(SerializedSelectionData::new( Some(1), Some(4), - Some("forward".to_string()), - "abc".to_string(), - Some(2), - Some(5), - Some(false), - Some(1), + Some(SelectionDirection::Forward), )); - assert_eq!(event.selection_start(), Some(1)); - assert_eq!(event.selection_end(), Some(4)); - assert_eq!(event.selection_direction().as_deref(), Some("forward")); - assert_eq!(event.selected_text(), "abc"); - assert_eq!(event.anchor_offset(), Some(2)); - assert_eq!(event.focus_offset(), Some(5)); - assert_eq!(event.is_collapsed(), Some(false)); - assert_eq!(event.range_count(), Some(1)); + 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 2b31d9f936..eb337b47e6 100644 --- a/packages/interpreter/src/js/hash.txt +++ b/packages/interpreter/src/js/hash.txt @@ -1 +1 @@ -[17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 18429234726379217184, 5052021921702764563, 15820664189553920704, 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 055c7bc2a4..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.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;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_start(&self) -> Option { + fn selection(&self) -> Option { with_text_control(&self.event, |input| { - input - .selection_start() + 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() - .map(|value| value as usize) - }) - .flatten() - } + .as_deref() + .map(selection_direction_from_web) + .unwrap_or_default(); - fn selection_end(&self) -> Option { - with_text_control(&self.event, |input| { - input - .selection_end() - .ok() - .flatten() - .map(|value| value as usize) - }) - .flatten() - } - - fn selection_direction(&self) -> Option { - with_text_control(&self.event, |input| { - input.selection_direction().ok().flatten() + Some(TextSelection::new(start..end, direction)) }) .flatten() } - fn selected_text(&self) -> String { - if let Some(text) = with_text_control(&self.event, selected_text_in_control) { - return text; - } - - web_sys::window() - .and_then(|window| window.get_selection().ok().flatten()) - .map(|selection| selection.to_string().as_string().unwrap_or_default()) - .unwrap_or_default() - } - - fn anchor_offset(&self) -> Option { - web_sys::window() - .and_then(|window| window.get_selection().ok().flatten()) - .map(|selection| selection.anchor_offset() as usize) - } - - fn focus_offset(&self) -> Option { - web_sys::window() - .and_then(|window| window.get_selection().ok().flatten()) - .map(|selection| selection.focus_offset() as usize) - } - - fn is_collapsed(&self) -> Option { - web_sys::window() - .and_then(|window| window.get_selection().ok().flatten()) - .map(|selection| selection.is_collapsed()) - } - - fn range_count(&self) -> Option { - web_sys::window() - .and_then(|window| window.get_selection().ok().flatten()) - .map(|selection| selection.range_count() as usize) - } - 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; @@ -120,49 +81,4 @@ impl TextControl<'_> { Self::TextArea(textarea) => textarea.selection_direction(), } } - - fn value(&self) -> String { - match self { - Self::Input(input) => input.value(), - Self::TextArea(textarea) => textarea.value(), - } - } -} - -fn selected_text_in_control(control: TextControl<'_>) -> String { - let start = control - .selection_start() - .ok() - .flatten() - .map(|value| value as usize) - .unwrap_or_default(); - let end = control - .selection_end() - .ok() - .flatten() - .map(|value| value as usize) - .unwrap_or(start); - let value = control.value(); - let start = byte_index_for_utf16(&value, start); - let end = byte_index_for_utf16(&value, end); - - value[start.min(end)..end.max(start)].to_string() -} - -fn byte_index_for_utf16(value: &str, utf16_offset: usize) -> usize { - let mut current = 0; - for (byte_index, c) in value.char_indices() { - if current >= utf16_offset { - return byte_index; - } - - let next = current + c.len_utf16(); - if next > utf16_offset { - return byte_index; - } - - current = next; - } - - value.len() } From 6d7f55a90d80799f10e33de6f78915cd2d323bb0 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 19 May 2026 14:48:56 -0500 Subject: [PATCH 3/3] document this is only Some on input/textarea elements --- packages/html/src/events/selection.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/html/src/events/selection.rs b/packages/html/src/events/selection.rs index f62110982a..ffb25ffefc 100644 --- a/packages/html/src/events/selection.rs +++ b/packages/html/src/events/selection.rs @@ -62,6 +62,12 @@ 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() } @@ -178,6 +184,10 @@ 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 }