diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index c253c236..ceeb3759 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -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,作为最后一刀。 diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs index c281e6a7..2cf15ee9 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs @@ -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 { + 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 { + 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 { + 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, 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 { + 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 { + 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::()) + .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, + message: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TreeCommandResult { + document_id: Option, + workspace_id: Option, + title: Option, + parent_id: Option, +} + +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, + title: String, +) -> Result { + 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 { + 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>, + workspace_id: ReadSignal>, + title: ReadSignal, + set_dirty_count: WriteSignal, + set_html_output: WriteSignal, + set_document_json: WriteSignal, + set_json_output: WriteSignal, + set_command_feedback: WriteSignal, + set_block_menu_open: WriteSignal, + set_block_menu_anchor: WriteSignal>, + set_block_turn_into_open: WriteSignal, +) { + let page_title = match block_plain_text_from_index(editor, block_index) { + Ok(value) => value, + Err(err) => { + set_command_feedback.set(err); + return; + } + }; + let 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 { diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index b1e57a48..5ff90a66 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -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 { block_state_from_index_runtime(index) } -fn prosemirror_node_size(node: &Value) -> Option { - 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 { - 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 { - 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 Result<&mut Vec, 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 { - 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 { - 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::()) - .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 { 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, - message: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TreeCommandResult { - document_id: Option, - workspace_id: Option, - title: Option, - parent_id: Option, -} - -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, - title: String, -) -> Result { - 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 { - 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>, - workspace_id: ReadSignal>, - title: ReadSignal, - set_dirty_count: WriteSignal, - set_html_output: WriteSignal, - set_document_json: WriteSignal, - set_json_output: WriteSignal, - set_command_feedback: WriteSignal, - set_block_menu_open: WriteSignal, - set_block_menu_anchor: WriteSignal>, - set_block_turn_into_open: WriteSignal, -) { - let page_title = match block_plain_text_from_index(editor, block_index) { - Ok(value) => value, - Err(err) => { - set_command_feedback.set(err); - return; - } - }; - let 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(setter: WriteSignal, result: Result<&'static str, E>) where E: Display,