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:
parent
45d962938b
commit
25eb2f0cc1
|
|
@ -14,6 +14,7 @@ pub enum CollabPanelHover {
|
|||
Connect,
|
||||
Cancel,
|
||||
JoinAddress,
|
||||
ClearJoinAddress,
|
||||
Discovered(usize),
|
||||
Retry,
|
||||
Leave,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue