diff --git a/bugs/0524.md b/bugs/0524.md index 1a93d577..43c1e08d 100644 --- a/bugs/0524.md +++ b/bugs/0524.md @@ -46,4 +46,26 @@ - Reasonix coding 在隔离 worktree `mnote-wt-0524c-reasonix-title` 完成可用修复,范围控制在 `local_folder_source.rs` 与 `task479`,主控复核后采纳。 - Claude Code coding 本轮 Hindsight 注入成功,但因 `--max-budget-usd 1.20` 超预算退出,只停留在代码探索阶段,未产生 diff;这说明 Claude Code full-mode 对编码任务成本较高,需要更小任务切片或更高预算。 4.CTRL+Z/CTRL+Y对于删除链接不可用,不能恢复删除的链接。(你应该系统看看还有没有其它ctrl+z/CTRL+Y(撤销/恢复)不能用的场景) + + 处理记录(2026-05-24): + - 归类:`05-editor-mainline`,本质是编辑器命令写入链路绕过 Tiptap/ProseMirror history,不能只按单个链接 UI 修补。 + - 根因:本地 Markdown 附件链接被选中后按 `Backspace/Delete` 时,旧逻辑识别本地附件链接并走 `get_html -> delete_top_level_block_html -> editor.set_content(html)`。`set_content` 是整文替换,不会生成可撤销的用户 transaction,因此 `Ctrl+Z/Ctrl+Y` 无法恢复删除的链接。 + - 修复: + - `rust/spikes/leptos-tiptap-spike/src/lib.rs`:删除本地附件链接时不再通过 HTML 整文替换;改为在 keydown 捕获阶段识别 Tiptap 当前非空选区是否位于 `/api/local-folder/files/open` 链接 mark 内,并调用 `editor.delete_selection()`,让删除进入 ProseMirror history。 + - `rust/spikes/leptos-tiptap-spike/src/lib.rs`:保留 DOM selection 识别作为鼠标选中文本兜底,但主判断改为读取 Tiptap editor state 的 link attributes,避免内部 selection 与 `window.getSelection()` 不同步。 + - `scripts/task488-local-attachment-link-delete-undo-smoke.js`:新增真实浏览器回归,上传本地 Markdown 附件、选中附件链接文本、Delete 删除、`Ctrl+Z` 恢复、`Ctrl+Y` 再删除。脚本显式等待超过 Tiptap history `newGroupDelay`,避免把“上传+删除”合并为同一个撤销事件而误判。 + - 验证: + - `cargo fmt --manifest-path rust/Cargo.toml --all --check` 通过。 + - `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml` 通过,只有既有 warning。 + - `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release` 通过,只有既有 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 --check scripts/task488-local-attachment-link-delete-undo-smoke.js` 通过。 + - `node scripts/task488-local-attachment-link-delete-undo-smoke.js` 通过。 + - Worker 对比: + - Claude Code 三轮任务均因预算超限或无可用 `finalText/process-handoff` 失败,隔离 worktree 没有可采纳 diff;本轮说明 Claude Code 轻量 patch mode 仍需要更窄任务和更稳定预算。 + - Reasonix 多轮任务卡住;其中 narrow patch worktree 有 diff,但没有 result/handoff,且包含无关格式和英文注释,未采纳。 + - 本轮对比结论:两者都没有直接解决第 4 条;可用价值主要是暴露 worker 失败模式。最终根因、修复和 smoke 由主控完成。 + - 系统性后续: + - 当前修复覆盖“选中文本删除本地附件链接”的高频路径。块菜单删除、拖拽重排、折叠标题转换等仍有 `set_content`/HTML 整文替换路径,后续应拆一个 editor command history 设计,把块级删除/重排统一迁移到 Tiptap transaction/command,而不是继续扩散整文替换。 5,md文件上传后刷新前面的黑色方块会变成灰色:/mnt/Data1T/mnote/tmp/image copy 80.png;/mnt/Data1T/mnote/tmp/image copy 81.png +6. 当前office文件本地可以打开,我们当前使用sakura frp映射3000端口至:https://www.aichem.dpdns.org 后,发现pdf可以正常打开,但onlyoffice打不开了,报错为:ONLYOFFICE 加载失败{"target":{"frameOrigin":"https://www.aichem.dpdns.org"},"data":{"errorCode":-4,"errorDescription":"下载失败"}}。请检查原因,同时要兼容未来更换域名。 diff --git a/rust/spikes/leptos-tiptap-spike/Cargo.lock b/rust/spikes/leptos-tiptap-spike/Cargo.lock index 6c83ca07..9b958bc3 100644 --- a/rust/spikes/leptos-tiptap-spike/Cargo.lock +++ b/rust/spikes/leptos-tiptap-spike/Cargo.lock @@ -975,6 +975,7 @@ dependencies = [ "leptos", "leptos-tiptap", "leptos_dom", + "send_wrapper", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/rust/spikes/leptos-tiptap-spike/Cargo.toml b/rust/spikes/leptos-tiptap-spike/Cargo.toml index d0358027..437ee8e7 100644 --- a/rust/spikes/leptos-tiptap-spike/Cargo.toml +++ b/rust/spikes/leptos-tiptap-spike/Cargo.toml @@ -20,6 +20,7 @@ leptos_dom = "0.8.8" serde = { version = "1", features = ["derive"] } serde_json = "1" serde-wasm-bindgen = "0.6" +send_wrapper = "0.6" js-sys = "0.3.77" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts index 67b4c978..d0dfa278 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts @@ -71,6 +71,7 @@ export interface InitOutput { readonly wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void; readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void; readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf: (a: number, b: number, c: any) => void; readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void; readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void; readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void; 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 5ef9999a..8c060b5f 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 @@ -297,6 +297,9 @@ function __wbg_get_imports() { const ret = arg0.activeElement; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, + __wbg_addEventListener_2ed1344165a839a7: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3, arg4 !== 0); + }, arguments); }, __wbg_addEventListener_8176dab41b09531c: function() { return handleError(function (arg0, arg1, arg2, arg3) { arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); }, arguments); }, @@ -1022,6 +1025,9 @@ function __wbg_get_imports() { const ret = arg0.removeChild(arg1); return ret; }, arguments); }, + __wbg_removeEventListener_0069d57d090a1674: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3, arg4 !== 0); + }, arguments); }, __wbg_removeEventListener_7bdf07404d9b24bd: function() { return handleError(function (arg0, arg1, arg2, arg3) { arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); }, arguments); }, @@ -1169,6 +1175,9 @@ function __wbg_get_imports() { const ret = arg0.status; return ret; }, + __wbg_stopImmediatePropagation_2228838acf177dc6: function(arg0) { + arg0.stopImmediatePropagation(); + }, __wbg_stopPropagation_e088fca8231e68c4: function(arg0) { arg0.stopPropagation(); }, @@ -1242,61 +1251,66 @@ function __wbg_get_imports() { } }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1128, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1134, 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: 1180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // 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`. 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: 956, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 962, 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: 1078, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1084, 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: 1128, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1134, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1080, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 297, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf); return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1095, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1086, 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: 1131, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1101, 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`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c); return ret; }, - __wbindgen_cast_0000000000000009: function(arg0) { + __wbindgen_cast_000000000000000a: function(arg0) { // Cast intrinsic for `F64 -> Externref`. const ret = arg0; return ret; }, - __wbindgen_cast_000000000000000a: function(arg0) { + __wbindgen_cast_000000000000000b: function(arg0) { // Cast intrinsic for `I64 -> Externref`. const ret = arg0; return ret; }, - __wbindgen_cast_000000000000000b: function(arg0, arg1) { + __wbindgen_cast_000000000000000c: function(arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); return ret; }, - __wbindgen_cast_000000000000000c: function(arg0) { + __wbindgen_cast_000000000000000d: function(arg0) { // Cast intrinsic for `U64 -> Externref`. const ret = BigInt.asUintN(64, arg0); return ret; @@ -1349,6 +1363,10 @@ function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, a wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2); } +function wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2); +} + function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) { const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2); if (ret[1]) { 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 205432a6..7a87386a 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/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts index bfa52689..d15b5a0d 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts @@ -24,6 +24,7 @@ export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: export const wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void; export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void; export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf: (a: number, b: number, c: any) => void; export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void; export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void; export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 1e6e977b..1b6b2ca1 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -9,6 +9,7 @@ use leptos_tiptap::{ TiptapNodeName, TiptapRange, TiptapSchemaTarget, TiptapSelectionState, TiptapTextAlign, TiptapTocNodeAttrs, }; +use send_wrapper::SendWrapper; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display}; @@ -5261,14 +5262,19 @@ fn hovered_block_from_selection() -> Option { block_state_from_index(index) } -fn selected_local_attachment_block_index() -> Option { - let selection = window().and_then(|win| win.get_selection().ok().flatten())?; +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 None; + return false; } - let root = editor_root_element()?; + 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 { @@ -5278,15 +5284,49 @@ fn selected_local_attachment_block_index() -> Option { if !href.contains("/api/local-folder/files/open") { continue; } - let text = link.text_content().unwrap_or_default(); - if text.trim() != selected_text { - continue; + if link.text_content().unwrap_or_default().trim() == selected_text { + return true; } - let block = direct_block_from_element(link, &root)?; - return top_level_block_index(&root, &block); } - None + 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 { @@ -8736,9 +8776,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { let editor = editor; let document_id = document_id; let workspace_id = workspace_id; - let dirty_count = dirty_count; let title = title; - let editor_focused = editor_focused; let set_dirty_count = set_dirty_count; let set_html_output = set_html_output; let set_document_json = set_document_json; @@ -8759,9 +8797,6 @@ fn App(mount_options: MountOptions) -> impl IntoView { let set_color_menu_open = set_color_menu_open; let more_menu_open = more_menu_open; let set_more_menu_open = set_more_menu_open; - let revision = revision; - let conflict_detection_key = conflict_detection_key; - let read_only = read_only; let set_locked_toolbar_anchor = set_locked_toolbar_anchor; let set_text_selection_active = set_text_selection_active; let set_floating_toolbar_anchor = set_floating_toolbar_anchor; @@ -8779,65 +8814,66 @@ fn App(mount_options: MountOptions) -> impl IntoView { let set_block_turn_into_open = set_block_turn_into_open; let set_block_folded_title_open = set_block_folded_title_open; let set_command_feedback = set_command_feedback; + let keydown_capture_closure = + SendWrapper::new(Closure::wrap(Box::new(move |event: ev::KeyboardEvent| { + if !active_editor_stage() + || event.ctrl_key() + || 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()) + { + return; + } + + event.prevent_default(); + event.stop_immediate_propagation(); + match editor.delete_selection() { + Ok(()) => { + sync_persisted_editor_command( + editor, + &runtime_persisted_identity(document_id, workspace_id), + set_dirty_count, + set_html_output, + set_document_json, + set_json_output, + title, + set_command_feedback, + "已删除附件引用", + ); + let _ = set_hovered_block.try_set(None); + let _ = set_block_menu_open.try_set(false); + let _ = set_block_menu_anchor.try_set(None); + } + Err(err) => { + let _ = set_command_feedback.try_set(format!("删除附件引用失败:{err}")); + } + } + }) as Box)); + if let Some(win) = window() { + let _ = win.add_event_listener_with_callback_and_bool( + "keydown", + keydown_capture_closure.as_ref().unchecked_ref(), + true, + ); + } + on_cleanup(move || { + if let Some(win) = window() { + let _ = win.remove_event_listener_with_callback_and_bool( + "keydown", + keydown_capture_closure.as_ref().unchecked_ref(), + true, + ); + } + drop(keydown_capture_closure); + }); let keydown_handle = window_event_listener(ev::keydown, move |event| { let focused = active_editor_stage(); if !focused { return; } - if !event.ctrl_key() - && !event.meta_key() - && !event.alt_key() - && matches!(event.key().as_str(), "Backspace" | "Delete") - { - if let Some(block_index) = selected_local_attachment_block_index() { - event.prevent_default(); - match editor.get_html() { - Ok(current_html) => { - match delete_top_level_block_html(¤t_html, block_index) { - Ok(next_html) => { - apply_html_update( - editor, - &runtime_persisted_identity(document_id, workspace_id), - next_html, - document_id, - workspace_id, - dirty_count, - set_dirty_count, - set_html_output, - set_document_json, - set_json_output, - title, - hovered_block, - editor_focused, - slash_open, - turn_into_open, - color_menu_open, - more_menu_open, - revision, - conflict_detection_key, - read_only, - set_command_feedback, - "已删除附件引用", - ); - let _ = set_hovered_block.try_set(None); - let _ = set_block_menu_open.try_set(false); - let _ = set_block_menu_anchor.try_set(None); - } - Err(err) => { - let _ = set_command_feedback.try_set(err); - } - } - } - Err(err) => { - let _ = - set_command_feedback.try_set(format!("读取当前 HTML 失败:{err}")); - } - } - return; - } - } - if (event.ctrl_key() || event.meta_key()) && event.shift_key() && event.key().eq_ignore_ascii_case("u") diff --git a/scripts/task488-local-attachment-link-delete-undo-smoke.js b/scripts/task488-local-attachment-link-delete-undo-smoke.js new file mode 100644 index 00000000..552bc993 --- /dev/null +++ b/scripts/task488-local-attachment-link-delete-undo-smoke.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task488-local-attachment-link-delete-undo-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + return url.toString(); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ownerId}:task488`, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + } +} + +async function openDocument(page, root, relativePath) { + await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function uploadAttachmentViaSlash(page, fileName, content) { + const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("End").catch(() => undefined); + await page.keyboard.type("/"); + const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first(); + await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }), + item.click({ timeout: UI_TIMEOUT_MS }), + ]); + await fileChooser.setFiles({ + name: fileName, + mimeType: "text/markdown", + buffer: Buffer.from(content, "utf8"), + }); + await page.waitForFunction( + (name) => { + const link = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'); + return link instanceof HTMLAnchorElement + && (link.textContent || "").includes(name) + && (link.getAttribute("href") || "").includes("/api/local-folder/files/open"); + }, + fileName, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function selectAttachmentLinkText(page, fileName) { + await page.evaluate((name) => { + const editorElement = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const editor = editorElement?.editor; + if (!editor || !editor.state || !editor.commands) throw new Error("editor_unavailable"); + let match = null; + editor.state.doc.descendants((node, pos) => { + if (match || !node || !node.isText) return; + const text = node.text || ""; + const offset = text.indexOf(name); + if (offset < 0) return; + const hasAttachmentLink = Array.isArray(node.marks) && node.marks.some((mark) => { + const href = mark && mark.attrs ? String(mark.attrs.href || "") : ""; + return mark.type && mark.type.name === "link" && href.includes("/api/local-folder/files/open"); + }); + if (!hasAttachmentLink) return; + match = { from: pos + offset, to: pos + offset + name.length }; + }); + if (!match) throw new Error("attachment_link_text_range_missing"); + editor.commands.focus(); + editor.commands.setTextSelection(match); + }, fileName); +} + +async function editorDiagnostics(page) { + return await page.evaluate(() => { + const editorElement = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const editor = editorElement?.editor; + const feedback = document.querySelector("#command-feedback")?.textContent || ""; + if (!editor || !editor.state) { + return { feedback, editorReady: false }; + } + return { + feedback, + editorReady: true, + selection: { + from: editor.state.selection.from, + to: editor.state.selection.to, + empty: editor.state.selection.empty, + }, + linkAttributes: typeof editor.getAttributes === "function" ? editor.getAttributes("link") : null, + windowSelection: String(window.getSelection?.() || ""), + canUndo: typeof editor.can === "function" ? editor.can().undo() : null, + }; + }); +} + +async function editorText(page) { + return await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().evaluate((node) => + node instanceof HTMLElement ? node.innerText : "", + ); +} + +async function waitForEditorText(page, predicateSource, arg) { + await page.waitForFunction( + ({ predicateSource, arg }) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const text = editor instanceof HTMLElement ? editor.innerText : ""; + return Function("text", "arg", `return (${predicateSource})(text, arg);`)(text, arg); + }, + { predicateSource, arg }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +(async () => { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task488-")); + writeWorkspaceManifest(root, "mnote-e2e"); + fs.writeFileSync(path.join(root, "UndoLink.md"), "# Undo Link\n\n正文\n", "utf8"); + + const browser = await chromium.launch({ + headless: true, + executablePath: CHROMIUM_EXECUTABLE_PATH, + }); + const page = await browser.newPage({ viewport: { width: 1366, height: 900 } }); + const fileName = "task488-attachment.md"; + const result = { root, fileName, states: [] }; + + try { + await quickLogin(page); + await openDocument(page, root, "UndoLink.md"); + await uploadAttachmentViaSlash(page, fileName, "# 附件\n"); + result.states.push({ step: "uploaded", text: await editorText(page) }); + await page.waitForTimeout(700); + + await selectAttachmentLinkText(page, fileName); + result.states.push({ step: "selected", diagnostics: await editorDiagnostics(page) }); + await page.keyboard.press("Delete"); + await waitForEditorText(page, "(text, name) => !text.includes(name)", fileName); + result.states.push({ step: "deleted", text: await editorText(page), diagnostics: await editorDiagnostics(page) }); + + await page.keyboard.press("Control+Z"); + try { + await waitForEditorText(page, "(text, name) => text.includes(name)", fileName); + } catch (error) { + const afterKeyboardUndo = await editorText(page); + const directUndo = await page.evaluate(() => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.editor; + if (!editor || !editor.commands || typeof editor.commands.undo !== "function") { + return { ok: false, reason: "editor_undo_unavailable" }; + } + return { ok: editor.commands.undo() === true }; + }); + const afterDirectUndo = await editorText(page); + result.states.push({ step: "keyboard-undo-timeout", text: afterKeyboardUndo, directUndo, afterDirectUndo }); + throw error; + } + result.states.push({ step: "undo", text: await editorText(page) }); + + await page.keyboard.press("Control+Y"); + await waitForEditorText(page, "(text, name) => !text.includes(name)", fileName); + result.states.push({ step: "redo", text: await editorText(page) }); + + await page.screenshot({ + path: path.join(OUTPUT_DIR, "after-redo.png"), + fullPage: true, + }); + + assert(result.states.find((state) => state.step === "uploaded").text.includes(fileName), "上传后应插入附件链接"); + assert(!result.states.find((state) => state.step === "deleted").text.includes(fileName), "Delete 后应删除附件链接"); + assert(result.states.find((state) => state.step === "undo").text.includes(fileName), "Ctrl+Z 应恢复附件链接"); + assert(!result.states.find((state) => state.step === "redo").text.includes(fileName), "Ctrl+Y 应再次删除附件链接"); + + fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8"); + await browser.close(); + console.log(`task488 ok: ${RESULT_PATH}`); + } catch (error) { + await page.screenshot({ + path: path.join(OUTPUT_DIR, "failure.png"), + fullPage: true, + }).catch(() => undefined); + fs.writeFileSync( + RESULT_PATH, + `${JSON.stringify({ ok: false, root, error: error && error.stack ? error.stack : String(error), states: result.states }, null, 2)}\n`, + "utf8", + ); + await browser.close().catch(() => undefined); + console.error(`task488 failed: ${RESULT_PATH}`); + process.exit(1); + } +})();