diff --git a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md index 8073db48..abf1bec7 100644 --- a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md +++ b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md @@ -349,12 +349,19 @@ GREEN: - 块菜单 refresh Wolai 基线:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task138-phase-e-wolai-refresh/`,确认二级/三级菜单、Esc/外部点击关闭、鼠标移出保持打开。 - 块菜单 refresh 本地 smoke:`MNOTE_PHASE_E_SCREENSHOT_DIR=/mnt/Data1T/mnote/tmp/wolai-editor-parity/task138-phase-e-local-smoke-10 node scripts/task137-wolai-phase-e-smoke.js`,通过;覆盖第二行手柄不跳行、240px 菜单、底部编辑信息、转换子菜单缺失项、鼠标移出保持、Esc 关闭、H4 真实转换。 - 块菜单 refresh subagent 本地复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task138-phase-e-local-subagent-postfix/`,通过;复核第 1-4 行手柄不跳行、菜单未被侧栏裁剪、鼠标移出保持、Esc/外部点击关闭、H4 真实转换。 -- `转换为 -> 页面` Wolai 基线:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task139-transform-page-wolai-baseline/`;确认 Wolai 点击页面后 URL 不变、不新开标签、不立即创建/选中 sidebar 新页面,当前块原地从 text 变 page block,标题沿用原文本,重新打开转换菜单时 `页面` 为勾选态。 -- `转换为 -> 页面` 本地 smoke:`MNOTE_PHASE_E_SCREENSHOT_DIR=/mnt/Data1T/mnote/tmp/wolai-editor-parity/task139-transform-page-local-smoke-5 node scripts/task137-wolai-phase-e-smoke.js`,通过;覆盖页面项可点击、URL 不变、无 `/api/tree/commands` create、页面块非蓝色普通链接、独立页面图标、整行弱红底、重新打开菜单页面勾选态。 -- `转换为 -> 页面` subagent 本地复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task139-transform-page-local-subagent-postfix/`,通过;关键截图 `06-transform-page.png`、`10-transform-page-current-menu.png`。 -- 代码改动:`rust/spikes/leptos-tiptap-spike/src/lib.rs` 与生成的 `rust/spikes/leptos-tiptap-spike/generated/island/*`。 +- `转换为 -> 页面` task139 本地实现曾只做原地伪页面块;该结论已被用户复核纠偏,不能作为完成口径。后续以 task140 Wolai 真源基线为准:转换必须产生/绑定真正子页面,页面树可见且点击页面块可打开。 +- `转换为 -> 页面` task140 Wolai 真源基线:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task140-transform-page-true-source-wolai-baseline/`;确认父页 URL 当下不变,当前块原地变 page block,点击后打开真实子页面 `5ZNTivwkmVyRSGN3AAntqJ`,返回父页后页面树 `软件开发 -> Hermes` 下可见同名子页面。 +- `转换为 -> 页面` task140 本地 smoke:`MNOTE_PHASE_E_SCREENSHOT_DIR=/mnt/Data1T/mnote/tmp/wolai-editor-parity/task140-transform-page-true-source-local-smoke-4 node scripts/task137-wolai-phase-e-smoke.js`,通过;覆盖 `/api/tree/commands` create、`parentId=当前文档`、返回 `documentId`、页面块 href 指向子页面、点击后进入子页面、刷新后页面树出现同名子页面。 +- `转换为 -> 页面` task140 subagent 本地复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task140-transform-page-true-source-local-subagent-postfix/`,通过;关键断言 child documentId 存在、`parentId` 指向当前文档、父页 URL 不立即变化、点击页面块进入子页面、页面树可见同名子页面;主线程复核截图 `06-transform-page.png` / `09-transform-page-open-child.png` 未见 true-source 语义差异。 +- 代码改动:`rust/spikes/leptos-tiptap-spike/src/lib.rs` 与生成的 `rust/spikes/leptos-tiptap-spike/generated/island/*`;`scripts/task137-wolai-phase-e-smoke.js` 已在本地 ignored smoke 中升级真源断言,默认不入库。 - 验证命令:`cargo fmt --check`、`cargo check`、`node wolai-frontend/scripts/build-leptos-tiptap-island.js`、`node scripts/task137-wolai-phase-e-smoke.js`、`node scripts/task121-rust-web-editor-island-hydration-smoke.js`、`node scripts/task136-wolai-aline-phase-d-smoke.js`。 +### Phase E 参考 Tiptap 审计记录(2026-04-30) + +- `tiptap-notion-like-registry` / `tiptap-notion-like-template` 包含 197 个 registry 组件,核心能力不是单个菜单,而是 `DragContextMenu`、`SlashDropdownMenu`、`NotionToolbarFloating`、AI、颜色、链接、表格、图片、TOC、缩进、节点背景、节点对齐、emoji、mention、协作和移动端 toolbar 的组合。 +- `leptos-tiptap` 当前已支持 `starter-kit` 与 `full`:blockquote、bold、bullet/ordered/task list、code/codeBlock、heading、history、horizontalRule、italic、strike、text-align、highlight、image、link、placeholder、youtube;未直接覆盖表格、数学、emoji、mention、TOC、AI、collaboration caret、节点背景、节点对齐、缩进和 UniqueID。 +- Phase E 后续实施按 Wolai 基线优先级拆小任务;参考 Tiptap 能力先进入 checklist,不直接视为 mnote 必须一次性照搬。 + ### Phase E 说明页扩展 backlog | ID | 状态 | 任务 | 验收要点 | @@ -374,7 +381,12 @@ GREEN: | E23 | TODO | 折叠待办三级菜单 | hover `折叠待办` 显示 `折叠待办列表/折叠高级待办列表`;真实折叠待办语义需接 editor/core 命令 | | E24 | TODO | 复制链接/颜色/文字翻译子菜单 | 复制链接、颜色、文字翻译按 Wolai 展开子菜单;颜色至少覆盖文字色/背景色真实写入,复制链接不只反馈文案 | | E25 | TODO | 块菜单关闭状态机 | Esc 与外部点击关闭所有层级;鼠标移出菜单保持打开,三级菜单可按 Wolai 收回到二级菜单;smoke 覆盖三条路径 | -| E26 | DONE | `转换为 -> 页面` | Wolai Hermes 实测为原地 page block 转换;本地点击后 URL 不变、不触发 tree create、当前块显示页面图标 + 标题 + 整行弱红底,重新打开转换菜单时 `页面` 显示勾选态 | +| E26 | DONE | `转换为 -> 页面` 真源 | 已按 task140 修正:本地转换时调用 `/api/tree/commands` 创建 `parentId=当前文档` 的真实子页面,当前块原地变 page block,页面块 href 指向子页面且点击可打开,刷新后页面树显示同名子页面;仍需在后续任务补齐 Wolai 重新打开菜单时 `页面` 勾选态的完整二次复核 | +| E27 | TODO | 参考 Tiptap slash 插入扩展 | 对齐 `tiptap-notion-like-template`:slash 菜单除文本/标题/列表/引用/代码外,还应覆盖 AI continue/ask、mention、emoji、table、divider、TOC、image upload;每项要按 schema/后端能力拆 smoke,不只显示文案 | +| E28 | TODO | 参考 Tiptap floating toolbar 扩展 | 对齐 floating toolbar:Improve/AI、TurnInto、bold/italic/underline/strike/code、image floating、link popover、text color、superscript/subscript、text align、indent/outdent;当前仅覆盖基础行内样式与颜色入口,真实写入需补齐 | +| E29 | TODO | 参考 Tiptap drag context menu 扩展 | 对齐 drag context:copy to clipboard、copy anchor link、reset formatting、Ask AI、ColorMenu、TableAlign/Fit/Clear、TOC show title、image download、delete;当前块菜单仍有多项静态或缺失 | +| E30 | TODO | 参考 Tiptap 表格/图片/TOC 节点 | 对齐 TableKit/TableHandle/TableSelectionOverlay/ImageUpload/Image floating/TOC sidebar;本地 `leptos-tiptap` crate 当前 full feature 只到 image/link/highlight/youtube,表格、数学、emoji、mention、TOC、协作光标等需按架构另列能力边界 | +| E31 | TODO | 参考 Tiptap 节点级样式扩展 | 对齐 NodeBackground、NodeAlignment、Indent、ListNormalization、UniqueID/anchor;当前有 text-align/color/highlight 的局部能力,块背景、缩进结构、锚点复制和列表规范化需补 smoke 与真源映射 | ## 9. Phase F:Page Aggregate 和命令边界 diff --git a/rust/spikes/leptos-tiptap-spike/Cargo.lock b/rust/spikes/leptos-tiptap-spike/Cargo.lock index d6df6614..6c83ca07 100644 --- a/rust/spikes/leptos-tiptap-spike/Cargo.lock +++ b/rust/spikes/leptos-tiptap-spike/Cargo.lock @@ -979,6 +979,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/rust/spikes/leptos-tiptap-spike/Cargo.toml b/rust/spikes/leptos-tiptap-spike/Cargo.toml index 82a982a9..d8f8eb5b 100644 --- a/rust/spikes/leptos-tiptap-spike/Cargo.toml +++ b/rust/spikes/leptos-tiptap-spike/Cargo.toml @@ -22,7 +22,8 @@ serde_json = "1" serde-wasm-bindgen = "0.6" js-sys = "0.3.77" wasm-bindgen = "0.2" -web-sys = { version = "0.3.77", features = ["CustomEvent", "CustomEventInit", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MessageEvent", "MouseEvent", "Node", "Range", "Selection", "Storage", "Window"] } +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3.77", features = ["CustomEvent", "CustomEventInit", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MessageEvent", "MouseEvent", "Node", "Range", "RequestInit", "RequestMode", "Response", "Selection", "Storage", "Window"] } [dependencies.leptos-tiptap] path = "../../../design/05-editor-mainline/reference-code/leptos-tiptap" 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 511d2b81..4af64cbc 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 @@ -284,6 +284,9 @@ function __wbg_get_imports() { const ret = arg0.appendChild(arg1); return ret; }, arguments); }, + __wbg_assign_d4fed0f8abb71719: function() { return handleError(function (arg0, arg1, arg2) { + arg0.assign(getStringFromWasm0(arg1, arg2)); + }, arguments); }, __wbg_body_c7b35a55457167ba: function(arg0) { const ret = arg0.body; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); @@ -461,6 +464,10 @@ function __wbg_get_imports() { wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); } }, + __wbg_fetch_3f6f4a922f97ca32: function(arg0, arg1, arg2, arg3) { + const ret = arg0.fetch(getStringFromWasm0(arg1, arg2), arg3); + return ret; + }, __wbg_firstElementChild_f67647a589d437a2: function(arg0) { const ret = arg0.firstElementChild; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); @@ -601,6 +608,16 @@ function __wbg_get_imports() { const ret = result; return ret; }, + __wbg_instanceof_Response_9b2d111407865ff2: function(arg0) { + let result; + try { + result = arg0 instanceof Response; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, __wbg_instanceof_ShadowRoot_d26d95cd2363a2c1: function(arg0) { let result; try { @@ -659,6 +676,10 @@ function __wbg_get_imports() { const ret = Symbol.iterator; return ret; }, + __wbg_json_cc3f4ccd040a504c: function() { return handleError(function (arg0) { + const ret = arg0.json(); + return ret; + }, arguments); }, __wbg_key_2cbc38fa83cdb336: function(arg0, arg1) { const ret = arg1.key; const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -752,6 +773,10 @@ function __wbg_get_imports() { const ret = arg0.next; return ret; }, + __wbg_ok_f7783a2e6ac7fe17: function(arg0) { + const ret = arg0.ok; + return ret; + }, __wbg_parentElement_d1271cca94202d1f: function(arg0) { const ret = arg0.parentElement; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); @@ -963,6 +988,9 @@ function __wbg_get_imports() { __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { arg0[arg1] = arg2; }, + __wbg_set_body_be11680f34217f75: function(arg0, arg1) { + arg0.body = arg1; + }, __wbg_set_detail_68bec5c91196ba57: function(arg0, arg1) { arg0.detail = arg1; }, @@ -973,6 +1001,12 @@ function __wbg_get_imports() { __wbg_set_innerHTML_a3c82996073b31ea: function(arg0, arg1, arg2) { arg0.innerHTML = getStringFromWasm0(arg1, arg2); }, + __wbg_set_method_c9f1f985f6b6c427: function(arg0, arg1, arg2) { + arg0.method = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_mode_5e08d503428c06b9: function(arg0, arg1) { + arg0.mode = __wbindgen_enum_RequestMode[arg1]; + }, __wbg_set_nodeValue_f39ed00fc286b285: function(arg0, arg1, arg2) { arg0.nodeValue = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); }, @@ -1006,6 +1040,10 @@ function __wbg_get_imports() { const ret = typeof window === 'undefined' ? null : window; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, + __wbg_status_43e0d2f15b22d69f: function(arg0) { + const ret = arg0.status; + return ret; + }, __wbg_stopPropagation_e088fca8231e68c4: function(arg0) { arg0.stopPropagation(); }, @@ -1024,6 +1062,10 @@ function __wbg_get_imports() { const ret = arg0.target; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, + __wbg_then_792e0c862b060889: function(arg0, arg1, arg2) { + const ret = arg0.then(arg1, arg2); + return ret; + }, __wbg_then_8e16ee11f05e4827: function(arg0, arg1) { const ret = arg0.then(arg1); return ret; @@ -1058,37 +1100,37 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1111, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1120, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1366, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1382, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1457, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1473, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1292, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1307, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1368, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1384, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1305, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1320, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f); return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1367, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1383, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9); return ret; }, @@ -1172,6 +1214,9 @@ function wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg const __wbindgen_enum_ReadableStreamType = ["bytes"]; + + +const __wbindgen_enum_RequestMode = ["same-origin", "no-cors", "cors", "navigate"]; const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr >>> 0, 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 7bc1b5c9..f5c365f2 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/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index bf4b3f69..dde07b89 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -12,9 +12,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display}; use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue}; +use wasm_bindgen_futures::{spawn_local, JsFuture}; use web_sys::{ window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement, - MouseEvent, Node, Storage, WheelEvent, + MouseEvent, Node, RequestInit, RequestMode, Response, Storage, WheelEvent, }; const EDITOR_STAGE_SELECTOR: &str = "#editor-stage"; @@ -4034,6 +4035,126 @@ fn normalized_page_block_title(text: &str) -> String { } } +#[derive(Debug, Deserialize)] +struct TreeCommandHttpResponse { + result: Option, + message: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TreeCommandResult { + document_id: Option, + workspace_id: Option, + title: Option, + parent_id: Option, +} + +fn dispatch_tree_local_command(body: &Value, result: &Value) { + let Some(win) = window() else { + return; + }; + let Ok(detail) = serde_wasm_bindgen::to_value(&json!({ + "body": body, + "result": result, + })) else { + return; + }; + let init = CustomEventInit::new(); + init.set_detail(&detail); + if let Ok(event) = CustomEvent::new_with_event_init_dict("tree:local-command", &init) { + let _ = win.dispatch_event(&event); + } +} + +async fn create_child_page_via_tree_command( + parent_id: String, + workspace_id: Option, + title: String, +) -> Result { + let body = json!({ + "action": "create", + "workspaceId": workspace_id, + "parentId": parent_id, + "title": title, + "accessScope": "private", + "content": [], + }); + let body_string = + serde_json::to_string(&body).map_err(|err| format!("序列化创建子页面请求失败:{err}"))?; + + let request_init = RequestInit::new(); + request_init.set_method("POST"); + request_init.set_mode(RequestMode::SameOrigin); + request_init.set_body(&JsValue::from_str(&body_string)); + let headers = js_sys::Object::new(); + js_sys::Reflect::set( + &headers, + &JsValue::from_str("content-type"), + &JsValue::from_str("application/json"), + ) + .map_err(|_| "设置创建子页面请求头失败".to_string())?; + js_sys::Reflect::set( + request_init.as_ref(), + &JsValue::from_str("headers"), + &headers, + ) + .map_err(|_| "设置创建子页面请求头失败".to_string())?; + + let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?; + let response_value = + JsFuture::from(win.fetch_with_str_and_init("/api/tree/commands", &request_init)) + .await + .map_err(|err| format!("创建子页面请求失败:{err:?}"))?; + let response: Response = response_value + .dyn_into() + .map_err(|_| "创建子页面响应类型错误".to_string())?; + let status = response.status(); + let json_value = JsFuture::from( + response + .json() + .map_err(|_| "读取创建子页面响应失败".to_string())?, + ) + .await + .map_err(|err| format!("解析创建子页面响应失败:{err:?}"))?; + let parsed: TreeCommandHttpResponse = serde_wasm_bindgen::from_value(json_value.clone()) + .map_err(|err| format!("创建子页面响应 JSON 不符合预期:{err}"))?; + if !response.ok() { + return Err(parsed + .message + .unwrap_or_else(|| format!("创建子页面失败:HTTP {status}"))); + } + let result = parsed + .result + .ok_or_else(|| "创建子页面响应缺少 result".to_string())?; + let document_id = result + .document_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "创建子页面响应缺少 documentId".to_string())?; + if document_id == parent_id { + return Err("创建子页面返回了当前页面 id".to_string()); + } + if let Some(created_parent_id) = result + .parent_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if created_parent_id != parent_id { + return Err(format!( + "创建子页面父节点不符合预期:期望 {parent_id},实际 {created_parent_id}" + )); + } + } + dispatch_tree_local_command( + &body, + &serde_wasm_bindgen::from_value(json_value).unwrap_or(Value::Null)["result"], + ); + Ok(result) +} + fn block_plain_text_from_index( editor: TiptapEditorHandle, block_index: usize, @@ -4093,9 +4214,14 @@ fn top_level_block_is_page(editor: TiptapEditorHandle, block_index: usize) -> bo .unwrap_or(false) } -fn page_reference_paragraph_node(page_id: &str, title: &str) -> Value { +fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value { let href = if page_id.trim().is_empty() { "#".to_string() + } else if let Some(workspace_id) = workspace_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + format!("/documents/{page_id}?workspaceId={workspace_id}") } else { format!("/documents/{page_id}") }; @@ -4118,6 +4244,7 @@ fn replace_block_with_page_reference( editor: TiptapEditorHandle, block_index: usize, page_id: &str, + workspace_id: Option<&str>, title: &str, ) -> Result<(), String> { let mut document = editor @@ -4127,7 +4254,7 @@ fn replace_block_with_page_reference( if block_index >= content.len() { return Err(format!("找不到第 {} 个块", block_index + 1)); } - content[block_index] = page_reference_paragraph_node(page_id, title); + content[block_index] = page_reference_paragraph_node(page_id, workspace_id, title); let next_payload = document .get("content") .and_then(Value::as_array) @@ -4163,28 +4290,59 @@ fn run_block_turn_into_page_action( return; } }; - let page_id = document_id - .get_untracked() - .unwrap_or_else(|| format!("page-block-{}", block_index + 1)); - match replace_block_with_page_reference(editor, block_index, &page_id, &page_title) { - Ok(()) => { - set_block_menu_open.set(false); - set_block_menu_anchor.set(None); - set_block_turn_into_open.set(false); - 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, - format!("已转换为页面:{page_title}"), - ); + let Some(parent_document_id) = document_id.get_untracked() else { + set_command_feedback.set("转换为页面失败:当前页面缺少 documentId".to_string()); + return; + }; + let workspace_id_value = workspace_id.get_untracked(); + set_command_feedback.set(format!("正在创建子页面:{page_title}")); + spawn_local(async move { + match create_child_page_via_tree_command( + parent_document_id.clone(), + workspace_id_value.clone(), + page_title.clone(), + ) + .await + { + Ok(created) => { + let Some(child_page_id) = created.document_id.as_deref() else { + set_command_feedback.set("转换为页面失败:创建响应缺少子页面 id".to_string()); + return; + }; + let effective_workspace_id = created + .workspace_id + .as_deref() + .or(workspace_id_value.as_deref()); + let effective_title = created.title.as_deref().unwrap_or(&page_title); + match replace_block_with_page_reference( + editor, + block_index, + child_page_id, + effective_workspace_id, + effective_title, + ) { + Ok(()) => { + set_block_menu_open.set(false); + set_block_menu_anchor.set(None); + set_block_turn_into_open.set(false); + 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, + format!("已转换为子页面:{effective_title}"), + ); + } + Err(err) => set_command_feedback.set(err), + } + } + Err(err) => set_command_feedback.set(err), } - Err(err) => set_command_feedback.set(err), - } + }); } fn apply_feedback_result(setter: WriteSignal, result: Result<&'static str, E>) @@ -4931,6 +5089,30 @@ fn App(mount_options: MountOptions) -> impl IntoView { on_cleanup(move || drop(keydown_handle)); } + { + let click_handle = window_event_listener(ev::click, move |event: MouseEvent| { + let Some(element) = event.target().and_then(target_element) else { + return; + }; + let Some(anchor) = element.closest("a.mnote-page-block-link").ok().flatten() else { + return; + }; + let Some(href) = anchor + .get_attribute("href") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && value != "#") + else { + return; + }; + event.prevent_default(); + event.stop_propagation(); + if let Some(win) = window() { + let _ = win.location().assign(&href); + } + }); + on_cleanup(move || drop(click_handle)); + } + let has_task_schema = move || { let snapshot = json_output.get(); snapshot.contains("\"taskList\"") && snapshot.contains("\"taskItem\"")