fix(editor): make the collab join field paste-replace, selectable and clearable

A pasted invite code now replaces the whole field instead of appending to
stale content, Cmd/Ctrl+A selects the field so Backspace/Delete clears it
in one stroke, and a clear button sits inside the input. IME commits keep
insert semantics; only clipboard paths replace. Every blur and navigation
arm drops the whole-field selection so no destructive replace-on-type
state survives a click.
This commit is contained in:
Kayshen-X 2026-08-02 08:30:12 +08:00
parent 45d962938b
commit 25eb2f0cc1
18 changed files with 486 additions and 14 deletions

View file

@ -14,6 +14,7 @@ pub enum CollabPanelHover {
Connect,
Cancel,
JoinAddress,
ClearJoinAddress,
Discovered(usize),
Retry,
Leave,

View file

@ -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()

View file

@ -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<CollabPanelHover>,
pub discovered: Arc<Vec<DiscoveredCollabEndpoint>>,
}

View file

@ -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)
}

View file

@ -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"),

View file

@ -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<Rect> {
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,

View file

@ -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());
}

View file

@ -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<bool
if !ui.collab.panel.join_address_focused {
return None;
}
if character.is_control()
|| ui.collab.panel.join_address.chars().count() >= 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
Some(true)
}
/// Presentation filter for the invite-or-`host:port` field. The runtime
/// performs authoritative validation; this merely keeps whitespace and
/// shell-like punctuation out of the one-line endpoint field.
fn join_address_char_allowed(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '.' | ':' | '-' | '[' | ']' | '_')
}
pub fn join_address_backspace(ui: &mut EditorUiState) -> Option<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
if !ui.collab.panel.join_address_focused {
return None;
@ -568,6 +638,7 @@ pub fn join_address_submit(ui: &mut EditorUiState) -> Option<bool> {
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))
}

View file

@ -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);
}

View file

@ -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"
);
}

View file

@ -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() {

View file

@ -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.

View file

@ -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;
}

View file

@ -562,7 +562,7 @@ fn handle_paste_event<C: RepaintContext + 'static>(
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<C: RepaintContext + 'static>(
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;

View file

@ -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());
}

View file

@ -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.

View file

@ -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;
}

View file

@ -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)]