2026-04-19 21:03:25 +08:00
|
|
|
use crate::app::AppState;
|
|
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
use crate::routes::documents::{
|
2026-04-29 12:24:44 +08:00
|
|
|
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
|
2026-04-19 21:03:25 +08:00
|
|
|
};
|
|
|
|
|
use axum::extract::{Extension, Json, Query, State};
|
2026-04-29 12:24:44 +08:00
|
|
|
use axum::http::{header, HeaderValue, StatusCode};
|
2026-04-19 21:03:25 +08:00
|
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
|
|
|
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-04-29 12:24:44 +08:00
|
|
|
use serde_json::{json, Value};
|
2026-04-19 21:03:25 +08:00
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct DocumentEditorShellQuery {
|
|
|
|
|
pub document_id: String,
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
pub host: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct RuntimeShellBlockWire {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub block_type: String,
|
|
|
|
|
pub parent_id: Option<String>,
|
|
|
|
|
pub depth: u16,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub text: String,
|
|
|
|
|
pub heading_level: Option<u8>,
|
|
|
|
|
pub checked: Option<bool>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub collapsed: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub editable: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub raw_type: String,
|
|
|
|
|
pub language: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub raw_block: Value,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct RuntimeTransformRequest {
|
|
|
|
|
pub document_id: String,
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
pub snapshot: RuntimeTransformSnapshotRequest,
|
|
|
|
|
pub command: RuntimeTransformCommandRequest,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct RuntimeTransformSnapshotRequest {
|
|
|
|
|
pub blocks: Vec<RuntimeShellBlockWire>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "snake_case", tag = "action")]
|
|
|
|
|
pub enum RuntimeTransformCommandRequest {
|
|
|
|
|
SetBlockType {
|
|
|
|
|
block_id: String,
|
|
|
|
|
block_type: String,
|
|
|
|
|
},
|
|
|
|
|
SplitBlock {
|
|
|
|
|
block_id: String,
|
|
|
|
|
offset: usize,
|
|
|
|
|
new_block_id: String,
|
|
|
|
|
},
|
|
|
|
|
MergeWithPrevious {
|
|
|
|
|
block_id: String,
|
|
|
|
|
},
|
|
|
|
|
IndentBlock {
|
|
|
|
|
block_id: String,
|
|
|
|
|
},
|
|
|
|
|
OutdentBlock {
|
|
|
|
|
block_id: String,
|
|
|
|
|
},
|
|
|
|
|
ToggleHeadingCollapse {
|
|
|
|
|
block_id: String,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_html(input: &str) -> String {
|
|
|
|
|
input
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_inline_json(input: &str) -> String {
|
|
|
|
|
input
|
|
|
|
|
.replace('&', "\\u0026")
|
|
|
|
|
.replace('<', "\\u003c")
|
|
|
|
|
.replace('>', "\\u003e")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_non_empty_string(value: &Value, keys: &[&str]) -> Option<String> {
|
|
|
|
|
let map = value.as_object()?;
|
|
|
|
|
for key in keys {
|
|
|
|
|
let candidate = map
|
|
|
|
|
.get(*key)
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
if !candidate.is_empty() {
|
|
|
|
|
return Some(candidate.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_bool(value: &Value, keys: &[&str], default: bool) -> bool {
|
|
|
|
|
let Some(map) = value.as_object() else {
|
|
|
|
|
return default;
|
|
|
|
|
};
|
|
|
|
|
for key in keys {
|
|
|
|
|
if let Some(flag) = map.get(*key).and_then(Value::as_bool) {
|
|
|
|
|
return flag;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
default
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_u64(value: &Value, keys: &[&str], default: u64) -> u64 {
|
|
|
|
|
let Some(map) = value.as_object() else {
|
|
|
|
|
return default;
|
|
|
|
|
};
|
|
|
|
|
for key in keys {
|
|
|
|
|
if let Some(number) = map.get(*key).and_then(Value::as_u64) {
|
|
|
|
|
return number;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
default
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_non_empty_prop_string(value: &Value, key: &str) -> Option<String> {
|
|
|
|
|
value
|
|
|
|
|
.get("props")
|
|
|
|
|
.and_then(Value::as_object)
|
|
|
|
|
.and_then(|props| props.get(key))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|item| !item.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_prop_bool(value: &Value, key: &str) -> Option<bool> {
|
|
|
|
|
value
|
|
|
|
|
.get("props")
|
|
|
|
|
.and_then(Value::as_object)
|
|
|
|
|
.and_then(|props| props.get(key))
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_prop_u8(value: &Value, key: &str) -> Option<u8> {
|
|
|
|
|
value
|
|
|
|
|
.get("props")
|
|
|
|
|
.and_then(Value::as_object)
|
|
|
|
|
.and_then(|props| props.get(key))
|
|
|
|
|
.and_then(Value::as_u64)
|
|
|
|
|
.and_then(|item| u8::try_from(item).ok())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_inline_text(value: &Value) -> String {
|
|
|
|
|
match value {
|
|
|
|
|
Value::String(text) => text.clone(),
|
|
|
|
|
Value::Array(items) => items
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|item| match item {
|
|
|
|
|
Value::String(text) => text.clone(),
|
|
|
|
|
Value::Object(map) => {
|
|
|
|
|
if let Some(text) = map.get("text").and_then(Value::as_str) {
|
|
|
|
|
return text.to_string();
|
|
|
|
|
}
|
|
|
|
|
if let Some(content) = map.get("content") {
|
|
|
|
|
return get_inline_text(content);
|
|
|
|
|
}
|
|
|
|
|
String::new()
|
|
|
|
|
}
|
|
|
|
|
_ => String::new(),
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<String>>()
|
|
|
|
|
.join(""),
|
|
|
|
|
_ => String::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn split_content_root(content_result: &Value) -> (Vec<Value>, Option<Value>) {
|
|
|
|
|
let current = content_result
|
|
|
|
|
.get("content")
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or(Value::Null);
|
|
|
|
|
if let Some(array) = current.as_array() {
|
|
|
|
|
return (array.clone(), None);
|
|
|
|
|
}
|
|
|
|
|
if let Some(map) = current.as_object() {
|
|
|
|
|
let blocks = map
|
|
|
|
|
.get("blocks")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let mut wrapper = map.clone();
|
|
|
|
|
wrapper.remove("blocks");
|
|
|
|
|
return (blocks, Some(Value::Object(wrapper)));
|
|
|
|
|
}
|
|
|
|
|
(Vec::new(), None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn runtime_block_type_for_raw_type(raw_type: &str) -> (&'static str, bool) {
|
|
|
|
|
match raw_type {
|
|
|
|
|
"paragraph" => ("paragraph", true),
|
|
|
|
|
"heading" => ("heading", true),
|
2026-04-30 18:36:50 +08:00
|
|
|
"bulletListItem" | "bullet_list_item" => ("bullet_list_item", true),
|
|
|
|
|
"numberedListItem" | "numbered_list_item" => ("numbered_list_item", true),
|
|
|
|
|
"checkListItem" | "advancedTodo" | "todo" => ("todo", true),
|
|
|
|
|
"quote" | "blockquote" => ("quote", true),
|
2026-04-19 21:03:25 +08:00
|
|
|
"divider" => ("divider", true),
|
|
|
|
|
"codeBlock" => ("code_block", true),
|
|
|
|
|
"pageReference" => ("page_reference", false),
|
|
|
|
|
"blockReference" => ("block_reference", false),
|
|
|
|
|
"progress" | "progressBlock" => ("progress_placeholder", false),
|
|
|
|
|
"media" | "mindmap" | "onlineTable" => ("media_placeholder", false),
|
|
|
|
|
_ => ("media_placeholder", false),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn raw_type_for_runtime_block_type(block_type: &str, fallback_raw_type: &str) -> String {
|
|
|
|
|
match block_type {
|
|
|
|
|
"paragraph" => "paragraph".into(),
|
|
|
|
|
"heading" => "heading".into(),
|
|
|
|
|
"bullet_list_item" => "bulletListItem".into(),
|
|
|
|
|
"numbered_list_item" => "numberedListItem".into(),
|
|
|
|
|
"todo" => {
|
|
|
|
|
if fallback_raw_type == "advancedTodo" {
|
|
|
|
|
"advancedTodo".into()
|
2026-04-30 18:36:50 +08:00
|
|
|
} else if fallback_raw_type == "todo" {
|
|
|
|
|
"todo".into()
|
2026-04-19 21:03:25 +08:00
|
|
|
} else {
|
|
|
|
|
"checkListItem".into()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"quote" => "quote".into(),
|
|
|
|
|
"divider" => "divider".into(),
|
|
|
|
|
"code_block" => "codeBlock".into(),
|
|
|
|
|
"page_reference" => "pageReference".into(),
|
|
|
|
|
"block_reference" => "blockReference".into(),
|
|
|
|
|
"progress_placeholder" => {
|
|
|
|
|
if fallback_raw_type.trim().is_empty() {
|
|
|
|
|
"progressBlock".into()
|
|
|
|
|
} else {
|
|
|
|
|
fallback_raw_type.to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"media_placeholder" => {
|
|
|
|
|
if fallback_raw_type.trim().is_empty() {
|
|
|
|
|
"media".into()
|
|
|
|
|
} else {
|
|
|
|
|
fallback_raw_type.to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
if fallback_raw_type.trim().is_empty() {
|
|
|
|
|
"paragraph".into()
|
|
|
|
|
} else {
|
|
|
|
|
fallback_raw_type.to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn append_runtime_shell_blocks(
|
|
|
|
|
raw_blocks: &[Value],
|
|
|
|
|
depth: u16,
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
output: &mut Vec<RuntimeShellBlockWire>,
|
|
|
|
|
) {
|
|
|
|
|
for (index, raw_block) in raw_blocks.iter().enumerate() {
|
|
|
|
|
let id = read_non_empty_string(raw_block, &["id"])
|
|
|
|
|
.unwrap_or_else(|| format!("runtime_block_{}_{}", depth, index + 1));
|
|
|
|
|
let raw_type =
|
|
|
|
|
read_non_empty_string(raw_block, &["type"]).unwrap_or_else(|| "paragraph".into());
|
|
|
|
|
let (block_type, editable) = runtime_block_type_for_raw_type(&raw_type);
|
|
|
|
|
let text = get_inline_text(raw_block.get("content").unwrap_or(&Value::Null));
|
|
|
|
|
let block = RuntimeShellBlockWire {
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
block_type: block_type.into(),
|
|
|
|
|
parent_id: parent_id.map(ToOwned::to_owned),
|
|
|
|
|
depth,
|
|
|
|
|
text: text.clone(),
|
|
|
|
|
heading_level: read_prop_u8(raw_block, "level"),
|
|
|
|
|
checked: read_prop_bool(raw_block, "checked").or_else(|| {
|
|
|
|
|
read_non_empty_prop_string(raw_block, "status")
|
|
|
|
|
.map(|status| matches!(status.as_str(), "done" | "completed"))
|
|
|
|
|
}),
|
|
|
|
|
collapsed: read_prop_bool(raw_block, "collapsed").unwrap_or(false),
|
|
|
|
|
editable,
|
|
|
|
|
raw_type: raw_type.clone(),
|
|
|
|
|
language: read_non_empty_prop_string(raw_block, "language"),
|
|
|
|
|
raw_block: raw_block.clone(),
|
|
|
|
|
};
|
|
|
|
|
output.push(block);
|
|
|
|
|
let children = raw_block
|
|
|
|
|
.get("children")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if !children.is_empty() {
|
|
|
|
|
append_runtime_shell_blocks(&children, depth + 1, Some(id.as_str()), output);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn initial_runtime_shell_blocks(
|
|
|
|
|
content_result: &Value,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
) -> (Vec<RuntimeShellBlockWire>, Option<Value>) {
|
|
|
|
|
let (raw_blocks, content_wrapper) = split_content_root(content_result);
|
|
|
|
|
let mut blocks = Vec::new();
|
|
|
|
|
append_runtime_shell_blocks(&raw_blocks, 0, None, &mut blocks);
|
|
|
|
|
if blocks.is_empty() {
|
|
|
|
|
blocks.push(RuntimeShellBlockWire {
|
|
|
|
|
id: format!("{document_id}_block_1"),
|
|
|
|
|
block_type: "paragraph".into(),
|
|
|
|
|
parent_id: None,
|
|
|
|
|
depth: 0,
|
|
|
|
|
text: String::new(),
|
|
|
|
|
heading_level: None,
|
|
|
|
|
checked: None,
|
|
|
|
|
collapsed: false,
|
|
|
|
|
editable: true,
|
|
|
|
|
raw_type: "paragraph".into(),
|
|
|
|
|
language: None,
|
|
|
|
|
raw_block: Value::Null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
(blocks, content_wrapper)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn core_block_type_from_runtime(block_type: &str) -> BlockType {
|
|
|
|
|
match block_type {
|
|
|
|
|
"paragraph" => BlockType::Paragraph,
|
|
|
|
|
"heading" => BlockType::Heading,
|
|
|
|
|
"bullet_list_item" => BlockType::BulletListItem,
|
|
|
|
|
"numbered_list_item" => BlockType::NumberedListItem,
|
|
|
|
|
"todo" => BlockType::Todo,
|
|
|
|
|
"quote" => BlockType::Quote,
|
|
|
|
|
"divider" => BlockType::Divider,
|
|
|
|
|
"code_block" => BlockType::CodeBlock,
|
|
|
|
|
"page_reference" => BlockType::PageReference,
|
|
|
|
|
"block_reference" => BlockType::BlockReference,
|
|
|
|
|
"progress_placeholder" => BlockType::ProgressPlaceholder,
|
|
|
|
|
_ => BlockType::MediaPlaceholder,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn runtime_block_type_from_core(block_type: &BlockType) -> &'static str {
|
|
|
|
|
match block_type {
|
|
|
|
|
BlockType::Paragraph => "paragraph",
|
|
|
|
|
BlockType::Heading => "heading",
|
|
|
|
|
BlockType::BulletListItem => "bullet_list_item",
|
|
|
|
|
BlockType::NumberedListItem => "numbered_list_item",
|
|
|
|
|
BlockType::Todo => "todo",
|
|
|
|
|
BlockType::Quote => "quote",
|
|
|
|
|
BlockType::Divider => "divider",
|
|
|
|
|
BlockType::CodeBlock => "code_block",
|
|
|
|
|
BlockType::PageReference => "page_reference",
|
|
|
|
|
BlockType::BlockReference => "block_reference",
|
|
|
|
|
BlockType::ProgressPlaceholder => "progress_placeholder",
|
|
|
|
|
BlockType::MediaPlaceholder => "media_placeholder",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn runtime_snapshot_to_document(blocks: &[RuntimeShellBlockWire]) -> DocumentModel {
|
|
|
|
|
DocumentModel::new(
|
|
|
|
|
blocks
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|block| {
|
|
|
|
|
let mut item = DocumentBlock::new(
|
|
|
|
|
block.id.clone(),
|
|
|
|
|
core_block_type_from_runtime(&block.block_type),
|
|
|
|
|
)
|
|
|
|
|
.with_text(block.text.clone())
|
|
|
|
|
.with_collapsed(block.collapsed);
|
|
|
|
|
item.parent_id = block.parent_id.clone();
|
|
|
|
|
item.indent = block.depth;
|
|
|
|
|
item.heading_level = block.heading_level;
|
|
|
|
|
item.checked = block.checked;
|
|
|
|
|
if let Some(language) = block.language.as_deref() {
|
|
|
|
|
item.content.language = Some(language.to_string());
|
|
|
|
|
}
|
|
|
|
|
item
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn runtime_snapshot_from_document(
|
|
|
|
|
document: &DocumentModel,
|
|
|
|
|
previous: &[RuntimeShellBlockWire],
|
|
|
|
|
) -> Vec<RuntimeShellBlockWire> {
|
|
|
|
|
document
|
|
|
|
|
.blocks()
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|block| {
|
|
|
|
|
let previous_block = previous.iter().find(|item| item.id == block.id);
|
|
|
|
|
let editable = previous_block.map(|item| item.editable).unwrap_or(true);
|
|
|
|
|
let raw_type = previous_block
|
|
|
|
|
.map(|item| {
|
|
|
|
|
raw_type_for_runtime_block_type(
|
|
|
|
|
runtime_block_type_from_core(&block.block_type),
|
|
|
|
|
&item.raw_type,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
raw_type_for_runtime_block_type(
|
|
|
|
|
runtime_block_type_from_core(&block.block_type),
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
RuntimeShellBlockWire {
|
|
|
|
|
id: block.id.clone(),
|
|
|
|
|
block_type: runtime_block_type_from_core(&block.block_type).into(),
|
|
|
|
|
parent_id: block.parent_id.clone(),
|
|
|
|
|
depth: block.indent,
|
|
|
|
|
text: block.content.text.clone(),
|
|
|
|
|
heading_level: block.heading_level,
|
|
|
|
|
checked: block.checked,
|
|
|
|
|
collapsed: block.collapsed,
|
|
|
|
|
editable,
|
|
|
|
|
raw_type,
|
|
|
|
|
language: block.content.language.clone(),
|
|
|
|
|
raw_block: Value::Null,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn runtime_editor_command(
|
|
|
|
|
request: RuntimeTransformCommandRequest,
|
|
|
|
|
) -> Result<EditorCommand, WebError> {
|
|
|
|
|
match request {
|
|
|
|
|
RuntimeTransformCommandRequest::SetBlockType {
|
|
|
|
|
block_id,
|
|
|
|
|
block_type,
|
|
|
|
|
} => {
|
|
|
|
|
let Some(block_type) = BlockType::from_editor_label(block_type.as_str()) else {
|
|
|
|
|
return Err(WebError::bad_request(format!(
|
|
|
|
|
"不支持的 blockType: {block_type}"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
Ok(EditorCommand::SetBlockType {
|
|
|
|
|
block_id,
|
|
|
|
|
block_type,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
RuntimeTransformCommandRequest::SplitBlock {
|
|
|
|
|
block_id,
|
|
|
|
|
offset,
|
|
|
|
|
new_block_id,
|
|
|
|
|
} => Ok(EditorCommand::SplitBlock {
|
|
|
|
|
block_id,
|
|
|
|
|
offset,
|
|
|
|
|
new_block_id,
|
|
|
|
|
}),
|
|
|
|
|
RuntimeTransformCommandRequest::MergeWithPrevious { block_id } => {
|
|
|
|
|
Ok(EditorCommand::MergeWithPrevious { block_id })
|
|
|
|
|
}
|
|
|
|
|
RuntimeTransformCommandRequest::IndentBlock { block_id } => {
|
|
|
|
|
Ok(EditorCommand::IndentBlock { block_id })
|
|
|
|
|
}
|
|
|
|
|
RuntimeTransformCommandRequest::OutdentBlock { block_id } => {
|
|
|
|
|
Ok(EditorCommand::OutdentBlock { block_id })
|
|
|
|
|
}
|
|
|
|
|
RuntimeTransformCommandRequest::ToggleHeadingCollapse { block_id } => {
|
|
|
|
|
Ok(EditorCommand::ToggleHeadingCollapse { block_id })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_document_editor_shell_html(
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
host: Option<&str>,
|
|
|
|
|
meta_result: &Value,
|
|
|
|
|
content_result: &Value,
|
|
|
|
|
) -> String {
|
|
|
|
|
let page_title =
|
|
|
|
|
read_non_empty_string(meta_result, &["title"]).unwrap_or_else(|| "无标题".into());
|
|
|
|
|
let updated_at = read_non_empty_string(meta_result, &["updated_at", "updatedAt"])
|
|
|
|
|
.unwrap_or_else(|| "未知".into());
|
|
|
|
|
let read_only = !read_bool(meta_result, &["can_edit", "canEdit"], true);
|
|
|
|
|
let revision = read_u64(content_result, &["revision"], 0);
|
|
|
|
|
let conflict_detection_key = read_non_empty_string(
|
|
|
|
|
content_result,
|
|
|
|
|
&["conflict_detection_key", "conflictDetectionKey"],
|
|
|
|
|
)
|
|
|
|
|
.unwrap_or_else(|| format!("{document_id}:{revision}"));
|
|
|
|
|
let (blocks, content_wrapper) = initial_runtime_shell_blocks(content_result, document_id);
|
|
|
|
|
let initial_focus_id = blocks
|
|
|
|
|
.first()
|
|
|
|
|
.map(|node| node.id.clone())
|
|
|
|
|
.unwrap_or_else(|| "block-empty".into());
|
|
|
|
|
let app_state = json!({
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"host": host,
|
|
|
|
|
"meta": {
|
|
|
|
|
"title": page_title,
|
|
|
|
|
"updatedAt": updated_at,
|
|
|
|
|
"readOnly": read_only,
|
|
|
|
|
"revision": revision,
|
|
|
|
|
"conflictDetectionKey": conflict_detection_key,
|
|
|
|
|
},
|
|
|
|
|
"contentWrapper": content_wrapper,
|
|
|
|
|
"initialFocusId": initial_focus_id,
|
|
|
|
|
"blocks": blocks,
|
|
|
|
|
});
|
|
|
|
|
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
|
|
|
|
|
|
|
|
|
|
let template = r##"<!doctype html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
<title>mnote Document Runtime Debug</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
color-scheme: light;
|
|
|
|
|
--bg: #f7f4ee;
|
|
|
|
|
--panel: rgba(255, 252, 247, 0.96);
|
|
|
|
|
--panel-strong: #ffffff;
|
|
|
|
|
--ink: #18222f;
|
|
|
|
|
--muted: #667085;
|
|
|
|
|
--line: rgba(148, 163, 184, 0.22);
|
|
|
|
|
--accent: #0f6c84;
|
|
|
|
|
--accent-soft: rgba(15, 108, 132, 0.1);
|
|
|
|
|
--focus: rgba(37, 99, 235, 0.18);
|
|
|
|
|
--selected: rgba(15, 108, 132, 0.14);
|
|
|
|
|
--hover: rgba(15, 23, 42, 0.05);
|
|
|
|
|
}
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-family: "Noto Sans CJK SC", "Source Han Sans SC", sans-serif;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
background:
|
|
|
|
|
radial-gradient(circle at top left, rgba(15, 108, 132, 0.14), transparent 28%),
|
|
|
|
|
linear-gradient(180deg, #fbf8f3 0%, var(--bg) 100%);
|
|
|
|
|
}
|
|
|
|
|
main {
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 18px;
|
|
|
|
|
}
|
|
|
|
|
.hero,
|
|
|
|
|
.status-card,
|
|
|
|
|
.editor-card {
|
|
|
|
|
background: var(--panel);
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
border-radius: 22px;
|
|
|
|
|
box-shadow: 0 18px 40px rgba(24, 34, 47, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.hero {
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
.eyebrow {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
letter-spacing: 0.12em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
h1 {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: clamp(24px, 4vw, 34px);
|
|
|
|
|
line-height: 1.05;
|
|
|
|
|
}
|
|
|
|
|
.summary {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
}
|
|
|
|
|
.meta-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.meta-pill {
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.78);
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
|
|
|
}
|
|
|
|
|
.meta-pill strong {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.status-card {
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.status-line {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: center;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.status-chip {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.72);
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.editor-card {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) 300px;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.editor-main {
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
border-right: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.editor-toolbar,
|
|
|
|
|
.editor-slash-menu {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
}
|
|
|
|
|
.editor-command-button {
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
|
|
|
|
background: rgba(255, 255, 255, 0.88);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
.editor-command-button[disabled] {
|
|
|
|
|
opacity: 0.45;
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
.editor-sidebar {
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.72);
|
|
|
|
|
}
|
|
|
|
|
.editor-list {
|
|
|
|
|
list-style: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
min-height: 88px;
|
|
|
|
|
align-content: start;
|
|
|
|
|
}
|
|
|
|
|
.editor-row {
|
|
|
|
|
position: relative;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
background: var(--panel-strong);
|
|
|
|
|
padding: 12px 12px 12px calc(12px + var(--depth-indent, 0px));
|
|
|
|
|
transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
|
|
|
|
}
|
|
|
|
|
.editor-row:hover,
|
|
|
|
|
.editor-row[data-hovered="true"] {
|
|
|
|
|
background: var(--hover);
|
|
|
|
|
}
|
|
|
|
|
.editor-row[data-focused="true"] {
|
|
|
|
|
border-color: rgba(37, 99, 235, 0.3);
|
|
|
|
|
background: var(--focus);
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
|
|
|
|
|
}
|
|
|
|
|
.editor-row[data-selected="true"] {
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.3);
|
|
|
|
|
background: var(--selected);
|
|
|
|
|
}
|
|
|
|
|
.editor-row[data-active="true"] .editor-row-title {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
.editor-badge {
|
|
|
|
|
min-width: 26px;
|
|
|
|
|
height: 26px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
background: rgba(15, 108, 132, 0.1);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
.editor-link {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.editor-input {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 34px;
|
|
|
|
|
resize: vertical;
|
|
|
|
|
border: 0;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
font: inherit;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
padding: 0;
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
.editor-input[readonly] {
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
cursor: default;
|
|
|
|
|
}
|
|
|
|
|
.editor-row-title {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
.editor-row-meta {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.editor-row[data-collapsed="true"] .editor-row-title::after {
|
|
|
|
|
content: " (collapsed)";
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
.editor-state-tag {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: rgba(15, 23, 42, 0.04);
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
}
|
|
|
|
|
.editor-ref-list {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
}
|
|
|
|
|
.editor-ref-chip {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: rgba(15, 108, 132, 0.12);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
padding: 3px 8px;
|
|
|
|
|
}
|
|
|
|
|
.panel-title {
|
|
|
|
|
margin: 0 0 12px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
.panel-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.panel-card {
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
background: rgba(255, 255, 255, 0.82);
|
|
|
|
|
padding: 12px;
|
|
|
|
|
}
|
|
|
|
|
.panel-card strong {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.panel-log {
|
|
|
|
|
margin: 0;
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
color: #243447;
|
|
|
|
|
}
|
|
|
|
|
.editor-empty {
|
|
|
|
|
border: 1px dashed rgba(148, 163, 184, 0.36);
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
padding: 24px 18px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
background: rgba(255, 255, 255, 0.64);
|
|
|
|
|
}
|
|
|
|
|
.visually-hidden {
|
|
|
|
|
position: absolute;
|
|
|
|
|
width: 1px;
|
|
|
|
|
height: 1px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: -1px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
clip: rect(0, 0, 0, 0);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
border: 0;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 920px) {
|
|
|
|
|
.editor-card {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
.editor-main {
|
|
|
|
|
border-right: 0;
|
|
|
|
|
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main>
|
|
|
|
|
<section class="hero">
|
|
|
|
|
<div class="eyebrow">Document Runtime Debug</div>
|
|
|
|
|
<h1 id="block-editor-title">__PAGE_TITLE__</h1>
|
|
|
|
|
<p class="summary">
|
|
|
|
|
这是 mnote-web 的文档 runtime 调试页,只用于事务诊断、IME 排障、保存链回归与 bridge API 观察。
|
|
|
|
|
默认文档页仍应走产品页主链,这里不再作为正式编辑器 surface。
|
|
|
|
|
</p>
|
|
|
|
|
<div class="meta-grid">
|
|
|
|
|
<div class="meta-pill"><strong>Workspace</strong><span>__WORKSPACE_ID__</span></div>
|
|
|
|
|
<div class="meta-pill"><strong>Document</strong><span>__DOCUMENT_ID__</span></div>
|
|
|
|
|
<div class="meta-pill"><strong>Host</strong><span>__HOST__</span></div>
|
|
|
|
|
<div class="meta-pill"><strong>ReadOnly</strong><span>__READ_ONLY__</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="status-card">
|
|
|
|
|
<div class="status-line">
|
|
|
|
|
<span class="status-chip">revision=<strong id="editor-revision">__REVISION__</strong></span>
|
|
|
|
|
<span class="status-chip">updatedAt=<strong id="editor-updated-at">__UPDATED_AT__</strong></span>
|
|
|
|
|
<span class="status-chip">blocks=<strong id="editor-block-count">0</strong></span>
|
|
|
|
|
<span class="status-chip">save=<strong id="editor-save-status">idle</strong></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="status-line">
|
|
|
|
|
<span class="status-chip">focus=<strong id="editor-focus-id">-</strong></span>
|
|
|
|
|
<span class="status-chip">selection=<strong id="editor-selection-id">-</strong></span>
|
|
|
|
|
<span class="status-chip">hover=<strong id="editor-hover-id">-</strong></span>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="editor-card">
|
|
|
|
|
<div class="editor-main">
|
|
|
|
|
<div class="editor-toolbar" role="toolbar" aria-label="Block editor controls">
|
|
|
|
|
<button type="button" class="editor-command-button" id="editor-command-slash">Slash 菜单</button>
|
|
|
|
|
<button type="button" class="editor-command-button" id="editor-command-toggle-heading">折叠标题</button>
|
|
|
|
|
<button type="button" class="editor-command-button" id="editor-command-indent">缩进</button>
|
|
|
|
|
<button type="button" class="editor-command-button" id="editor-command-outdent">取消缩进</button>
|
|
|
|
|
<button type="button" class="editor-command-button" id="editor-command-page-ref">[[page]]</button>
|
|
|
|
|
<button type="button" class="editor-command-button" id="editor-command-block-ref">((block))</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="editor-slash-menu" id="editor-slash-menu" hidden>
|
|
|
|
|
<button type="button" class="editor-command-button" data-slash-action="paragraph">Paragraph</button>
|
|
|
|
|
<button type="button" class="editor-command-button" data-slash-action="heading">Heading</button>
|
|
|
|
|
<button type="button" class="editor-command-button" data-slash-action="todo">Todo</button>
|
|
|
|
|
</div>
|
|
|
|
|
<h2 class="visually-hidden">Block List</h2>
|
|
|
|
|
<ul class="editor-list" id="block-editor-list" role="listbox" aria-label="Block list"></ul>
|
|
|
|
|
<div class="editor-empty" id="block-editor-empty" hidden>当前文档没有可展示的 block。</div>
|
|
|
|
|
</div>
|
|
|
|
|
<aside class="editor-sidebar">
|
|
|
|
|
<h2 class="panel-title">交互状态</h2>
|
|
|
|
|
<div class="panel-grid">
|
|
|
|
|
<div class="panel-card">
|
|
|
|
|
<strong>最小状态机</strong>
|
|
|
|
|
<div>focus / selection / hover / beforeinput / composition / slash / heading collapse / indent 现在直接驱动可输入 runtime,并接入保存链。</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="panel-card">
|
|
|
|
|
<strong>当前 block</strong>
|
|
|
|
|
<div id="editor-current-title">尚未聚焦</div>
|
|
|
|
|
<div id="editor-current-meta" style="margin-top: 6px; color: var(--muted); font-size: 12px;">等待交互</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="panel-card">
|
|
|
|
|
<strong>最近事件</strong>
|
|
|
|
|
<pre class="panel-log" id="editor-event-log">block_editor_shell.ready</pre>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
</section>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<script id="block-editor-shell-state" type="application/json">__APP_STATE__</script>
|
|
|
|
|
<script>
|
|
|
|
|
(() => {
|
|
|
|
|
const stateElement = document.getElementById("block-editor-shell-state");
|
|
|
|
|
const listElement = document.getElementById("block-editor-list");
|
|
|
|
|
const emptyElement = document.getElementById("block-editor-empty");
|
|
|
|
|
const focusElement = document.getElementById("editor-focus-id");
|
|
|
|
|
const selectionElement = document.getElementById("editor-selection-id");
|
|
|
|
|
const hoverElement = document.getElementById("editor-hover-id");
|
|
|
|
|
const blockCountElement = document.getElementById("editor-block-count");
|
|
|
|
|
const saveStatusElement = document.getElementById("editor-save-status");
|
|
|
|
|
const revisionElement = document.getElementById("editor-revision");
|
|
|
|
|
const currentTitleElement = document.getElementById("editor-current-title");
|
|
|
|
|
const currentMetaElement = document.getElementById("editor-current-meta");
|
|
|
|
|
const eventLogElement = document.getElementById("editor-event-log");
|
|
|
|
|
const slashButton = document.getElementById("editor-command-slash");
|
|
|
|
|
const toggleHeadingButton = document.getElementById("editor-command-toggle-heading");
|
|
|
|
|
const indentButton = document.getElementById("editor-command-indent");
|
|
|
|
|
const outdentButton = document.getElementById("editor-command-outdent");
|
|
|
|
|
const pageRefButton = document.getElementById("editor-command-page-ref");
|
|
|
|
|
const blockRefButton = document.getElementById("editor-command-block-ref");
|
|
|
|
|
const slashMenuElement = document.getElementById("editor-slash-menu");
|
|
|
|
|
if (!stateElement || !listElement || !emptyElement) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parseState = () => {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(stateElement.textContent || "{}");
|
|
|
|
|
} catch {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const state = parseState();
|
|
|
|
|
const READ_ONLY = Boolean(state.meta && state.meta.readOnly);
|
|
|
|
|
const meta = {
|
|
|
|
|
revision: Number((state.meta && state.meta.revision) || 0) || 0,
|
|
|
|
|
conflictDetectionKey:
|
|
|
|
|
String((state.meta && state.meta.conflictDetectionKey) || "").trim() ||
|
|
|
|
|
String(state.documentId || "document") + ":0",
|
|
|
|
|
};
|
|
|
|
|
const contentWrapper =
|
|
|
|
|
state.contentWrapper && typeof state.contentWrapper === "object" && !Array.isArray(state.contentWrapper)
|
|
|
|
|
? state.contentWrapper
|
|
|
|
|
: null;
|
|
|
|
|
const cloneJson = (value) => {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(JSON.stringify(value));
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const normalizeBlock = (block) => ({
|
|
|
|
|
id: String(block && block.id ? block.id : "block-missing"),
|
|
|
|
|
blockType: String(block && block.blockType ? block.blockType : "paragraph"),
|
|
|
|
|
parentId: typeof (block && block.parentId) === "string" && block.parentId.trim() ? block.parentId.trim() : null,
|
|
|
|
|
depth: Math.max(0, Number(block && block.depth ? block.depth : 0) || 0),
|
|
|
|
|
text: typeof (block && block.text) === "string" ? block.text : "",
|
|
|
|
|
headingLevel:
|
|
|
|
|
Number(block && block.headingLevel ? block.headingLevel : 0) > 0
|
|
|
|
|
? Number(block.headingLevel)
|
|
|
|
|
: null,
|
|
|
|
|
checked: typeof (block && block.checked) === "boolean" ? block.checked : null,
|
|
|
|
|
collapsed: Boolean(block && block.collapsed),
|
|
|
|
|
editable: Boolean(block && block.editable),
|
|
|
|
|
rawType: String(block && block.rawType ? block.rawType : ""),
|
|
|
|
|
language: typeof (block && block.language) === "string" && block.language.trim() ? block.language.trim() : null,
|
|
|
|
|
rawBlock:
|
|
|
|
|
block && typeof block.rawBlock === "object" && block.rawBlock !== null ? block.rawBlock : null,
|
|
|
|
|
});
|
|
|
|
|
const blocks = Array.isArray(state.blocks) ? state.blocks.map(normalizeBlock) : [];
|
|
|
|
|
const uiState = {
|
|
|
|
|
focusedId: typeof state.initialFocusId === "string" ? state.initialFocusId : null,
|
|
|
|
|
hoveredId: null,
|
|
|
|
|
selection: null,
|
|
|
|
|
slashMenuOpen: false,
|
|
|
|
|
pendingFocus: null,
|
|
|
|
|
composingId: null,
|
|
|
|
|
didBootLog: false,
|
|
|
|
|
};
|
|
|
|
|
const MAX_INDENT = 3;
|
|
|
|
|
const saveState = {
|
|
|
|
|
dirty: false,
|
|
|
|
|
saving: false,
|
|
|
|
|
timer: 0,
|
|
|
|
|
lastSerialized: "",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const log = (message) => {
|
|
|
|
|
if (!eventLogElement) return;
|
|
|
|
|
const previous = String(eventLogElement.textContent || "").trim();
|
|
|
|
|
eventLogElement.textContent = previous ? previous + "\n" + message : message;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const focusedBlock = () => blocks.find((item) => item && item.id === uiState.focusedId) || null;
|
|
|
|
|
const escapeSelector = (value) => {
|
|
|
|
|
if (window.CSS && typeof window.CSS.escape === "function") {
|
|
|
|
|
return window.CSS.escape(value);
|
|
|
|
|
}
|
|
|
|
|
return String(value).replace(/["\\]/g, "\\$&");
|
|
|
|
|
};
|
|
|
|
|
const getTextarea = (blockId) => {
|
|
|
|
|
const selector = `textarea[data-block-input-id="${escapeSelector(blockId)}"]`;
|
|
|
|
|
return listElement.querySelector(selector);
|
|
|
|
|
};
|
|
|
|
|
const visibleBlocks = () => {
|
|
|
|
|
const items = [];
|
|
|
|
|
let hiddenDepth = null;
|
|
|
|
|
for (const block of blocks) {
|
|
|
|
|
if (hiddenDepth !== null) {
|
|
|
|
|
if (Number(block.depth || 0) > hiddenDepth) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
hiddenDepth = null;
|
|
|
|
|
}
|
|
|
|
|
items.push(block);
|
|
|
|
|
if (block.blockType === "heading" && block.collapsed) {
|
|
|
|
|
hiddenDepth = Number(block.depth || 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return items;
|
|
|
|
|
};
|
|
|
|
|
const blockTypeLabel = (block) => {
|
|
|
|
|
switch (block.blockType) {
|
|
|
|
|
case "heading":
|
|
|
|
|
return `Heading${block.headingLevel ? ` h${block.headingLevel}` : ""}`;
|
|
|
|
|
case "todo":
|
|
|
|
|
return "Todo";
|
|
|
|
|
case "bullet_list_item":
|
|
|
|
|
return "Bullet";
|
|
|
|
|
case "numbered_list_item":
|
|
|
|
|
return "Numbered";
|
|
|
|
|
case "quote":
|
|
|
|
|
return "Quote";
|
|
|
|
|
case "code_block":
|
|
|
|
|
return "Code";
|
|
|
|
|
case "divider":
|
|
|
|
|
return "Divider";
|
|
|
|
|
case "page_reference":
|
|
|
|
|
return "[[page]]";
|
|
|
|
|
case "block_reference":
|
|
|
|
|
return "((block))";
|
|
|
|
|
case "media_placeholder":
|
|
|
|
|
return "Media";
|
|
|
|
|
case "progress_placeholder":
|
|
|
|
|
return "Progress";
|
|
|
|
|
default:
|
|
|
|
|
return "Paragraph";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const runtimeMeta = (block) => {
|
|
|
|
|
const tokens = [
|
|
|
|
|
(block.rawType || block.blockType || "paragraph"),
|
|
|
|
|
`depth=${Number(block.depth || 0)}`,
|
|
|
|
|
`editable=${block.editable === true ? "true" : "false"}`,
|
|
|
|
|
];
|
|
|
|
|
if (block.headingLevel) tokens.push(`h${block.headingLevel}`);
|
|
|
|
|
if (typeof block.checked === "boolean") tokens.push(`checked=${block.checked ? "true" : "false"}`);
|
|
|
|
|
if (block.collapsed) tokens.push("collapsed=true");
|
|
|
|
|
return tokens.join(" · ");
|
|
|
|
|
};
|
|
|
|
|
const displayTitle = (block) => {
|
|
|
|
|
const text = String(block.text || "").trim();
|
|
|
|
|
if (text) return text;
|
|
|
|
|
return blockTypeLabel(block);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const focusBlock = (blockId, position) => {
|
|
|
|
|
if (!blockId) return;
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
const textarea = getTextarea(blockId);
|
|
|
|
|
if (!(textarea instanceof HTMLTextAreaElement)) return;
|
|
|
|
|
textarea.focus();
|
|
|
|
|
const valueLength = textarea.value.length;
|
|
|
|
|
if (position === "start") {
|
|
|
|
|
textarea.setSelectionRange(0, 0);
|
|
|
|
|
} else if (position === "end") {
|
|
|
|
|
textarea.setSelectionRange(valueLength, valueLength);
|
|
|
|
|
} else if (position && typeof position.start === "number" && typeof position.end === "number") {
|
|
|
|
|
textarea.setSelectionRange(position.start, position.end);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateSaveStatus = (label) => {
|
|
|
|
|
if (saveStatusElement) {
|
|
|
|
|
saveStatusElement.textContent = label;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const syncToolbarState = () => {
|
|
|
|
|
const current = focusedBlock();
|
|
|
|
|
const isHeading = current && current.blockType === "heading";
|
|
|
|
|
const canEditCurrent = Boolean(current && current.editable && !READ_ONLY);
|
|
|
|
|
if (toggleHeadingButton) toggleHeadingButton.disabled = !canEditCurrent || !isHeading;
|
|
|
|
|
if (indentButton) indentButton.disabled = !canEditCurrent || Number(current.depth || 0) >= MAX_INDENT;
|
|
|
|
|
if (outdentButton) outdentButton.disabled = !canEditCurrent || Number(current.depth || 0) <= 0;
|
|
|
|
|
if (pageRefButton) pageRefButton.disabled = !canEditCurrent;
|
|
|
|
|
if (blockRefButton) blockRefButton.disabled = !canEditCurrent;
|
|
|
|
|
if (slashButton) slashButton.disabled = !canEditCurrent;
|
|
|
|
|
if (slashMenuElement) slashMenuElement.hidden = !uiState.slashMenuOpen;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateSummary = () => {
|
|
|
|
|
const current = focusedBlock();
|
|
|
|
|
if (focusElement) focusElement.textContent = uiState.focusedId || "-";
|
|
|
|
|
if (selectionElement) {
|
|
|
|
|
if (uiState.selection && uiState.selection.blockId) {
|
|
|
|
|
selectionElement.textContent =
|
|
|
|
|
`${uiState.selection.blockId}:${uiState.selection.start}-${uiState.selection.end}`;
|
|
|
|
|
} else {
|
|
|
|
|
selectionElement.textContent = "-";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (hoverElement) hoverElement.textContent = uiState.hoveredId || "-";
|
|
|
|
|
if (blockCountElement) blockCountElement.textContent = String(visibleBlocks().length);
|
|
|
|
|
if (currentTitleElement) {
|
|
|
|
|
currentTitleElement.textContent = current ? displayTitle(current) : "尚未聚焦";
|
|
|
|
|
}
|
|
|
|
|
if (currentMetaElement) {
|
|
|
|
|
currentMetaElement.textContent = current ? runtimeMeta(current) : "等待交互";
|
|
|
|
|
}
|
|
|
|
|
syncToolbarState();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyRowState = () => {
|
|
|
|
|
listElement.querySelectorAll("[data-block-id]").forEach((node) => {
|
|
|
|
|
const id = node.getAttribute("data-block-id") || "";
|
|
|
|
|
node.setAttribute("data-focused", String(id === uiState.focusedId));
|
|
|
|
|
node.setAttribute(
|
|
|
|
|
"data-selected",
|
|
|
|
|
String(Boolean(uiState.selection && uiState.selection.blockId === id)),
|
|
|
|
|
);
|
|
|
|
|
node.setAttribute("data-hovered", String(id === uiState.hoveredId));
|
|
|
|
|
node.setAttribute(
|
|
|
|
|
"data-active",
|
|
|
|
|
String(id === uiState.focusedId || Boolean(uiState.selection && uiState.selection.blockId === id)),
|
|
|
|
|
);
|
|
|
|
|
const block = blocks.find((item) => item && item.id === id) || null;
|
|
|
|
|
node.setAttribute("data-collapsed", String(Boolean(block && block.collapsed)));
|
|
|
|
|
});
|
|
|
|
|
updateSummary();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setFocused = (id, selection) => {
|
|
|
|
|
uiState.focusedId = id;
|
|
|
|
|
if (selection) {
|
|
|
|
|
uiState.selection = selection;
|
|
|
|
|
} else if (!uiState.selection || uiState.selection.blockId !== id) {
|
|
|
|
|
uiState.selection = { blockId: id, start: 0, end: 0 };
|
|
|
|
|
}
|
|
|
|
|
applyRowState();
|
|
|
|
|
log("human_editor.focus:" + id);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setHovered = (id) => {
|
|
|
|
|
uiState.hoveredId = id;
|
|
|
|
|
applyRowState();
|
|
|
|
|
if (id) log("human_editor.hover:" + id);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateSelectionFromTextarea = (blockId, textarea) => {
|
|
|
|
|
if (!(textarea instanceof HTMLTextAreaElement)) return;
|
|
|
|
|
uiState.selection = {
|
|
|
|
|
blockId,
|
|
|
|
|
start: Number(textarea.selectionStart || 0),
|
|
|
|
|
end: Number(textarea.selectionEnd || 0),
|
|
|
|
|
};
|
|
|
|
|
applyRowState();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildTransformSnapshot = () => ({
|
|
|
|
|
blocks: blocks.map((block) => ({
|
|
|
|
|
id: block.id,
|
|
|
|
|
blockType: block.blockType,
|
|
|
|
|
parentId: block.parentId,
|
|
|
|
|
depth: block.depth,
|
|
|
|
|
text: block.text,
|
|
|
|
|
headingLevel: block.headingLevel,
|
|
|
|
|
checked: block.checked,
|
|
|
|
|
collapsed: block.collapsed,
|
|
|
|
|
editable: block.editable,
|
|
|
|
|
rawType: block.rawType,
|
|
|
|
|
language: block.language,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const applyTransformedBlocks = (nextBlocks) => {
|
|
|
|
|
const rawById = new Map(blocks.map((block) => [block.id, block.rawBlock]));
|
|
|
|
|
const rawTypeById = new Map(blocks.map((block) => [block.id, block.rawType]));
|
|
|
|
|
blocks.splice(
|
|
|
|
|
0,
|
|
|
|
|
blocks.length,
|
|
|
|
|
...nextBlocks.map((block) => {
|
|
|
|
|
const normalized = normalizeBlock(block);
|
|
|
|
|
normalized.rawBlock = rawById.get(normalized.id) || null;
|
|
|
|
|
if (!normalized.rawType) {
|
|
|
|
|
normalized.rawType = rawTypeById.get(normalized.id) || normalized.rawType;
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildTextNodes = (text) =>
|
|
|
|
|
text ? [{ type: "text", text }] : [];
|
|
|
|
|
|
|
|
|
|
const materializeBlock = (block) => {
|
|
|
|
|
const raw =
|
|
|
|
|
block.rawBlock && typeof block.rawBlock === "object" && !Array.isArray(block.rawBlock)
|
|
|
|
|
? cloneJson(block.rawBlock) || {}
|
|
|
|
|
: {};
|
|
|
|
|
raw.id = block.id;
|
|
|
|
|
raw.type =
|
|
|
|
|
block.blockType === "paragraph"
|
|
|
|
|
? "paragraph"
|
|
|
|
|
: block.blockType === "heading"
|
|
|
|
|
? "heading"
|
|
|
|
|
: block.blockType === "bullet_list_item"
|
|
|
|
|
? "bulletListItem"
|
|
|
|
|
: block.blockType === "numbered_list_item"
|
|
|
|
|
? "numberedListItem"
|
|
|
|
|
: block.blockType === "todo"
|
|
|
|
|
? (block.rawType || "checkListItem")
|
|
|
|
|
: block.blockType === "quote"
|
|
|
|
|
? "quote"
|
|
|
|
|
: block.blockType === "divider"
|
|
|
|
|
? "divider"
|
|
|
|
|
: block.blockType === "code_block"
|
|
|
|
|
? "codeBlock"
|
|
|
|
|
: block.blockType === "page_reference"
|
|
|
|
|
? "pageReference"
|
|
|
|
|
: block.blockType === "block_reference"
|
|
|
|
|
? "blockReference"
|
|
|
|
|
: (block.rawType || "media");
|
|
|
|
|
raw.props =
|
|
|
|
|
raw.props && typeof raw.props === "object" && !Array.isArray(raw.props) ? raw.props : {};
|
|
|
|
|
raw.children = [];
|
|
|
|
|
if (raw.type === "heading") {
|
|
|
|
|
raw.props.level = Number(block.headingLevel || 1);
|
|
|
|
|
raw.props.collapsed = Boolean(block.collapsed);
|
|
|
|
|
}
|
|
|
|
|
if (raw.type === "checkListItem" || raw.type === "advancedTodo") {
|
|
|
|
|
raw.props.checked = Boolean(block.checked);
|
|
|
|
|
}
|
|
|
|
|
if (raw.type === "codeBlock" && block.language) {
|
|
|
|
|
raw.props.language = block.language;
|
|
|
|
|
}
|
|
|
|
|
if (block.editable !== false) {
|
|
|
|
|
raw.content = raw.type === "divider" ? [] : buildTextNodes(String(block.text || ""));
|
|
|
|
|
}
|
|
|
|
|
return raw;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildDocumentPayload = () => {
|
|
|
|
|
const roots = [];
|
|
|
|
|
const stack = [];
|
|
|
|
|
blocks.forEach((block) => {
|
|
|
|
|
const node = materializeBlock(block);
|
|
|
|
|
while (stack.length > 0 && stack[stack.length - 1].depth >= block.depth) {
|
|
|
|
|
stack.pop();
|
|
|
|
|
}
|
|
|
|
|
if (block.depth > 0 && stack.length > 0) {
|
|
|
|
|
stack[stack.length - 1].node.children.push(node);
|
|
|
|
|
} else {
|
|
|
|
|
roots.push(node);
|
|
|
|
|
}
|
|
|
|
|
stack.push({ depth: block.depth, node });
|
|
|
|
|
});
|
|
|
|
|
if (contentWrapper) {
|
|
|
|
|
const wrapper = cloneJson(contentWrapper) || {};
|
|
|
|
|
wrapper.blocks = roots;
|
|
|
|
|
return wrapper;
|
|
|
|
|
}
|
|
|
|
|
return roots;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const queueSave = (reason, immediate) => {
|
|
|
|
|
if (READ_ONLY) return;
|
|
|
|
|
saveState.dirty = true;
|
|
|
|
|
if (saveState.timer) {
|
|
|
|
|
window.clearTimeout(saveState.timer);
|
|
|
|
|
saveState.timer = 0;
|
|
|
|
|
}
|
|
|
|
|
if (immediate) {
|
|
|
|
|
void saveNow(reason);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
updateSaveStatus("dirty");
|
|
|
|
|
saveState.timer = window.setTimeout(() => {
|
|
|
|
|
saveState.timer = 0;
|
|
|
|
|
void saveNow(reason);
|
|
|
|
|
}, 450);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveNow = async (reason) => {
|
|
|
|
|
if (READ_ONLY) return;
|
|
|
|
|
const content = buildDocumentPayload();
|
|
|
|
|
const serialized = JSON.stringify(content);
|
|
|
|
|
if (!saveState.dirty && serialized === saveState.lastSerialized) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
saveState.saving = true;
|
|
|
|
|
updateSaveStatus("saving");
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch("/api/documents/save", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
documentId: state.documentId,
|
|
|
|
|
workspaceId: state.workspaceId || null,
|
|
|
|
|
revision: meta.revision,
|
|
|
|
|
conflictDetectionKey: meta.conflictDetectionKey,
|
|
|
|
|
content,
|
|
|
|
|
blockCount: blocks.length,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
if (!response.ok || !payload || payload.ok !== true) {
|
|
|
|
|
const message =
|
|
|
|
|
(payload && payload.error && payload.error.message) ||
|
|
|
|
|
(payload && payload.message) ||
|
|
|
|
|
`save_failed_${response.status}`;
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
const result = payload.result || {};
|
|
|
|
|
meta.revision =
|
|
|
|
|
typeof result.revision === "number" && Number.isInteger(result.revision)
|
|
|
|
|
? result.revision
|
|
|
|
|
: meta.revision;
|
|
|
|
|
meta.conflictDetectionKey =
|
|
|
|
|
typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim()
|
|
|
|
|
? result.conflict_detection_key.trim()
|
|
|
|
|
: typeof result.conflictDetectionKey === "string" && result.conflictDetectionKey.trim()
|
|
|
|
|
? result.conflictDetectionKey.trim()
|
|
|
|
|
: meta.conflictDetectionKey;
|
|
|
|
|
if (revisionElement) {
|
|
|
|
|
revisionElement.textContent = String(meta.revision);
|
|
|
|
|
}
|
|
|
|
|
saveState.lastSerialized = serialized;
|
|
|
|
|
saveState.dirty = false;
|
|
|
|
|
updateSaveStatus("saved");
|
|
|
|
|
log("human_editor.save:" + reason);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
updateSaveStatus("error");
|
|
|
|
|
log(
|
|
|
|
|
"human_editor.save.error:" +
|
|
|
|
|
(error instanceof Error ? error.message : String(error)),
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
saveState.saving = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyRuntimeCommand = async (command, focusOptions) => {
|
|
|
|
|
if (READ_ONLY) return;
|
|
|
|
|
const response = await fetch("/api/documents/runtime/transform", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
documentId: state.documentId,
|
|
|
|
|
workspaceId: state.workspaceId || null,
|
|
|
|
|
snapshot: buildTransformSnapshot(),
|
|
|
|
|
command,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
if (!response.ok || !payload || payload.ok !== true) {
|
|
|
|
|
const message =
|
|
|
|
|
(payload && payload.error && payload.error.message) ||
|
|
|
|
|
(payload && payload.message) ||
|
|
|
|
|
`transform_failed_${response.status}`;
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
const nextBlocks = Array.isArray(payload.result && payload.result.blocks)
|
|
|
|
|
? payload.result.blocks
|
|
|
|
|
: [];
|
|
|
|
|
applyTransformedBlocks(nextBlocks);
|
|
|
|
|
uiState.slashMenuOpen = false;
|
|
|
|
|
render();
|
|
|
|
|
applyRowState();
|
|
|
|
|
if (focusOptions && focusOptions.blockId) {
|
|
|
|
|
focusBlock(focusOptions.blockId, focusOptions.position || "end");
|
|
|
|
|
}
|
|
|
|
|
queueSave(focusOptions && focusOptions.reason ? focusOptions.reason : "transform", true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleHeadingCollapse = async () => {
|
|
|
|
|
const current = focusedBlock();
|
|
|
|
|
if (!current || current.blockType !== "heading" || READ_ONLY) return;
|
|
|
|
|
log("human_editor.toggle_heading_collapse:" + current.id);
|
|
|
|
|
await applyRuntimeCommand(
|
|
|
|
|
{ action: "toggle_heading_collapse", block_id: current.id },
|
|
|
|
|
{ blockId: current.id, position: "end", reason: "toggle_heading_collapse" },
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const adjustIndent = async (delta) => {
|
|
|
|
|
const current = focusedBlock();
|
|
|
|
|
if (!current || READ_ONLY) return;
|
|
|
|
|
const action = delta > 0 ? "indent_block" : "outdent_block";
|
|
|
|
|
log("human_editor." + action + ":" + current.id);
|
|
|
|
|
await applyRuntimeCommand(
|
|
|
|
|
{ action, block_id: current.id },
|
|
|
|
|
{ blockId: current.id, position: "end", reason: action },
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const insertReferenceToken = (kind) => {
|
|
|
|
|
const current = focusedBlock();
|
|
|
|
|
if (!current || !current.editable || READ_ONLY) return;
|
|
|
|
|
const textarea = getTextarea(current.id);
|
|
|
|
|
if (!(textarea instanceof HTMLTextAreaElement)) return;
|
|
|
|
|
const token = kind === "page" ? "[[page]]" : "((block))";
|
|
|
|
|
const start = Number(textarea.selectionStart || 0);
|
|
|
|
|
const end = Number(textarea.selectionEnd || start);
|
|
|
|
|
current.text = current.text.slice(0, start) + token + current.text.slice(end);
|
|
|
|
|
textarea.value = current.text;
|
|
|
|
|
const nextCursor = start + token.length;
|
|
|
|
|
textarea.setSelectionRange(nextCursor, nextCursor);
|
|
|
|
|
updateSelectionFromTextarea(current.id, textarea);
|
|
|
|
|
updateSummary();
|
|
|
|
|
log("human_editor.reference:" + kind + ":" + current.id);
|
|
|
|
|
queueSave("reference", false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applySlashAction = async (action) => {
|
|
|
|
|
const current = focusedBlock();
|
|
|
|
|
if (!current || !current.editable || READ_ONLY) return;
|
|
|
|
|
const blockType = action === "heading" ? "heading" : action === "todo" ? "todo" : "paragraph";
|
|
|
|
|
log("human_editor.slash:" + action + ":" + current.id);
|
|
|
|
|
await applyRuntimeCommand(
|
|
|
|
|
{ action: "set_block_type", block_id: current.id, block_type: blockType },
|
|
|
|
|
{ blockId: current.id, position: "end", reason: "slash" },
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const render = () => {
|
|
|
|
|
const currentBlocks = visibleBlocks();
|
|
|
|
|
listElement.innerHTML = "";
|
|
|
|
|
if (currentBlocks.length === 0) {
|
|
|
|
|
emptyElement.hidden = false;
|
|
|
|
|
updateSummary();
|
|
|
|
|
log("block_editor_shell.empty");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
emptyElement.hidden = true;
|
|
|
|
|
|
|
|
|
|
currentBlocks.forEach((block, index) => {
|
|
|
|
|
const row = document.createElement("li");
|
|
|
|
|
row.className = "editor-row";
|
|
|
|
|
row.setAttribute("data-block-id", String(block.id || ""));
|
|
|
|
|
row.style.setProperty("--depth-indent", String(Number(block.depth || 0) * 18) + "px");
|
|
|
|
|
|
|
|
|
|
const badge = document.createElement("span");
|
|
|
|
|
badge.className = "editor-badge";
|
|
|
|
|
badge.textContent = String(index + 1);
|
|
|
|
|
|
|
|
|
|
const body = document.createElement("div");
|
|
|
|
|
body.className = "editor-link";
|
|
|
|
|
const title = document.createElement("span");
|
|
|
|
|
title.className = "editor-row-title";
|
|
|
|
|
title.textContent = blockTypeLabel(block);
|
|
|
|
|
const meta = document.createElement("span");
|
|
|
|
|
meta.className = "editor-row-meta";
|
|
|
|
|
meta.textContent = runtimeMeta(block);
|
|
|
|
|
const textarea = document.createElement("textarea");
|
|
|
|
|
textarea.className = "editor-input";
|
|
|
|
|
textarea.setAttribute("data-block-input-id", String(block.id || ""));
|
|
|
|
|
textarea.value = String(block.text || "");
|
|
|
|
|
textarea.placeholder = displayTitle(block);
|
|
|
|
|
textarea.readOnly = READ_ONLY || block.editable !== true;
|
|
|
|
|
textarea.rows = Math.max(1, String(block.text || "").split("\n").length || 1);
|
|
|
|
|
body.appendChild(title);
|
|
|
|
|
body.appendChild(meta);
|
|
|
|
|
body.appendChild(textarea);
|
|
|
|
|
|
|
|
|
|
const tag = document.createElement("span");
|
|
|
|
|
tag.className = "editor-state-tag";
|
|
|
|
|
tag.textContent = [
|
|
|
|
|
block.id ? "block:" + String(block.id) : "node",
|
|
|
|
|
block.blockType ? "type:" + String(block.blockType) : null,
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(" · ");
|
|
|
|
|
|
|
|
|
|
row.appendChild(badge);
|
|
|
|
|
row.appendChild(body);
|
|
|
|
|
row.appendChild(tag);
|
|
|
|
|
|
|
|
|
|
const id = String(block.id || "");
|
|
|
|
|
row.addEventListener("mouseenter", () => setHovered(id));
|
|
|
|
|
row.addEventListener("mouseleave", () => setHovered(null));
|
|
|
|
|
row.addEventListener("click", () => {
|
|
|
|
|
if (textarea.readOnly) {
|
|
|
|
|
setFocused(id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
textarea.addEventListener("focus", () => {
|
|
|
|
|
setFocused(id, {
|
|
|
|
|
blockId: id,
|
|
|
|
|
start: Number(textarea.selectionStart || 0),
|
|
|
|
|
end: Number(textarea.selectionEnd || 0),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
textarea.addEventListener("select", () => updateSelectionFromTextarea(id, textarea));
|
|
|
|
|
textarea.addEventListener("click", () => updateSelectionFromTextarea(id, textarea));
|
|
|
|
|
textarea.addEventListener("keyup", () => updateSelectionFromTextarea(id, textarea));
|
|
|
|
|
textarea.addEventListener("beforeinput", (event) => {
|
|
|
|
|
updateSelectionFromTextarea(id, textarea);
|
|
|
|
|
log(`human_editor.beforeinput:${id}:${event.inputType || "unknown"}`);
|
|
|
|
|
});
|
|
|
|
|
textarea.addEventListener("compositionstart", () => {
|
|
|
|
|
uiState.composingId = id;
|
|
|
|
|
log("human_editor.compositionstart:" + id);
|
|
|
|
|
});
|
|
|
|
|
textarea.addEventListener("compositionend", () => {
|
|
|
|
|
uiState.composingId = null;
|
|
|
|
|
updateSelectionFromTextarea(id, textarea);
|
|
|
|
|
log("human_editor.compositionend:" + id);
|
|
|
|
|
});
|
|
|
|
|
textarea.addEventListener("input", () => {
|
|
|
|
|
block.text = textarea.value;
|
|
|
|
|
textarea.rows = Math.max(1, textarea.value.split("\n").length || 1);
|
|
|
|
|
updateSelectionFromTextarea(id, textarea);
|
|
|
|
|
updateSummary();
|
|
|
|
|
queueSave("input", false);
|
|
|
|
|
});
|
|
|
|
|
textarea.addEventListener("keydown", async (event) => {
|
|
|
|
|
if (READ_ONLY || textarea.readOnly) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Tab") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
try {
|
|
|
|
|
await adjustIndent(event.shiftKey ? -1 : 1);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const offset = Number(textarea.selectionStart || 0);
|
|
|
|
|
const newBlockId = `${id}_split_${Date.now().toString(36)}`;
|
|
|
|
|
log("human_editor.split:" + id);
|
|
|
|
|
try {
|
|
|
|
|
await applyRuntimeCommand(
|
|
|
|
|
{
|
|
|
|
|
action: "split_block",
|
|
|
|
|
block_id: id,
|
|
|
|
|
offset,
|
|
|
|
|
new_block_id: newBlockId,
|
|
|
|
|
},
|
|
|
|
|
{ blockId: newBlockId, position: "start", reason: "split" },
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Backspace") {
|
|
|
|
|
const start = Number(textarea.selectionStart || 0);
|
|
|
|
|
const end = Number(textarea.selectionEnd || 0);
|
|
|
|
|
if (start === 0 && end === 0) {
|
|
|
|
|
const currentIndex = visibleBlocks().findIndex((item) => item.id === id);
|
|
|
|
|
const previous = currentIndex > 0 ? visibleBlocks()[currentIndex - 1] : null;
|
|
|
|
|
if (previous) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
log("human_editor.merge:" + id);
|
|
|
|
|
try {
|
|
|
|
|
await applyRuntimeCommand(
|
|
|
|
|
{ action: "merge_with_previous", block_id: id },
|
|
|
|
|
{ blockId: previous.id, position: "end", reason: "merge" },
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log(
|
|
|
|
|
"human_editor.transform.error:" +
|
|
|
|
|
(error instanceof Error ? error.message : String(error)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
listElement.appendChild(row);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
updateSummary();
|
|
|
|
|
if (!uiState.didBootLog) {
|
|
|
|
|
log("human_editor_runtime.ready");
|
|
|
|
|
log("human_editor_input.beforeinput");
|
|
|
|
|
log("human_editor_input.composition");
|
|
|
|
|
log("human_editor_transactions.boundary");
|
|
|
|
|
uiState.didBootLog = true;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (slashButton) {
|
|
|
|
|
slashButton.addEventListener("click", () => {
|
|
|
|
|
uiState.slashMenuOpen = !uiState.slashMenuOpen;
|
|
|
|
|
syncToolbarState();
|
|
|
|
|
log("human_editor.slash_menu:" + (uiState.slashMenuOpen ? "open" : "close"));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (toggleHeadingButton) {
|
|
|
|
|
toggleHeadingButton.addEventListener("click", () => {
|
|
|
|
|
void toggleHeadingCollapse().catch((error) => {
|
|
|
|
|
log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (indentButton) {
|
|
|
|
|
indentButton.addEventListener("click", () => {
|
|
|
|
|
void adjustIndent(1).catch((error) => {
|
|
|
|
|
log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (outdentButton) {
|
|
|
|
|
outdentButton.addEventListener("click", () => {
|
|
|
|
|
void adjustIndent(-1).catch((error) => {
|
|
|
|
|
log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (pageRefButton) {
|
|
|
|
|
pageRefButton.addEventListener("click", () => insertReferenceToken("page"));
|
|
|
|
|
}
|
|
|
|
|
if (blockRefButton) {
|
|
|
|
|
blockRefButton.addEventListener("click", () => insertReferenceToken("block"));
|
|
|
|
|
}
|
|
|
|
|
if (slashMenuElement) {
|
|
|
|
|
slashMenuElement.querySelectorAll("[data-slash-action]").forEach((node) => {
|
|
|
|
|
node.addEventListener("click", async () => {
|
|
|
|
|
const action = node.getAttribute("data-slash-action");
|
|
|
|
|
if (action) {
|
|
|
|
|
try {
|
|
|
|
|
await applySlashAction(action);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render();
|
|
|
|
|
applyRowState();
|
|
|
|
|
updateSaveStatus(READ_ONLY ? "read-only" : "idle");
|
|
|
|
|
if (uiState.focusedId) {
|
|
|
|
|
focusBlock(uiState.focusedId, "end");
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>"##;
|
|
|
|
|
|
|
|
|
|
template
|
|
|
|
|
.replace("__PAGE_TITLE__", &escape_html(&page_title))
|
|
|
|
|
.replace(
|
|
|
|
|
"__WORKSPACE_ID__",
|
|
|
|
|
&escape_html(workspace_id.unwrap_or("未指定")),
|
|
|
|
|
)
|
|
|
|
|
.replace("__DOCUMENT_ID__", &escape_html(document_id))
|
|
|
|
|
.replace("__HOST__", &escape_html(host.unwrap_or("mnote-web")))
|
|
|
|
|
.replace("__READ_ONLY__", if read_only { "true" } else { "false" })
|
|
|
|
|
.replace("__REVISION__", &escape_html(&revision.to_string()))
|
|
|
|
|
.replace("__UPDATED_AT__", &escape_html(&updated_at))
|
|
|
|
|
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn document_editor_shell(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<DocumentEditorShellQuery>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
2026-04-29 12:24:44 +08:00
|
|
|
let (_, _, meta_json) = document_meta(
|
2026-04-19 21:03:25 +08:00
|
|
|
State(state.clone()),
|
|
|
|
|
Extension(context.clone()),
|
|
|
|
|
Query(DocumentMetaQuery {
|
|
|
|
|
document_id: query.document_id.clone(),
|
|
|
|
|
workspace_id: query.workspace_id.clone(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
let (_, _, content_json) = document_content(
|
2026-04-19 21:03:25 +08:00
|
|
|
State(state),
|
|
|
|
|
Extension(context),
|
|
|
|
|
Query(DocumentContentQuery {
|
|
|
|
|
document_id: query.document_id.clone(),
|
|
|
|
|
workspace_id: query.workspace_id.clone(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let meta_result = meta_json.0.get("result").cloned().unwrap_or(Value::Null);
|
|
|
|
|
let content_result = content_json.0.get("result").cloned().unwrap_or(Value::Null);
|
|
|
|
|
let html = build_document_editor_shell_html(
|
|
|
|
|
query.workspace_id.as_deref(),
|
|
|
|
|
&query.document_id,
|
|
|
|
|
query.host.as_deref(),
|
|
|
|
|
&meta_result,
|
|
|
|
|
&content_result,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut response = Html(html).into_response();
|
|
|
|
|
response.headers_mut().insert(
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
|
|
|
);
|
|
|
|
|
response
|
|
|
|
|
.headers_mut()
|
|
|
|
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
|
|
|
response.headers_mut().insert(
|
|
|
|
|
"x-mnote-shell",
|
|
|
|
|
HeaderValue::from_static("document-runtime-debug"),
|
|
|
|
|
);
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn transform_runtime_snapshot(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(body): Json<RuntimeTransformRequest>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let document_id = body.document_id.trim();
|
|
|
|
|
if document_id.is_empty() {
|
|
|
|
|
return Err(
|
|
|
|
|
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
|
|
|
|
.with_context(&context),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
let mut session = EditorSession::new(runtime_snapshot_to_document(&body.snapshot.blocks));
|
|
|
|
|
let command =
|
|
|
|
|
runtime_editor_command(body.command).map_err(|error| error.with_context(&context))?;
|
|
|
|
|
session
|
|
|
|
|
.apply_command(command)
|
|
|
|
|
.map_err(|error| WebError::bad_request(error.to_string()).with_context(&context))?;
|
|
|
|
|
let blocks = runtime_snapshot_from_document(session.document(), &body.snapshot.blocks);
|
|
|
|
|
Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"result": {
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"workspaceId": body.workspace_id,
|
|
|
|
|
"blocks": blocks,
|
|
|
|
|
},
|
|
|
|
|
})),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-04-29 12:24:44 +08:00
|
|
|
use crate::app::{build_app, AppConfig, AppState};
|
|
|
|
|
use axum::body::{to_bytes, Body};
|
2026-04-19 21:03:25 +08:00
|
|
|
use axum::http::{Request, StatusCode};
|
2026-04-29 12:24:44 +08:00
|
|
|
use serde_json::{json, Value};
|
2026-04-19 21:03:25 +08:00
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
|
|
|
fn app() -> axum::Router {
|
|
|
|
|
build_app(AppState::new(AppConfig {
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
2026-04-29 12:24:44 +08:00
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
enable_legacy_next_compat: true,
|
2026-04-23 07:38:34 +08:00
|
|
|
enable_debug_shell_routes: true,
|
2026-05-16 22:03:14 +08:00
|
|
|
enable_editor_actor: true,
|
2026-04-19 21:03:25 +08:00
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
convex_url: None,
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
query_fixtures_json: Some(
|
|
|
|
|
r#"{
|
|
|
|
|
"documents:getMeta": {
|
|
|
|
|
"id": "doc_shell",
|
|
|
|
|
"workspace_id": "ws_demo",
|
|
|
|
|
"title": "编辑器壳页面",
|
|
|
|
|
"updated_at": "2026-04-18T11:22:33Z",
|
|
|
|
|
"can_edit": true,
|
|
|
|
|
"show_structure": true
|
|
|
|
|
},
|
|
|
|
|
"documents:getContent": {
|
|
|
|
|
"title": "编辑器壳页面",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"id": "heading_1",
|
|
|
|
|
"type": "heading",
|
|
|
|
|
"props": { "level": 1 },
|
|
|
|
|
"content": [{ "type": "text", "text": "第一节" }]
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"revision": 9,
|
|
|
|
|
"conflict_detection_key": "doc_shell:9",
|
|
|
|
|
"page_subtree": {
|
|
|
|
|
"projection_id": "kernel_projection:page_tree:doc_shell",
|
|
|
|
|
"projection": "page_tree",
|
|
|
|
|
"root_node_id": "doc_shell",
|
|
|
|
|
"root_node": {
|
|
|
|
|
"id": "doc_shell",
|
|
|
|
|
"parent_node_id": null,
|
|
|
|
|
"node_type": "page",
|
|
|
|
|
"block_id": null,
|
|
|
|
|
"anchor_block_id": null,
|
|
|
|
|
"depth": 0,
|
|
|
|
|
"metadata": {
|
|
|
|
|
"title": "编辑器壳页面",
|
|
|
|
|
"text_snippet": "页面首段",
|
|
|
|
|
"block_type": null,
|
|
|
|
|
"heading_level": null,
|
|
|
|
|
"numbering": null,
|
|
|
|
|
"child_count": 2,
|
|
|
|
|
"order": 0,
|
|
|
|
|
"path": ["编辑器壳页面"]
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"subtree": {
|
|
|
|
|
"root_node_id": "doc_shell",
|
|
|
|
|
"nodes": [
|
|
|
|
|
{
|
|
|
|
|
"id": "doc_shell",
|
|
|
|
|
"parent_node_id": null,
|
|
|
|
|
"node_type": "page",
|
|
|
|
|
"block_id": null,
|
|
|
|
|
"anchor_block_id": null,
|
|
|
|
|
"depth": 0,
|
|
|
|
|
"metadata": {
|
|
|
|
|
"title": "编辑器壳页面",
|
|
|
|
|
"text_snippet": "页面首段",
|
|
|
|
|
"block_type": null,
|
|
|
|
|
"heading_level": null,
|
|
|
|
|
"numbering": null,
|
|
|
|
|
"child_count": 2,
|
|
|
|
|
"order": 0,
|
|
|
|
|
"path": ["编辑器壳页面"]
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "node_heading_1",
|
|
|
|
|
"parent_node_id": "doc_shell",
|
|
|
|
|
"node_type": "section",
|
|
|
|
|
"block_id": "heading_1",
|
|
|
|
|
"anchor_block_id": "heading_1",
|
|
|
|
|
"depth": 1,
|
|
|
|
|
"metadata": {
|
|
|
|
|
"title": "第一节",
|
|
|
|
|
"text_snippet": "第一节 页面首段",
|
|
|
|
|
"block_type": "heading",
|
|
|
|
|
"heading_level": 1,
|
|
|
|
|
"numbering": "1",
|
|
|
|
|
"child_count": 1,
|
|
|
|
|
"order": 0,
|
|
|
|
|
"path": ["编辑器壳页面", "第一节"]
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "node_para_1",
|
|
|
|
|
"parent_node_id": "node_heading_1",
|
|
|
|
|
"node_type": "content_node",
|
|
|
|
|
"block_id": "paragraph_1",
|
|
|
|
|
"anchor_block_id": null,
|
|
|
|
|
"depth": 2,
|
|
|
|
|
"metadata": {
|
|
|
|
|
"title": null,
|
|
|
|
|
"text_snippet": "这是正文第一段",
|
|
|
|
|
"block_type": "paragraph",
|
|
|
|
|
"heading_level": null,
|
|
|
|
|
"numbering": null,
|
|
|
|
|
"child_count": 0,
|
|
|
|
|
"order": 1,
|
|
|
|
|
"path": ["编辑器壳页面", "第一节", "这是正文第一段"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"outline": [
|
|
|
|
|
{
|
|
|
|
|
"id": "outline_heading_1",
|
|
|
|
|
"node_id": "node_heading_1",
|
|
|
|
|
"anchor_block_id": "heading_1",
|
|
|
|
|
"title": "第一节",
|
|
|
|
|
"level": 1,
|
|
|
|
|
"numbering": "1"
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"evidence": [],
|
|
|
|
|
"stats": {
|
|
|
|
|
"block_count": 3,
|
|
|
|
|
"heading_count": 1,
|
|
|
|
|
"evidence_count": 0,
|
|
|
|
|
"max_depth": 2
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}"#
|
|
|
|
|
.into(),
|
|
|
|
|
),
|
|
|
|
|
mutation_fixtures_json: None,
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn block_editor_shell_returns_interactive_html_document() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo&host=next-document-page")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let content_type = response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("content-type")
|
|
|
|
|
.and_then(|value| value.to_str().ok())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
assert!(content_type.contains("text/html"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("x-mnote-shell")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("document-runtime-debug")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("block-editor-shell-state"));
|
|
|
|
|
assert!(html.contains("block_editor_shell.ready"));
|
|
|
|
|
assert!(html.contains("human_editor.focus:"));
|
|
|
|
|
assert!(html.contains("data-block-id"));
|
|
|
|
|
assert!(html.contains("editor-row"));
|
|
|
|
|
assert!(html.contains("next-document-page"));
|
|
|
|
|
assert!(html.contains("focus / selection / hover"));
|
|
|
|
|
assert!(html.contains("\"blocks\":["));
|
|
|
|
|
assert!(html.contains("第一节"));
|
|
|
|
|
assert!(html.contains("\"id\":\"heading_1\""));
|
|
|
|
|
assert!(html.contains("Document Runtime Debug"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn editor_interactions_shell_exposes_slash_reference_and_indent_controls() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo&host=next-document-page")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("editor-command-slash"));
|
|
|
|
|
assert!(html.contains("editor-command-indent"));
|
|
|
|
|
assert!(html.contains("editor-command-outdent"));
|
|
|
|
|
assert!(html.contains("[[page]]"));
|
|
|
|
|
assert!(html.contains("((block))"));
|
|
|
|
|
assert!(html.contains("human_editor.slash:"));
|
|
|
|
|
assert!(html.contains("human_editor.reference:"));
|
|
|
|
|
assert!(html.contains("\"indent_block\""));
|
|
|
|
|
assert!(html.contains("\"outdent_block\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn editor_interactions_shell_exposes_heading_collapse_hooks() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("editor-command-toggle-heading"));
|
|
|
|
|
assert!(html.contains("human_editor.toggle_heading_collapse:"));
|
|
|
|
|
assert!(html.contains("\"collapsed\":false"));
|
|
|
|
|
assert!(html.contains("\"headingLevel\":1"));
|
|
|
|
|
assert!(html.contains("\"blockType\":\"heading\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn human_editor_runtime_shell_includes_real_input_and_save_markers() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("editor-save-status"));
|
|
|
|
|
assert!(html.contains("/api/documents/save"));
|
|
|
|
|
assert!(html.contains("editor-input"));
|
|
|
|
|
assert!(html.contains("human_editor_runtime.ready"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn human_editor_input_shell_wires_beforeinput_and_composition_events() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("human_editor_input.beforeinput"));
|
|
|
|
|
assert!(html.contains("human_editor_input.composition"));
|
|
|
|
|
assert!(html.contains("selectionStart"));
|
|
|
|
|
assert!(html.contains("compositionstart"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn human_editor_transactions_transform_route_applies_split_command() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/documents/runtime/transform")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
json!({
|
|
|
|
|
"documentId": "doc_shell",
|
|
|
|
|
"workspaceId": "ws_demo",
|
|
|
|
|
"snapshot": {
|
|
|
|
|
"blocks": [
|
|
|
|
|
{
|
|
|
|
|
"id": "block_a",
|
|
|
|
|
"blockType": "paragraph",
|
|
|
|
|
"parentId": null,
|
|
|
|
|
"depth": 0,
|
|
|
|
|
"text": "AlphaBeta",
|
|
|
|
|
"headingLevel": null,
|
|
|
|
|
"checked": null,
|
|
|
|
|
"collapsed": false,
|
|
|
|
|
"editable": true,
|
|
|
|
|
"rawType": "paragraph",
|
|
|
|
|
"language": null
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"command": {
|
|
|
|
|
"action": "split_block",
|
|
|
|
|
"block_id": "block_a",
|
|
|
|
|
"offset": 5,
|
|
|
|
|
"new_block_id": "block_b"
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
let blocks = payload["result"]["blocks"].as_array().expect("blocks");
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
assert_eq!(blocks[0]["text"], "Alpha");
|
|
|
|
|
assert_eq!(blocks[1]["text"], "Beta");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn human_editor_commands_shell_exposes_slash_and_reference_runtime_controls() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("human_editor.slash_menu:"));
|
|
|
|
|
assert!(html.contains("human_editor.reference:"));
|
|
|
|
|
assert!(html.contains("[[page]]"));
|
|
|
|
|
assert!(html.contains("((block))"));
|
|
|
|
|
assert!(html.contains("set_block_type"));
|
|
|
|
|
}
|
|
|
|
|
}
|