refactor: split tiptap block page transforms

This commit is contained in:
lix-2026
2026-05-26 03:28:51 +08:00
parent 396c64e09e
commit 3fc8723eea
3 changed files with 585 additions and 576 deletions
@@ -246,7 +246,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tree -- --
- [x] E1. `style.rs`:迁出 `SPIKE_STYLE`,根 `lib.rs` 只引用常量。
- [ ] E2. `mount.rs`:迁出 mount context、mounted handles、`mount_app_into` 周边可独立部分;`#[wasm_bindgen]` wrapper 可暂留根文件。
- [x] E3. `runtime_bridge.rs`:迁出 `dispatch_runtime_event``dispatch_*_to_target`、host command listener install。
- [ ] E4. `block_transform.rs`:迁出 `run_block_turn_into_action``top_level_block_matches_action`、page/block transform helpers。
- [x] E4. `block_transform.rs`:迁出 `run_block_turn_into_action``top_level_block_matches_action`、page/block transform helpers。
- [ ] E5. `slash_menu_view.rs`:迁出 slash menu Leptos view 和 click handling。
- [ ] E6. `block_handle_menu_view.rs`:迁出 block handle menu 和 submenu view,作为最后一刀。
@@ -1,13 +1,193 @@
use leptos::prelude::*;
use leptos_tiptap::{
TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditorHandle,
TiptapHeadingLevel, TiptapHighlightAttributes, TiptapNodeName, TiptapSchemaTarget,
TiptapTextAlign, TiptapTocNodeAttrs,
TiptapHeadingLevel, TiptapHighlightAttributes, TiptapInsertContentOptions, TiptapNodeName,
TiptapRange, TiptapSchemaTarget, TiptapTextAlign, TiptapTocNodeAttrs,
};
use serde::Deserialize;
use serde_json::{json, Value};
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::{window, CustomEvent, CustomEventInit, Node, RequestInit, RequestMode, Response};
use crate::editor_runtime::attachment_upload::dispatch_editor_upload_request;
use crate::editor_runtime::block_menu_document::mindmap_paragraph_node;
use crate::editor_runtime::block_hover_state::HoveredBlockState;
use crate::editor_runtime::block_menu_document::{
collect_plain_text, mindmap_paragraph_node, paragraph_node,
};
use crate::editor_runtime::command_sync::sync_persisted_editor_command;
use crate::editor_runtime::persistence::persisted_document_identity;
use crate::editor_runtime::slash_actions::SlashActionKind;
use crate::editor_root_element;
pub(crate) fn prosemirror_node_size(node: &Value) -> Option<u32> {
match node.get("type").and_then(Value::as_str) {
Some("text") => Some(
node.get("text")
.and_then(Value::as_str)
.map(|text| text.encode_utf16().count() as u32)
.unwrap_or(0),
),
Some("hardBreak") | Some("horizontalRule") => Some(1),
_ => {
let Some(children) = node.get("content").and_then(Value::as_array) else {
return Some(match node.get("type").and_then(Value::as_str) {
Some("paragraph") | Some("heading") | Some("blockquote")
| Some("codeBlock") | Some("bulletList") | Some("orderedList")
| Some("taskList") | Some("listItem") | Some("taskItem") | Some("table")
| Some("tableRow") | Some("tableCell") | Some("tableHeader") => 2,
_ => 1,
});
};
let content_size = children.iter().try_fold(0_u32, |acc, child| {
prosemirror_node_size(child).map(|size| acc + size)
})?;
Some(content_size + 2)
}
}
}
fn nested_edge_child_size(node: &Value, at_start: bool) -> Option<u32> {
let outer = node
.get("content")
.and_then(Value::as_array)
.and_then(|children| {
if at_start {
children.first()
} else {
children.last()
}
})?;
let inner = outer
.get("content")
.and_then(Value::as_array)
.and_then(|children| {
if at_start {
children.first()
} else {
children.last()
}
})?;
prosemirror_node_size(inner)
}
pub(crate) fn top_level_block_range(document: &Value, index: usize) -> Option<TiptapRange> {
let content = document.get("content").and_then(Value::as_array)?;
let mut position = 0_u32;
for (current_index, node) in content.iter().enumerate() {
let node_size = prosemirror_node_size(node)?;
if current_index == index {
let from = position + nested_edge_child_size(node, true).unwrap_or(1);
let mut to = position
+ node_size.saturating_sub(nested_edge_child_size(node, false).unwrap_or(1));
if to < from {
to = from;
}
return Some(TiptapRange { from, to });
}
position += node_size;
}
None
}
pub(crate) fn document_content_mut(document: &mut Value) -> Result<&mut Vec<Value>, String> {
document
.get_mut("content")
.and_then(Value::as_array_mut)
.ok_or_else(|| "文档 JSON 缺少顶层 content 数组".to_string())
}
pub(crate) fn top_level_block_boundary_position(
document: &Value,
index: usize,
before: bool,
) -> Option<u32> {
let content = document.get("content").and_then(Value::as_array)?;
let mut position = 0_u32;
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;
}
None
}
pub(crate) fn insert_editor_paragraph_relative_to_block(
editor: TiptapEditorHandle,
index: usize,
before: bool,
) -> Result<usize, String> {
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)
}
pub(crate) 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::<Node>())
.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}"))
}
pub(crate) fn apply_text_color(
editor: TiptapEditorHandle,
@@ -147,6 +327,120 @@ pub(crate) fn run_slash_action(
.map_err(|err| format!("命令执行失败:{err}"))
}
pub(crate) fn run_block_turn_into_action(
editor: TiptapEditorHandle,
block_index: usize,
action: SlashActionKind,
) -> Result<&'static str, String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let range = top_level_block_range(&document, block_index)
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
match action {
SlashActionKind::SimpleTable => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.insert_table(4, 3, false)
.map(|_| "已把当前块转成简单表格")
.map_err(|err| format!("命令执行失败:{err}"))
}
SlashActionKind::Toc => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.insert_toc_node(TiptapTocNodeAttrs {
top_offset: Some(0),
max_show_count: Some(20),
show_title: Some(true),
})
.map(|_| "已把当前块转成页面目录")
.map_err(|err| format!("命令执行失败:{err}"))
}
SlashActionKind::Divider => editor
.insert_content_at(
range,
TiptapContent::json(json!({ "type": "horizontalRule" })),
None,
)
.map(|_| "已把当前块转成分割线")
.map_err(|err| format!("命令执行失败:{err}")),
SlashActionKind::Mindmap => editor
.insert_content_at(
range,
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
)
.map(|_| "已把当前块转成思维导图")
.map_err(|err| format!("命令执行失败:{err}")),
_ => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.clear_nodes()
.map_err(|err| format!("清理当前块样式失败:{err}"))?;
let message = run_slash_action(editor, action)?;
let _ = editor.focus();
let _ = editor.select_textblock_end();
Ok(message)
}
}
}
fn heading_level_from_u8(level: u8) -> TiptapHeadingLevel {
match level {
1 => TiptapHeadingLevel::H1,
2 => TiptapHeadingLevel::H2,
3 => TiptapHeadingLevel::H3,
4 => TiptapHeadingLevel::H4,
5 => TiptapHeadingLevel::H5,
_ => TiptapHeadingLevel::H6,
}
}
pub(crate) fn run_block_turn_into_folded_heading_action(
editor: TiptapEditorHandle,
block_index: usize,
level: u8,
) -> Result<&'static str, String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let range = top_level_block_range(&document, block_index)
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.clear_nodes()
.map_err(|err| format!("清理当前块样式失败:{err}"))?;
editor
.set_heading_with_collapsed(heading_level_from_u8(level), true)
.map_err(|err| format!("命令执行失败:{err}"))?;
let mut attrs = leptos_tiptap::TiptapAttributes::new();
attrs.insert("collapsed", true);
editor
.update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Heading), attrs)
.map_err(|err| format!("写入折叠属性失败:{err}"))?;
let _ = editor.focus();
let _ = editor.select_textblock_end();
Ok(match level {
1 => "已切到折叠主标题",
2 => "已切到折叠大标题",
3 => "已切到折叠中标题",
4 => "已切到折叠小标题",
_ => "已切到折叠标题",
})
}
pub(crate) fn top_level_block_is_page(
editor: TiptapEditorHandle,
block_index: usize,
@@ -268,6 +562,284 @@ fn inline_has_page_block_link(node: &Value) -> bool {
.unwrap_or(false)
}
fn normalized_page_block_title(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
"未命名页面".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Debug, Deserialize)]
struct TreeCommandHttpResponse {
result: Option<TreeCommandResult>,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TreeCommandResult {
document_id: Option<String>,
workspace_id: Option<String>,
title: Option<String>,
parent_id: Option<String>,
}
fn dispatch_tree_local_command(body: &Value, result: &Value) {
let Some(win) = window() else {
return;
};
let Ok(detail) = serde_wasm_bindgen::to_value(&json!({
"body": body,
"result": result,
})) else {
return;
};
let init = CustomEventInit::new();
init.set_detail(&detail);
if let Ok(event) = CustomEvent::new_with_event_init_dict("tree:local-command", &init) {
let _ = win.dispatch_event(&event);
}
}
async fn create_child_page_via_tree_command(
parent_id: String,
workspace_id: Option<String>,
title: String,
) -> Result<TreeCommandResult, String> {
let body = json!({
"action": "create",
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": "private",
"content": [],
});
let body_string =
serde_json::to_string(&body).map_err(|err| format!("序列化创建子页面请求失败:{err}"))?;
let request_init = RequestInit::new();
request_init.set_method("POST");
request_init.set_mode(RequestMode::SameOrigin);
request_init.set_body(&JsValue::from_str(&body_string));
let headers = js_sys::Object::new();
js_sys::Reflect::set(
&headers,
&JsValue::from_str("content-type"),
&JsValue::from_str("application/json"),
)
.map_err(|_| "设置创建子页面请求头失败".to_string())?;
js_sys::Reflect::set(
request_init.as_ref(),
&JsValue::from_str("headers"),
&headers,
)
.map_err(|_| "设置创建子页面请求头失败".to_string())?;
let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?;
let response_value =
JsFuture::from(win.fetch_with_str_and_init("/api/tree/commands", &request_init))
.await
.map_err(|err| format!("创建子页面请求失败:{err:?}"))?;
let response: Response = response_value
.dyn_into()
.map_err(|_| "创建子页面响应类型错误".to_string())?;
let status = response.status();
let json_value = JsFuture::from(
response
.json()
.map_err(|_| "读取创建子页面响应失败".to_string())?,
)
.await
.map_err(|err| format!("解析创建子页面响应失败:{err:?}"))?;
let parsed: TreeCommandHttpResponse = serde_wasm_bindgen::from_value(json_value.clone())
.map_err(|err| format!("创建子页面响应 JSON 不符合预期:{err}"))?;
if !response.ok() {
return Err(parsed
.message
.unwrap_or_else(|| format!("创建子页面失败:HTTP {status}")));
}
let result = parsed
.result
.ok_or_else(|| "创建子页面响应缺少 result".to_string())?;
let document_id = result
.document_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "创建子页面响应缺少 documentId".to_string())?;
if document_id == parent_id {
return Err("创建子页面返回了当前页面 id".to_string());
}
if let Some(created_parent_id) = result
.parent_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if created_parent_id != parent_id {
return Err(format!(
"创建子页面父节点不符合预期:期望 {parent_id},实际 {created_parent_id}"
));
}
}
dispatch_tree_local_command(
&body,
&serde_wasm_bindgen::from_value(json_value).unwrap_or(Value::Null)["result"],
);
Ok(result)
}
fn block_plain_text_from_index(
editor: TiptapEditorHandle,
block_index: usize,
) -> 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 page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value {
let href = if page_id.trim().is_empty() {
"#".to_string()
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
} else {
format!("/documents/{page_id}")
};
paragraph_node(vec![json!({
"type": "text",
"text": normalized_page_block_title(title),
"marks": [{
"type": "link",
"attrs": {
"href": href,
"target": "_self",
"rel": "noopener noreferrer nofollow",
"class": "mnote-page-block-link",
}
}]
})])
}
fn replace_block_with_page_reference(
editor: TiptapEditorHandle,
block_index: usize,
page_id: &str,
workspace_id: Option<&str>,
title: &str,
) -> Result<(), String> {
let mut document = editor
.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, workspace_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(())
}
pub(crate) 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 Some(parent_document_id) = document_id.get_untracked() else {
set_command_feedback.set("转换为页面失败:当前页面缺少 documentId".to_string());
return;
};
let workspace_id_value = workspace_id.get_untracked();
set_command_feedback.set(format!("正在创建子页面:{page_title}"));
spawn_local(async move {
match create_child_page_via_tree_command(
parent_document_id.clone(),
workspace_id_value.clone(),
page_title.clone(),
)
.await
{
Ok(created) => {
let Some(child_page_id) = created.document_id.as_deref() else {
set_command_feedback.set("转换为页面失败:创建响应缺少子页面 id".to_string());
return;
};
let effective_workspace_id = created
.workspace_id
.as_deref()
.or(workspace_id_value.as_deref());
let effective_title = created.title.as_deref().unwrap_or(&page_title);
match replace_block_with_page_reference(
editor,
block_index,
child_page_id,
effective_workspace_id,
effective_title,
) {
Ok(()) => {
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_block_turn_into_open.set(false);
sync_persisted_editor_command(
editor,
&persisted_document_identity(
document_id.get_untracked(),
workspace_id.get_untracked(),
),
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
format!("已转换为子页面:{effective_title}"),
);
}
Err(err) => set_command_feedback.set(err),
}
}
Err(err) => set_command_feedback.set(err),
}
});
}
/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。
/// 返回 true 表示树发生了变更。
pub(crate) fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool {
+9 -572
View File
@@ -3,19 +3,18 @@ use leptos::mount::{mount_to, mount_to_body};
use leptos::prelude::*;
use leptos_dom::helpers::window_event_listener;
use leptos_tiptap::{
TiptapContent, TiptapEditor, TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel,
TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, TiptapNodeName, TiptapRange,
TiptapSchemaTarget, TiptapSelectionState, TiptapTextAlign, TiptapTocNodeAttrs,
TiptapContent, TiptapEditor, TiptapEditorHandle, TiptapExtension, TiptapLinkResource,
TiptapMarkName, TiptapSelectionState, TiptapTextAlign,
};
use send_wrapper::SendWrapper;
use serde::Deserialize;
use serde_json::{json, Value};
use std::{cell::Cell, fmt::Display};
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use wasm_bindgen_futures::spawn_local;
use web_sys::{
window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement,
MouseEvent, Node, RequestInit, RequestMode, Response, WheelEvent,
window, CustomEvent, DragEvent, Element, Event, EventTarget, HtmlElement, MouseEvent,
WheelEvent,
};
use editor_runtime::block_dnd::{
@@ -27,14 +26,13 @@ use editor_runtime::block_dnd::{
use editor_runtime::block_hover_state::{
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
};
use editor_runtime::block_menu_document::{
collect_plain_text, mindmap_paragraph_node, paragraph_node,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_transform::{
apply_block_delta_to_json, apply_highlight_color, apply_image_align, apply_text_align,
apply_text_color, clear_text_color, run_slash_action, top_level_block_is_page,
top_level_block_matches_action,
apply_text_color, clear_text_color, insert_editor_paragraph_relative_to_block,
prosemirror_node_size, run_block_turn_into_action, run_block_turn_into_folded_heading_action,
run_block_turn_into_page_action, run_slash_action, top_level_block_boundary_position,
top_level_block_is_page, top_level_block_matches_action, top_level_block_range,
};
use editor_runtime::bridge_dispatch::HostCommandKind;
use editor_runtime::bridge_events::{
@@ -892,79 +890,6 @@ fn block_state_from_index(index: usize) -> Option<HoveredBlockState> {
block_state_from_index_runtime(index)
}
fn prosemirror_node_size(node: &Value) -> Option<u32> {
match node.get("type").and_then(Value::as_str) {
Some("text") => Some(
node.get("text")
.and_then(Value::as_str)
.map(|text| text.encode_utf16().count() as u32)
.unwrap_or(0),
),
Some("hardBreak") | Some("horizontalRule") => Some(1),
_ => {
let Some(children) = node.get("content").and_then(Value::as_array) else {
return Some(match node.get("type").and_then(Value::as_str) {
Some("paragraph") | Some("heading") | Some("blockquote")
| Some("codeBlock") | Some("bulletList") | Some("orderedList")
| Some("taskList") | Some("listItem") | Some("taskItem") | Some("table")
| Some("tableRow") | Some("tableCell") | Some("tableHeader") => 2,
_ => 1,
});
};
let content_size = children.iter().try_fold(0_u32, |acc, child| {
prosemirror_node_size(child).map(|size| acc + size)
})?;
Some(content_size + 2)
}
}
}
fn nested_edge_child_size(node: &Value, at_start: bool) -> Option<u32> {
let outer = node
.get("content")
.and_then(Value::as_array)
.and_then(|children| {
if at_start {
children.first()
} else {
children.last()
}
})?;
let inner = outer
.get("content")
.and_then(Value::as_array)
.and_then(|children| {
if at_start {
children.first()
} else {
children.last()
}
})?;
prosemirror_node_size(inner)
}
fn top_level_block_range(document: &Value, index: usize) -> Option<TiptapRange> {
let content = document.get("content").and_then(Value::as_array)?;
let mut position = 0_u32;
for (current_index, node) in content.iter().enumerate() {
let node_size = prosemirror_node_size(node)?;
if current_index == index {
let from = position + nested_edge_child_size(node, true).unwrap_or(1);
let mut to = position
+ node_size.saturating_sub(nested_edge_child_size(node, false).unwrap_or(1));
if to < from {
to = from;
}
return Some(TiptapRange { from, to });
}
position += node_size;
}
None
}
fn slash_menu_anchor_style() -> String {
const MENU_WIDTH: f64 = 316.0;
const MENU_HEIGHT: f64 = 430.0;
@@ -1013,105 +938,6 @@ fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option<DropIndicat
drop_indicator_from_point_runtime(client_x, client_y, HANDLE_SHELL_SELECTOR)
}
fn document_content_mut(document: &mut Value) -> Result<&mut Vec<Value>, String> {
document
.get_mut("content")
.and_then(Value::as_array_mut)
.ok_or_else(|| "文档 JSON 缺少顶层 content 数组".to_string())
}
fn next_mindmap_id() -> String {
let now = js_sys::Date::new_0();
format!(
"思维导图{:02}{:02}{:02}.json",
now.get_hours(),
now.get_minutes(),
now.get_seconds()
)
}
fn top_level_block_boundary_position(document: &Value, index: usize, before: bool) -> Option<u32> {
let content = document.get("content").and_then(Value::as_array)?;
let mut position = 0_u32;
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;
}
None
}
fn insert_editor_paragraph_relative_to_block(
editor: TiptapEditorHandle,
index: usize,
before: bool,
) -> Result<usize, String> {
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 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::<Node>())
.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 p0_extensions() -> Vec<TiptapExtension> {
vec![
TiptapExtension::Document,
@@ -1317,395 +1143,6 @@ fn apply_host_document_payload(
}
}
fn run_block_turn_into_action(
editor: TiptapEditorHandle,
block_index: usize,
action: SlashActionKind,
) -> Result<&'static str, String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let range = top_level_block_range(&document, block_index)
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
match action {
SlashActionKind::SimpleTable => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.insert_table(4, 3, false)
.map(|_| "已把当前块转成简单表格")
.map_err(|err| format!("命令执行失败:{err}"))
}
SlashActionKind::Toc => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.insert_toc_node(TiptapTocNodeAttrs {
top_offset: Some(0),
max_show_count: Some(20),
show_title: Some(true),
})
.map(|_| "已把当前块转成页面目录")
.map_err(|err| format!("命令执行失败:{err}"))
}
SlashActionKind::Divider => editor
.insert_content_at(
range,
TiptapContent::json(json!({ "type": "horizontalRule" })),
None,
)
.map(|_| "已把当前块转成分割线")
.map_err(|err| format!("命令执行失败:{err}")),
SlashActionKind::Mindmap => editor
.insert_content_at(
range,
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
)
.map(|_| "已把当前块转成思维导图")
.map_err(|err| format!("命令执行失败:{err}")),
_ => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.clear_nodes()
.map_err(|err| format!("清理当前块样式失败:{err}"))?;
let message = run_slash_action(editor, action)?;
let _ = editor.focus();
let _ = editor.select_textblock_end();
Ok(message)
}
}
}
fn heading_level_from_u8(level: u8) -> TiptapHeadingLevel {
match level {
1 => TiptapHeadingLevel::H1,
2 => TiptapHeadingLevel::H2,
3 => TiptapHeadingLevel::H3,
4 => TiptapHeadingLevel::H4,
5 => TiptapHeadingLevel::H5,
_ => TiptapHeadingLevel::H6,
}
}
fn run_block_turn_into_folded_heading_action(
editor: TiptapEditorHandle,
block_index: usize,
level: u8,
) -> Result<&'static str, String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let range = top_level_block_range(&document, block_index)
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.clear_nodes()
.map_err(|err| format!("清理当前块样式失败:{err}"))?;
editor
.set_heading_with_collapsed(heading_level_from_u8(level), true)
.map_err(|err| format!("命令执行失败:{err}"))?;
let mut attrs = leptos_tiptap::TiptapAttributes::new();
attrs.insert("collapsed", true);
editor
.update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Heading), attrs)
.map_err(|err| format!("写入折叠属性失败:{err}"))?;
let _ = editor.focus();
let _ = editor.select_textblock_end();
Ok(match level {
1 => "已切到折叠主标题",
2 => "已切到折叠大标题",
3 => "已切到折叠中标题",
4 => "已切到折叠小标题",
_ => "已切到折叠标题",
})
}
fn normalized_page_block_title(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
"未命名页面".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Debug, Deserialize)]
struct TreeCommandHttpResponse {
result: Option<TreeCommandResult>,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TreeCommandResult {
document_id: Option<String>,
workspace_id: Option<String>,
title: Option<String>,
parent_id: Option<String>,
}
fn dispatch_tree_local_command(body: &Value, result: &Value) {
let Some(win) = window() else {
return;
};
let Ok(detail) = serde_wasm_bindgen::to_value(&json!({
"body": body,
"result": result,
})) else {
return;
};
let init = CustomEventInit::new();
init.set_detail(&detail);
if let Ok(event) = CustomEvent::new_with_event_init_dict("tree:local-command", &init) {
let _ = win.dispatch_event(&event);
}
}
async fn create_child_page_via_tree_command(
parent_id: String,
workspace_id: Option<String>,
title: String,
) -> Result<TreeCommandResult, String> {
let body = json!({
"action": "create",
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": "private",
"content": [],
});
let body_string =
serde_json::to_string(&body).map_err(|err| format!("序列化创建子页面请求失败:{err}"))?;
let request_init = RequestInit::new();
request_init.set_method("POST");
request_init.set_mode(RequestMode::SameOrigin);
request_init.set_body(&JsValue::from_str(&body_string));
let headers = js_sys::Object::new();
js_sys::Reflect::set(
&headers,
&JsValue::from_str("content-type"),
&JsValue::from_str("application/json"),
)
.map_err(|_| "设置创建子页面请求头失败".to_string())?;
js_sys::Reflect::set(
request_init.as_ref(),
&JsValue::from_str("headers"),
&headers,
)
.map_err(|_| "设置创建子页面请求头失败".to_string())?;
let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?;
let response_value =
JsFuture::from(win.fetch_with_str_and_init("/api/tree/commands", &request_init))
.await
.map_err(|err| format!("创建子页面请求失败:{err:?}"))?;
let response: Response = response_value
.dyn_into()
.map_err(|_| "创建子页面响应类型错误".to_string())?;
let status = response.status();
let json_value = JsFuture::from(
response
.json()
.map_err(|_| "读取创建子页面响应失败".to_string())?,
)
.await
.map_err(|err| format!("解析创建子页面响应失败:{err:?}"))?;
let parsed: TreeCommandHttpResponse = serde_wasm_bindgen::from_value(json_value.clone())
.map_err(|err| format!("创建子页面响应 JSON 不符合预期:{err}"))?;
if !response.ok() {
return Err(parsed
.message
.unwrap_or_else(|| format!("创建子页面失败:HTTP {status}")));
}
let result = parsed
.result
.ok_or_else(|| "创建子页面响应缺少 result".to_string())?;
let document_id = result
.document_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "创建子页面响应缺少 documentId".to_string())?;
if document_id == parent_id {
return Err("创建子页面返回了当前页面 id".to_string());
}
if let Some(created_parent_id) = result
.parent_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if created_parent_id != parent_id {
return Err(format!(
"创建子页面父节点不符合预期:期望 {parent_id},实际 {created_parent_id}"
));
}
}
dispatch_tree_local_command(
&body,
&serde_wasm_bindgen::from_value(json_value).unwrap_or(Value::Null)["result"],
);
Ok(result)
}
fn block_plain_text_from_index(
editor: TiptapEditorHandle,
block_index: usize,
) -> 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 page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value {
let href = if page_id.trim().is_empty() {
"#".to_string()
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
} else {
format!("/documents/{page_id}")
};
paragraph_node(vec![json!({
"type": "text",
"text": normalized_page_block_title(title),
"marks": [{
"type": "link",
"attrs": {
"href": href,
"target": "_self",
"rel": "noopener noreferrer nofollow",
"class": "mnote-page-block-link",
}
}]
})])
}
fn replace_block_with_page_reference(
editor: TiptapEditorHandle,
block_index: usize,
page_id: &str,
workspace_id: Option<&str>,
title: &str,
) -> Result<(), String> {
let mut document = editor
.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, workspace_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 Some(parent_document_id) = document_id.get_untracked() else {
set_command_feedback.set("转换为页面失败:当前页面缺少 documentId".to_string());
return;
};
let workspace_id_value = workspace_id.get_untracked();
set_command_feedback.set(format!("正在创建子页面:{page_title}"));
spawn_local(async move {
match create_child_page_via_tree_command(
parent_document_id.clone(),
workspace_id_value.clone(),
page_title.clone(),
)
.await
{
Ok(created) => {
let Some(child_page_id) = created.document_id.as_deref() else {
set_command_feedback.set("转换为页面失败:创建响应缺少子页面 id".to_string());
return;
};
let effective_workspace_id = created
.workspace_id
.as_deref()
.or(workspace_id_value.as_deref());
let effective_title = created.title.as_deref().unwrap_or(&page_title);
match replace_block_with_page_reference(
editor,
block_index,
child_page_id,
effective_workspace_id,
effective_title,
) {
Ok(()) => {
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_block_turn_into_open.set(false);
sync_persisted_editor_command(
editor,
&runtime_persisted_identity(document_id, workspace_id),
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
format!("已转换为子页面:{effective_title}"),
);
}
Err(err) => set_command_feedback.set(err),
}
}
Err(err) => set_command_feedback.set(err),
}
});
}
fn apply_feedback_result<E>(setter: WriteSignal<String>, result: Result<&'static str, E>)
where
E: Display,