refactor: extract editor dom selection runtime
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+33
@@ -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 检查
|
||||
|
||||
+1
-1
@@ -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;
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
@@ -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<web_sys::Selection> {
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Element> {
|
||||
pub(crate) fn editor_root_element() -> Option<Element> {
|
||||
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<TiptapRange>
|
||||
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<web_sys::Selection> {
|
||||
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<FloatingToolbarAnchor> {
|
||||
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<usize>) -> 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 {
|
||||
|
||||
Reference in New Issue
Block a user