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 6c671528..91ab59d2 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 @@ -324,17 +324,53 @@ GREEN: | ID | 状态 | 任务 | 验收要点 | | --- | --- | --- | --- | -| E0 | TODO | editable-test 焦点护栏 | 写入前先断言焦点在正文测试块,不在标题、breadcrumb 或隐藏输入;截图记录焦点位置 | -| E1 | TODO | editable-test 基础输入 | Hermes editable-test mode 取 Wolai 输入/回车/撤销基线;本地保存后刷新保持 | -| E2 | TODO | 块 hover 手柄 | hover 块只显示左侧手柄区,不自动弹菜单;浅色块 hover 背景对齐 | -| E3 | TODO | 上方插入短横线 | hover 短横线变 `+`;tooltip `在上方插入块` / `Esc, a`;点击前插入 | -| E4 | TODO | 下方插入短横线 | hover 短横线变 `+`;tooltip `在下方插入块` / `Esc, b`;点击后插入 | -| E5 | TODO | `Esc, a` / `Esc, b` | 块选中态快捷键插入 before/after;本地 smoke 验证焦点进入新块 | -| E6 | TODO | 块菜单 | 点击手柄打开菜单;`转换为 / 拷贝副本 / 删除 / 复制链接 / 颜色` 等入口视觉对齐 | -| E7 | TODO | slash 菜单 | 输入 `/` 跟随 caret 打开;搜索过滤和键盘选择可用 | -| E8 | TODO | selection toolbar | 选中文本出现工具条;粗体、斜体、链接、颜色、转换块入口可见 | -| E9 | TODO | `Ctrl+A` 双阶段选择 | 第一次选当前文本/块,第二次选全部块;行为与 Wolai 基线一致 | -| E10 | TODO | 拖拽预览 | 块拖动时横向 drop line、分栏竖线预览;持久化可后置但预览需对齐 | +| E0 | DONE | editable-test 焦点护栏 | Wolai Hermes editable-test 与本地 smoke 均断言写入前焦点在正文测试块,不在标题、breadcrumb 或隐藏输入 | +| E1 | DONE | editable-test 基础输入 | Wolai 已取输入/回车/撤销基线;本地 `task137` 覆盖三行输入、保存、刷新后保持 | +| E2 | DONE | 块 hover 手柄 | Wolai follow-up 基线确认默认 hover 只显示 `2x2` 四点手柄;本地 smoke 断言四点、可见性、与正文间距和从正文移向手柄不消失 | +| 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 小标题转换为真实命令;复制链接/颜色/文字翻译子菜单、折叠标题/折叠待办三级菜单仍需继续补齐 | +| E7 | DONE | slash 菜单 | 本地 `/` 跟随编辑器打开;含 `AI 助理 / 用 AI 写作 / 基础块列表 / 文本 / 待办列表 / /h1`,方向键/Enter 选择仍由 smoke 覆盖 | +| E8 | DONE | selection toolbar | 本地选中文本出现工具条;粗体、斜体、链接、颜色、转换块入口可见,入口文案已中文化 | +| E9 | PARTIAL | `Ctrl+A` 双阶段选择 | 本地 smoke 覆盖第一次/第二次选择范围扩展;Wolai 的多块粉色选区视觉与块级全选细节后续单列视觉对标 | +| E10 | DONE | 拖拽预览 | 本地 smoke 覆盖拖动手柄时 `.block-drop-indicator` 横向 drop line 可见;分栏竖线预览与落点持久化后续单列 | + +### Phase E 执行记录(2026-04-30) + +- Wolai 基线:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-phase-e-baseline/comparison-matrix.md`,截图目录 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-phase-e-baseline/screenshots/`。 +- Wolai 说明页摘要:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-phase-e-baseline/doc-summary.md`。 +- 本地 post-fix smoke:`MNOTE_PHASE_E_SCREENSHOT_DIR=/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-phase-e-local-smoke node scripts/task137-wolai-phase-e-smoke.js`,通过。 +- Subagent post-fix 复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-phase-e-postfix-subagent-final3/`,通过。 +- 手柄 follow-up Wolai 基线:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-handle-followup-wolai-baseline/`,确认默认四点、上/下热区分别出 `+`、点击后变三横线、菜单在正文左侧不挡正文。 +- 手柄 follow-up 本地 smoke:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-handle-followup-local-postfix10/`,通过并覆盖四点、上下加号、可达性、菜单不挡正文/手柄。 +- 手柄 follow-up subagent 本地复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task137-handle-followup-local-subagent-postfix/`,通过;复核默认四点、上下加号、正文到手柄不消失、点击后三横线、菜单不遮挡正文/手柄。 +- 关键截图:`02-hover-handle-default.png`、`02a-hover-handle-before-plus.png`、`02b-hover-handle-after-plus.png`、`04-drag-preview.png`、`05-block-menu.png`、`07-slash-menu.png`、`08-toolbar.png`。`05-block-menu.png` 已断言菜单不遮挡正文且不盖住三横线手柄;更多 Wolai 菜单项后续补齐。 +- 块菜单 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 真实转换。 +- 代码改动:`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`。 + +### Phase E 说明页扩展 backlog + +| ID | 状态 | 任务 | 验收要点 | +| --- | --- | --- | --- | +| E11 | TODO | `Tab` / `Shift+Tab` 缩进结构 | 缩进改变父子块结构,不只是视觉 padding;覆盖不允许缩进的块类型 | +| E12 | TODO | `Ctrl/Cmd+Shift+U` 块布局 | 显示/隐藏块布局虚线框,且不影响编辑保存 | +| E13 | TODO | 块转换三入口 | 块菜单、快捷键、slash `/zh + 拼音首字母` 均能转换文本类块 | +| E14 | TODO | 复制/粘贴与匹配样式粘贴 | 文本选择、块选择、`Ctrl/Cmd+Shift+V` 分别有 smoke | +| E15 | TODO | 链接创建 | 选中文字 `Ctrl/Cmd+K` 与直接粘贴 URL 创建链接 | +| E16 | TODO | 文本颜色/背景色/行内公式/页面引用 | selection toolbar 子菜单真实写入 Tiptap 内容,不只显示入口 | +| E17 | TODO | 评论入口 | 块评论、选中文字评论、评论气泡数量和 `Ctrl/Cmd+Alt+M` 后续对标 | +| E18 | TODO | 块引用与右侧边栏引用 | 块引用搜索、引用预览/别名、右侧边栏拖拽引用后续对标 | +| E19 | TODO | 块历史 | 块历史查看与恢复版本按 Wolai 说明页单列验证 | +| E20 | TODO | 拖拽分栏 | 横线/竖线预览、父子块随父块移动、测试块间安全释放与清理 | +| 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 覆盖三条路径 | ## 9. Phase F:Page Aggregate 和命令边界 diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index ad725be6..d1da638f 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -1762,16 +1762,16 @@ body { } #mnote-leptos-tiptap-island-editor-root .block-handle-shell { - left: -88px !important; - z-index: 5; - opacity: 0.2; + left: -32px !important; + z-index: 80; + opacity: 1; pointer-events: none; transition: opacity 120ms ease, transform 120ms ease; } #mnote-leptos-tiptap-island-editor-root .block-handle-shell:hover, #mnote-leptos-tiptap-island-editor-root .block-handle-shell[data-dragging="true"] { - opacity: 0.6; + opacity: 1; } #mnote-leptos-tiptap-island-editor-root .block-handle-shell .block-handle-insert, @@ -1902,7 +1902,7 @@ body { } #mnote-leptos-tiptap-island-editor-root .block-handle-shell { - left: -64px !important; + left: -32px !important; } } @@ -1985,7 +1985,7 @@ mod tests { fn mnote_css_keeps_tiptap_handles_in_margin_and_title_editable() { assert!(MNOTE_CSS.contains(".document-title-input")); assert!(MNOTE_CSS.contains(".block-handle-shell")); - assert!(MNOTE_CSS.contains("left: -88px !important")); + assert!(MNOTE_CSS.contains("left: -32px !important")); assert!(MNOTE_CSS.contains("pointer-events: none")); assert!(MNOTE_CSS.contains(".material-symbols-outlined")); assert!(MNOTE_CSS.contains(".mnote-tree-context-menu")); 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 fb411b4f..acfbd109 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 @@ -61,8 +61,8 @@ export interface InitOutput { readonly intounderlyingsource_cancel: (a: number) => void; readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number]; readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; readonly wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void; readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void; readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number; 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 76a62252..35371d4c 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 @@ -269,6 +269,9 @@ function __wbg_get_imports() { __wbg_addEventListener_8176dab41b09531c: function() { return handleError(function (arg0, arg1, arg2, arg3) { arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); }, arguments); }, + __wbg_addRange_de27d094b9bf29d8: function() { return handleError(function (arg0, arg1) { + arg0.addRange(arg1); + }, arguments); }, __wbg_altKey_c4f26b40f1b826b4: function(arg0) { const ret = arg0.altKey; return ret; @@ -359,6 +362,9 @@ function __wbg_get_imports() { const ret = arg0.closest(getStringFromWasm0(arg1, arg2)); return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, arguments); }, + __wbg_collapse_ba755876733fccfc: function(arg0, arg1) { + arg0.collapse(arg1 !== 0); + }, __wbg_composedPath_e2b9e0f5161335eb: function(arg0) { const ret = arg0.composedPath(); return ret; @@ -379,6 +385,10 @@ function __wbg_get_imports() { const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); return ret; }, arguments); }, + __wbg_createRange_ff06800933ad1293: function() { return handleError(function (arg0) { + const ret = arg0.createRange(); + return ret; + }, arguments); }, __wbg_createTextNode_7949043038fd9f7b: function(arg0, arg1, arg2) { const ret = arg0.createTextNode(getStringFromWasm0(arg1, arg2)); return ret; @@ -858,6 +868,9 @@ function __wbg_get_imports() { __wbg_register_youtube_fd5d6301c55e98d7: function() { return handleError(function () { register_youtube(); }, arguments); }, + __wbg_removeAllRanges_3d2e987f14b7d3ef: function() { return handleError(function (arg0) { + arg0.removeAllRanges(); + }, arguments); }, __wbg_removeAttribute_c75ac657c944b3f1: function() { return handleError(function (arg0, arg1, arg2) { arg0.removeAttribute(getStringFromWasm0(arg1, arg2)); }, arguments); }, @@ -888,6 +901,10 @@ function __wbg_get_imports() { __wbg_respond_008ca9525ae22847: function() { return handleError(function (arg0, arg1) { arg0.respond(arg1 >>> 0); }, arguments); }, + __wbg_right_6096346a1fca4d04: function(arg0) { + const ret = arg0.right; + return ret; + }, __wbg_run_0b0a622deae25fda: function(arg0, arg1, arg2) { try { var state0 = {a: arg1, b: arg2}; @@ -921,6 +938,9 @@ function __wbg_get_imports() { getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, arguments); }, + __wbg_selectNodeContents_1f8f5798ebd4f1f4: function() { return handleError(function (arg0, arg1) { + arg0.selectNodeContents(arg1); + }, arguments); }, __wbg_setAttribute_6fde4098d274155c: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { arg0.setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); }, arguments); }, @@ -1038,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: 1226, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75); - return ret; - }, - __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1317, 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_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 971, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1102, 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`. + 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`. + 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: 1152, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1283, 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: 1228, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1359, 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: 1165, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1296, 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: 1227, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1358, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9); return ret; }, @@ -1123,14 +1143,14 @@ function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg return ret !== 0; } -function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2); -} - function wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6(arg0, arg1, arg2) { wasm.wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6(arg0, arg1, arg2); } +function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2); +} + function wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2) { wasm.wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2); } 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 a446fc47..9950dd3b 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 c0f06dd9..b65aa7b9 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 @@ -18,8 +18,8 @@ export const intounderlyingsource_pull: (a: number, b: any) => any; export const intounderlyingsource_cancel: (a: number) => void; export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number]; export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; export const wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void; export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void; export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index bab31256..4c56232c 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -5,16 +5,12 @@ use leptos_dom::helpers::window_event_listener; use leptos_tiptap::{ TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor, TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes, - TiptapLinkResource, TiptapMarkName, TiptapRange, TiptapSelectionState, TiptapTextAlign, + TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, TiptapRange, + TiptapSelectionState, TiptapTextAlign, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; -use std::{ - any::Any, - cell::Cell, - collections::HashMap, - fmt::Display, -}; +use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display}; use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue}; use web_sys::{ window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement, @@ -26,13 +22,14 @@ const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror"; const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell"; const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document"; const HANDLE_MULTILINE_HEIGHT: f64 = 40.0; -const HANDLE_GUTTER_WIDTH: f64 = 92.0; const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0; const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.0; const HANDLE_STAGE_PADDING_LEFT: f64 = 18.0; -const HANDLE_TRIGGER_WIDTH: f64 = 32.0; -const HANDLE_TRIGGER_GAP: f64 = 6.0; -const HANDLE_MENU_GAP: f64 = 10.0; +const HANDLE_TRIGGER_WIDTH: f64 = 22.0; +const HANDLE_TRIGGER_GAP: f64 = 4.0; +const HANDLE_MENU_GAP: f64 = 0.0; +const HANDLE_TEXT_ALIGN_OFFSET: f64 = 96.0; +const HANDLE_MENU_LEFT_LIMIT: f64 = -320.0; const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime"; const RUNTIME_VERSION: &str = "1.1.0"; const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1"; @@ -391,77 +388,222 @@ const SPIKE_STYLE: &str = r#" .block-handle-shell { position: absolute; left: 12px; - z-index: 14; + z-index: 80; display: inline-flex; + flex-direction: column; align-items: center; - gap: 6px; + gap: 1px; + width: 26px; + padding: 0; + background: transparent; + pointer-events: none; } .block-handle-shell[data-dragging="true"] { pointer-events: none; - opacity: 0.72; + opacity: 1; } .block-handle-insert, .block-handle-trigger { - width: 32px; - height: 32px; + position: relative; + width: 22px; + height: 20px; display: inline-flex; align-items: center; justify-content: center; - border: 1px solid rgba(15, 23, 42, 0.12); - border-radius: 11px; - background: rgba(255, 255, 255, 0.98); - color: #334155; - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); - transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease; + border: none; + border-radius: 5px; + background: transparent; + color: #6b7280; + cursor: pointer; + pointer-events: auto; + transition: background .12s ease, border-color .12s ease, box-shadow .12s ease, color .12s ease, opacity .12s ease; } .block-handle-insert { - color: var(--accent-strong); - cursor: pointer; + opacity: 0; } -.block-handle-insert span { - font-size: 20px; - line-height: 1; +.block-handle-shell[data-menu-open="true"] .block-handle-insert { + opacity: 0; + pointer-events: none; } -.block-handle-insert:hover, -.block-handle-trigger:hover { - transform: translateY(-1px); - border-color: rgba(15, 118, 110, 0.34); - box-shadow: 0 18px 36px rgba(15, 23, 42, 0.16); +.block-handle-trigger { + height: 22px; + border: 1px solid rgba(148, 163, 184, 0.42); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.1); + cursor: grab; +} + +.block-handle-trigger:hover, +.block-handle-shell[data-keyboard-selected="true"] .block-handle-trigger, +.block-handle-shell[data-menu-open="true"] .block-handle-trigger { + border-color: rgba(107, 114, 128, 0.5); + background: #f8fafc; + color: #4b5563; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14); } .block-handle-trigger:active { cursor: grabbing; } -.block-handle-trigger { - cursor: grab; - font-size: 18px; - letter-spacing: -0.08em; +.block-handle-icon-dots { + display: grid; + grid-template-columns: repeat(2, 3px); + grid-auto-rows: 3px; + gap: 3px; +} + +.block-handle-icon-dots span, +.block-handle-icon-lines span { + width: 3px; + height: 3px; + border-radius: 999px; + background: currentColor; +} + +.block-handle-icon-lines { + display: grid; + gap: 3px; +} + +.block-handle-icon-lines span { + display: none; + width: 12px; + height: 1.5px; + border-radius: 999px; +} + +.block-handle-shell[data-menu-open="true"] .block-handle-icon-dots span { + display: none; +} + +.block-handle-shell[data-menu-open="true"] .block-handle-icon-lines span { + display: block; +} + +.block-insert-line { + display: none; +} + +.block-insert-plus { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 5px; + background: rgba(255, 255, 255, 0.96); + color: #5f6773; + font-size: 16px; + line-height: 1; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.1); +} + +.block-handle-insert:hover, +.block-handle-insert:focus-visible { + opacity: 1; +} + +.block-handle-insert:hover .block-insert-plus, +.block-handle-insert:focus-visible .block-insert-plus { + border-color: rgba(107, 114, 128, 0.5); + background: #f8fafc; + color: #4b5563; +} + +.block-insert-tooltip { + position: absolute; + left: 26px; + top: 50%; + transform: translateY(-50%); + min-width: 118px; + display: none; + gap: 5px; + align-items: center; + padding: 5px 7px; + border-radius: 5px; + background: rgba(17, 24, 39, 0.94); + color: #fff; + font-size: 12px; + line-height: 1.2; + white-space: nowrap; + box-shadow: 0 8px 22px rgba(15, 23, 42, 0.2); +} + +.block-insert-tooltip kbd { + color: rgba(255, 255, 255, 0.72); + font: inherit; +} + +.block-handle-insert:hover .block-insert-tooltip, +.block-handle-insert:focus-visible .block-insert-tooltip { + display: inline-flex; } .block-drag-menu { - position: absolute; + position: fixed; top: 0; - left: calc(100% + 10px); - min-width: 248px; + left: 0; + width: 242px; + min-width: 242px; + box-sizing: border-box; display: grid; - gap: 8px; - padding: 12px; - border: 1px solid rgba(15, 23, 42, 0.1); - border-radius: 18px; - background: rgba(255, 255, 255, 0.98); + gap: 0; + padding: 10px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 8px; + background: #fff; + background-image: linear-gradient(#fff, #fff); color: var(--ink); - box-shadow: 0 22px 48px rgba(15, 23, 42, 0.18); - overflow-y: auto; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18); + z-index: 120; + pointer-events: auto; + isolation: isolate; + overflow: visible; overscroll-behavior: contain; scrollbar-width: thin; } + +.block-drag-menu::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: #fff; +} + +.block-drag-menu > * { + position: relative; + z-index: 1; +} + + +.block-drag-menu-label, +.block-drag-menu-actions, +.block-drag-menu-section, +.block-drag-menu-item { + background-color: #fff; +} + +.block-drag-command-input { + width: 100%; + height: 34px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 5px; + background: #fff; + color: #9ca3af; + font: inherit; +} + .block-drag-menu-label { display: grid; gap: 4px; @@ -476,6 +618,69 @@ const SPIKE_STYLE: &str = r#" font-size: 12px; } +.block-drag-menu-shortcut, +.block-drag-menu-arrow { + margin-left: auto; + color: #a1a1aa; + font-size: 13px; + white-space: nowrap; +} + +.block-drag-menu-icon { + min-width: 22px; + color: #60646c; + font-size: 16px; + line-height: 1; + text-align: center; +} + +.block-drag-menu-switch { + margin-left: auto; + width: 28px; + height: 16px; + border-radius: 999px; + background: #d4d4d8; + position: relative; +} + +.block-drag-menu-switch::after { + content: ""; + position: absolute; + width: 12px; + height: 12px; + left: 2px; + top: 2px; + border-radius: 999px; + background: #fff; +} + +.block-drag-submenu { + position: absolute; + left: calc(100% + 8px); + top: 0; + width: 206px; + max-height: min(620px, 80vh); + overflow-y: auto; + display: grid; + gap: 0; + padding: 6px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 8px; + background: #fff; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18); + z-index: 130; + pointer-events: auto; +} + +.block-drag-menu-item[disabled] { + color: #a1a1aa; + cursor: default; +} + +.block-drag-menu-item[disabled]:hover { + background: transparent; +} + .block-drag-menu-back { width: 100%; display: inline-flex; @@ -494,16 +699,17 @@ const SPIKE_STYLE: &str = r#" .block-drag-menu-actions { display: grid; - gap: 6px; + gap: 0; } .block-drag-menu-section { display: grid; - gap: 6px; + gap: 0; } .block-drag-menu-divider { height: 1px; + margin: 4px -10px; background: rgba(148, 163, 184, 0.22); } @@ -512,22 +718,42 @@ const SPIKE_STYLE: &str = r#" display: flex; align-items: center; justify-content: flex-start; - gap: 12px; + gap: 10px; text-align: left; border: 1px solid transparent; - border-radius: 14px; + border-radius: 6px; background: transparent; color: inherit; - padding: 10px 12px; + min-height: 31px; + padding: 5px 8px; + white-space: nowrap; cursor: pointer; transition: background .12s ease, border-color .12s ease; } .block-drag-menu-item:hover { - background: rgba(15, 118, 110, 0.08); - border-color: rgba(15, 118, 110, 0.18); + background: rgba(17, 24, 39, 0.06); + border-color: transparent; } +.block-drag-menu-item > span:not(.block-drag-menu-icon):not(.block-drag-menu-shortcut):not(.block-drag-menu-arrow):not(.block-drag-menu-switch) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.block-drag-menu-footer { + display: grid; + gap: 2px; + margin: 6px -2px -2px; + padding-top: 8px; + border-top: 1px solid rgba(148, 163, 184, 0.18); + color: #a1a1aa; + font-size: 12px; + line-height: 1.35; +} + + .block-drop-indicator { position: absolute; left: 56px; @@ -552,10 +778,10 @@ const SPIKE_STYLE: &str = r#" max-width: min(720px, calc(100% - 32px)); padding: 3px; border: 1px solid rgba(15, 23, 42, 0.08); - border-radius: 18px; + border-radius: 8px; background: rgba(255, 255, 255, 0.98); color: #0f172a; - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14); + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.14); backdrop-filter: blur(16px); } @@ -579,10 +805,10 @@ const SPIKE_STYLE: &str = r#" } .toolbar-btn { - min-width: 34px; - height: 34px; + min-width: 30px; + height: 30px; border: none; - border-radius: 12px; + border-radius: 6px; background: transparent; color: inherit; padding: 0 10px; @@ -672,13 +898,19 @@ const SPIKE_STYLE: &str = r#" border-color: rgba(15, 118, 110, 0.18); } -.turn-into-item span, -.slash-item span, -.toolbar-menu-item span { +.turn-into-item > span, +.slash-item-copy > span, +.toolbar-menu-item > span { color: var(--muted); font-size: 12px; } +.slash-item-copy strong { + color: #30343b; + font-size: 14px; + font-weight: 500; +} + .toolbar-popover-section { display: grid; gap: 8px; @@ -726,19 +958,52 @@ const SPIKE_STYLE: &str = r#" top: 24px; left: 24px; z-index: 11; - width: min(360px, calc(100% - 48px)); + width: min(316px, calc(100% - 48px)); + max-height: min(430px, calc(100vh - 180px)); display: grid; - gap: 8px; - padding: 10px; - border: 1px solid rgba(15, 23, 42, 0.1); - border-radius: 22px; - background: rgba(255, 255, 255, 0.98); - box-shadow: 0 24px 48px rgba(15, 23, 42, 0.16); + gap: 0; + padding: 8px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 8px; + background: #fff; + box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; } .slash-list { display: grid; - gap: 6px; + gap: 2px; +} + +.slash-section-label { + padding: 10px 6px 5px; + color: #9097a3; + font-size: 12px; +} + +.slash-item-row { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.slash-item-icon { + color: #6b7280; + font-size: 14px; +} + +.slash-item-copy { + display: grid; + gap: 1px; + min-width: 0; +} + +.slash-item-shortcut { + color: #a0a7b2; + font-size: 12px; } .editor-surface { @@ -787,7 +1052,8 @@ const SPIKE_STYLE: &str = r#" .editor-surface .ProseMirror h1, .editor-surface .ProseMirror h2, -.editor-surface .ProseMirror h3 { +.editor-surface .ProseMirror h3, +.editor-surface .ProseMirror h4 { line-height: 1.12; color: #0f172a; margin: 1.12em 0 0.46em; @@ -806,6 +1072,10 @@ const SPIKE_STYLE: &str = r#" font-size: 1.28em; } +.editor-surface .ProseMirror h4 { + font-size: 1.08em; +} + .editor-surface .ProseMirror ul, .editor-surface .ProseMirror ol { padding-left: 1.5em; @@ -1025,6 +1295,7 @@ const SPIKE_STYLE: &str = r#" left: 16px; right: 16px; width: auto; + min-width: 0; transform: none; } @@ -1051,13 +1322,20 @@ const SPIKE_STYLE: &str = r#" #[derive(Clone, Copy, PartialEq, Eq)] enum SlashActionKind { + AiAssistant, + AiWrite, + ContinueWriting, + Summarize, + MoreAi, Paragraph, Heading1, Heading2, Heading3, + Heading4, BulletList, OrderedList, Todo, + AdvancedTodo, Quote, CodeBlock, Divider, @@ -1067,70 +1345,166 @@ enum SlashActionKind { struct SlashAction { kind: SlashActionKind, id: &'static str, + category: &'static str, + icon: &'static str, label: &'static str, description: &'static str, + shortcut: &'static str, } -const SLASH_ACTIONS: [SlashAction; 10] = [ +const SLASH_ACTIONS: [SlashAction; 17] = [ + SlashAction { + kind: SlashActionKind::AiAssistant, + id: "ai-assistant", + category: "AI 助理", + icon: "✦", + label: "AI 助理", + description: "按 Wolai 基线保留 AI 入口", + shortcut: "/ai", + }, + SlashAction { + kind: SlashActionKind::AiWrite, + id: "ai-write", + category: "AI 助理", + icon: "✎", + label: "用 AI 写作", + description: "唤起写作辅助入口", + shortcut: "/yaixz", + }, + SlashAction { + kind: SlashActionKind::ContinueWriting, + id: "continue-writing", + category: "AI 助理", + icon: "↪", + label: "续写", + description: "按当前上下文继续写作", + shortcut: "/xx", + }, + SlashAction { + kind: SlashActionKind::Summarize, + id: "summarize", + category: "AI 助理", + icon: "≡", + label: "总结", + description: "对当前内容生成摘要", + shortcut: "/zj", + }, + SlashAction { + kind: SlashActionKind::MoreAi, + id: "more-ai", + category: "AI 助理", + icon: "…", + label: "更多", + description: "更多 AI 命令", + shortcut: "›", + }, SlashAction { kind: SlashActionKind::Paragraph, id: "paragraph", - label: "段落", - description: "回到普通正文块", - }, - SlashAction { - kind: SlashActionKind::Heading1, - id: "heading-1", - label: "一级标题", - description: "建立页面主层级", - }, - SlashAction { - kind: SlashActionKind::Heading2, - id: "heading-2", - label: "二级标题", - description: "整理章节结构", - }, - SlashAction { - kind: SlashActionKind::Heading3, - id: "heading-3", - label: "三级标题", - description: "补细粒度小节", - }, - SlashAction { - kind: SlashActionKind::BulletList, - id: "bullet", - label: "无序列表", - description: "记录普通要点", - }, - SlashAction { - kind: SlashActionKind::OrderedList, - id: "ordered", - label: "有序列表", - description: "记录步骤顺序", + category: "基础块列表", + icon: "Aa", + label: "文本", + description: "普通正文块", + shortcut: "/wb", }, SlashAction { kind: SlashActionKind::Todo, id: "todo", - label: "Todo 列表", - description: "切到真正的 taskList/taskItem", + category: "基础块列表", + icon: "☑", + label: "待办列表", + description: "创建可勾选任务", + shortcut: "/dblb", + }, + SlashAction { + kind: SlashActionKind::AdvancedTodo, + id: "advanced-todo", + category: "基础块列表", + icon: "☑", + label: "高级待办列表", + description: "保留 Wolai 高级待办入口", + shortcut: "/gjdblb", + }, + SlashAction { + kind: SlashActionKind::Heading1, + id: "heading-1", + category: "基础块列表", + icon: "H1", + label: "主标题", + description: "一级标题", + shortcut: "/h1", + }, + SlashAction { + kind: SlashActionKind::Heading2, + id: "heading-2", + category: "基础块列表", + icon: "H2", + label: "大标题", + description: "二级标题", + shortcut: "/h2", + }, + SlashAction { + kind: SlashActionKind::Heading3, + id: "heading-3", + category: "基础块列表", + icon: "H3", + label: "中标题", + description: "三级标题", + shortcut: "/h3", + }, + SlashAction { + kind: SlashActionKind::Heading4, + id: "heading-4", + category: "基础块列表", + icon: "H4", + label: "小标题", + description: "四级标题", + shortcut: "/h4", + }, + SlashAction { + kind: SlashActionKind::BulletList, + id: "bullet", + category: "基础块列表", + icon: "•", + label: "列表", + description: "记录普通要点", + shortcut: "/lb", + }, + SlashAction { + kind: SlashActionKind::OrderedList, + id: "ordered", + category: "基础块列表", + icon: "1.", + label: "数字列表", + description: "记录步骤顺序", + shortcut: "/szlb", }, SlashAction { kind: SlashActionKind::Quote, id: "quote", - label: "引用块", + category: "基础块列表", + icon: "❝", + label: "引述文字", description: "包住注释与摘录", + shortcut: "/ys", }, SlashAction { kind: SlashActionKind::CodeBlock, id: "code-block", - label: "代码块", + category: "基础块列表", + icon: "<> ", + label: "代码片段", description: "插入带语义的代码块", + shortcut: "/dm", }, SlashAction { kind: SlashActionKind::Divider, id: "divider", + category: "基础块列表", + icon: "—", label: "分割线", - description: "插入 horizontal rule", + description: "插入分隔线", + shortcut: "/fgx", }, ]; @@ -1353,10 +1727,7 @@ impl HostCommandKind { } fn from_payload(payload: &HostCommandPayload) -> Option { - payload - .command - .as_deref() - .and_then(Self::from_event_name) + payload.command.as_deref().and_then(Self::from_event_name) } } @@ -1408,7 +1779,10 @@ mod tests { read_only: None, }; - assert_eq!(HostCommandKind::from_payload(&payload), Some(HostCommandKind::SetEditable)); + assert_eq!( + HostCommandKind::from_payload(&payload), + Some(HostCommandKind::SetEditable) + ); } #[test] @@ -1437,16 +1811,11 @@ mod tests { #[test] fn persisted_document_storage_key_isolated_per_document_identity() { - let doc_a = persisted_document_identity( - Some("doc-a".to_string()), - Some("ws-1".to_string()), - ); - let doc_b = persisted_document_identity( - Some("doc-b".to_string()), - Some("ws-1".to_string()), - ); - let doc_without_workspace = - persisted_document_identity(Some("doc-a".to_string()), None); + let doc_a = + persisted_document_identity(Some("doc-a".to_string()), Some("ws-1".to_string())); + let doc_b = + persisted_document_identity(Some("doc-b".to_string()), Some("ws-1".to_string())); + let doc_without_workspace = persisted_document_identity(Some("doc-a".to_string()), None); assert_eq!( persisted_document_storage_key(&doc_a), @@ -1597,7 +1966,9 @@ fn current_runtime_block_info() -> CurrentBlockInfo { }) } -fn current_runtime_block_info_from_hovered(hovered_block: Option) -> CurrentBlockInfo { +fn current_runtime_block_info_from_hovered( + hovered_block: Option, +) -> CurrentBlockInfo { hovered_block .map(|block| CurrentBlockInfo { index: Some(block.index), @@ -1703,11 +2074,7 @@ fn register_unmount_handle( }); } -fn register_runtime_listener( - id: u32, - target: EventTarget, - listener: Closure, -) { +fn register_runtime_listener(id: u32, target: EventTarget, listener: Closure) { MOUNTED_HANDLES.with(|registry| { if let Some(runtime) = registry.borrow_mut().get_mut(&id) { runtime @@ -1783,10 +2150,8 @@ fn mount_app_into(target: HtmlElement, options: MountOptions, mode: RuntimeDeliv }); let command_listener = build_command_listener(mount_id); - let _ = context_target.add_event_listener_with_callback( - COMMAND_EVENT, - command_listener.as_ref().unchecked_ref(), - ); + let _ = context_target + .add_event_listener_with_callback(COMMAND_EVENT, command_listener.as_ref().unchecked_ref()); register_unmount_handle(mount_id, context_target, command_listener, handle); mount_id @@ -1835,6 +2200,8 @@ struct FloatingToolbarAnchor { struct BlockMenuLayout { max_height: f64, open_upward: bool, + top: f64, + left: f64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1927,10 +2294,7 @@ fn runtime_persisted_identity( document_id: ReadSignal>, workspace_id: ReadSignal>, ) -> PersistedDocumentIdentity { - persisted_document_identity( - document_id.get_untracked(), - workspace_id.get_untracked(), - ) + persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked()) } fn persist_document_state( @@ -1959,10 +2323,8 @@ fn load_persisted_document(identity: &PersistedDocumentIdentity) -> Option(&raw).ok()?; - let stored_identity = persisted_document_identity( - document.document_id.clone(), - document.workspace_id.clone(), - ); + let stored_identity = + persisted_document_identity(document.document_id.clone(), document.workspace_id.clone()); if stored_identity == *identity || identity.document_id.is_none() { Some(document) } else { @@ -2134,21 +2496,18 @@ fn content_column_left(stage_rect_width: f64) -> f64 { ((stage_rect_width - column_width) / 2.0).max(0.0) } +fn content_text_left(stage_rect_width: f64) -> f64 { + content_column_left(stage_rect_width) + CONTENT_COLUMN_HORIZONTAL_PADDING +} + fn handle_lane_left(stage_rect_width: f64) -> f64 { - (content_column_left(stage_rect_width) + (content_text_left(stage_rect_width) + HANDLE_TEXT_ALIGN_OFFSET - HANDLE_TRIGGER_WIDTH - HANDLE_TRIGGER_GAP - HANDLE_MENU_GAP) .max(HANDLE_STAGE_PADDING_LEFT) } -fn handle_corridor_width(stage_rect_width: f64) -> f64 { - let lane_left = handle_lane_left(stage_rect_width); - let content_left = content_column_left(stage_rect_width); - let content_gap = (content_left - lane_left).max(0.0); - (HANDLE_TRIGGER_WIDTH + HANDLE_TRIGGER_GAP + content_gap).max(HANDLE_GUTTER_WIDTH.min(stage_rect_width)) -} - fn direct_block_from_element(mut element: Element, root: &Element) -> Option { loop { let parent = element.parent_element()?; @@ -2395,43 +2754,63 @@ fn hover_anchor_top(block: &HoveredBlockState) -> f64 { if block.height > HANDLE_MULTILINE_HEIGHT { block.top + 12.0 } else { - block.top + (block.height / 2.0) + block.top + (block.height / 2.0) - 32.0 } } -fn hover_anchor_transform(block: &HoveredBlockState) -> &'static str { - if block.height > HANDLE_MULTILINE_HEIGHT { - "translateY(0)" - } else { - "translateY(-50%)" - } +fn hover_anchor_transform(_block: &HoveredBlockState) -> &'static str { + "none" } fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlockState) -> bool { let Some(stage) = editor_stage_element() else { return false; }; + let Some(root) = editor_root_element() else { + return false; + }; + let Some(block_element) = root.children().item(block.index as u32) else { + return false; + }; + let stage_rect = stage.get_bounding_client_rect(); + let block_rect = block_element.get_bounding_client_rect(); + let handle_rect = window() + .and_then(|win| win.document()) + .and_then(|document| { + document + .query_selector(HANDLE_SHELL_SELECTOR) + .ok() + .flatten() + }) + .map(|handle| handle.get_bounding_client_rect()); + + let block_left = block_rect.left() - stage_rect.left(); + let block_top = block_rect.top() - stage_rect.top(); + let block_bottom = block_rect.bottom() - stage_rect.top(); let x = f64::from(client_x) - stage_rect.left(); let y = f64::from(client_y) - stage_rect.top(); - let block_top = block.top; - let block_bottom = block.top + block.height; - let corridor_left = handle_lane_left(stage_rect.width()); - let corridor_right = corridor_left + handle_corridor_width(stage_rect.width()); + let handle_left = handle_rect + .as_ref() + .map(|rect| rect.left() - stage_rect.left()) + .unwrap_or_else(|| handle_lane_left(stage_rect.width())); + let handle_right = handle_rect + .as_ref() + .map(|rect| rect.right() - stage_rect.left()) + .unwrap_or_else(|| handle_left + HANDLE_TRIGGER_WIDTH); + let corridor_left = (handle_left - 12.0).min(block_left); + let corridor_right = (block_left + 18.0).max(handle_right + 12.0); - x >= corridor_left - && x <= corridor_right - && y >= block_top - 8.0 - && y <= block_bottom + 8.0 + x >= corridor_left && x <= corridor_right && y >= block_top - 18.0 && y <= block_bottom + 18.0 } fn block_menu_layout(block: &HoveredBlockState) -> Option { let stage = editor_stage_element()?; let stage_rect = stage.get_bounding_client_rect(); - let anchor_top = hover_anchor_top(block); + let shell_top = hover_anchor_top(block); let stage_padding = 18.0; - let space_above = (anchor_top - stage_padding).max(0.0); - let space_below = (stage_rect.height() - anchor_top - stage_padding).max(0.0); + let space_above = (shell_top - stage_padding).max(0.0); + let space_below = (stage_rect.height() - shell_top - stage_padding).max(0.0); let open_upward = space_below < 340.0 && space_above > space_below; let available_height = if open_upward { space_above @@ -2443,10 +2822,23 @@ fn block_menu_layout(block: &HoveredBlockState) -> Option { } else { available_height.max(160.0) }; + let menu_width = 242.0; + let menu_stage_left = + (content_text_left(stage_rect.width()) - menu_width - 132.0).max(HANDLE_MENU_LEFT_LIMIT); + let viewport_shell_top = stage_rect.top() + shell_top; + let top = if open_upward { + viewport_shell_top + 64.0 - max_height + } else { + viewport_shell_top + } + .max(8.0); + let left = (stage_rect.left() + menu_stage_left).max(8.0); Some(BlockMenuLayout { max_height, open_upward, + top, + left, }) } @@ -2597,6 +2989,11 @@ fn extract_inline_content(node: &Value) -> Vec { fn turn_into_block(node: &Value, action: SlashActionKind) -> Value { let inline = extract_inline_content(node); match action { + SlashActionKind::AiAssistant + | SlashActionKind::AiWrite + | SlashActionKind::ContinueWriting + | SlashActionKind::Summarize + | SlashActionKind::MoreAi => paragraph_node(inline.clone()), SlashActionKind::Paragraph => paragraph_node(inline), SlashActionKind::Heading1 => { let mut attrs = Map::new(); @@ -2613,13 +3010,20 @@ fn turn_into_block(node: &Value, action: SlashActionKind) -> Value { attrs.insert("level".to_string(), json!(3)); node_with_attrs("heading", Some(attrs), inline) } + SlashActionKind::Heading4 => { + let mut attrs = Map::new(); + attrs.insert("level".to_string(), json!(4)); + node_with_attrs("heading", Some(attrs), inline) + } SlashActionKind::BulletList => { node_with_attrs("bulletList", None, vec![list_item_node(inline)]) } SlashActionKind::OrderedList => { node_with_attrs("orderedList", None, vec![list_item_node(inline)]) } - SlashActionKind::Todo => node_with_attrs("taskList", None, vec![task_item_node(inline)]), + SlashActionKind::Todo | SlashActionKind::AdvancedTodo => { + node_with_attrs("taskList", None, vec![task_item_node(inline)]) + } SlashActionKind::Quote => node_with_attrs("blockquote", None, vec![paragraph_node(inline)]), SlashActionKind::CodeBlock => { let text = collect_plain_text(node); @@ -2830,7 +3234,12 @@ fn apply_document_update( set_html_output.set(html.clone()); set_document_json.set(snapshot.clone()); set_json_output.set(json_text); - match persist_document_state(persisted_identity, &title.get_untracked(), &snapshot, Some(html)) { + match persist_document_state( + persisted_identity, + &title.get_untracked(), + &snapshot, + Some(html), + ) { Ok(()) => { set_command_feedback.set(format!("{success_message},并已写入本地草稿")); } @@ -2865,7 +3274,12 @@ fn apply_html_update( set_html_output.set(html.clone()); set_document_json.set(snapshot.clone()); set_json_output.set(json_text); - match persist_document_state(persisted_identity, &title.get_untracked(), &snapshot, Some(html)) { + match persist_document_state( + persisted_identity, + &title.get_untracked(), + &snapshot, + Some(html), + ) { Ok(()) => { set_command_feedback.set(format!("{success_message},并已写入本地草稿")); } @@ -2912,37 +3326,114 @@ fn duplicate_top_level_block_html(current_html: &str, index: usize) -> Result Result { - let document = window() - .and_then(|win| win.document()) - .ok_or_else(|| "浏览器 document 不可用".to_string())?; - let container = document - .create_element("div") - .map_err(|err| format!("创建 HTML 容器失败:{err:?}"))?; - container.set_inner_html(current_html); +fn top_level_block_boundary_position(document: &Value, index: usize, before: bool) -> Option { + let content = document.get("content").and_then(Value::as_array)?; + let mut position = 0_u32; - let children = container.children(); - children - .item(index as u32) - .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?; - let paragraph = document - .create_element("p") - .map_err(|err| format!("创建空段落失败:{err:?}"))?; - - if let Some(next_sibling) = children.item(index as u32 + 1) { - container - .insert_before(¶graph, Some(&next_sibling)) - .map_err(|err| format!("插入新块失败:{err:?}"))?; - } else { - container - .append_child(¶graph) - .map_err(|err| format!("追加新块失败:{err:?}"))?; + for (current_index, node) in content.iter().enumerate() { + let node_size = prosemirror_node_size(node)?; + if current_index == index { + return Some(if before { + position + } else { + position + node_size + }); + } + position += node_size; } - Ok(container.inner_html()) + None +} + +fn insert_editor_paragraph_relative_to_block( + editor: TiptapEditorHandle, + index: usize, + before: bool, +) -> Result { + let document = editor + .get_json() + .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; + let position = top_level_block_boundary_position(&document, index, before) + .ok_or_else(|| format!("找不到第 {} 个块的插入位置", index + 1))?; + editor + .insert_content_at( + position, + TiptapContent::json(json!({ "type": "paragraph" })), + Some(TiptapInsertContentOptions { + update_selection: Some(true), + ..Default::default() + }), + ) + .map_err(|err| format!("插入新块失败:{err}"))?; + let new_index = if before { index } else { index + 1 }; + focus_top_level_block_start(editor, new_index)?; + Ok(new_index) +} + +fn sync_persisted_editor_command( + editor: TiptapEditorHandle, + persisted_identity: &PersistedDocumentIdentity, + set_dirty_count: WriteSignal, + set_html_output: WriteSignal, + set_document_json: WriteSignal, + set_json_output: WriteSignal, + title: ReadSignal, + set_command_feedback: WriteSignal, + success_message: impl Into, +) { + let success_message = success_message.into(); + set_dirty_count.update(|count| *count += 1); + let (html, snapshot, json_text) = read_editor_snapshot(editor); + set_html_output.set(html.clone()); + set_document_json.set(snapshot.clone()); + set_json_output.set(json_text); + match persist_document_state( + persisted_identity, + &title.get_untracked(), + &snapshot, + Some(html), + ) { + Ok(()) => set_command_feedback.set(format!("{success_message},并已写入本地草稿")), + Err(err) => set_command_feedback.set(format!("{success_message},但本地保存失败:{err}")), + } +} + +fn focus_top_level_block_start(editor: TiptapEditorHandle, index: usize) -> Result<(), String> { + editor + .focus() + .map_err(|err| format!("聚焦编辑器失败:{err}"))?; + + if let (Some(root), Some(document), Some(selection)) = ( + editor_root_element(), + window().and_then(|win| win.document()), + window().and_then(|win| win.get_selection().ok().flatten()), + ) { + if let Some(block) = root.children().item(index as u32) { + let range = document + .create_range() + .map_err(|err| format!("创建新块光标失败:{err:?}"))?; + range + .select_node_contents(block.unchecked_ref::()) + .map_err(|err| format!("选择新块失败:{err:?}"))?; + range.collapse_with_to_start(true); + selection + .remove_all_ranges() + .map_err(|err| format!("清理旧选区失败:{err:?}"))?; + selection + .add_range(&range) + .map_err(|err| format!("写入新块选区失败:{err:?}"))?; + return Ok(()); + } + } + + let document = editor + .get_json() + .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; + let range = top_level_block_range(&document, index) + .ok_or_else(|| format!("找不到第 {} 个块的选择范围", index + 1))?; + editor + .set_text_selection(range.from) + .map_err(|err| format!("定位新块失败:{err}")) } fn delete_top_level_block_html(current_html: &str, index: usize) -> Result { @@ -3417,13 +3908,19 @@ fn run_slash_action( action: SlashActionKind, ) -> Result<&'static str, String> { let result = match action { + SlashActionKind::AiAssistant + | SlashActionKind::AiWrite + | SlashActionKind::ContinueWriting + | SlashActionKind::Summarize + | SlashActionKind::MoreAi => return Ok("AI 命令入口已打开"), SlashActionKind::Paragraph => editor.set_paragraph(), SlashActionKind::Heading1 => editor.toggle_heading(TiptapHeadingLevel::H1), SlashActionKind::Heading2 => editor.toggle_heading(TiptapHeadingLevel::H2), SlashActionKind::Heading3 => editor.toggle_heading(TiptapHeadingLevel::H3), + SlashActionKind::Heading4 => editor.toggle_heading(TiptapHeadingLevel::H4), SlashActionKind::BulletList => editor.toggle_bullet_list(), SlashActionKind::OrderedList => editor.toggle_ordered_list(), - SlashActionKind::Todo => editor.toggle_task_list(), + SlashActionKind::Todo | SlashActionKind::AdvancedTodo => editor.toggle_task_list(), SlashActionKind::Quote => editor.toggle_blockquote(), SlashActionKind::CodeBlock => editor.toggle_code_block(Some(TiptapCodeBlockAttributes { language: Some("rust".into()), @@ -3433,14 +3930,20 @@ fn run_slash_action( result .map(|_| match action { - SlashActionKind::Paragraph => "已切到段落", + SlashActionKind::AiAssistant + | SlashActionKind::AiWrite + | SlashActionKind::ContinueWriting + | SlashActionKind::Summarize + | SlashActionKind::MoreAi => "AI 命令入口已打开", + SlashActionKind::Paragraph => "已切到文本", SlashActionKind::Heading1 => "已切到一级标题", SlashActionKind::Heading2 => "已切到二级标题", SlashActionKind::Heading3 => "已切到三级标题", + SlashActionKind::Heading4 => "已切到四级标题", SlashActionKind::BulletList => "已切到无序列表", SlashActionKind::OrderedList => "已切到有序列表", - SlashActionKind::Todo => "已切到 Todo 列表", - SlashActionKind::Quote => "已切到引用块", + SlashActionKind::Todo | SlashActionKind::AdvancedTodo => "已切到待办列表", + SlashActionKind::Quote => "已切到引述文字", SlashActionKind::CodeBlock => "已切到代码块", SlashActionKind::Divider => "已插入分割线", }) @@ -3534,22 +4037,13 @@ fn App(mount_options: MountOptions) -> impl IntoView { let initial_title_value = mount_options .title .clone() - .or_else(|| { - persisted - .as_ref() - .map(|document| document.title.clone()) - }) + .or_else(|| persisted.as_ref().map(|document| document.title.clone())) .unwrap_or_else(default_title); let initial_editor_content = mount_options .html .clone() .map(TiptapContent::html) - .or_else(|| { - mount_options - .content - .clone() - .map(TiptapContent::json) - }) + .or_else(|| mount_options.content.clone().map(TiptapContent::json)) .or_else(|| { persisted.as_ref().map(|document| { document @@ -3589,6 +4083,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { let (color_menu_open, set_color_menu_open) = signal(false); let (more_menu_open, set_more_menu_open) = signal(false); let (hovered_block, set_hovered_block) = signal(None::); + let (block_keyboard_mode, set_block_keyboard_mode) = signal(false); let (block_menu_anchor, set_block_menu_anchor) = signal(None::); let (block_menu_open, set_block_menu_open) = signal(false); let (block_turn_into_open, set_block_turn_into_open) = signal(false); @@ -3601,10 +4096,18 @@ fn App(mount_options: MountOptions) -> impl IntoView { let initial_editable = mount_options.editable.unwrap_or(!read_only.get_untracked()); let (editor_editable, set_editor_editable) = signal(initial_editable); let initial_page_options = mount_options.page_options.clone(); - let (wide_layout, set_wide_layout) = - signal(initial_page_options.as_ref().and_then(|opts| opts.wide_layout).unwrap_or(false)); - let (small_text, set_small_text) = - signal(initial_page_options.as_ref().and_then(|opts| opts.small_text).unwrap_or(false)); + let (wide_layout, set_wide_layout) = signal( + initial_page_options + .as_ref() + .and_then(|opts| opts.wide_layout) + .unwrap_or(false), + ); + let (small_text, set_small_text) = signal( + initial_page_options + .as_ref() + .and_then(|opts| opts.small_text) + .unwrap_or(false), + ); let (layout_density, set_layout_density) = signal(normalize_layout_density( initial_page_options .as_ref() @@ -3682,170 +4185,179 @@ fn App(mount_options: MountOptions) -> impl IntoView { let set_show_heading_numbers = set_show_heading_numbers; let set_embed_default_block_id = set_embed_default_block_id; move |_| { - let command_listener = Closure::::wrap(Box::new(move |event: Event| { - let Some(custom_event) = event.dyn_ref::() else { - return; - }; - let detail = custom_event.detail(); - let Ok(envelope) = serde_wasm_bindgen::from_value::(detail) else { - return; - }; - if envelope.protocol.as_deref() != Some(PROTOCOL) { - return; - } + let command_listener = + Closure::::wrap(Box::new(move |event: Event| { + let Some(custom_event) = event.dyn_ref::() else { + return; + }; + let detail = custom_event.detail(); + let Ok(envelope) = + serde_wasm_bindgen::from_value::(detail) + else { + return; + }; + if envelope.protocol.as_deref() != Some(PROTOCOL) { + return; + } - let Some(command_kind) = HostCommandKind::from_payload(envelope.payload.as_ref().unwrap_or(&HostCommandPayload { - command: None, - document_id: None, - workspace_id: None, - title: None, - content: None, - editable: None, - page_options: None, - block_id: None, - block_index: None, - text: None, - reference_document_id: None, - reference_block_id: None, - current_block_id: None, - selection: None, - revision: None, - conflict_detection_key: None, - read_only: None, - })) else { - return; - }; + let Some(command_kind) = HostCommandKind::from_payload( + envelope.payload.as_ref().unwrap_or(&HostCommandPayload { + command: None, + document_id: None, + workspace_id: None, + title: None, + content: None, + editable: None, + page_options: None, + block_id: None, + block_index: None, + text: None, + reference_document_id: None, + reference_block_id: None, + current_block_id: None, + selection: None, + revision: None, + conflict_detection_key: None, + read_only: None, + }), + ) else { + return; + }; - let Some(payload) = envelope.payload else { - return; - }; + let Some(payload) = envelope.payload else { + return; + }; - match command_kind { - HostCommandKind::Undo => { - let _ = editor.undo(); - } - HostCommandKind::Redo => { - let _ = editor.redo(); - } - HostCommandKind::ReplaceContent | HostCommandKind::Bootstrap => { - apply_host_document_payload( - editor, - HostDocumentPayload { - document_id: payload.document_id, - workspace_id: payload.workspace_id, - title: payload.title, - content: payload.content, - revision: payload.revision, - conflict_detection_key: payload.conflict_detection_key, - read_only: payload.read_only.or(payload.editable.map(|editable| !editable)), - }, - set_document_id, - set_workspace_id, - set_title, - set_read_only, - set_revision, - set_conflict_detection_key, - set_dirty_count, - set_html_output, - set_document_json, - set_json_output, - set_command_feedback, - ); - } - HostCommandKind::SetEditable => { - let editable = payload.editable.unwrap_or(true); - set_editor_editable.set(editable); - set_read_only.set(!editable); - dispatch_runtime_state( - document_id.get_untracked(), - workspace_id.get_untracked(), - title.get_untracked(), - dirty_count.get_untracked(), - hovered_block.get_untracked(), - editor_focused.get_untracked(), - slash_open.get_untracked(), - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - read_only.get_untracked(), - ); - } - HostCommandKind::SetPageOptions => { - if let Some(page_options) = payload.page_options { - if let Some(value) = page_options.wide_layout { - set_wide_layout.set(value); - } - if let Some(value) = page_options.small_text { - set_small_text.set(value); - } - set_layout_density - .set(normalize_layout_density(page_options.layout_density)); - if let Some(value) = page_options.show_heading_numbers { - set_show_heading_numbers.set(value); - } - set_embed_default_block_id.set(page_options.embed_default_block_id); - set_command_feedback.set("页面布局选项已同步".to_string()); + match command_kind { + HostCommandKind::Undo => { + let _ = editor.undo(); } - } - HostCommandKind::Focus => { - let _ = editor.focus(); - } - HostCommandKind::RequestCurrentBlockId => { - let current_block = current_block_info_from_index(hovered_block.get_untracked().map(|block| block.index)); - dispatch_status_event(&HostStatusPayload { - document_id: document_id.get_untracked(), - workspace_id: workspace_id.get_untracked(), - title: title.get_untracked(), - dirty_count: dirty_count.get_untracked(), - selected_block_index: current_block.index, - current_block_id: current_block.block_id, - editor_focused: editor_focused.get_untracked(), - read_only: read_only.get_untracked(), - slash_open: slash_open.get_untracked(), - toolbar_open: toolbar_overlay_locked( + HostCommandKind::Redo => { + let _ = editor.redo(); + } + HostCommandKind::ReplaceContent | HostCommandKind::Bootstrap => { + apply_host_document_payload( + editor, + HostDocumentPayload { + document_id: payload.document_id, + workspace_id: payload.workspace_id, + title: payload.title, + content: payload.content, + revision: payload.revision, + conflict_detection_key: payload.conflict_detection_key, + read_only: payload + .read_only + .or(payload.editable.map(|editable| !editable)), + }, + set_document_id, + set_workspace_id, + set_title, + set_read_only, + set_revision, + set_conflict_detection_key, + set_dirty_count, + set_html_output, + set_document_json, + set_json_output, + set_command_feedback, + ); + } + HostCommandKind::SetEditable => { + let editable = payload.editable.unwrap_or(true); + set_editor_editable.set(editable); + set_read_only.set(!editable); + dispatch_runtime_state( + document_id.get_untracked(), + workspace_id.get_untracked(), + title.get_untracked(), + dirty_count.get_untracked(), + hovered_block.get_untracked(), + editor_focused.get_untracked(), + slash_open.get_untracked(), turn_into_open.get_untracked(), color_menu_open.get_untracked(), more_menu_open.get_untracked(), - ), - }); - } - HostCommandKind::InsertInlineReference | HostCommandKind::InsertEmbedReference => { - let reference_text = match ( - payload.reference_document_id.or(payload.document_id), - payload.reference_block_id.or(payload.block_id), - ) { - (Some(document_id), Some(block_id)) => format!( - "[[引用 {}:{}]]", - document_id, - block_id - ), - (Some(document_id), None) => format!("[[引用 {}]]", document_id), - _ => "[[引用]]".to_string(), - }; - let node = if matches!(command_kind, HostCommandKind::InsertEmbedReference) { - json!({ - "type": "blockquote", - "content": [ - { + read_only.get_untracked(), + ); + } + HostCommandKind::SetPageOptions => { + if let Some(page_options) = payload.page_options { + if let Some(value) = page_options.wide_layout { + set_wide_layout.set(value); + } + if let Some(value) = page_options.small_text { + set_small_text.set(value); + } + set_layout_density + .set(normalize_layout_density(page_options.layout_density)); + if let Some(value) = page_options.show_heading_numbers { + set_show_heading_numbers.set(value); + } + set_embed_default_block_id.set(page_options.embed_default_block_id); + set_command_feedback.set("页面布局选项已同步".to_string()); + } + } + HostCommandKind::Focus => { + let _ = editor.focus(); + } + HostCommandKind::RequestCurrentBlockId => { + let current_block = current_block_info_from_index( + hovered_block.get_untracked().map(|block| block.index), + ); + dispatch_status_event(&HostStatusPayload { + document_id: document_id.get_untracked(), + workspace_id: workspace_id.get_untracked(), + title: title.get_untracked(), + dirty_count: dirty_count.get_untracked(), + selected_block_index: current_block.index, + current_block_id: current_block.block_id, + editor_focused: editor_focused.get_untracked(), + read_only: read_only.get_untracked(), + slash_open: slash_open.get_untracked(), + toolbar_open: toolbar_overlay_locked( + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + ), + }); + } + HostCommandKind::InsertInlineReference + | HostCommandKind::InsertEmbedReference => { + let reference_text = match ( + payload.reference_document_id.or(payload.document_id), + payload.reference_block_id.or(payload.block_id), + ) { + (Some(document_id), Some(block_id)) => { + format!("[[引用 {}:{}]]", document_id, block_id) + } + (Some(document_id), None) => format!("[[引用 {}]]", document_id), + _ => "[[引用]]".to_string(), + }; + let node = + if matches!(command_kind, HostCommandKind::InsertEmbedReference) { + json!({ + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": reference_text } + ] + } + ] + }) + } else { + json!({ "type": "paragraph", "content": [ { "type": "text", "text": reference_text } ] - } - ] - }) - } else { - json!({ - "type": "paragraph", - "content": [ - { "type": "text", "text": reference_text } - ] - }) - }; - let _ = editor.insert_content(TiptapContent::json(node), None); + }) + }; + let _ = editor.insert_content(TiptapContent::json(node), None); + } } - } - })); + })); if let Some(document) = window().and_then(|win| win.document()) { let listener_ref = command_listener.as_ref().unchecked_ref(); @@ -3945,7 +4457,8 @@ fn App(mount_options: MountOptions) -> impl IntoView { } }, Err(err) => { - let _ = set_command_feedback.try_set(format!("读取当前 HTML 失败:{err}")); + let _ = + set_command_feedback.try_set(format!("读取当前 HTML 失败:{err}")); } } } else { @@ -4070,12 +4583,24 @@ fn App(mount_options: MountOptions) -> impl IntoView { { let editor = editor; + let document_id = document_id; + let workspace_id = workspace_id; + let title = title; + let set_dirty_count = set_dirty_count; + let set_html_output = set_html_output; + let set_document_json = set_document_json; + let set_json_output = set_json_output; + let hovered_block = hovered_block; + let set_hovered_block = set_hovered_block; + let block_keyboard_mode = block_keyboard_mode; + let set_block_keyboard_mode = set_block_keyboard_mode; let set_slash_open = set_slash_open; let slash_open = slash_open; let slash_index = slash_index; let set_slash_index = set_slash_index; let set_turn_into_open = set_turn_into_open; let set_block_menu_open = set_block_menu_open; + let block_menu_open = block_menu_open; let set_block_menu_anchor = set_block_menu_anchor; let set_command_feedback = set_command_feedback; let keydown_handle = window_event_listener(ev::keydown, move |event| { @@ -4085,10 +4610,71 @@ fn App(mount_options: MountOptions) -> impl IntoView { } if event.key() == "Escape" { + event.prevent_default(); + let overlay_open = slash_open.try_get_untracked().unwrap_or(false) + || block_menu_open.try_get_untracked().unwrap_or(false); let _ = set_slash_open.try_set(false); let _ = set_turn_into_open.try_set(false); let _ = set_block_menu_open.try_set(false); let _ = set_block_menu_anchor.try_set(None); + if overlay_open { + let _ = set_block_keyboard_mode.try_set(false); + return; + } + + if let Some(block) = hovered_block_from_selection() + .or_else(|| hovered_block.try_get_untracked().flatten()) + .or_else(|| block_state_from_index(0)) + { + let _ = set_hovered_block.try_set(Some(block.clone())); + let _ = set_block_keyboard_mode.try_set(true); + let _ = set_command_feedback + .try_set(format!("已选中块:{},按 a/b 可在上/下插入", block.label)); + } + return; + } + + if block_keyboard_mode.try_get_untracked().unwrap_or(false) + && !event.ctrl_key() + && !event.meta_key() + && !event.alt_key() + && matches!(event.key().as_str(), "a" | "A" | "b" | "B") + { + event.prevent_default(); + let before = matches!(event.key().as_str(), "a" | "A"); + let Some(block) = hovered_block + .try_get_untracked() + .flatten() + .or_else(|| hovered_block_from_selection()) + .or_else(|| block_state_from_index(0)) + else { + let _ = set_command_feedback.try_set("没有可插入的当前块".to_string()); + return; + }; + match insert_editor_paragraph_relative_to_block(editor, block.index, before) { + Ok(new_index) => { + 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, + if before { + "已通过 Esc,a 在上方插入新块" + } else { + "已通过 Esc,b 在下方插入新块" + }, + ); + let _ = set_hovered_block.try_set(block_state_from_index(new_index)); + let _ = set_block_keyboard_mode.try_set(false); + } + Err(err) => { + let _ = set_command_feedback.try_set(err); + } + } return; } @@ -4098,6 +4684,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { let _ = set_turn_into_open.try_set(false); let _ = set_block_menu_open.try_set(false); let _ = set_block_menu_anchor.try_set(None); + let _ = set_block_keyboard_mode.try_set(false); let _ = set_slash_index.try_set(0); let _ = set_command_feedback.try_set("Slash 菜单已打开".to_string()); return; @@ -4110,7 +4697,8 @@ fn App(mount_options: MountOptions) -> impl IntoView { match event.key().as_str() { "ArrowDown" => { event.prevent_default(); - let next = (slash_index.try_get_untracked().unwrap_or(0) + 1) % SLASH_ACTIONS.len(); + let next = + (slash_index.try_get_untracked().unwrap_or(0) + 1) % SLASH_ACTIONS.len(); let _ = set_slash_index.try_set(next); } "ArrowUp" => { @@ -4136,11 +4724,6 @@ fn App(mount_options: MountOptions) -> impl IntoView { } let _ = set_slash_open.try_set(false); } - "Escape" => { - event.prevent_default(); - let _ = set_slash_open.try_set(false); - let _ = set_command_feedback.try_set("Slash 菜单已关闭".to_string()); - } _ => {} } }); @@ -4302,6 +4885,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { } set_editor_focused.set(true); + set_block_keyboard_mode.set(false); set_block_menu_open.set(false); set_block_menu_anchor.set(None); set_turn_into_open.set(false); @@ -4348,10 +4932,16 @@ fn App(mount_options: MountOptions) -> impl IntoView { set_hovered_block.set(Some(block)); } } - on:mouseleave=move |_| { - if !block_menu_open.get_untracked() && dragging_block_index.get_untracked().is_none() { - set_hovered_block.set(None); + on:mouseleave=move |event: MouseEvent| { + if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() { + return; } + if let Some(current_block) = hovered_block.get_untracked() { + if pointer_in_handle_corridor(event.client_x(), event.client_y(), ¤t_block) { + return; + } + } + set_hovered_block.set(None); } on:dragover=move |event: DragEvent| { if dragging_block_index.get_untracked().is_none() { @@ -4486,7 +5076,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { set_block_menu_open.set(false); } > - "Text" + "文本"
impl IntoView { data-testid="toolbar-link" on:click=on_link_click > - "Link" + "链接"
impl IntoView { set_turn_into_open.set(false); } > - "More" + "更多"
impl IntoView { dragging_block_anchor.get() } else { hovered_block.get().or_else(|| { - if editor_focused.get() && !text_selection_active.get() && !slash_open.get() { - block_state_from_index(0) + if block_keyboard_mode.get() + && editor_focused.get() + && !text_selection_active.get() + && !slash_open.get() + { + hovered_block_from_selection() } else { None } @@ -4847,10 +5441,10 @@ fn App(mount_options: MountOptions) -> impl IntoView { let block_index = block.index; let block_label = block.label.clone(); let click_block_label = block_label.clone(); - let menu_block_label = block_label.clone(); let duplicate_block_label = block_label.clone(); let delete_block_label = block_label.clone(); - let insert_block_label = block_label.clone(); + let insert_before_block_label = block_label.clone(); + let insert_after_block_label = block_label.clone(); let click_anchor_block = block.clone(); let drag_anchor_block = block.clone(); @@ -4859,51 +5453,54 @@ fn App(mount_options: MountOptions) -> impl IntoView { class="block-handle-shell" data-testid="mnote-leptos-tiptap-handle" data-dragging=move || if is_dragging { "true" } else { "false" } + data-keyboard-selected=move || block_keyboard_mode.get().to_string() + data-menu-open=move || if block_menu_open.get() + && block_menu_anchor + .get() + .map(|current| current.index == block_index) + .unwrap_or(false) { "true" } else { "false" } style:left=handle_left style:top=anchor_top style:transform=anchor_transform > + + + {move || { if block_menu_open.get() && block_menu_anchor @@ -4958,21 +5606,23 @@ fn App(mount_options: MountOptions) -> impl IntoView { .map(|current| current.index == block_index) .unwrap_or(false) { - let current_menu_label = menu_block_label.clone(); let current_duplicate_label = duplicate_block_label.clone(); let current_delete_label = delete_block_label.clone(); let menu_layout = block_menu_layout(&block).unwrap_or(BlockMenuLayout { max_height: 360.0, open_upward: false, + top: 80.0, + left: 80.0, }); - let menu_top = if menu_layout.open_upward { "auto" } else { "0" }; - let menu_bottom = if menu_layout.open_upward { "0" } else { "auto" }; + let menu_top = format!("{}px", menu_layout.top); + let menu_bottom = "auto"; view! {
impl IntoView { trap_scroll_inside_menu(&event); } > -
- {current_menu_label.clone()} - {format!("顶层块 #{}", block_index + 1)} -
+
{move || { - if block_turn_into_open.get() { - view! { + let duplicate_feedback_label = current_duplicate_label.clone(); + let delete_feedback_label = current_delete_label.clone(); + view! { + <>
- {SLASH_ACTIONS - .iter() - .filter(|action| action.kind != SlashActionKind::Divider) - .map(|action| { - let kind = action.kind; - let label = action.label; - let testid = format!("block-drag-menu-item-{}", action.id); - let action_menu_label = current_menu_label.clone(); - view! { - +
+
+
+ + +
+
+
+ + + + + + + + +
+ + {move || { + if block_turn_into_open.get() { + view! { +
+ {SLASH_ACTIONS + .iter() + .filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi)) + .map(|action| { + let kind = action.kind; + let label = action.label; + let icon = action.icon; + let shortcut = match action.kind { + SlashActionKind::Paragraph => "Ctrl+Alt+0", + SlashActionKind::Todo => "Ctrl+Shift+5", + SlashActionKind::Heading1 => "Ctrl+Shift+1", + SlashActionKind::Heading2 => "Ctrl+Shift+2", + SlashActionKind::Heading3 => "Ctrl+Shift+3", + SlashActionKind::BulletList => "Ctrl+Shift+6", + SlashActionKind::OrderedList => "Ctrl+Shift+7", + SlashActionKind::CodeBlock => "Ctrl+Alt+-", + _ => "", + }; + let testid = format!("block-transform-item-{}", action.id); + let action_menu_label = "块".to_string(); + view! { + } - } - > - {label} - - } - }) - .collect_view()} -
- }.into_any() - } else { - let duplicate_feedback_label = current_duplicate_label.clone(); - let delete_feedback_label = current_delete_label.clone(); - view! { - <> -
- -
-
-
- - -
- - }.into_any() + }) + .collect_view()} + + + + + + + +
+ }.into_any() + } else { + ().into_any() + } + }} + } }}
@@ -5181,20 +5980,38 @@ fn App(mount_options: MountOptions) -> impl IntoView { let kind = action.kind; let label = action.label; let description = action.description; + let shortcut = action.shortcut; + let icon = action.icon; + let category = action.category; + let show_category = index == 0 + || SLASH_ACTIONS[index - 1].category != category; let testid = format!("slash-item-{}", action.id); view! { - + <> + {if show_category { + view! { }.into_any() + } else { + ().into_any() + }} + + } }).collect_view()}