diff --git a/design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md b/design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md index 99e81f69..85125135 100644 --- a/design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md +++ b/design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md @@ -696,8 +696,8 @@ Reasonix read-only audit: - [x] 浏览器断言: - `document.documentElement.dataset.mnoteTreeLiveTransport` 正确。 - 外置 module 实际执行并设置 `window.__mnoteTreeLiveControllerStarted`。 -- 后续专项验证:WS 推送仍更新 page/filetree。 -- 后续专项验证:WS 不可用时仍 fallback 到 SSE。 +- 后续专项浏览器回归:WS 推送仍更新 page/filetree。 +- 后续专项浏览器回归:强制禁用 WS 时仍 fallback 到 SSE。 ### P3:外置 debug `/tree` shell runtime bridge diff --git a/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md b/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md index a08b0b3a..96e668ef 100644 --- a/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md +++ b/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md @@ -470,6 +470,39 @@ Reasonix read-only audit: - `node scripts/task488-local-attachment-link-delete-undo-smoke.js` - `node scripts/task489-block-menu-delete-undo-smoke.js` +### 10.7 Batch 5 DOM selection 第一刀(2026-05-25) + +Codex 主控基于上一批 Reasonix P4 只读审计直接完成: + +- 新增 `editor_runtime/dom_selection.rs`。 +- 迁出纯 DOM / selection helper: + - `node_is_within_root` + - `active_editor_text_selection` + - `has_active_text_selection` + - `selection_summary` + - `selection_has_rich_marks` +- `src/lib.rs` 保留 `SelectionPayload`、`selection_payload`、`send_selection_state`、`send_selection_state_to_target`、`selection_event_payload` 和事件 dispatch;这些仍依赖 bridge event、`HoveredBlockState`、block id 解析和 target dispatch,后续等 `bridge_events.rs` / overlay state 边界更稳后再迁。 +- `editor_root_element()` 改为 `pub(crate)`,供 `dom_selection.rs` 读取当前编辑器根节点;shell/resource 语义未进入 editor DOM 模块。 + +已通过验证: + +- `cargo fmt --manifest-path rust/Cargo.toml --all --check` +- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`(35 个既有 warning) +- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release`(35 个既有 warning) +- `wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/release/mnote_leptos_tiptap_spike.wasm --target web --out-dir rust/spikes/leptos-tiptap-spike/generated/island --out-name mnote-leptos-tiptap-spike-island` +- `node scripts/task488-local-attachment-link-delete-undo-smoke.js` + - 结果:`ok=true` + - 证据:`tmp/task488-local-attachment-link-delete-undo-smoke/result.json` +- `node scripts/task489-block-menu-delete-undo-smoke.js` + - 结果:`ok=true` + - 证据:`tmp/task489-block-menu-delete-undo-smoke/result.json` + - 备注:首次与 `task488` 并行执行时在 `Ctrl+Z` 等待上超时;单独重跑通过,后续 smoke 建议串行跑编辑器键盘类场景,避免浏览器/服务端时序争用误报。 + +后续建议: + +- 下一刀可迁 `floating_toolbar_anchor_from_selection` / `sync_text_selection_overlay` 等 overlay anchor 纯计算部分进入 `overlays.rs`。 +- `selection_payload` / `send_selection_state*` 应与 `bridge_events.rs` 一起迁,避免为了单次拆分扩大 `SelectionPayload`、`HoveredBlockState` 和 runtime block id 的可见性。 + ## 11. 测试与验收 ### Rust 检查 diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index 20fb5a3f..df393296 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -1284,7 +1284,7 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 504, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf); return ret; }, diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index be4602a9..4156e2cc 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/dom_selection.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/dom_selection.rs new file mode 100644 index 00000000..575ebbcb --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/dom_selection.rs @@ -0,0 +1,105 @@ +use leptos_tiptap::TiptapSelectionState; +use web_sys::{window, Element, Node}; + +use crate::editor_root_element; + +pub(crate) fn node_is_within_root(node: Node, root: &Element) -> bool { + let mut current = Some(node); + while let Some(node) = current { + if node.is_same_node(Some(root)) { + return true; + } + current = node.parent_node(); + } + false +} + +pub(crate) fn active_editor_text_selection() -> Option { + let selection = window().and_then(|win| win.get_selection().ok().flatten())?; + if selection.is_collapsed() { + return None; + } + + let root = editor_root_element()?; + let anchor_inside = selection + .anchor_node() + .map(|node| node_is_within_root(node, &root)) + .unwrap_or(false); + let focus_inside = selection + .focus_node() + .map(|node| node_is_within_root(node, &root)) + .unwrap_or(false); + + if anchor_inside || focus_inside { + Some(selection) + } else { + None + } +} + +pub(crate) fn has_active_text_selection() -> bool { + active_editor_text_selection().is_some() +} + +pub(crate) fn selection_summary(selection: &TiptapSelectionState) -> String { + let block = if selection.h1 { + "一级标题" + } else if selection.h2 { + "二级标题" + } else if selection.h3 { + "三级标题" + } else if selection.task_list { + "Todo 列表" + } else if selection.ordered_list { + "有序列表" + } else if selection.bullet_list { + "无序列表" + } else if selection.blockquote { + "引用块" + } else if selection.paragraph { + "段落" + } else { + "未定位" + }; + + let mut marks = Vec::new(); + if selection.bold { + marks.push("Bold"); + } + if selection.italic { + marks.push("Italic"); + } + if selection.underline { + marks.push("Underline"); + } + if selection.strike { + marks.push("Strike"); + } + if selection.highlight { + marks.push("Highlight"); + } + if selection.text_style { + marks.push("Color"); + } + if selection.link { + marks.push("Link"); + } + + let mark_text = if marks.is_empty() { + "无行内样式".to_string() + } else { + marks.join(" / ") + }; + + format!("当前块:{block} | 行内样式:{mark_text}") +} + +pub(crate) fn selection_has_rich_marks(selection: &TiptapSelectionState) -> bool { + selection.bold + || selection.italic + || selection.underline + || selection.strike + || selection.highlight + || selection.text_style + || selection.link +} 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 4eb1a916..397ef0eb 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod attachment_links; pub(crate) mod attachment_upload; pub(crate) mod command_sync; +pub(crate) mod dom_selection; pub(crate) mod history_safe_commands; pub(crate) mod persistence; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index bf0279d7..53d250f7 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -24,6 +24,10 @@ use editor_runtime::attachment_upload::dispatch_editor_upload_request; use editor_runtime::command_sync::{ read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command, }; +use editor_runtime::dom_selection::{ + active_editor_text_selection, has_active_text_selection, node_is_within_root, + selection_summary, +}; use editor_runtime::persistence::{ load_persisted_document, normalize_identity_value, persist_document_state, persisted_document_identity, PersistedDocumentIdentity, @@ -5015,7 +5019,7 @@ where dispatch_runtime_event(event_name, payload); } -fn editor_root_element() -> Option { +pub(crate) fn editor_root_element() -> Option { window() .and_then(|win| win.document()) .and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten()) @@ -5334,40 +5338,6 @@ fn top_level_block_range(document: &Value, index: usize) -> Option None } -fn node_is_within_root(node: Node, root: &Element) -> bool { - let mut current = Some(node); - while let Some(node) = current { - if node.is_same_node(Some(root)) { - return true; - } - current = node.parent_node(); - } - false -} - -fn active_editor_text_selection() -> Option { - let selection = window().and_then(|win| win.get_selection().ok().flatten())?; - if selection.is_collapsed() { - return None; - } - - let root = editor_root_element()?; - let anchor_inside = selection - .anchor_node() - .map(|node| node_is_within_root(node, &root)) - .unwrap_or(false); - let focus_inside = selection - .focus_node() - .map(|node| node_is_within_root(node, &root)) - .unwrap_or(false); - - if anchor_inside || focus_inside { - Some(selection) - } else { - None - } -} - fn floating_toolbar_anchor_from_selection() -> Option { let selection = active_editor_text_selection()?; let range = selection.get_range_at(0).ok()?; @@ -5866,10 +5836,6 @@ fn replace_top_level_block_kind( Ok(()) } -fn has_active_text_selection() -> bool { - active_editor_text_selection().is_some() -} - fn toolbar_overlay_locked( turn_into_open: bool, color_menu_open: bool, @@ -6534,59 +6500,6 @@ fn active_editor_stage() -> bool { anchor_inside || focus_inside } -fn selection_summary(selection: &TiptapSelectionState) -> String { - let block = if selection.h1 { - "一级标题" - } else if selection.h2 { - "二级标题" - } else if selection.h3 { - "三级标题" - } else if selection.task_list { - "Todo 列表" - } else if selection.ordered_list { - "有序列表" - } else if selection.bullet_list { - "无序列表" - } else if selection.blockquote { - "引用块" - } else if selection.paragraph { - "段落" - } else { - "未定位" - }; - - let mut marks = Vec::new(); - if selection.bold { - marks.push("Bold"); - } - if selection.italic { - marks.push("Italic"); - } - if selection.underline { - marks.push("Underline"); - } - if selection.strike { - marks.push("Strike"); - } - if selection.highlight { - marks.push("Highlight"); - } - if selection.text_style { - marks.push("Color"); - } - if selection.link { - marks.push("Link"); - } - - let mark_text = if marks.is_empty() { - "无行内样式".to_string() - } else { - marks.join(" / ") - }; - - format!("当前块:{block} | 行内样式:{mark_text}") -} - fn current_block_info_from_index(index: Option) -> CurrentBlockInfo { index .map(|value| CurrentBlockInfo { @@ -6860,16 +6773,6 @@ fn bridge_ready_payload() -> ReadyPayload { } } -fn selection_has_rich_marks(selection: &TiptapSelectionState) -> bool { - selection.bold - || selection.italic - || selection.underline - || selection.strike - || selection.highlight - || selection.text_style - || selection.link -} - fn apply_text_color(editor: TiptapEditorHandle, color: &str) -> Result<&'static str, String> { editor .set_color(TiptapColorAttributes {