diff --git a/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md b/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md index 5abb33b3..b2c34bd7 100644 --- a/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md +++ b/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md @@ -109,14 +109,14 @@ rust/spikes/leptos-tiptap-spike/src/ ### P1:抽出附件链接和 history-safe delete -- [ ] 新增 `editor_runtime/attachment_links.rs`。 -- [ ] 迁出本地附件链接识别: +- [x] 新增 `editor_runtime/attachment_links.rs`。 +- [x] 迁出本地附件链接识别: - DOM selection fallback。 - Tiptap editor state link attributes 判断。 - `/api/local-folder/files/open` 链接识别。 -- [ ] 新增 `editor_runtime/history_safe_commands.rs`。 -- [ ] 将“选中附件链接文本后 Delete/Backspace”收口为命名函数,例如 `delete_selected_attachment_link_with_history(...)`。 -- [ ] 验证 `task488` 仍通过。 +- [x] 新增 `editor_runtime/history_safe_commands.rs`。 +- [x] 将“选中附件链接文本后 Delete/Backspace”收口为命名函数,例如 `delete_selected_attachment_link_with_history(...)`。 +- [x] 验证 `task488` 仍通过。 ### P2:抽出 command sync / persistence @@ -261,6 +261,42 @@ Reasonix R2 run: - Reasonix 对大文件只读审计有效,但输出中的“行号和风险分级”只能作为候选事实;主控必须用本地 `rg` / 源码抽样复核。 - Reasonix runner 产出 `completed` 结果后底层 `reasonix acp` 进程未自动退出;主控必须在每批结束时检查并清理本轮启动进程。 +### 10.2 Batch 1 执行记录(2026-05-24) + +Reasonix run: + +- run id: `reasonix-2026-05-24T14-00-12-979Z-1125fb29` +- worker worktree: `/mnt/Data1T/mnote-wt-5-28-attachment-history-b1` +- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T14-00-12-979Z-1125fb29/process-handoff.md` +- 类型:窄范围机械迁移,允许修改 `src/lib.rs` 和 `src/editor_runtime/{mod.rs,attachment_links.rs,history_safe_commands.rs}`。 + +采纳内容: + +- 新增 `editor_runtime/mod.rs`。 +- 新增 `editor_runtime/attachment_links.rs`,迁出本地附件链接的 DOM selection fallback 与 Tiptap editor state link attributes 判断。 +- 新增 `editor_runtime/history_safe_commands.rs`,把选中本地附件链接后的 `Delete/Backspace` 删除收口到 `delete_selected_attachment_link_with_history(...)`。 +- `src/lib.rs` 只保留模块装配和 keydown handler 调用,不再直接承载本地附件链接识别函数。 + +Codex 主控复核修正: + +- worker 初版把底层错误格式化为 `删除附件引用失败:{e}`,调用处也会加同一前缀;主控已改为 `error.to_string()`,避免用户反馈重复。 +- worker 初版自报 `cargo check` 通过依赖临时 `reference-code` symlink;主控在 worktree 和主工作区分别重新验证。 +- Reasonix runner 写出 completed 后底层 `reasonix acp` 残留,主控已清理本轮进程。 + +已通过验证: + +- `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` +- `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` + - 步骤:`uploaded -> selected -> deleted -> undo -> redo` + - 证据:`tmp/task488-local-attachment-link-delete-undo-smoke/result.json`、`tmp/task488-local-attachment-link-delete-undo-smoke/after-redo.png` +- `codegraph index . --force && codegraph status .` + - 结果:索引 318 个文件,状态 up to date。 + - 复核:`delete_selected_attachment_link_with_history`、`selected_local_attachment_link_text`、`is_local_attachment_link_selected` 均可通过 CodeGraph 搜索定位到 `editor_runtime/*`。 + ## 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 8c060b5f..7988fc76 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 @@ -1251,27 +1251,27 @@ function __wbg_get_imports() { } }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1134, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1186, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1188, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 962, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 964, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1084, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1086, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1134, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4); return ret; }, @@ -1281,17 +1281,17 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1086, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1088, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f); return ret; }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1101, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1103, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5); return ret; }, __wbindgen_cast_0000000000000009: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1137, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c); 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 7a87386a..c0d907e8 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/attachment_links.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs new file mode 100644 index 00000000..1d3db3e5 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs @@ -0,0 +1,87 @@ +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{window, Element}; + +const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror"; + +fn editor_root_element() -> Option { + window() + .and_then(|win| win.document()) + .and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten()) +} + +/// 当前 DOM 选中文本匹配本地附件链接文本时返回 `true`。 +pub(crate) fn selected_local_attachment_link_text() -> bool { + let selection = match window().and_then(|win| win.get_selection().ok().flatten()) { + Some(selection) if !selection.is_collapsed() => selection, + _ => return false, + }; + let selected_text = String::from(selection.to_string()).trim().to_string(); + if selected_text.is_empty() { + return false; + } + + let Some(root) = editor_root_element() else { + return false; + }; + let links = root.get_elements_by_tag_name("a"); + for index in 0..links.length() { + let Some(link) = links.item(index) else { + continue; + }; + let href = link.get_attribute("href").unwrap_or_default(); + if !href.contains("/api/local-folder/files/open") { + continue; + } + if link.text_content().unwrap_or_default().trim() == selected_text { + return true; + } + } + + false +} + +/// 当前非空选区命中本地附件链接 mark 时返回 `true`。 +pub(crate) fn selected_local_attachment_link_in_editor_state() -> bool { + let Some(root) = editor_root_element() else { + return false; + }; + let Ok(editor_value) = js_sys::Reflect::get(root.as_ref(), &JsValue::from_str("editor")) + else { + return false; + }; + let Ok(state_value) = js_sys::Reflect::get(&editor_value, &JsValue::from_str("state")) else { + return false; + }; + let Ok(selection_value) = js_sys::Reflect::get(&state_value, &JsValue::from_str("selection")) + else { + return false; + }; + let is_empty = js_sys::Reflect::get(&selection_value, &JsValue::from_str("empty")) + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(true); + if is_empty { + return false; + } + let Some(get_attributes) = js_sys::Reflect::get( + &editor_value, + &JsValue::from_str("getAttributes"), + ) + .ok() + .and_then(|value| value.dyn_into::().ok()) + else { + return false; + }; + let Ok(attributes) = get_attributes.call1(&editor_value, &JsValue::from_str("link")) else { + return false; + }; + js_sys::Reflect::get(&attributes, &JsValue::from_str("href")) + .ok() + .and_then(|value| value.as_string()) + .is_some_and(|href| href.contains("/api/local-folder/files/open")) +} + +/// DOM selection 或 Tiptap editor state 任一路径命中本地附件链接即可。 +pub(crate) fn is_local_attachment_link_selected() -> bool { + selected_local_attachment_link_in_editor_state() || selected_local_attachment_link_text() +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs new file mode 100644 index 00000000..db3ee2ad --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs @@ -0,0 +1,17 @@ +use leptos_tiptap::TiptapEditorHandle; + +use crate::editor_runtime::attachment_links; + +/// 通过 Tiptap `delete_selection()` 删除当前选中的本地附件链接,保留撤销/重做历史。 +/// +/// 删除成功返回 `Ok(())`;当前选区不是本地附件链接或删除失败时返回 `Err(message)`。 +pub(crate) fn delete_selected_attachment_link_with_history( + editor: &TiptapEditorHandle, +) -> Result<(), String> { + // 先确认当前选区确实是本地附件链接。 + if !attachment_links::is_local_attachment_link_selected() { + return Err("当前选区不是本地附件链接".to_string()); + } + // 继续走 Tiptap 命令路径,保留撤销/重做历史。 + editor.delete_selection().map_err(|error| error.to_string()) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs new file mode 100644 index 00000000..96fe433c --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod attachment_links; +pub(crate) mod history_safe_commands; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 1b6b2ca1..5194fb0d 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -54,6 +54,9 @@ const STANDALONE_ROOT_ID: &str = "mnote-leptos-tiptap-standalone-root"; const E24_IMAGE_PLACEHOLDER_SRC: &str = "/api/editor/image-placeholder.svg"; const E24_IMAGE_PLACEHOLDER_ALT: &str = "E24 图片占位"; const E24_IMAGE_PLACEHOLDER_TITLE: &str = "E24 图片"; + +mod editor_runtime; + #[wasm_bindgen(inline_js = r#" export function write_mnote_text_to_clipboard(text) { try { @@ -5262,73 +5265,6 @@ fn hovered_block_from_selection() -> Option { block_state_from_index(index) } -fn selected_local_attachment_link_text() -> bool { - let selection = match window().and_then(|win| win.get_selection().ok().flatten()) { - Some(selection) if !selection.is_collapsed() => selection, - _ => return false, - }; - let selected_text = String::from(selection.to_string()).trim().to_string(); - if selected_text.is_empty() { - return false; - } - - let Some(root) = editor_root_element() else { - return false; - }; - let links = root.get_elements_by_tag_name("a"); - for index in 0..links.length() { - let Some(link) = links.item(index) else { - continue; - }; - let href = link.get_attribute("href").unwrap_or_default(); - if !href.contains("/api/local-folder/files/open") { - continue; - } - if link.text_content().unwrap_or_default().trim() == selected_text { - return true; - } - } - - false -} - -fn selected_local_attachment_link_in_editor_state() -> bool { - let Some(root) = editor_root_element() else { - return false; - }; - let Ok(editor_value) = js_sys::Reflect::get(root.as_ref(), &JsValue::from_str("editor")) - else { - return false; - }; - let Ok(state_value) = js_sys::Reflect::get(&editor_value, &JsValue::from_str("state")) else { - return false; - }; - let Ok(selection_value) = js_sys::Reflect::get(&state_value, &JsValue::from_str("selection")) - else { - return false; - }; - let is_empty = js_sys::Reflect::get(&selection_value, &JsValue::from_str("empty")) - .ok() - .and_then(|value| value.as_bool()) - .unwrap_or(true); - if is_empty { - return false; - } - let Some(get_attributes) = js_sys::Reflect::get(&editor_value, &JsValue::from_str("getAttributes")) - .ok() - .and_then(|value| value.dyn_into::().ok()) - else { - return false; - }; - let Ok(attributes) = get_attributes.call1(&editor_value, &JsValue::from_str("link")) else { - return false; - }; - js_sys::Reflect::get(&attributes, &JsValue::from_str("href")) - .ok() - .and_then(|value| value.as_string()) - .is_some_and(|href| href.contains("/api/local-folder/files/open")) -} - fn block_state_from_index(index: usize) -> Option { let root = editor_root_element()?; let stage = editor_stage_element()?; @@ -8821,15 +8757,16 @@ fn App(mount_options: MountOptions) -> impl IntoView { || event.meta_key() || event.alt_key() || !matches!(event.key().as_str(), "Backspace" | "Delete") - || !(selected_local_attachment_link_in_editor_state() - || selected_local_attachment_link_text()) + || !editor_runtime::attachment_links::is_local_attachment_link_selected() { return; } event.prevent_default(); event.stop_immediate_propagation(); - match editor.delete_selection() { + match editor_runtime::history_safe_commands::delete_selected_attachment_link_with_history( + &editor, + ) { Ok(()) => { sync_persisted_editor_command( editor,