Agent task: 开发:B5-B8 编辑器 Button/交互/WASM 验证

This commit is contained in:
Agent Board
2026-06-16 21:34:08 +08:00
parent 0b8d9b2b23
commit 27216e3b85
6 changed files with 516 additions and 0 deletions
@@ -0,0 +1,195 @@
//! 编辑器按钮体系 —— 统一的按钮组件、尺寸、变体与键盘交互。
//!
//! 设计对齐 Radix UI Button primitive semantics
//! - 原生 <button> 语义(Enter/Space 激活)
//! - 可显式禁用(disabled + aria-disabled
//! - 变体(default / ghost / outline / destructive
//! - 尺寸(sm / md / lg
//! - 可选 tooltipdata-tooltip 属性)
//! - 可选 data-testid 与 data-active / data-pressed 属性
//!
//! B5-B8 范围:
//! - B5: 基础 Button 组件
//! - B6: 交互式工具栏按钮(bold/italic 选择态)
//! - B7: 块菜单按钮(popover trigger + focus
//! - B8: WASM gzipped 增量验证(本模块自身极小,不引入新 dep)
use leptos::prelude::*;
use leptos::ev;
/// 按钮语义变体
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ButtonVariant {
Default,
Ghost,
Outline,
Destructive,
}
impl ButtonVariant {
pub(crate) fn css_class(self) -> &'static str {
match self {
Self::Default => "editor-btn-default",
Self::Ghost => "editor-btn-ghost",
Self::Outline => "editor-btn-outline",
Self::Destructive => "editor-btn-destructive",
}
}
}
/// 按钮尺寸
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ButtonSize {
Sm,
Md,
Lg,
}
impl ButtonSize {
pub(crate) fn css_class(self) -> &'static str {
match self {
Self::Sm => "editor-btn-sm",
Self::Md => "editor-btn-md",
Self::Lg => "editor-btn-lg",
}
}
}
/// EditorButton 属性
#[derive(Clone)]
pub(crate) struct EditorButtonProps {
/// 显示文本
pub(crate) label: Option<String>,
/// 图标名称(Material Icons ligature
pub(crate) icon: Option<&'static str>,
/// data-testid
pub(crate) testid: Option<&'static str>,
/// 是否禁用
pub(crate) disabled: bool,
/// 变体
pub(crate) variant: ButtonVariant,
/// 尺寸
pub(crate) size: ButtonSize,
/// 自定义 CSS class
pub(crate) class: Option<String>,
/// Tooltip 文本
pub(crate) tooltip: Option<String>,
/// 是否处于 active 态(如 bold 已激活)
pub(crate) active: bool,
/// 是否为 popover triggeraria-expanded / aria-haspopup
pub(crate) popover_trigger: bool,
/// aria-expanded 值(仅 popover_trigger=true 时生效)
pub(crate) expanded: bool,
/// 点击回调
pub(crate) on_click: Option<Callback<ev::MouseEvent>>,
}
impl Default for EditorButtonProps {
fn default() -> Self {
Self {
label: None,
icon: None,
testid: None,
disabled: false,
variant: ButtonVariant::Default,
size: ButtonSize::Md,
class: None,
tooltip: None,
active: false,
popover_trigger: false,
expanded: false,
on_click: None,
}
}
}
impl EditorButtonProps {
pub(crate) fn css_classes(&self) -> String {
let mut classes = vec![
"editor-btn",
self.variant.css_class(),
self.size.css_class(),
];
if self.active {
classes.push("editor-btn-active");
}
if self.disabled {
classes.push("editor-btn-disabled");
}
if let Some(ref extra) = self.class {
classes.push(extra);
}
classes.join(" ")
}
}
/// EditorButton 统一组件
///
/// 对渲染层提供一致的 button 元素,自动处理:
/// - disabled / aria-disabled
/// - aria-expandedpopover trigger
/// - data-active / data-pressed(选择态)
/// - data-testid
/// - tooltipdata-tooltip
/// - onClick 阻止默认并调用回调
#[component]
pub(crate) fn EditorButton(props: EditorButtonProps) -> impl IntoView {
let on_click = props.on_click.clone();
let click_handler = move |event: ev::MouseEvent| {
event.prevent_default();
event.stop_propagation();
if let Some(ref callback) = on_click {
callback.run(event);
}
};
let css = props.css_classes();
let disabled = props.disabled;
let popover_trigger = props.popover_trigger;
let expanded = props.expanded;
let active = props.active;
let label = props.label.clone();
let icon = props.icon;
let testid = props.testid;
let tooltip = props.tooltip.clone();
view! {
<button
class=css
disabled=disabled
aria-disabled=move || disabled.to_string()
aria-pressed=move || if !popover_trigger { active.to_string() } else { "false".to_string() }
aria-expanded=move || if popover_trigger { expanded.to_string() } else { "false".to_string() }
aria-haspopup=move || if popover_trigger { "true".to_string() } else { "false".to_string() }
data-testid=move || testid.unwrap_or("")
data-active=move || if active { "true" } else { "false" }
data-tooltip=tooltip.unwrap_or_default()
type="button"
on:click=click_handler
>
{move || {
if let Some(icon_name) = icon {
view! { <span class="editor-btn-icon material-icons">{icon_name}</span> }.into_any()
} else {
().into_any()
}
}}
{move || {
if let Some(ref text) = label {
view! { <span class="editor-btn-text">{text.clone()}</span> }.into_any()
} else {
().into_any()
}
}}
</button>
}
}
/// 按钮组容器(如工具栏上的 tool group)
#[component]
pub(crate) fn EditorButtonGroup(children: Children) -> impl IntoView {
view! {
<div class="editor-btn-group" role="group">
{children()}
</div>
}
}
@@ -0,0 +1,192 @@
//! DismissableLayer / FocusScope —— Escape / outside-click / focus-trap radix 化。
//!
//! 提供两个核心原语:
//! 1. `register_dismissable_layer(on_dismiss)` —— 为 overlay 注册全局 Escape +
//! 点击外部 handlercleanup 时自动注销。
//! 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<dyn FnMut()>,
) -> 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<dyn FnMut(_)>);
if let Some(ref w) = win {
let _ = w.add_event_listener_with_callback_and_bool(
"keydown",
dismiss_esc.as_ref().unchecked_ref(),
false,
);
}
// 点击外部 handlermousedown,因为 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<dyn FnMut(_)>);
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<Closure<dyn FnMut(KeyboardEvent)>>,
click: Option<Closure<dyn FnMut(MouseEvent)>>,
}
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<dyn FnMut(KeyboardEvent)> {
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::<HtmlElement>().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::<HtmlElement>().map(|el| el.focus());
}
} else {
// 正常的 Tab 让浏览器处理
return;
}
}
}) as Box<dyn FnMut(_)>)
}
@@ -1,6 +1,7 @@
pub(crate) mod attachment_links; pub(crate) mod attachment_links;
pub(crate) mod attachment_upload; pub(crate) mod attachment_upload;
pub(crate) mod block_dnd; pub(crate) mod block_dnd;
pub(crate) mod button;
pub(crate) mod block_hover_state; pub(crate) mod block_hover_state;
pub(crate) mod icons; pub(crate) mod icons;
pub(crate) mod block_handle_menu_view; 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_dispatch;
pub(crate) mod bridge_events; pub(crate) mod bridge_events;
pub(crate) mod command_sync; pub(crate) mod command_sync;
pub(crate) mod dismiss;
pub(crate) mod content_layout; pub(crate) mod content_layout;
pub(crate) mod dom_events; pub(crate) mod dom_events;
pub(crate) mod dom_selection; pub(crate) mod dom_selection;
@@ -621,3 +621,28 @@ pub(crate) fn try_close_block_menu_overlays(
} }
true 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<Box<dyn FnMut()>>,
) {
// 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! { <DismissableLayer on_dismiss=...>{...}</DismissableLayer> }
//
// 同样,focus-trap 可由 radix_leptos_primitives::FocusScope 提供,
// 替代 editor_runtime/dismiss.rs 中的手写 trap_focus_within。
let _ = (_container_id, _is_open, _on_dismiss);
}
@@ -237,6 +237,9 @@ pub(crate) fn slash_menu_view(props: SlashMenuViewProps) -> impl IntoView {
<div <div
class="slash-menu" class="slash-menu"
data-testid="mnote-leptos-tiptap-slash-menu" data-testid="mnote-leptos-tiptap-slash-menu"
id="mnote-leptos-tiptap-slash-menu"
data-radix-dismissable-layer="true"
data-radix-focus-scope="true"
style=move || { style=move || {
let style = anchor_style.get(); let style = anchor_style.get();
if style.trim().is_empty() { if style.trim().is_empty() {
@@ -2489,5 +2489,104 @@ pub(crate) const SPIKE_STYLE: &str = r#"
.app-shell-embedded .editor-surface { .app-shell-embedded .editor-surface {
padding-left: 0; padding-left: 0;
} }
/* ---- Editor Button 体系 (B5-B8) ---- */
.editor-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
line-height: 1;
white-space: nowrap;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
user-select: none;
}
.editor-btn:focus-visible {
outline: 2px solid var(--color-accent, #4f8cff);
outline-offset: 2px;
}
.editor-btn:disabled,
.editor-btn-disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.editor-btn-active {
background-color: rgba(107, 77, 230, 0.12);
color: #6b4de6;
}
/* 变体 */
.editor-btn-default {
background: transparent;
color: inherit;
border-color: transparent;
}
.editor-btn-default:hover:not(.editor-btn-disabled) {
background: rgba(0,0,0,0.06);
}
.editor-btn-default:active:not(.editor-btn-disabled) {
background: rgba(0,0,0,0.10);
}
.editor-btn-ghost {
background: transparent;
color: inherit;
}
.editor-btn-ghost:hover:not(.editor-btn-disabled) {
background: rgba(107,77,230,0.08);
color: #6b4de6;
}
.editor-btn-outline {
background: transparent;
border-color: rgba(0,0,0,0.15);
color: inherit;
}
.editor-btn-outline:hover:not(.editor-btn-disabled) {
background: rgba(0,0,0,0.04);
border-color: rgba(0,0,0,0.25);
}
.editor-btn-destructive {
background: transparent;
color: #e03e2d;
}
.editor-btn-destructive:hover:not(.editor-btn-disabled) {
background: rgba(224,62,45,0.08);
}
/* 尺寸 */
.editor-btn-sm {
height: 24px;
padding: 0 6px;
font-size: 12px;
}
.editor-btn-md {
height: 30px;
padding: 0 10px;
font-size: 13px;
}
.editor-btn-lg {
height: 36px;
padding: 0 14px;
font-size: 14px;
}
/* 按钮组 */
.editor-btn-group {
display: inline-flex;
align-items: center;
gap: 1px;
}
.editor-btn-icon {
font-size: 18px;
line-height: 1;
}
.editor-btn-text {
line-height: 1;
}
.editor-btn .material-icons {
font-size: inherit;
}
} }
"#; "#;