diff --git a/crates/op-editor-core/src/collab_panel_hover.rs b/crates/op-editor-core/src/collab_panel_hover.rs index 3db7d9d49..f455f98d6 100644 --- a/crates/op-editor-core/src/collab_panel_hover.rs +++ b/crates/op-editor-core/src/collab_panel_hover.rs @@ -14,6 +14,7 @@ pub enum CollabPanelHover { Connect, Cancel, JoinAddress, + ClearJoinAddress, Discovered(usize), Retry, Leave, diff --git a/crates/op-editor-core/src/collab_ui_debug.rs b/crates/op-editor-core/src/collab_ui_debug.rs index 4d240a57c..83c2d052d 100644 --- a/crates/op-editor-core/src/collab_ui_debug.rs +++ b/crates/op-editor-core/src/collab_ui_debug.rs @@ -12,6 +12,7 @@ impl fmt::Debug for CollabPanelState { .field("view", &self.view) .field("join_address", &"[REDACTED]") .field("join_address_focused", &self.join_address_focused) + .field("join_address_selected", &self.join_address_selected) .field("hover", &self.hover) .field("discovered", &self.discovered) .finish() diff --git a/crates/op-editor-core/src/collab_ui_state.rs b/crates/op-editor-core/src/collab_ui_state.rs index 9fca22948..991d2eca7 100644 --- a/crates/op-editor-core/src/collab_ui_state.rs +++ b/crates/op-editor-core/src/collab_ui_state.rs @@ -112,6 +112,9 @@ pub struct CollabPanelState { pub view: CollabPanelView, pub join_address: String, pub join_address_focused: bool, + /// Whole-field selection for the plain-string join field (Cmd/Ctrl+A). + /// The next typed character or Backspace replaces/clears the field. + pub join_address_selected: bool, pub hover: Option, pub discovered: Arc>, } diff --git a/crates/op-editor-core/src/host_keyboard_transitions.rs b/crates/op-editor-core/src/host_keyboard_transitions.rs index 7db4089e3..0b1cbfa53 100644 --- a/crates/op-editor-core/src/host_keyboard_transitions.rs +++ b/crates/op-editor-core/src/host_keyboard_transitions.rs @@ -92,7 +92,10 @@ impl EditorUiState { } /// Drop even a stale Join-field focus bit when another surface takes over. + /// The whole-field selection dies with the focus so a later refocus never + /// resurrects a destructive replace-on-type state. pub fn blur_collab_join_input(&mut self) -> bool { + self.collab.panel.join_address_selected = false; std::mem::take(&mut self.collab.panel.join_address_focused) } diff --git a/crates/op-editor-ui/src/widgets/collab_panel.rs b/crates/op-editor-ui/src/widgets/collab_panel.rs index e71608f25..9382b5239 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel.rs @@ -33,6 +33,7 @@ const INPUT_HEIGHT: f32 = 32.0; const ACTION_HEIGHT: f32 = 32.0; const ACTION_GAP: f32 = 8.0; const NOTICE_HEIGHT: f32 = 42.0; +const CLEAR_BUTTON_SIZE: f32 = 22.0; const CONNECTION_PATH_HEIGHT: f32 = 28.0; const INVITE_HEIGHT: f32 = 48.0; const SHARE_ENDPOINT_HEIGHT: f32 = 38.0; @@ -45,6 +46,7 @@ const MAX_VISIBLE_ENDPOINTS: usize = 6; pub enum CollabPanelHit { Close, FocusJoinAddress, + ClearJoinAddress, OpenSignIn, CopyInvite(String), CopyShareEndpoint(String), @@ -57,6 +59,7 @@ impl std::fmt::Debug for CollabPanelHit { match self { Self::Close => formatter.write_str("Close"), Self::FocusJoinAddress => formatter.write_str("FocusJoinAddress"), + Self::ClearJoinAddress => formatter.write_str("ClearJoinAddress"), Self::OpenSignIn => formatter.write_str("OpenSignIn"), Self::CopyInvite(_) => formatter.write_str("CopyInvite([REDACTED])"), Self::CopyShareEndpoint(_) => formatter.write_str("CopyShareEndpoint([REDACTED])"), @@ -229,8 +232,10 @@ impl Widget for CollabPanel<'_> { ); let input = self.address_rect(rect, body_top + 22.0); cx.backend.fill_round_rect(input, 6.0, self.theme.input); - let input_hovered = - self.ui.collab.panel.hover == Some(CollabPanelHover::JoinAddress); + let input_hovered = matches!( + self.ui.collab.panel.hover, + Some(CollabPanelHover::JoinAddress | CollabPanelHover::ClearJoinAddress) + ); if input_hovered && !self.ui.collab.panel.join_address_focused { cx.backend .fill_round_rect(input, 6.0, self.theme.button_hover); @@ -247,13 +252,32 @@ impl Widget for CollabPanel<'_> { }, 1.0, ); + let clear = self.clear_join_rect(rect, body_top + 22.0); + let text_width = if clear.is_some() { + input.size.x - 18.0 - CLEAR_BUTTON_SIZE + } else { + input.size.x - 18.0 + }; let shown = if address.is_empty() { op_i18n::translate(self.ui.locale, "collab.join.codePlaceholder").to_string() } else { - crate::util::ellipsize_to_width(address, input.size.x - 18.0, |text| { + crate::util::ellipsize_to_width(address, text_width, |text| { cx.backend.measure_text(text, 12.0) }) }; + if !address.is_empty() + && self.ui.collab.panel.join_address_focused + && self.ui.collab.panel.join_address_selected + { + let selection = Rect::xywh( + input.origin.x + 6.0, + input.origin.y + 6.0, + cx.backend.measure_text(&shown, 12.0).min(text_width) + 6.0, + input.size.y - 12.0, + ); + cx.backend + .fill_round_rect(selection, 4.0, self.theme.ring.with_alpha(0.35)); + } paint_text( cx, &shown, @@ -266,6 +290,24 @@ impl Widget for CollabPanel<'_> { Point2D::new(input.origin.x + 9.0, input.origin.y + 21.0), 400, ); + if let Some(clear) = clear { + if self.ui.collab.panel.hover == Some(CollabPanelHover::ClearJoinAddress) { + cx.backend + .fill_round_rect(clear, 6.0, self.theme.button_hover); + } + let icon_size = 12.0; + draw_icon( + cx.backend, + Icon::Close, + Point2D::new( + clear.origin.x + (clear.size.x - icon_size) / 2.0, + clear.origin.y + (clear.size.y - icon_size) / 2.0, + ), + icon_size, + self.theme.muted_foreground, + 1.5, + ); + } paint_text( cx, op_i18n::translate(self.ui.locale, "collab.join.publicHint"), diff --git a/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs b/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs index 41446d785..666517062 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs @@ -35,6 +35,14 @@ impl CollabPanel<'_> { } } CollabPanelScreen::Join { discovered, .. } => { + // The clear affordance sits inside the input rect, so it must + // win before the focus hit. + if self + .clear_join_rect(panel, body_top + 22.0) + .is_some_and(|rect| rect.contains(point)) + { + return Some(CollabPanelHit::ClearJoinAddress); + } if self.address_rect(panel, body_top + 22.0).contains(point) { return Some(CollabPanelHit::FocusJoinAddress); } @@ -123,6 +131,12 @@ impl CollabPanel<'_> { } } CollabPanelScreen::Join { discovered, .. } => { + if self + .clear_join_rect(panel, body_top + 22.0) + .is_some_and(|rect| rect.contains(point)) + { + return Some(CollabPanelHover::ClearJoinAddress); + } if self.address_rect(panel, body_top + 22.0).contains(point) { return Some(CollabPanelHover::JoinAddress); } @@ -293,6 +307,21 @@ impl CollabPanel<'_> { ) } + /// Clear (×) affordance inside the join field. `None` while the field is + /// empty so an idle input never paints or hit-tests a dead button. + pub(super) fn clear_join_rect(&self, panel: Rect, y: f32) -> Option { + if self.ui.collab.panel.join_address.is_empty() { + return None; + } + let input = self.address_rect(panel, y); + Some(Rect::xywh( + input.origin.x + input.size.x - CLEAR_BUTTON_SIZE - 5.0, + input.origin.y + (input.size.y - CLEAR_BUTTON_SIZE) / 2.0, + CLEAR_BUTTON_SIZE, + CLEAR_BUTTON_SIZE, + )) + } + pub(super) fn sign_in_rect(&self, panel: Rect, body_top: f32) -> Rect { Rect::xywh( panel.origin.x + PAD, diff --git a/crates/op-editor-ui/src/widgets/collab_panel_tests.rs b/crates/op-editor-ui/src/widgets/collab_panel_tests.rs index 829f8de29..ebdfe647b 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel_tests.rs @@ -713,3 +713,50 @@ fn share_address_label_is_localized() { "分享地址" ); } + +#[test] +fn join_clear_button_wins_inside_the_input_and_needs_content() { + let mut ui = EditorUiState::default(); + ui.collab.availability = CollabAvailability::Ready; + ui.collab.panel.open = true; + ui.collab.panel.view = CollabPanelView::Join; + ui.collab.panel.join_address = "opc1_public-invite".into(); + + let panel = CollabPanel::for_editor_ui(&ui).unwrap(); + let rect = panel.rect_at(Rect::xywh(600.0, 8.0, 100.0, 26.0), viewport()); + let body_top = panel.body_top(rect); + let clear = panel + .clear_join_rect(rect, body_top + 22.0) + .expect("non-empty field exposes the clear affordance"); + assert!( + panel + .address_rect(rect, body_top + 22.0) + .contains(center(clear)), + "clear button sits inside the input" + ); + assert_eq!( + panel.hit_test(rect, center(clear)), + Some(CollabPanelHit::ClearJoinAddress) + ); + assert_eq!( + panel.hover_at(rect, center(clear)), + Some(CollabPanelHover::ClearJoinAddress) + ); + // Outside the button the input still takes focus. + let input = panel.address_rect(rect, body_top + 22.0); + let left = Point2D::new(input.origin.x + 8.0, input.origin.y + input.size.y / 2.0); + assert_eq!( + panel.hit_test(rect, left), + Some(CollabPanelHit::FocusJoinAddress) + ); + + // An empty field paints and hit-tests no dead button. + let mut empty = EditorUiState::default(); + empty.collab.availability = CollabAvailability::Ready; + empty.collab.panel.open = true; + empty.collab.panel.view = CollabPanelView::Join; + let panel = CollabPanel::for_editor_ui(&empty).unwrap(); + let rect = panel.rect_at(Rect::xywh(600.0, 8.0, 100.0, 26.0), viewport()); + let body_top = panel.body_top(rect); + assert!(panel.clear_join_rect(rect, body_top + 22.0).is_none()); +} diff --git a/crates/op-editor-ui/src/widgets/collab_ui.rs b/crates/op-editor-ui/src/widgets/collab_ui.rs index 9f90bc29e..a17c74c71 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui.rs @@ -464,11 +464,22 @@ pub fn apply_panel_hit( CollabPanelHit::Close => { ui.collab.panel.open = false; ui.collab.panel.join_address_focused = false; + ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::FocusJoinAddress => { + // A plain click focuses with a collapsed caret; it never keeps a + // stale whole-field selection alive. ui.collab.panel.join_address_focused = true; + ui.collab.panel.join_address_selected = false; + true + } + CollabPanelHit::ClearJoinAddress => { + ui.collab.panel.join_address.clear(); + ui.collab.panel.join_address_selected = false; + ui.collab.panel.join_address_focused = true; + ui.collab.panel.hover = None; true } CollabPanelHit::OpenSignIn => { @@ -479,6 +490,7 @@ pub fn apply_panel_hit( ui.login_modal_hover = None; ui.collab.panel.open = false; ui.collab.panel.join_address_focused = false; + ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } @@ -488,22 +500,28 @@ pub fn apply_panel_hit( CollabPanelHit::CopyInvite(_) => false, CollabPanelHit::Inside => { ui.collab.panel.join_address_focused = false; + ui.collab.panel.join_address_selected = false; true } CollabPanelHit::Action(CollabUiAction::OpenCreate) => { ui.collab.panel.view = op_editor_core::CollabPanelView::Create; ui.collab.panel.join_address_focused = false; + ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::Action(CollabUiAction::OpenJoin) => { ui.collab.panel.view = op_editor_core::CollabPanelView::Join; ui.collab.panel.join_address_focused = true; + ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::Action(CollabUiAction::BeginDiscovery) => { ui.collab.panel.view = op_editor_core::CollabPanelView::Join; + // Find-nearby keeps the field focused; a surviving whole-field + // selection would make the next keystroke destructive. + ui.collab.panel.join_address_selected = false; request_action(ui, CollabUiAction::BeginDiscovery) } CollabPanelHit::Action(CollabUiAction::Cancel) @@ -511,11 +529,13 @@ pub fn apply_panel_hit( { ui.collab.panel.view = op_editor_core::CollabPanelView::Home; ui.collab.panel.join_address_focused = false; + ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; true } CollabPanelHit::Action(action) => { ui.collab.panel.join_address_focused = false; + ui.collab.panel.join_address_selected = false; request_action(ui, action) } } @@ -527,17 +547,20 @@ pub fn join_address_text(ui: &mut EditorUiState, character: char) -> Option= MAX_JOIN_TARGET_CHARS - { + if character.is_control() { return Some(false); } - // The runtime performs authoritative SocketAddr/hostname validation. - // This presentation filter merely keeps whitespace and shell-like - // punctuation out of the one-line endpoint field. - if !(character.is_ascii_alphanumeric() - || matches!(character, '.' | ':' | '-' | '[' | ']' | '_')) - { + if !join_address_char_allowed(character) { + return Some(false); + } + // A whole-field selection replaces on type, like every range-selection + // input: the first accepted character clears the old value. The length + // cap below deliberately runs AFTER the take — a full field must still + // be replaceable by typing over the selection. + if std::mem::take(&mut ui.collab.panel.join_address_selected) { + ui.collab.panel.join_address.clear(); + } + if ui.collab.panel.join_address.chars().count() >= MAX_JOIN_TARGET_CHARS { return Some(false); } ui.collab.panel.join_address.push(character); @@ -545,10 +568,25 @@ pub fn join_address_text(ui: &mut EditorUiState, character: char) -> Option bool { + character.is_ascii_alphanumeric() || matches!(character, '.' | ':' | '-' | '[' | ']' | '_') +} + pub fn join_address_backspace(ui: &mut EditorUiState) -> Option { if !ui.collab.panel.join_address_focused { return None; } + if std::mem::take(&mut ui.collab.panel.join_address_selected) { + let changed = !ui.collab.panel.join_address.is_empty(); + ui.collab.panel.join_address.clear(); + if changed { + ui.collab.panel.hover = None; + } + return Some(changed); + } let changed = ui.collab.panel.join_address.pop().is_some(); if changed { ui.collab.panel.hover = None; @@ -556,6 +594,38 @@ pub fn join_address_backspace(ui: &mut EditorUiState) -> Option { Some(changed) } +/// Cmd/Ctrl+A on the focused join field — whole-field selection. `None` +/// means the field is not focused and the chord belongs to someone else. +pub fn join_address_select_all(ui: &mut EditorUiState) -> Option { + if !ui.collab.panel.join_address_focused { + return None; + } + let selectable = !ui.collab.panel.join_address.is_empty(); + ui.collab.panel.join_address_selected = selectable; + Some(selectable) +} + +/// Clipboard paste into the focused join field. Replaces the whole field — +/// an invite code is pasted as a unit, and append semantics silently +/// produced corrupt old+new concatenations. `None` means not focused. +pub fn join_address_paste(ui: &mut EditorUiState, text: &str) -> Option { + if !ui.collab.panel.join_address_focused { + return None; + } + let sanitized: String = text + .chars() + .filter(|character| !character.is_control() && join_address_char_allowed(*character)) + .take(MAX_JOIN_TARGET_CHARS) + .collect(); + if sanitized.is_empty() { + return Some(false); + } + ui.collab.panel.join_address = sanitized; + ui.collab.panel.join_address_selected = false; + ui.collab.panel.hover = None; + Some(true) +} + pub fn join_address_submit(ui: &mut EditorUiState) -> Option { if !ui.collab.panel.join_address_focused { return None; @@ -568,6 +638,7 @@ pub fn join_address_submit(ui: &mut EditorUiState) -> Option { endpoint: endpoint.to_string(), }; ui.collab.panel.join_address_focused = false; + ui.collab.panel.join_address_selected = false; ui.collab.panel.hover = None; Some(request_action(ui, action)) } diff --git a/crates/op-editor-ui/src/widgets/collab_ui_tests.rs b/crates/op-editor-ui/src/widgets/collab_ui_tests.rs index f315f8a0a..bd68bc51c 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui_tests.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui_tests.rs @@ -268,3 +268,85 @@ fn conflict_notice_names_the_discarded_fields_and_offers_reapply() { ui.collab.clear_authenticated(); assert!(ui.collab.discarded_edit.is_none()); } + +#[test] +fn paste_replaces_the_whole_join_field() { + let mut ui = EditorUiState::default(); + ui.collab.panel.join_address_focused = true; + ui.collab.panel.join_address = "opc1_stale-old-code".into(); + + assert_eq!(join_address_paste(&mut ui, "opc1_fresh_code\n"), Some(true)); + assert_eq!(ui.collab.panel.join_address, "opc1_fresh_code"); + + // Whitespace-only payloads change nothing rather than clearing the field. + assert_eq!(join_address_paste(&mut ui, " \n\t"), Some(false)); + assert_eq!(ui.collab.panel.join_address, "opc1_fresh_code"); + + ui.collab.panel.join_address_focused = false; + assert_eq!(join_address_paste(&mut ui, "opc1_x"), None); +} + +#[test] +fn select_all_then_backspace_clears_and_type_replaces() { + let mut ui = EditorUiState::default(); + ui.collab.panel.join_address_focused = true; + ui.collab.panel.join_address = "opc1_very-long-invite".into(); + + assert_eq!(join_address_select_all(&mut ui), Some(true)); + assert!(ui.collab.panel.join_address_selected); + assert_eq!(join_address_backspace(&mut ui), Some(true)); + assert!(ui.collab.panel.join_address.is_empty()); + assert!(!ui.collab.panel.join_address_selected); + + // Select-all on an empty field selects nothing. + assert_eq!(join_address_select_all(&mut ui), Some(false)); + assert!(!ui.collab.panel.join_address_selected); + + ui.collab.panel.join_address = "opc1_old".into(); + assert_eq!(join_address_select_all(&mut ui), Some(true)); + assert_eq!(join_address_text(&mut ui, 'x'), Some(true)); + assert_eq!(ui.collab.panel.join_address, "x"); + assert!(!ui.collab.panel.join_address_selected); +} + +#[test] +fn clear_hit_empties_the_field_and_keeps_focus() { + let mut ui = EditorUiState::default(); + ui.collab.availability = CollabAvailability::Ready; + ui.collab.panel.open = true; + ui.collab.panel.view = CollabPanelView::Join; + ui.collab.panel.join_address = "opc1_something".into(); + ui.collab.panel.join_address_selected = true; + + assert!(apply_panel_hit( + &mut ui, + crate::widgets::collab_panel::CollabPanelHit::ClearJoinAddress, + )); + assert!(ui.collab.panel.join_address.is_empty()); + assert!(ui.collab.panel.join_address_focused); + assert!(!ui.collab.panel.join_address_selected); +} + +#[test] +fn blur_paths_drop_the_whole_field_selection() { + let mut ui = EditorUiState::default(); + ui.collab.panel.join_address_focused = true; + ui.collab.panel.join_address = "opc1_abc".into(); + ui.collab.panel.join_address_selected = true; + + assert!(apply_panel_hit( + &mut ui, + crate::widgets::collab_panel::CollabPanelHit::Inside, + )); + assert!(!ui.collab.panel.join_address_focused); + assert!(!ui.collab.panel.join_address_selected); + + // Re-focusing by click never resurrects a stale selection. + ui.collab.panel.join_address_selected = true; + assert!(apply_panel_hit( + &mut ui, + crate::widgets::collab_panel::CollabPanelHit::FocusJoinAddress, + )); + assert!(ui.collab.panel.join_address_focused); + assert!(!ui.collab.panel.join_address_selected); +} diff --git a/crates/op-host-native/src/widget_host/collab_input_tests.rs b/crates/op-host-native/src/widget_host/collab_input_tests.rs index 615e761b1..3e6a411a6 100644 --- a/crates/op-host-native/src/widget_host/collab_input_tests.rs +++ b/crates/op-host-native/src/widget_host/collab_input_tests.rs @@ -260,3 +260,64 @@ fn native_collab_sign_in_hands_press_ownership_to_modal() { assert!(!host.editor_state().editor_ui.login_modal_open); assert!(host.editor_state().editor_ui.collab.panel.open); } + +#[test] +fn native_join_paste_replaces_and_select_all_clears() { + let mut host = WidgetHostNative::new(); + focus_join(&mut host); + + assert!(host.apply_input_paste("opc1_first-code")); + assert!(host.apply_input_paste("opc1_second-code")); + assert_eq!( + host.editor_state().editor_ui.collab.panel.join_address, + "opc1_second-code", + "a pasted invite replaces the stale one instead of appending" + ); + + // Cmd/Ctrl+A owns the chord and selects the whole field... + assert!(host.apply_select_all()); + assert!( + host.editor_state() + .editor_ui + .collab + .panel + .join_address_selected + ); + // ...so one Backspace clears it. + assert!(host.apply_backspace()); + assert!(host + .editor_state() + .editor_ui + .collab + .panel + .join_address + .is_empty()); +} + +#[test] +fn native_join_delete_clears_selection_and_never_deletes_nodes() { + let mut host = host_with_selected_node(); + let before = host.editor_state().active_children().len(); + host.editor_state_mut().editor_ui.collab.panel.join_address = "opc1_code".into(); + host.editor_state_mut() + .editor_ui + .collab + .panel + .join_address_selected = true; + + assert!(host.apply_delete()); + assert!(host + .editor_state() + .editor_ui + .collab + .panel + .join_address + .is_empty()); + // Delete with an empty, unselected field is still swallowed. + assert!(!host.apply_delete()); + assert_eq!( + host.editor_state().active_children().len(), + before, + "canvas selection behind the panel must survive" + ); +} diff --git a/crates/op-host-native/src/widget_host/keyboard_clipboard.rs b/crates/op-host-native/src/widget_host/keyboard_clipboard.rs index dcb277c1a..85314e33c 100644 --- a/crates/op-host-native/src/widget_host/keyboard_clipboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard_clipboard.rs @@ -31,6 +31,20 @@ impl WidgetHostNative { /// dropped since these inputs are single-line. Returns `true` if /// anything was inserted. pub fn apply_input_paste(&mut self, text: &str) -> bool { + // The join field takes a pasted invite code as a whole-field + // replacement: char-by-char append silently concatenated a new code + // onto a stale one, producing an invalid join target. + if self.editor_state.editor_ui.collab_join_input_active() { + let changed = op_editor_ui::widgets::collab_ui::join_address_paste( + &mut self.editor_state.editor_ui, + text, + ) + .unwrap_or(false); + if changed { + self.mark_dirty(); + } + return true; + } let mut inserted = false; for c in text.chars() { if c.is_control() { diff --git a/crates/op-host-native/src/widget_host/keyboard_delete.rs b/crates/op-host-native/src/widget_host/keyboard_delete.rs index 3b15707d9..2ed62fa09 100644 --- a/crates/op-host-native/src/widget_host/keyboard_delete.rs +++ b/crates/op-host-native/src/widget_host/keyboard_delete.rs @@ -267,6 +267,26 @@ impl WidgetHostNative { if self.apply_image_panel_delete() { return true; } + // A whole-field selection in the join input makes Delete a clear. + // Without one, `delete_owned_by_chrome_input` below still swallows + // the key before it can reach the canvas selection. + if self.editor_state.editor_ui.collab_join_input_active() + && self + .editor_state + .editor_ui + .collab + .panel + .join_address_selected + { + let changed = op_editor_ui::widgets::collab_ui::join_address_backspace( + &mut self.editor_state.editor_ui, + ) + .unwrap_or(false); + if changed { + self.mark_dirty(); + } + return true; + } // The open font picker owns Delete. Its search draft handles // Backspace separately; forward-delete must never reach the canvas // selection behind the overlay. diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index a50900e5e..6bfca0627 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -180,6 +180,15 @@ impl WidgetHostNative { } fn apply_input_select_all(&mut self) -> bool { + if self.editor_state.editor_ui.collab_join_input_active() { + if op_editor_ui::widgets::collab_ui::join_address_select_all( + &mut self.editor_state.editor_ui, + ) == Some(true) + { + self.mark_dirty(); + } + return true; + } if self.apply_image_panel_select_all() { return true; } diff --git a/crates/op-host-web/src/dom_io.rs b/crates/op-host-web/src/dom_io.rs index e47f740f1..782949a36 100644 --- a/crates/op-host-web/src/dom_io.rs +++ b/crates/op-host-web/src/dom_io.rs @@ -562,7 +562,7 @@ fn handle_paste_event( let text = dt.get_data("text/plain").unwrap_or_default(); if !text.is_empty() { let mut b = inner.borrow_mut(); - if b.host_mut().apply_paste_text(&text) { + if b.host_mut().apply_clipboard_text(&text) { let _ = b.repaint(); } } @@ -621,7 +621,7 @@ fn handle_paste_event( let text = dt.get_data("text/plain").unwrap_or_default(); if !text.is_empty() { let mut b = inner.borrow_mut(); - if b.host_mut().apply_paste_text(&text) { + if b.host_mut().apply_clipboard_text(&text) { evt.prevent_default(); let _ = b.repaint(); return; diff --git a/crates/op-host-web/src/widget_host/collab_input_tests.rs b/crates/op-host-web/src/widget_host/collab_input_tests.rs index e771e79b5..1989a732e 100644 --- a/crates/op-host-web/src/widget_host/collab_input_tests.rs +++ b/crates/op-host-web/src/widget_host/collab_input_tests.rs @@ -249,3 +249,41 @@ fn web_collab_sign_in_hands_press_ownership_to_modal() { assert!(!host.editor_state.editor_ui.login_modal_open); assert!(host.editor_state.editor_ui.collab.panel.open); } + +#[test] +fn web_join_clipboard_replaces_and_select_all_clears() { + let mut host = WidgetHost::new(); + focus_join(&mut host); + + assert!(host.apply_clipboard_text("opc1_first-code")); + assert!(host.apply_clipboard_text("opc1_second-code")); + assert_eq!( + host.editor_state.editor_ui.collab.panel.join_address, "opc1_second-code", + "a pasted invite replaces the stale one instead of appending" + ); + + // IME commits keep insert semantics — only the clipboard replaces. + let ime = crate::event::ime::composition_end("Z".to_string()); + assert!(host.apply_ime(&ime)); + assert_eq!( + host.editor_state.editor_ui.collab.panel.join_address, + "opc1_second-codeZ" + ); + + assert!(host.apply_select_all()); + assert!( + host.editor_state + .editor_ui + .collab + .panel + .join_address_selected + ); + assert!(host.apply_backspace()); + assert!(host + .editor_state + .editor_ui + .collab + .panel + .join_address + .is_empty()); +} diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index 6d10aa82c..ed4426e16 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -338,6 +338,26 @@ impl WidgetHost { if self.apply_image_panel_delete() { return true; } + // A whole-field selection in the join input makes Delete a clear. + // Without one, `delete_owned_by_chrome_input` below still swallows + // the key before it can reach the canvas selection. + if self.editor_state.editor_ui.collab_join_input_active() + && self + .editor_state + .editor_ui + .collab + .panel + .join_address_selected + { + let changed = op_editor_ui::widgets::collab_ui::join_address_backspace( + &mut self.editor_state.editor_ui, + ) + .unwrap_or(false); + if changed { + self.mark_dirty(); + } + return true; + } // The open font picker owns Delete. Its search draft handles // Backspace separately; forward-delete must never reach the canvas // selection behind the overlay. diff --git a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs index ab526a2d5..9068025ee 100644 --- a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs +++ b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs @@ -180,6 +180,15 @@ impl WidgetHost { } fn apply_input_select_all(&mut self) -> bool { + if self.editor_state.editor_ui.collab_join_input_active() { + if op_editor_ui::widgets::collab_ui::join_address_select_all( + &mut self.editor_state.editor_ui, + ) == Some(true) + { + self.mark_dirty(); + } + return true; + } if self.apply_image_panel_select_all() { return true; } diff --git a/crates/op-host-web/src/widget_host/keyboard_ime.rs b/crates/op-host-web/src/widget_host/keyboard_ime.rs index f5b5ff47a..d7e045daf 100644 --- a/crates/op-host-web/src/widget_host/keyboard_ime.rs +++ b/crates/op-host-web/src/widget_host/keyboard_ime.rs @@ -95,6 +95,28 @@ impl WidgetHost { } consumed } + + /// Clipboard-paste routing. Identical to `apply_paste_text` except that + /// the join field takes the payload as a whole-field replacement: an + /// invite code is pasted as a unit, and char-by-char append silently + /// concatenated a new code onto a stale one. IME commits must NOT come + /// through here — mid-composition text is an insertion, not a paste. + pub fn apply_clipboard_text(&mut self, text: &str) -> bool { + if self.editor_state.editor_ui.collab_join_input_active() { + let changed = op_editor_ui::widgets::collab_ui::join_address_paste( + &mut self.editor_state.editor_ui, + text, + ) + .unwrap_or(false); + if changed { + self.mark_dirty(); + } + // Consumed either way: the browser default must never route the + // payload into the hidden IME input behind the focused field. + return true; + } + self.apply_paste_text(text) + } } #[cfg(test)]