Merge branch 'group/1f21b27c/1-b5-b8-button-wasm'
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
//! 编辑器按钮体系 —— 统一的按钮组件、尺寸、变体与键盘交互。
|
||||
//!
|
||||
//! 设计对齐 Radix UI Button primitive semantics:
|
||||
//! - 原生 <button> 语义(Enter/Space 激活)
|
||||
//! - 可显式禁用(disabled + aria-disabled)
|
||||
//! - 变体(default / ghost / outline / destructive)
|
||||
//! - 尺寸(sm / md / lg)
|
||||
//! - 可选 tooltip(data-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 trigger(aria-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-expanded(popover trigger)
|
||||
/// - data-active / data-pressed(选择态)
|
||||
/// - data-testid
|
||||
/// - tooltip(data-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 +
|
||||
//! 点击外部 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<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,
|
||||
);
|
||||
}
|
||||
|
||||
// 点击外部 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<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_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;
|
||||
|
||||
@@ -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<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
|
||||
class="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 || {
|
||||
let style = anchor_style.get();
|
||||
if style.trim().is_empty() {
|
||||
|
||||
@@ -2489,5 +2489,104 @@ pub(crate) const SPIKE_STYLE: &str = r#"
|
||||
.app-shell-embedded .editor-surface {
|
||||
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;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
Reference in New Issue
Block a user