Implement Rust web sidebar title and tree interactions

This commit is contained in:
lix-2026
2026-04-30 05:46:36 +08:00
parent 559c5ce652
commit 3cc090ba5e
35 changed files with 3029 additions and 424 deletions
+166 -140
View File
@@ -6,6 +6,7 @@ use crate::routes::documents::{
load_document_content_result, load_document_meta_result, DocumentContentQuery,
DocumentMetaQuery,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
@@ -24,9 +25,10 @@ use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::json;
use std::path::{Component, Path as FsPath, PathBuf};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
@@ -52,7 +54,7 @@ pub async fn document_page_shell(
query.workspace_id.as_deref(),
)
.await?;
let title = aggregate.head_title();
let title = aggregate.head.title.as_str();
let workspace_id = aggregate.identity.workspace_id.clone();
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let mut workspace_projection = load_workspace_shell_projection(
@@ -86,6 +88,7 @@ pub async fn document_page_shell(
<DocumentPage
title={title.to_string()}
document_id={document_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
@@ -105,6 +108,7 @@ pub async fn document_page_shell(
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
{}
{}
</body>
</html>"#,
escape_html(title),
@@ -113,6 +117,7 @@ pub async fn document_page_shell(
body_content,
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
render_document_title_controller_script(),
render_editor_island_adapter_script(),
);
let mut response = Html(html).into_response();
@@ -128,6 +133,7 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
"workspaceId": aggregate.identity.workspace_id,
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
"saveEndpoint": "/api/documents/save",
"titleEndpoint": "/api/documents/title",
"editorHostKind": "leptos_tiptap_island",
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
"requestId": context.trace.request_id,
@@ -136,6 +142,123 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
.unwrap_or_else(|_| "{}".to_string())
}
fn render_document_title_controller_script() -> &'static str {
r#"<script>
(() => {
const CONTRACT = 'mnote.document_title_controller.v1';
const input = document.querySelector('[data-page-title-input="true"]');
if (!(input instanceof HTMLTextAreaElement)) return;
input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
let lastSavedTitle = input.value.trim() || '无标题';
let saving = false;
const cssEscape = (value) => {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
};
const autosize = () => {
input.style.height = 'auto';
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
};
const setStatus = (status, message) => {
input.setAttribute('data-title-save-status', status);
const shell = input.closest('.document-shell');
if (shell instanceof HTMLElement) shell.setAttribute('data-title-save-status', status);
if (message) input.setAttribute('data-title-save-error', message);
else input.removeAttribute('data-title-save-error');
};
const setText = (selector, title) => {
document.querySelectorAll(selector).forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
};
const updateVisibleTitle = (title) => {
document.title = title;
setText('[data-page-title-current]', title);
const current = document.querySelector('.wolai-breadcrumb-current');
if (current instanceof HTMLElement) {
let titleNode = current.querySelector('[data-page-title-current]');
if (!(titleNode instanceof HTMLElement)) {
titleNode = document.createElement('span');
titleNode.setAttribute('data-page-title-current', 'true');
current.appendChild(titleNode);
}
titleNode.textContent = title;
}
if (!documentId) return;
const escapedId = cssEscape(documentId);
setText(`[data-node-id="${escapedId}"] .tree-link-title`, title);
setText(`[data-document-id="${escapedId}"] .tree-link-title`, title);
setText(`[data-doc-id="${escapedId}"] .tree-link-title`, title);
setText(`[data-node-id="${escapedId}"] .wolai-row-title`, title);
setText(`a[href="/documents/${escapedId}"] .wolai-row-title`, title);
setText(`a[href^="/documents/${escapedId}?"] .wolai-row-title`, title);
};
const saveTitle = async () => {
const title = input.value.trim() || '无标题';
autosize();
if (!documentId || saving || title === lastSavedTitle) {
updateVisibleTitle(title);
setStatus('saved');
return;
}
saving = true;
setStatus('saving');
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId,
workspaceId: workspaceId || null,
title,
commandName: 'page.head.updateTitle',
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
}
lastSavedTitle = title;
updateVisibleTitle(title);
setStatus('saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', {
detail: { documentId, workspaceId: workspaceId || null, title, payload },
}));
} catch (error) {
setStatus('error', error instanceof Error ? error.message : String(error));
} finally {
saving = false;
}
};
input.addEventListener('input', () => {
autosize();
setStatus(input.value.trim() === lastSavedTitle ? 'saved' : 'dirty');
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
input.blur();
}
});
input.addEventListener('blur', () => { void saveTitle(); });
autosize();
updateVisibleTitle(lastSavedTitle);
setStatus('saved');
})();
</script>"#
}
fn render_editor_island_adapter_script() -> &'static str {
r#"<script type="module">
(() => {
@@ -441,6 +564,7 @@ pub async fn page_aggregate(
query.workspace_id.as_deref(),
)
.await?;
let projection_owner = aggregate.source_label();
let mut response = (
StatusCode::OK,
Json(json!({
@@ -454,6 +578,11 @@ pub async fn page_aggregate(
)
.into_response();
stamp_shell_headers(response.headers_mut(), "page-aggregate");
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
if let Ok(value) = HeaderValue::from_str(projection_owner) {
response.headers_mut().insert(name, value);
}
}
Ok(response)
}
@@ -482,145 +611,24 @@ async fn build_page_aggregate_snapshot(
)
.await?;
let conflict_detection_key = content
.get("conflictDetectionKey")
.or_else(|| content.get("conflict_detection_key"))
.cloned()
.unwrap_or(Value::Null);
let page_subtree = content
.get("pageSubtree")
.or_else(|| content.get("page_subtree"))
.cloned()
.unwrap_or(Value::Null);
let todo_total = meta
.get("todo_total")
.or_else(|| meta.get("todo_total_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let todo_done = meta
.get("todo_done")
.or_else(|| meta.get("todo_done_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let projection = execute_runtime_query_against_data(
context,
workspace_id,
RuntimeQueryEnvelopeWire {
name: "page.aggregate.get".into(),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
}),
},
json!({
"meta": meta,
"content": content,
}),
)?;
Ok(PageAggregate::builder()
// identity
.document_id(
meta.get("id")
.and_then(Value::as_str)
.unwrap_or(document_id),
)
.workspace_id(
meta.get("workspace_id")
.and_then(Value::as_str)
.unwrap_or("default"),
)
// head
.title(
meta.get("title")
.and_then(Value::as_str)
.unwrap_or("无标题"),
)
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
.read_only(
meta.get("can_edit")
.and_then(Value::as_bool)
.map(|can_edit| !can_edit)
.unwrap_or(false),
)
.disable_download(
meta.get("disable_download")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.disable_copy(
meta.get("disable_copy")
.and_then(Value::as_bool)
.unwrap_or(false),
)
// layout
.wide_layout(
meta.get("wide_layout")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.small_text(
meta.get("use_small_text")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_heading_numbers(
meta.get("show_heading_numbers")
.and_then(Value::as_bool)
.unwrap_or(true),
)
.show_toc(
meta.get("show_toc")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_structure(
meta.get("show_structure")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.protect_editing(
meta.get("protect_editing")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_word_count(
meta.get("show_word_count")
.and_then(Value::as_bool)
.unwrap_or(true),
)
.collapse_backlinks(
meta.get("collapse_backlinks")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.page_font(
meta.get("page_font")
.and_then(Value::as_str)
.unwrap_or("default"),
)
.layout_density(
meta.get("layout_density")
.and_then(Value::as_str)
.unwrap_or("normal"),
)
.hide_child_pages(
meta.get("hide_child_pages")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_block_ref_count(
meta.get("show_block_ref_count")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.embed_default_block_id(
meta.get("embed_default_block_id")
.cloned()
.unwrap_or(Value::Null),
)
// body
.content(content.get("content").cloned().unwrap_or(Value::Null))
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
.conflict_detection_key(conflict_detection_key)
// tree
.page_subtree(page_subtree)
// stats
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
.character_count(
meta.get("character_count")
.and_then(Value::as_u64)
.unwrap_or(0),
)
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
.todo_total(todo_total)
.todo_done(todo_done)
.build())
serde_json::from_value::<PageAggregate>(projection)
.map_err(|error| WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}")))
}
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
@@ -929,6 +937,15 @@ mod tests {
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
assert!(html.contains("aria-label=\"页面标题\""));
assert!(html.contains("data-page-title-input=\"true\""));
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
assert!(html.contains("mnote.document_title_controller.v1"));
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
assert!(html.contains("/api/tree/events"));
assert!(html.contains("data-mnote-tree-live-transport"));
assert!(!html.contains("mnote-web-document-shell"));
}
@@ -945,12 +962,21 @@ mod tests {
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-page-aggregate-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-kernel")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["source"], "KernelProjection");
assert_eq!(payload["result"]["projectionVersion"], 1);
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
}