add editor transform to page parity
This commit is contained in:
@@ -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 和命令边界
|
||||
|
||||
|
||||
+7
-7
@@ -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;
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user