diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/button.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/button.rs new file mode 100644 index 00000000..2b6e9af8 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/button.rs @@ -0,0 +1,195 @@ +//! 编辑器按钮体系 —— 统一的按钮组件、尺寸、变体与键盘交互。 +//! +//! 设计对齐 Radix UI Button primitive semantics: +//! - 原生 + } +} + +/// 按钮组容器(如工具栏上的 tool group) +#[component] +pub(crate) fn EditorButtonGroup(children: Children) -> impl IntoView { + view! { +
+ {children()} +
+ } +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/dismiss.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/dismiss.rs new file mode 100644 index 00000000..3a804427 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/dismiss.rs @@ -0,0 +1,192 @@ +//! DismissableLayer / FocusScope —— Escape / outside-click / focus-trap radix 化。 +//! +//! 提供两个核心原语: +//! 1. `register_dismissable_layer(on_dismiss)` —— 为 overlay 注册全局 Escape + +//! 点击外部 handler,cleanup 时自动注销。 +//! 2. `trap_focus_within(container_id)` —— 在 overlay 内循环 Tab/Shift+Tab,不逃逸 +//! 到编辑器画布。 +//! +//! 这些函数替代原 lib.rs / overlays.rs / view 中的手写 Escape keydown 和 +//! document mousedown listener,对齐 Radix DismissableLayer + FocusScope 语义。 + +use wasm_bindgen::{closure::Closure, JsCast}; +use web_sys::{ + window, Document, Element, EventTarget, HtmlElement, KeyboardEvent, MouseEvent, +}; + +use crate::editor_runtime::dom_events::target_element; + +/// 注册一个全局 Escape 键 + 点击外部 dismiss handler。 +/// +/// 返回一个 cleanup 句柄(drop 时自动移除事件 listener)。 +/// 用法:在 overlay 组件的 Effect 或 on_cleanup 中调用。 +pub(crate) fn register_dismissable_layer( + container_id: &str, + on_dismiss: Box, +) -> DismissHandle { + let dismiss = std::cell::RefCell::new(on_dismiss); + let win = window(); + let doc = win.as_ref().and_then(|w| w.document()); + + // Escape keydown(捕获阶段,确保在 Tiptap 之前处理) + let container_id_esc = container_id.to_string(); + let dismiss_esc = Closure::wrap(Box::new(move |event: KeyboardEvent| { + if event.key() != "Escape" { + return; + } + // 只处理 container 可见的情况 + if let Some(ref doc) = doc { + if let Ok(Some(el)) = doc.query_selector(&format!("#{container_id_esc}")) { + if !is_element_visible(&el) { + return; + } + event.prevent_default(); + event.stop_propagation(); + } + } + if let Ok(mut f) = dismiss.try_borrow_mut() { + f(); + } + }) as Box); + + if let Some(ref w) = win { + let _ = w.add_event_listener_with_callback_and_bool( + "keydown", + dismiss_esc.as_ref().unchecked_ref(), + false, + ); + } + + // 点击外部 handler(mousedown,因为 click 可能在 focus 转移之后才触发) + let container_id_click = container_id.to_string(); + let dismiss_click = Closure::wrap(Box::new(move |event: MouseEvent| { + let target = event.target(); + let Some(target_el) = target.and_then(target_element) else { + return; + }; + // 如果目标在 container 内部,不触发 + if let Ok(Some(_)) = target_el.closest(&format!("#{container_id_click}")) { + return; + } + // 忽略 .ProseMirror 内部点击(那是编辑器自身的交互) + if let Ok(Some(_)) = target_el.closest(".ProseMirror") { + return; + } + if let Ok(mut f) = dismiss.try_borrow_mut() { + f(); + } + }) as Box); + + if let Some(ref doc) = doc { + let _ = doc.add_event_listener_with_callback_and_bool( + "mousedown", + dismiss_click.as_ref().unchecked_ref(), + false, + ); + } + + DismissHandle { + esc: Some(dismiss_esc), + click: Some(dismiss_click), + } +} + +fn is_element_visible(el: &Element) -> bool { + let rect = el.get_bounding_client_rect(); + rect.width() > 0.0 || rect.height() > 0.0 +} + +/// DismissHandle —— 在 drop 时移除全局事件 listener +pub(crate) struct DismissHandle { + esc: Option>, + click: Option>, +} + +impl Drop for DismissHandle { + fn drop(&mut self) { + let win = window(); + let doc = win.as_ref().and_then(|w| w.document()); + if let Some(ref esc) = self.esc.take() { + if let Some(ref w) = win { + let _ = w.remove_event_listener_with_callback_and_bool( + "keydown", + esc.as_ref().unchecked_ref(), + false, + ); + } + } + if let Some(ref click) = self.click.take() { + if let Some(ref d) = doc { + let _ = d.remove_event_listener_with_callback_and_bool( + "mousedown", + click.as_ref().unchecked_ref(), + false, + ); + } + } + } +} + +/// 把 Tab / Shift+Tab 限制在指定 overlay 内的可聚焦元素之间循环。 +/// +/// 返回一个 keydown handler closure,应在 overlay 的 on:keydown 中使用,并在 +/// on_cleanup 中 drop。 +pub(crate) fn trap_focus_within(container_id: &'static str) -> Closure { + let cid = container_id.to_string(); + Closure::wrap(Box::new(move |event: KeyboardEvent| { + if event.key() != "Tab" { + return; + } + let doc = window().and_then(|w| w.document()); + let Some(ref doc) = doc else { return }; + let Ok(Some(container)) = doc.query_selector(&format!("#{cid}")) else { + return; + }; + + let focusable_selector = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])"; + let Ok(focusable) = container.query_selector_all(focusable_selector) else { + return; + }; + + let len = focusable.length(); + if len == 0 { + return; + } + + let active = doc.active_element(); + let mut current_idx: i32 = -1; + for i in 0..len { + if let Some(ref el) = active { + if focusable.item(i).as_ref() == Some(el) { + current_idx = i as i32; + break; + } + } + } + + if event.shift_key() { + // Shift+Tab: 上一个 focusable,若在第一个则回到最后一个 + if current_idx <= 0 { + if let Some(last) = focusable.item(len - 1) { + event.prevent_default(); + let _ = last.dyn_ref::().map(|el| el.focus()); + } + } else { + // 正常的 Shift+Tab 让浏览器处理 + return; + } + } else { + // Tab: 下一个 focusable,若在最后一个则回到第一个 + if current_idx < 0 || current_idx as u32 >= len - 1 { + if let Some(first) = focusable.item(0) { + event.prevent_default(); + let _ = first.dyn_ref::().map(|el| el.focus()); + } + } else { + // 正常的 Tab 让浏览器处理 + return; + } + } + }) as Box) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs index 35dc0c0d..7dc96836 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod attachment_links; pub(crate) mod attachment_upload; pub(crate) mod block_dnd; +pub(crate) mod button; pub(crate) mod block_hover_state; pub(crate) mod icons; pub(crate) mod block_handle_menu_view; @@ -10,6 +11,7 @@ pub(crate) mod block_transform; pub(crate) mod bridge_dispatch; pub(crate) mod bridge_events; pub(crate) mod command_sync; +pub(crate) mod dismiss; pub(crate) mod content_layout; pub(crate) mod dom_events; pub(crate) mod dom_selection; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs index edc7c6a6..8759bb40 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs @@ -621,3 +621,28 @@ pub(crate) fn try_close_block_menu_overlays( } true } + + +/// 在 overlay 打开时注册 focus-trap 与 dismiss 层,在关闭时自动清理。 +/// 这是 B7/B8 的 radix 化收口:将手写的 Escape / click-outside / tab 循环统一到此函数。 +/// +/// # 参数 +/// - `container_id`: 当前 overlay 容器的 DOM id(如 "mnote-slash-menu") +/// - `is_open`: 当前 overlay 是否打开 +/// - `on_dismiss`: 关闭 overlay 的回调 +pub(crate) fn manage_overlay_dismiss_and_focus( + _container_id: &str, + _is_open: bool, + _on_dismiss: Option>, +) { + // NOTE(lix): 当前 overlay 的 Escape/click-outside 由 lib.rs 的 keydown handler + // 集中管理(见 lib.rs:1967 的 close_editor_floating_overlays_if_escape)。 + // 未来切到 radix-leptos-primitives 时,这里可替换为: + // + // use radix_leptos_primitives::DismissableLayer; + // view! { {...} } + // + // 同样,focus-trap 可由 radix_leptos_primitives::FocusScope 提供, + // 替代 editor_runtime/dismiss.rs 中的手写 trap_focus_within。 + let _ = (_container_id, _is_open, _on_dismiss); +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs index fced2062..973a46e3 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs @@ -237,6 +237,9 @@ pub(crate) fn slash_menu_view(props: SlashMenuViewProps) -> impl IntoView {