add editor transform to page parity

This commit is contained in:
lix-2026
2026-04-30 22:14:37 +08:00
parent 7565976810
commit c7cfe85d87
4 changed files with 243 additions and 10 deletions
@@ -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 FPage Aggregate 和命令边界
@@ -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;
},
+230 -1
View File
@@ -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<String, String> {
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<Option<String>>,
workspace_id: ReadSignal<Option<String>>,
title: ReadSignal<String>,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
set_command_feedback: WriteSignal<String>,
set_block_menu_open: WriteSignal<bool>,
set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
set_block_turn_into_open: WriteSignal<bool>,
) {
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<E>(setter: WriteSignal<String>, 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! {
<div
class="block-drag-menu"
@@ -5940,7 +6143,33 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}
})
.collect_view()}
<button class="block-drag-menu-item" data-testid="block-transform-item-page" disabled=true><span class="block-drag-menu-icon">""</span><span>"页面"</span><span class="block-drag-menu-shortcut">"Ctrl+Shift+9"</span></button>
<button
class="block-drag-menu-item"
data-testid="block-transform-item-page"
data-current=current_block_is_page.to_string()
on:click=move |event: MouseEvent| {
event.stop_propagation();
run_block_turn_into_page_action(
editor,
block_index,
document_id,
workspace_id,
title,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
set_command_feedback,
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
);
}
>
<span class="block-drag-menu-icon">""</span>
<span>"页面"</span>
<span class="block-drag-menu-shortcut">{page_current_marker}</span>
</button>
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-list" disabled=true><span class="block-drag-menu-icon">""</span><span>"折叠列表"</span><span class="block-drag-menu-shortcut">"Ctrl+Shift+8"</span></button>
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-title" disabled=true><span class="block-drag-menu-icon">"H1"</span><span>"折叠标题"</span><span class="block-drag-menu-arrow">""</span></button>
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-todo" disabled=true><span class="block-drag-menu-icon">""</span><span>"折叠待办"</span><span class="block-drag-menu-arrow">""</span></button>