fix: preserve undo for attachment link deletion

This commit is contained in:
lix-2026
2026-05-24 19:55:37 +08:00
parent 9c4bb58257
commit cafd29a963
9 changed files with 401 additions and 82 deletions
+22
View File
@@ -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":"下载失败"}}。请检查原因,同时要兼容未来更换域名。
+1
View File
@@ -975,6 +975,7 @@ dependencies = [
"leptos",
"leptos-tiptap",
"leptos_dom",
"send_wrapper",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -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"
@@ -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;
@@ -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]) {
@@ -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;
+104 -68
View File
@@ -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<HoveredBlockState> {
block_state_from_index(index)
}
fn selected_local_attachment_block_index() -> Option<usize> {
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<usize> {
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::<js_sys::Function>().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<HoveredBlockState> {
@@ -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<dyn FnMut(_)>));
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(&current_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")
@@ -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);
}
})();