Files
mnote/rust/crates/mnote-web/src/routes/editor.rs
T

2133 lines
79 KiB
Rust

use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::documents::{
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
};
use axum::extract::{Extension, Json, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
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),
"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),
"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()
} else if fallback_raw_type == "todo" {
"todo".into()
} 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> {
let (_, _, meta_json) = document_meta(
State(state.clone()),
Extension(context.clone()),
Query(DocumentMetaQuery {
document_id: query.document_id.clone(),
workspace_id: query.workspace_id.clone(),
}),
)
.await?;
let (_, _, content_json) = document_content(
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 {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
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(),
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,
enable_debug_shell_routes: true,
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"));
}
}