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 91ab59d2..8073db48 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 @@ -330,7 +330,7 @@ GREEN: | E3 | DONE | 上方插入加号 | 本地仅在 hover 四点上方热区时显示上方 `+`,点击可在目标块前插入并聚焦;tooltip/title 包含 `在上方插入块` / `Esc, a` | | E4 | DONE | 下方插入加号 | 本地仅在 hover 四点下方热区时显示下方 `+`,点击可在目标块后插入并聚焦;tooltip/title 包含 `在下方插入块` / `Esc, b` | | E5 | DONE | `Esc, a` / `Esc, b` | 本地块选中态快捷键可插入 before/after,并把输入焦点放入新块 | -| E6 | PARTIAL | 块菜单 | 本地点击四点后手柄切换为三横线;主菜单约 240px、浮在侧栏之上、位于三横线左侧且不遮挡正文/手柄;`转换为` 二级菜单已补齐 Wolai 当前基线项目,H4 小标题转换为真实命令;复制链接/颜色/文字翻译子菜单、折叠标题/折叠待办三级菜单仍需继续补齐 | +| E6 | PARTIAL | 块菜单 | 本地点击四点后手柄切换为三横线;主菜单约 240px、浮在侧栏之上、位于三横线左侧且不遮挡正文/手柄;`转换为` 二级菜单已补齐 Wolai 当前基线项目,H4 小标题与页面转换已有真实命令;复制链接/颜色/文字翻译子菜单、折叠标题/折叠待办三级菜单仍需继续补齐 | | E7 | DONE | slash 菜单 | 本地 `/` 跟随编辑器打开;含 `AI 助理 / 用 AI 写作 / 基础块列表 / 文本 / 待办列表 / /h1`,方向键/Enter 选择仍由 smoke 覆盖 | | E8 | DONE | selection toolbar | 本地选中文本出现工具条;粗体、斜体、链接、颜色、转换块入口可见,入口文案已中文化 | | E9 | PARTIAL | `Ctrl+A` 双阶段选择 | 本地 smoke 覆盖第一次/第二次选择范围扩展;Wolai 的多块粉色选区视觉与块级全选细节后续单列视觉对标 | @@ -349,6 +349,9 @@ 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/*`。 - 验证命令:`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`。 @@ -366,11 +369,12 @@ GREEN: | E18 | TODO | 块引用与右侧边栏引用 | 块引用搜索、引用预览/别名、右侧边栏拖拽引用后续对标 | | E19 | TODO | 块历史 | 块历史查看与恢复版本按 Wolai 说明页单列验证 | | E20 | TODO | 拖拽分栏 | 横线/竖线预览、父子块随父块移动、测试块间安全释放与清理 | -| E21 | DONE | 块菜单二级转换基础版 | `转换为` hover 后主菜单保持、二级菜单包含 Wolai 当前项目;`文本/待办/高级待办/H1/H2/H3/H4/列表/数字列表/引述/代码` 其中 H4 至少有真实 smoke | +| E21 | DONE | 块菜单二级转换基础版 | `转换为` hover 后主菜单保持、二级菜单包含 Wolai 当前项目;`文本/待办/高级待办/H1/H2/H3/H4/页面/列表/数字列表/引述/代码` 其中 H4 与页面至少有真实 smoke | | E22 | TODO | 折叠标题三级菜单 | hover `折叠标题` 显示 `折叠主标题/折叠大标题/折叠中标题/折叠小标题`;真实折叠语义需接 editor/core 命令,不用 disabled 文案伪装完成 | | E23 | TODO | 折叠待办三级菜单 | hover `折叠待办` 显示 `折叠待办列表/折叠高级待办列表`;真实折叠待办语义需接 editor/core 命令 | | E24 | TODO | 复制链接/颜色/文字翻译子菜单 | 复制链接、颜色、文字翻译按 Wolai 展开子菜单;颜色至少覆盖文字色/背景色真实写入,复制链接不只反馈文案 | | E25 | TODO | 块菜单关闭状态机 | Esc 与外部点击关闭所有层级;鼠标移出菜单保持打开,三级菜单可按 Wolai 收回到二级菜单;smoke 覆盖三条路径 | +| E26 | DONE | `转换为 -> 页面` | Wolai Hermes 实测为原地 page block 转换;本地点击后 URL 不变、不触发 tree create、当前块显示页面图标 + 标题 + 整行弱红底,重新打开转换菜单时 `页面` 显示勾选态 | ## 9. Phase F:Page Aggregate 和命令边界 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 35371d4c..511d2b81 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 @@ -1058,37 +1058,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: 1102, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1111, 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: 1357, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1366, 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: 1448, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // 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`. 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: 1283, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1292, 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: 1359, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1368, 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: 1296, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1305, 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: 1358, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1367, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9); 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 9950dd3b..7bc1b5c9 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 4c56232c..bf4b3f69 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -1196,6 +1196,45 @@ const SPIKE_STYLE: &str = r#" min-height: 1.6em; } +.editor-surface .ProseMirror p:has(> a.mnote-page-block-link) { + width: 100%; + margin: 0 0 8px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(255, 71, 71, 0.12); +} + +.editor-surface .ProseMirror a.mnote-page-block-link { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 1px 4px; + border-radius: 4px; + color: #37352f; + font-weight: 600; + line-height: 1.45; + text-decoration: none; + cursor: pointer; +} + +.editor-surface .ProseMirror a.mnote-page-block-link::before { + content: ""; + width: 14px; + height: 14px; + flex: 0 0 auto; + border: 1.4px solid #c8c5c0; + border-radius: 3px; + background: linear-gradient(180deg, #ffffff 0%, #f6f4f1 100%); + box-shadow: inset 0 -2px 0 rgba(55, 53, 47, 0.08); +} + +.editor-surface .ProseMirror a.mnote-page-block-link:hover { + color: #37352f; + background: rgba(55, 53, 47, 0.08); + text-decoration: none; +} + .footer-strip { display: flex; justify-content: space-between; @@ -3986,6 +4025,168 @@ fn run_block_turn_into_action( } } +fn normalized_page_block_title(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + "未命名页面".to_string() + } else { + trimmed.to_string() + } +} + +fn block_plain_text_from_index( + editor: TiptapEditorHandle, + block_index: usize, +) -> Result { + let document = editor + .get_json() + .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; + let block = document + .get("content") + .and_then(Value::as_array) + .and_then(|content| content.get(block_index)) + .ok_or_else(|| format!("找不到第 {} 个块", block_index + 1))?; + Ok(normalized_page_block_title(&collect_plain_text(block))) +} + +fn inline_has_page_block_link(node: &Value) -> bool { + if node + .get("marks") + .and_then(Value::as_array) + .map(|marks| { + marks.iter().any(|mark| { + mark.get("type").and_then(Value::as_str) == Some("link") + && mark + .get("attrs") + .and_then(|attrs| attrs.get("class")) + .and_then(Value::as_str) + .map(|class_name| { + class_name + .split_whitespace() + .any(|part| part == "mnote-page-block-link") + }) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + { + return true; + } + + node.get("content") + .and_then(Value::as_array) + .map(|children| children.iter().any(inline_has_page_block_link)) + .unwrap_or(false) +} + +fn top_level_block_is_page(editor: TiptapEditorHandle, block_index: usize) -> bool { + editor + .get_json() + .ok() + .and_then(|document| { + document + .get("content") + .and_then(Value::as_array) + .and_then(|content| content.get(block_index).cloned()) + }) + .map(|block| inline_has_page_block_link(&block)) + .unwrap_or(false) +} + +fn page_reference_paragraph_node(page_id: &str, title: &str) -> Value { + let href = if page_id.trim().is_empty() { + "#".to_string() + } else { + format!("/documents/{page_id}") + }; + paragraph_node(vec![json!({ + "type": "text", + "text": normalized_page_block_title(title), + "marks": [{ + "type": "link", + "attrs": { + "href": href, + "target": null, + "rel": "noopener noreferrer nofollow", + "class": "mnote-page-block-link", + } + }] + })]) +} + +fn replace_block_with_page_reference( + editor: TiptapEditorHandle, + block_index: usize, + page_id: &str, + title: &str, +) -> Result<(), String> { + let mut document = editor + .get_json() + .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; + let content = document_content_mut(&mut document)?; + if block_index >= content.len() { + return Err(format!("找不到第 {} 个块", block_index + 1)); + } + content[block_index] = page_reference_paragraph_node(page_id, title); + let next_payload = document + .get("content") + .and_then(Value::as_array) + .cloned() + .map(Value::Array) + .unwrap_or(document); + editor + .set_content(TiptapContent::json(next_payload)) + .map_err(|err| format!("转换为页面失败:{err}"))?; + focus_top_level_block_start(editor, block_index)?; + Ok(()) +} + +fn run_block_turn_into_page_action( + editor: TiptapEditorHandle, + block_index: usize, + document_id: ReadSignal>, + workspace_id: ReadSignal>, + title: ReadSignal, + set_dirty_count: WriteSignal, + set_html_output: WriteSignal, + set_document_json: WriteSignal, + set_json_output: WriteSignal, + set_command_feedback: WriteSignal, + set_block_menu_open: WriteSignal, + set_block_menu_anchor: WriteSignal>, + set_block_turn_into_open: WriteSignal, +) { + let page_title = match block_plain_text_from_index(editor, block_index) { + Ok(value) => value, + Err(err) => { + set_command_feedback.set(err); + 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}"), + ); + } + Err(err) => set_command_feedback.set(err), + } +} + fn apply_feedback_result(setter: WriteSignal, result: Result<&'static str, E>) where E: Display, @@ -5616,6 +5817,8 @@ fn App(mount_options: MountOptions) -> impl IntoView { }); let menu_top = format!("{}px", menu_layout.top); let menu_bottom = "auto"; + let current_block_is_page = top_level_block_is_page(editor, block_index); + let page_current_marker = if current_block_is_page { "✓" } else { "Ctrl+Shift+9" }; view! {
impl IntoView { } }) .collect_view()} - +