fix: relink rust web tree editor runtime

This commit is contained in:
lix-2026
2026-04-29 14:36:24 +08:00
parent 6c3d20ca55
commit 33ddda9dfd
28 changed files with 1839 additions and 303 deletions
@@ -10,8 +10,8 @@
//! ```
use crate::page_aggregate::{
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
PagePermissions, PageStats, PageTree,
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions,
PageStats, PageTree,
};
use serde_json::Value;
+164 -40
View File
@@ -1,13 +1,17 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::web_shell::{load_sidebar_tree_html, load_workspace_shell_projection};
use crate::routes::web_shell::{
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::workspace_shell::render_workspace_shell_sidebar_html;
use axum::body::Body;
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
@@ -29,6 +33,7 @@ struct GatewayManifest {
#[serde(rename_all = "camelCase")]
pub(crate) struct RootEntryQuery {
page_id: Option<String>,
workspace_id: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> Response {
@@ -82,44 +87,61 @@ pub async fn root_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Response {
let workspace_id = context
.workspace
.workspace_id
.as_deref()
.unwrap_or("ws_demo");
let requested_page_id = query
.page_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
) -> Result<Response, WebError> {
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
let active_page_id = requested_page_id.or(recent_page_id.as_deref());
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
let requested_or_recent_page_id =
choose_root_entry_active_page_id(requested_page_id, recent_page_id, None, None);
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
workspace_id,
active_page_id,
&workspace_id,
requested_or_recent_page_id.as_deref(),
&default_workspace_name,
)
.await;
let sidebar_tree_html =
load_sidebar_tree_html(
state.config(),
&context,
workspace_id,
workspace_projection.active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id,
recent_page_id,
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = load_sidebar_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let file_tree_html = load_file_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
let workspace_name = workspace_projection.workspace_name.clone();
let active_page_id = selected_active_page_id.unwrap_or_default();
let active_page_title = workspace_projection
.active_page_title
.clone()
.unwrap_or_default();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_sidebar_html={workspace_sidebar_html} />
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_id={workspace_id.clone()} workspace_sidebar_html={workspace_sidebar_html} active_page_id={active_page_id} active_page_title={active_page_title} />
});
let mut response = Html(format!(
r#"<!doctype html>
@@ -138,7 +160,7 @@ pub async fn root_entry(
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
response
Ok(response)
}
pub async fn legacy_next_proxy(
@@ -241,21 +263,82 @@ pub async fn legacy_next_proxy(
Ok(response)
}
async fn resolve_root_workspace_id(
state: &AppState,
context: &RequestContext,
requested_workspace_id: Option<&str>,
) -> Result<String, WebError> {
if let Some(workspace_id) = normalize_optional_id(requested_workspace_id)
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
{
return Ok(workspace_id.to_string());
}
let bootstrap = execute_convex_mutation_by_name(
state.config(),
context,
"workspaces:ensureDefaultWorkspace",
json!({
"fallbackName": state.config().dev_user_name,
"workspaceIdIfCreate": format!("ws_{}", context.trace.request_id),
}),
None,
None,
"root_workspace_bootstrap",
)
.await?;
bootstrap
.get("activeWorkspaceId")
.and_then(serde_json::Value::as_str)
.and_then(|value| normalize_optional_id(Some(value)))
.map(ToOwned::to_owned)
.ok_or_else(|| {
WebError::bad_gateway_code(
"root_workspace_bootstrap_bad_response",
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
)
.with_context(context)
.with_header("x-error-phase", "root_workspace_bootstrap")
})
}
fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn choose_root_entry_active_page_id(
requested_page_id: Option<&str>,
recent_page_id: Option<&str>,
projection_active_page_id: Option<&str>,
first_page_id: Option<&str>,
) -> Option<String> {
normalize_optional_id(requested_page_id)
.or_else(|| normalize_optional_id(recent_page_id))
.or_else(|| normalize_optional_id(projection_active_page_id))
.or_else(|| normalize_optional_id(first_page_id))
.map(ToOwned::to_owned)
}
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
context.auth.cookie_header.as_deref()?.split(';').find_map(|part| {
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
if cookie_name.trim() == name {
let value = cookie_value.trim();
if value.is_empty() {
None
context
.auth
.cookie_header
.as_deref()?
.split(';')
.find_map(|part| {
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
if cookie_name.trim() == name {
let value = cookie_value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
} else {
Some(value.to_string())
None
}
} else {
None
}
})
})
}
fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) {
@@ -340,7 +423,7 @@ mod tests {
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
@@ -475,7 +558,8 @@ mod tests {
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
assert!(html
.contains(r#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
assert!(html.contains(r#"data-testid="wolai-sidebar""#));
assert!(html.contains(r#"data-testid="wolai-topbar""#));
assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
@@ -487,6 +571,43 @@ mod tests {
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
}
#[test]
fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
let selected = super::choose_root_entry_active_page_id(
Some("page_query"),
Some("page_recent"),
Some("page_projection"),
Some("page_first"),
);
assert_eq!(selected.as_deref(), Some("page_query"));
}
#[test]
fn root_entry_active_selection_falls_back_to_recent_projection_first_then_empty() {
let from_recent = super::choose_root_entry_active_page_id(
None,
Some("page_recent"),
Some("page_projection"),
Some("page_first"),
);
assert_eq!(from_recent.as_deref(), Some("page_recent"));
let from_projection = super::choose_root_entry_active_page_id(
Some(" "),
None,
Some("page_projection"),
Some("page_first"),
);
assert_eq!(from_projection.as_deref(), Some("page_projection"));
let from_first =
super::choose_root_entry_active_page_id(None, None, None, Some("page_first"));
assert_eq!(from_first.as_deref(), Some("page_first"));
let empty = super::choose_root_entry_active_page_id(None, None, None, None);
assert_eq!(empty, None);
}
#[tokio::test]
async fn root_entry_uses_recent_page_cookie_as_active_page() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -494,6 +615,7 @@ mod tests {
Request::builder()
.uri("/")
.header("cookie", "mnote_recent_page_id=page_child")
.header("x-mnote-workspace-id", "ws_demo")
.body(Body::empty())
.expect("request"),
)
@@ -506,7 +628,9 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-node-id="page_child""#));
assert!(html.contains(r#"class="wolai-page-row wolai-active-row" href="/documents/page_child?workspaceId=ws_demo" data-node-id="page_child""#));
assert!(html.contains(r#"href="/documents/page_child?workspaceId=ws_demo""#));
assert!(html.contains(r#"data-active="true""#));
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
}
#[tokio::test]
+8
View File
@@ -44,6 +44,14 @@ pub fn build_router(state: AppState) -> Router {
"/api/page-aggregate/{document_id}",
get(web_shell::page_aggregate),
)
.route(
"/api/leptos-tiptap-runtime/manifest.json",
get(web_shell::leptos_tiptap_manifest),
)
.route(
"/api/leptos-tiptap-runtime/{*asset_path}",
get(web_shell::leptos_tiptap_asset),
)
.route("/api/search/documents", post(search::documents))
.route("/api/gateway/health", get(gateway::gateway_health))
.route("/api/runtime/config", get(session::runtime_config))
+3 -4
View File
@@ -64,10 +64,9 @@ pub async fn shell(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, None)
.await
.unwrap_or_default();
let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None)
.await
.unwrap_or_default();
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
let contract = json!({
"schema": "mnote.search_shell.v1",
+8 -9
View File
@@ -264,7 +264,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.unwrap_or_default()
}
fn collect_filetree_render_rows(
pub(crate) fn collect_filetree_render_rows(
projection: &Value,
active_document_id: Option<&str>,
) -> Vec<FileTreeRenderRow> {
@@ -4654,13 +4654,9 @@ pub async fn tree_command(
None,
Some(*sort_order),
),
TreeCommandRequest::Purge { document_id, .. } => (
"purge",
document_id.clone(),
None,
None,
None,
),
TreeCommandRequest::Purge { document_id, .. } => {
("purge", document_id.clone(), None, None, None)
}
};
let effective_workspace_id = match &request {
TreeCommandRequest::Create { parent_id, .. } => {
@@ -5166,7 +5162,10 @@ mod tests {
payload["result"]["documentId"],
Value::String("page_child".into())
);
assert_eq!(payload["result"]["execution"]["deletedCount"], Value::from(1));
assert_eq!(
payload["result"]["execution"]["deletedCount"],
Value::from(1)
);
}
#[tokio::test]
+528 -40
View File
@@ -9,19 +9,25 @@ use crate::routes::documents::{
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
use crate::routes::tree::collect_page_tree_render_rows;
use crate::tree_shell::page_renderer::{
render_initial_page_tree_html, PageTreeInitialRenderInput,
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
use crate::ssr::pages::document::DocumentPage;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput,
};
use crate::workspace_shell::{build_workspace_shell_projection, WorkspaceShellProjection};
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
use crate::workspace_shell::{
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
WorkspaceShellProjection,
};
use axum::body::Body;
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 core_protocol::KernelProjectionKind;
use serde::Deserialize;
use crate::ssr::pages::document::DocumentPage;
use serde_json::{json, Value};
use std::path::{Component, Path as FsPath, PathBuf};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
@@ -47,19 +53,44 @@ pub async fn document_page_shell(
)
.await?;
let title = aggregate.head_title();
let workspace_id = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
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(
state.config(),
&context,
&workspace_id,
Some(&document_id),
&default_workspace_name,
)
.await;
apply_active_page(&mut workspace_projection, Some(&document_id));
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&document_id))
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default();
let file_tree_html =
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default();
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
let workspace_name = workspace_projection.workspace_name.clone();
let page_subtree_json =
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage title={title.to_string()} sidebar_tree_html={sidebar_tree_html} />
<DocumentPage
title={title.to_string()}
document_id={document_id.clone()}
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
page_subtree_json={page_subtree_json}
/>
});
let html = format!(
r#"<!doctype html>
@@ -72,6 +103,8 @@ pub async fn document_page_shell(
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
{}
</body>
</html>"#,
escape_html(title),
@@ -79,6 +112,8 @@ pub async fn document_page_shell(
escape_html(&document_id),
body_content,
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
render_editor_island_adapter_script(),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "document");
@@ -86,6 +121,313 @@ pub async fn document_page_shell(
Ok(response)
}
fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestContext) -> String {
serde_json::to_string(&json!({
"schema": "mnote.editor_bootstrap.v1",
"documentId": aggregate.identity.document_id,
"workspaceId": aggregate.identity.workspace_id,
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
"saveEndpoint": "/api/documents/save",
"editorHostKind": "leptos_tiptap_island",
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn render_editor_island_adapter_script() -> &'static str {
r#"<script type="module">
(() => {
const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]';
const EVENT_PREFIX = 'mnote:leptos-tiptap-spike';
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
const parseJsonScript = (id) => {
const node = document.getElementById(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || 'null');
} catch (error) {
console.warn(`mnote rust-web editor bootstrap JSON 解析失败: ${id}`, error);
return null;
}
};
const root = document.querySelector(ROOT_SELECTOR);
const observability = document.querySelector('[data-editor-host-observability]');
const aggregate = parseJsonScript('__MNOTE_PAGE_AGGREGATE__');
const bootstrap = parseJsonScript('__MNOTE_EDITOR_BOOTSTRAP__');
if (!(root instanceof HTMLElement) || !aggregate || !bootstrap) return;
const setStatus = (status, message) => {
root.setAttribute('data-runtime-editor-status', status);
if (message) root.setAttribute('data-runtime-editor-error', message);
if (observability instanceof HTMLElement) {
observability.setAttribute('data-editor-host-status', status);
observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island');
}
};
const flattenText = (value) => {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(flattenText).join('');
if (value && typeof value === 'object') {
return `${flattenText(value.text)}${flattenText(value.content)}`;
}
return '';
};
const legacyBlockToTiptap = (block) => {
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
const text = flattenText(block?.content);
const content = text ? [{ type: 'text', text }] : [];
if (type === 'heading') {
const level = Number(block?.props?.level || block?.level || 1) || 1;
return { type: 'heading', attrs: { level: Math.max(1, Math.min(6, level)) }, content };
}
if (type === 'bulletListItem') {
return { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content }] }] };
}
if (type === 'numberedListItem') {
return { type: 'orderedList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content }] }] };
}
if (type === 'checkListItem' || type === 'advancedTodo') {
return { type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: Boolean(block?.props?.checked) }, content: [{ type: 'paragraph', content }] }] };
}
if (type === 'quote') {
return { type: 'blockquote', content: [{ type: 'paragraph', content }] };
}
if (type === 'codeBlock') {
return { type: 'codeBlock', attrs: { language: block?.props?.language || null }, content };
}
return { type: 'paragraph', content };
};
const textToTiptapDocument = (text) => ({
type: 'doc',
content: [{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : [],
}],
});
const currentEditorText = () => {
const editor = root.querySelector('.editor-surface .ProseMirror');
return editor?.textContent || '';
};
const isTiptapDocument = (content) => (
content &&
typeof content === 'object' &&
!Array.isArray(content) &&
content.type === 'doc'
);
const toTiptapDocument = (content, fallbackText = '') => {
if (isTiptapDocument(content)) {
return content;
}
const blocks = Array.isArray(content)
? content
: Array.isArray(content?.blocks)
? content.blocks
: [];
const nodes = blocks.map(legacyBlockToTiptap).filter(Boolean);
if (nodes.length) {
return { type: 'doc', content: nodes };
}
return textToTiptapDocument(fallbackText);
};
const pageBody = aggregate.body || {};
const permissions = aggregate.head?.permissions || {};
const editorMeta = {
revision: Number.isInteger(pageBody.revision) ? pageBody.revision : null,
conflictDetectionKey: typeof pageBody.conflictDetectionKey === 'string' ? pageBody.conflictDetectionKey : null,
};
const mountOptions = {
documentId: bootstrap.documentId,
workspaceId: bootstrap.workspaceId,
title: aggregate.head?.title || '无标题',
content: toTiptapDocument(pageBody.content),
revision: editorMeta.revision,
conflictDetectionKey: editorMeta.conflictDetectionKey,
readOnly: Boolean(permissions.readOnly),
editable: !Boolean(permissions.readOnly),
pageOptions: aggregate.layout?.pageOptions || {},
};
let saveTimer = 0;
let lastSavedSerialized = '';
const normalizeEnvelopePayload = (event) => {
const detail = event?.detail;
if (!detail || typeof detail !== 'object') return null;
const payload = detail.payload && typeof detail.payload === 'object' ? detail.payload : detail;
return payload && typeof payload === 'object' ? payload : null;
};
const savePayload = async (payload) => {
const tiptapDocument = toTiptapDocument(
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
currentEditorText(),
);
const serialized = JSON.stringify(tiptapDocument);
if (serialized === lastSavedSerialized) {
setStatus('saved');
return;
}
setStatus('saving');
const response = await fetch(bootstrap.saveEndpoint || '/api/documents/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: bootstrap.documentId,
workspaceId: bootstrap.workspaceId,
revision: editorMeta.revision,
conflictDetectionKey: editorMeta.conflictDetectionKey,
content: [],
tiptapDocument,
blockCount: null,
}),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result || result.ok !== true) {
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
throw new Error(message);
}
const saved = result.result || {};
if (Number.isInteger(saved.revision)) editorMeta.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key;
if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey;
lastSavedSerialized = serialized;
setStatus('saved');
};
const queueSave = (event) => {
const payload = normalizeEnvelopePayload(event);
if (!payload) return;
if (saveTimer) window.clearTimeout(saveTimer);
setStatus('dirty');
saveTimer = window.setTimeout(() => {
saveTimer = 0;
savePayload(payload).catch((error) => {
setStatus('error', error instanceof Error ? error.message : String(error));
});
}, 650);
};
root.addEventListener(READY_EVENT, () => setStatus('ready'));
root.addEventListener(ERROR_EVENT, (event) => {
const payload = normalizeEnvelopePayload(event);
setStatus('error', payload?.message || 'runtime_error');
});
root.addEventListener(CHANGE_EVENT, queueSave);
root.addEventListener(SAVE_EVENT, queueSave);
const start = async () => {
setStatus('loading-assets');
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
const manifest = await manifestResponse.json();
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
const runtime = await import(entryUrl);
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function') {
throw new Error('island runtime 导出不完整');
}
await runtime.default(wasmUrl);
const mountId = runtime.mount(root, mountOptions);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
setStatus('ready');
};
start().catch((error) => {
setStatus('error', error instanceof Error ? error.message : String(error));
});
})();
</script>"#
}
fn runtime_asset_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../spikes/leptos-tiptap-spike/generated/island")
}
fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
let asset_path = asset_path.trim();
if asset_path.is_empty() || asset_path.starts_with('/') || asset_path.contains('\\') {
return None;
}
let mut resolved = runtime_asset_root();
for component in FsPath::new(asset_path).components() {
match component {
Component::Normal(part) => resolved.push(part),
_ => return None,
}
}
Some(resolved)
}
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
if asset_path.ends_with(".wasm") {
"application/wasm"
} else if asset_path.ends_with(".js") {
"application/javascript; charset=utf-8"
} else if asset_path.ends_with(".json") {
"application/json; charset=utf-8"
} else {
"application/octet-stream"
}
}
pub async fn leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
"assetPaths": [
"mnote-leptos-tiptap-spike-island.js",
"mnote-leptos-tiptap-spike-island_bg.wasm"
],
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
});
let mut response = Json(manifest).into_response();
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
response
}
pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
let Some(resolved) = resolve_runtime_asset_path(&asset_path) else {
return Err(WebError::bad_request_code(
"runtime_asset_path_invalid",
"leptos-tiptap runtime asset 路径非法",
));
};
let bytes = std::fs::read(&resolved).map_err(|_| {
WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
)
})?;
let response = Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
runtime_asset_content_type(&asset_path),
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(bytes))
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
Ok(response)
}
pub async fn page_aggregate(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -163,28 +505,105 @@ async fn build_page_aggregate_snapshot(
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"))
.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("无标题"))
.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))
.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))
.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))
@@ -193,14 +612,17 @@ async fn build_page_aggregate_snapshot(
.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))
.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())
}
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
let value = document_id
.chars()
@@ -311,14 +733,23 @@ pub(crate) async fn load_sidebar_tree_html(
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some(snapshot.projection),
Err(_) if config.allow_dev_fixtures => {
// Dev 模式降级:使用内建示例页面树数据集
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
let documents = active_document_id
.map(|document_id| {
serde_json::json!([
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
])
})
.unwrap_or_else(|| {
serde_json::json!([
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }
])
});
let dev_dataset = serde_json::json!({
"active_workspace_id": workspace_id,
"documents": [
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 },
],
"documents": documents,
"trashed_documents": [],
"media_assets": [],
"trashed_media_assets": [],
@@ -343,6 +774,61 @@ pub(crate) async fn load_sidebar_tree_html(
})
}
/// 加载文件树 HTMLSSR
///
/// 文件树与页面树共用同一份 sidebar dataset,再由 Rust kernel 输出 file_tree projection。
pub(crate) async fn load_file_tree_html(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
) -> Option<String> {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::FileTree,
query: None,
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some(snapshot.projection),
Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id
.map(|document_id| {
serde_json::json!([
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
])
})
.unwrap_or_else(|| {
serde_json::json!([
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }
])
});
let dev_dataset = serde_json::json!({
"active_workspace_id": workspace_id,
"documents": documents,
"trashed_documents": [],
"media_assets": [],
"trashed_media_assets": [],
"mindmap_assets": [],
"trashed_mindmap_assets": [],
"table_assets": [],
"trashed_table_assets": [],
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
}
Err(_) => None,
};
result.map(|projection| {
let rows = collect_filetree_render_rows(&projection, active_document_id);
render_initial_filetree_html(&FileTreeInitialRenderInput { rows })
})
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
@@ -440,6 +926,8 @@ mod tests {
assert!(html.contains("垃圾箱"));
assert!(html.contains("模板中心"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
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("mnote-web-document-shell"));
}
+2 -2
View File
@@ -22,8 +22,8 @@ pub fn render_view(view: impl RenderHtml) -> String {
view,
&mut buf,
&mut position,
true, // escape — 对 HTML 特殊字符进行转义
false, // mark_branches — 不标记分支注释
true, // escape — 对 HTML 特殊字符进行转义
false, // mark_branches — 不标记分支注释
vec![], // extra_attrs — 无需额外属性
);
buf
+1 -1
View File
@@ -1,7 +1,7 @@
//! MNOTE 登录页面组件
use leptos::prelude::*;
use super::layout::PageLayout;
use leptos::prelude::*;
/// MNOTE 登录页面
///
@@ -3,8 +3,8 @@
//! 提供文档编辑器的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
use leptos::prelude::*;
/// MNOTE 文档页面
///
@@ -17,21 +17,53 @@ use crate::ssr::pages::layout::PageLayout;
pub fn DocumentPage(
/// 文档标题
title: String,
/// 文档 id
document_id: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
/// Page Aggregate 子树 JSON(可选)
#[prop(optional)]
page_subtree_json: Option<String>,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "null")
.is_some();
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island">
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()}>
<header class="document-shell-header">
<h1>{title}</h1>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island"></section>
<section
data-testid="mnote-page-subtree"
data-page-tree-source="page_aggregate.tree.pageSubtree"
data-page-subtree-present={has_page_subtree.to_string()}
></section>
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island">
<div
id="mnote-leptos-tiptap-island-editor-root"
data-testid="mnote-leptos-tiptap-island-editor-root"
data-editor-host-kind="leptos_tiptap_island"
data-runtime-editor-status="booting"
></div>
<div
class="sr-only"
data-editor-host-observability="rust-web-inline-island"
data-editor-host-active="leptos_tiptap_island"
data-editor-host-requested="leptos_tiptap_island"
data-editor-host-status="booting"
></div>
</section>
</main>
</PageLayout>
}
+51 -15
View File
@@ -1,7 +1,7 @@
//! MNOTE 首页组件(Wolai 风格)
use leptos::prelude::*;
use super::layout::PageLayout;
use leptos::prelude::*;
/// MNOTE 首页。
#[component]
@@ -12,25 +12,61 @@ pub fn HomePage(
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
/// 工作区 id(可选)
#[prop(optional)]
workspace_id: Option<String>,
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
/// 当前选中的页面 id(可选)
#[prop(optional)]
active_page_id: Option<String>,
/// 当前选中的页面标题(可选)
#[prop(optional)]
active_page_title: Option<String>,
) -> impl IntoView {
let active_page_id = active_page_id.unwrap_or_default();
let active_page_title = active_page_title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人".to_string());
let has_active_page = !active_page_id.trim().is_empty();
let workspace_id = workspace_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let workspace_id_value = workspace_id.clone().unwrap_or_default();
let active_href = workspace_id
.as_deref()
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
view! {
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}>
<div class="mnote-home">
<div class="mnote-home-icon" aria-hidden="true">""</div>
<h1>"个人"</h1>
<div class="mnote-home-links">
<a href="/documents/backup"><span>""</span>"正版软件备份"</a>
<a href="/documents/growth"><span>""</span>"个人发展"</a>
<a href="/documents/notes"><span>""</span>"杂记"</a>
<a href="/documents/passwords"><span>""</span>"密码"</a>
<a href="/documents/wolai-guide"><span>""</span>"Wolai 教程"</a>
<a href="/documents/health"><span>""</span>"个人健康"</a>
<a href="/documents/software"><span>""</span>"软件开发"</a>
</div>
</div>
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()}>
{move || if has_active_page {
view! {
<main class="document-shell" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
<header class="document-shell-header">
<h1>{active_page_title.clone()}</h1>
</header>
<section class="mnote-workspace-active-state" data-testid="mnote-workspace-active-page-state">
<a class="mnote-workspace-active-link" href={active_href.clone()}>"打开当前页面"</a>
</section>
</main>
}.into_any()
} else {
view! {
<section class="mnote-workspace-empty-state" data-testid="mnote-workspace-empty-state">
<h1>"暂无页面"</h1>
<p>"当前工作区还没有可显示的页面。"</p>
<button
type="button"
class="mnote-empty-create-page"
data-testid="mnote-empty-create-page"
data-mnote-action="create-page"
data-workspace-id={workspace_id_value.clone()}
>"新建页面"</button>
</section>
}.into_any()
}}
</PageLayout>
}
}
+93 -22
View File
@@ -3,27 +3,65 @@
use leptos::prelude::*;
const SIDEBAR_TREE_JS: &str = r##"
<script>
(function(){
var tree = document.getElementById('sidebar-tree-root');
if (!tree) return;
function closestAction(target, selector) {
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
}
// 高亮当前页
var currentPath = window.location.pathname;
var match = currentPath.match(/^\/documents\/([^\/]+)/);
if (match) {
var activeId = match[1];
var links = tree.querySelectorAll('[data-rust-action="open"]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-node-id') === activeId) {
links[i].closest('.tree-row').setAttribute('data-active', 'true');
function resolveWorkspaceId(trigger) {
var direct = (trigger.getAttribute('data-workspace-id') || '').trim();
if (direct) return direct;
var root = trigger.closest('[data-workspace-id]');
return root ? (root.getAttribute('data-workspace-id') || '').trim() : '';
}
async function dispatchTreeCommand(trigger, body) {
trigger.setAttribute('data-pending', 'true');
trigger.setAttribute('disabled', 'disabled');
try {
var response = await fetch('/api/tree/commands', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || !payload.result) {
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
}
return payload.result;
} catch (error) {
trigger.removeAttribute('disabled');
trigger.setAttribute('data-pending', 'false');
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
throw error;
}
}
// 展开/折叠
tree.addEventListener('click', function(e) {
var btn = e.target.closest('[data-rust-action]');
async function createPage(trigger, parentId) {
var workspaceId = resolveWorkspaceId(trigger);
var effectiveParentId = (parentId || trigger.getAttribute('data-parent-id') || '').trim();
if (!workspaceId) return;
var result = await dispatchTreeCommand(trigger, {
action: 'create',
workspaceId: workspaceId,
parentId: effectiveParentId || null,
title: '新页面'
});
var nextWorkspaceId = result.workspaceId || workspaceId;
window.location.href = '/documents/' + encodeURIComponent(result.documentId) + '?workspaceId=' + encodeURIComponent(nextWorkspaceId);
}
document.addEventListener('click', function(e) {
var createTrigger = closestAction(e.target, '[data-mnote-action="create-page"]');
if (createTrigger) {
e.preventDefault();
void createPage(createTrigger, null);
return;
}
var tree = document.getElementById('sidebar-tree-root');
if (!tree || !tree.contains(e.target)) return;
var btn = closestAction(e.target, '[data-rust-action]');
if (!btn) return;
var nodeId = btn.getAttribute('data-node-id');
var action = btn.getAttribute('data-rust-action');
@@ -41,12 +79,40 @@ const SIDEBAR_TREE_JS: &str = r##"
}
e.preventDefault();
} else if (action === 'open') {
window.location.href = '/documents/' + encodeURIComponent(nodeId);
var workspaceId = resolveWorkspaceId(btn);
window.location.href = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
e.preventDefault();
} else if (action === 'create') {
e.preventDefault();
void createPage(btn, nodeId);
} else if (action === 'rename') {
e.preventDefault();
var title = window.prompt('重命名页面');
if (title && title.trim()) {
void dispatchTreeCommand(btn, {
action: 'rename',
workspaceId: resolveWorkspaceId(btn),
documentId: nodeId,
title: title.trim()
}).then(function(){ window.location.reload(); });
}
}
});
var tree = document.getElementById('sidebar-tree-root');
if (!tree) return;
var currentPath = window.location.pathname;
var match = currentPath.match(/^\/documents\/([^\/]+)/);
if (match) {
var activeId = match[1];
var links = tree.querySelectorAll('[data-rust-action="open"]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-node-id') === activeId) {
links[i].closest('.tree-row').setAttribute('data-active', 'true');
}
}
}
})();
</script>
"##;
/// MNOTE Wolai 风格页面布局
@@ -76,6 +142,9 @@ pub fn PageLayout(
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
#[prop(optional)]
workspace_sidebar_html: Option<String>,
/// 顶栏当前页面标题(可选)
#[prop(optional)]
topbar_title: Option<String>,
) -> impl IntoView {
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
@@ -83,6 +152,10 @@ pub fn PageLayout(
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "开发用户 的空间".to_string());
let topbar_title = topbar_title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人".to_string());
let sidebar_sections_html = workspace_sidebar_html
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
@@ -92,14 +165,12 @@ pub fn PageLayout(
"documents": []
});
let projection = crate::workspace_shell::build_workspace_shell_projection(
&dataset,
"default",
None,
&ws_name,
&dataset, "default", None, &ws_name,
);
crate::workspace_shell::render_workspace_shell_sidebar_html(
&projection,
Some(sidebar_tree_html.as_str()),
None,
)
});
@@ -124,7 +195,7 @@ pub fn PageLayout(
</aside>
<div class="mnote-main">
<header class="wolai-topbar" data-testid="wolai-topbar">
<div class="wolai-topbar-left"><span class="wolai-menu-icon">""</span><span class="wolai-home-icon">""</span><span>{"个人"}</span></div>
<div class="wolai-topbar-left"><span class="wolai-menu-icon">""</span><span class="wolai-home-icon">""</span><span>{topbar_title}</span></div>
<div class="wolai-topbar-actions" aria-label="页面操作">
<span title="收藏">""</span>
<span title="演示">""</span>
@@ -3,8 +3,8 @@
//! 提供思维导图页面的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use super::layout::PageLayout;
use leptos::prelude::*;
/// MNOTE 思维导图页面
///
@@ -3,8 +3,8 @@
//! 提供搜索页面的服务器端渲染壳。
//! 由 `<body>` 外层包装、搜索契约 JSON 嵌入脚本由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
use leptos::prelude::*;
/// MNOTE 搜索页面
///
+47 -1
View File
@@ -183,8 +183,36 @@ a:hover {
.wolai-section-add {
margin-left: auto;
font-size: 22px;
width: 24px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
color: #B5B5B2;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.wolai-section-add:hover {
background: var(--wolai-bg-hover);
color: #555;
}
.mnote-empty-create-page {
margin-top: 14px;
height: 34px;
padding: 0 14px;
border: 1px solid var(--wolai-border);
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
cursor: pointer;
}
.mnote-empty-create-page:hover {
background: var(--wolai-bg-hover);
}
.wolai-page-row {
@@ -333,6 +361,17 @@ a:hover {
padding: 4px 8px;
}
.mnote-sidebar-nav.wolai-quick-actions {
flex: 0 0 auto;
}
.wolai-sidebar-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.mnote-sidebar-nav a {
display: flex;
align-items: center;
@@ -397,6 +436,13 @@ a:hover {
margin: 0;
}
.sidebar-tree .tree-empty {
padding: 6px 8px 8px;
color: var(--wolai-text-secondary);
font-size: 13px;
line-height: 1.5;
}
.sidebar-tree .tree-node {
list-style: none;
padding: 0;
+66 -5
View File
@@ -1,6 +1,7 @@
use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use base64::Engine;
use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeBridgeContextWire, RuntimeCommandArtifactPlan,
RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
@@ -9,7 +10,6 @@ use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use base64::Engine;
const HEADER_REQUEST_ID: &str = "x-request-id";
const HEADER_TRACE_ID: &str = "x-trace-id";
@@ -98,8 +98,8 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
"name": config.dev_user_name,
"email": config.dev_user_email,
});
let identity_encoded = base64::engine::general_purpose::STANDARD
.encode(identity.to_string().as_bytes());
let identity_encoded =
base64::engine::general_purpose::STANDARD.encode(identity.to_string().as_bytes());
Ok(format!("Convex {admin_key}:{identity_encoded}"))
}
@@ -332,6 +332,26 @@ pub async fn execute_sidebar_dataset_query(
execute_convex_query_plan(config, context, plan).await
}
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
let mut args = plan.args_json.clone();
if matches!(
plan.command_name.as_str(),
"documents.save" | "page.body.save"
) {
if let Value::Object(map) = &mut args {
// 当前自托管 Convex 的 documents:updateContent 仍是 legacy validator。
// Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。
map.remove("editorDocument");
map.remove("tiptapDocument");
map.remove("streamDeltaHint");
map.remove("domainEventHint");
map.remove("domainEventPlan");
map.remove("domainEventPlans");
}
}
args
}
pub async fn execute_convex_command_plan(
config: &AppConfig,
context: &RequestContext,
@@ -356,7 +376,7 @@ pub async fn execute_convex_command_plan(
let payload = json!({
"path": plan.function_name,
"format": "convex_encoded_json",
"args": [plan.args_json.clone()],
"args": [convex_command_args_for_plan(plan)],
});
let client = reqwest::Client::builder()
@@ -712,11 +732,13 @@ pub async fn execute_convex_command_plan_with_artifacts(
#[cfg(test)]
mod tests {
use super::build_authorization;
use super::{build_authorization, convex_command_args_for_plan};
use crate::app::AppConfig;
use crate::context::RequestContext;
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
use base64::Engine;
use bridge_runtime::RuntimeCommandExecutionPlan;
use serde_json::json;
fn config() -> AppConfig {
AppConfig {
@@ -750,6 +772,45 @@ mod tests {
)
}
#[test]
fn convex_command_args_strips_editor_runtime_fields_for_legacy_document_save() {
let plan = RuntimeCommandExecutionPlan {
command_name: "documents.save".into(),
command_id: "cmd_1".into(),
function_name: "documents:updateContent".into(),
workspace_id: Some("ws_1".into()),
request_id: "req_1".into(),
trace_id: "trace_1".into(),
actor_id: "actor_1".into(),
idempotency_key: None,
payload_json: "{}".into(),
args_json: json!({
"id": "doc_1",
"content": [],
"expectedRevision": 0,
"conflictDetectionKey": "doc_1:0",
"editorDocument": {"rootBlockIds": []},
"tiptapDocument": {"type": "doc", "content": []},
"streamDeltaHint": {"family": "tree"},
"domainEventHint": {"eventType": "page.body.saved"},
"domainEventPlan": {"eventType": "page.body.saved"},
"domainEventPlans": [{"eventType": "page.body.saved"}],
}),
};
let args = convex_command_args_for_plan(&plan);
assert_eq!(
args,
json!({
"id": "doc_1",
"content": [],
"expectedRevision": 0,
"conflictDetectionKey": "doc_1:0",
})
);
}
#[test]
fn build_authorization_prefers_forwarded_authorization() {
let mut headers = HeaderMap::new();
@@ -89,7 +89,7 @@ pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> Strin
);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">当前 file tree 没有可渲染的页面</li>"#,
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>"#,
);
html.push_str("</ul>");
return html;
@@ -89,12 +89,21 @@ fn render_page_row(
} else {
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
};
let parent_attr = input
.rows
.iter()
.find(|source| source.node_id == row.node_id)
.and_then(|source| source.parent_node_id.as_deref())
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
test_id = row.test_id,
parent_attr = parent_attr,
depth = row.depth,
active = active,
focused = focused,
tab_index = if focused { "0" } else { "-1" },
@@ -119,7 +128,7 @@ pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> Stri
String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面</li>"#,
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>"#,
);
html.push_str("</ul>");
return html;
+135 -24
View File
@@ -20,6 +20,7 @@ pub struct WorkspaceShellItem {
pub id: String,
pub title: String,
pub icon: Option<String>,
pub parent_id: Option<String>,
pub href: String,
pub depth: u32,
pub active: bool,
@@ -71,9 +72,7 @@ pub fn build_workspace_shell_projection(
})
.or_else(|| my_page_items.first().map(|item| item.id.clone()));
for item in &mut my_page_items {
item.active = active_page_id.as_deref().is_some_and(|active_id| active_id == item.id);
}
apply_active_page_to_items(&mut my_page_items, active_page_id.as_deref());
let mut starred_items = documents
.iter()
@@ -88,12 +87,7 @@ pub fn build_workspace_shell_projection(
.collect::<Vec<_>>();
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
let active_page_title = active_page_id.as_deref().and_then(|active_id| {
my_page_items
.iter()
.find(|item| item.id == active_id)
.map(|item| item.title.clone())
});
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
WorkspaceShellProjection {
schema: "mnote.workspace_shell.v1".into(),
@@ -176,16 +170,17 @@ fn document_to_item(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题");
let parent_id = document
.get("parent_id")
.or_else(|| document.get("parentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let depth = document
.get("depth")
.and_then(Value::as_u64)
.or_else(|| {
document
.get("parent_id")
.or_else(|| document.get("parentId"))
.and_then(Value::as_str)
.map(|_| 1)
})
.or_else(|| parent_id.as_ref().map(|_| 1))
.unwrap_or(0) as u32;
Some(WorkspaceShellItem {
id: id.to_string(),
@@ -196,12 +191,51 @@ fn document_to_item(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
parent_id,
href: format!("/documents/{id}?workspaceId={workspace_id}"),
depth,
active: active_page_id.is_some_and(|active_id| active_id == id),
})
}
fn apply_active_page_to_items(items: &mut [WorkspaceShellItem], active_page_id: Option<&str>) {
for item in items {
item.active = active_page_id.is_some_and(|active_id| active_id == item.id);
}
}
fn active_title_from_items(
items: &[WorkspaceShellItem],
active_page_id: Option<&str>,
) -> Option<String> {
active_page_id.and_then(|active_id| {
items
.iter()
.find(|item| item.id == active_id)
.map(|item| item.title.clone())
})
}
pub fn apply_active_page(projection: &mut WorkspaceShellProjection, active_page_id: Option<&str>) {
let normalized = active_page_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
projection.active_page_id = normalized;
apply_active_page_to_items(
&mut projection.my_page_items,
projection.active_page_id.as_deref(),
);
apply_active_page_to_items(
&mut projection.starred_items,
projection.active_page_id.as_deref(),
);
projection.active_page_title = active_title_from_items(
&projection.my_page_items,
projection.active_page_id.as_deref(),
);
}
trait StringEmptyExt {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}
@@ -248,15 +282,65 @@ mod tests {
assert_eq!(projection.starred_items[0].title, "个人");
assert_eq!(projection.my_page_items.len(), 2);
assert!(projection.my_page_items[0].active);
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "垃圾箱"));
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "模板中心"));
assert_eq!(
projection.my_page_items[1].parent_id.as_deref(),
Some("page_home")
);
assert!(projection
.bottom_entries
.iter()
.any(|entry| entry.label == "垃圾箱"));
assert!(projection
.bottom_entries
.iter()
.any(|entry| entry.label == "模板中心"));
}
#[test]
fn workspace_shell_sidebar_html_outputs_projection_rows_with_active_and_parent() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": [
{ "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 },
{ "id": "doc_child", "workspace_id": "ws_demo", "title": "Child", "parent_id": "doc_root", "sort_order": 1 }
]
});
let projection = build_workspace_shell_projection(
&dataset,
"ws_demo",
Some("doc_root"),
"开发用户 的工作区",
);
let html = render_workspace_shell_sidebar_html(&projection, None, None);
assert!(html.contains("data-testid=\"wolai-sidebar-row\""));
assert!(html.contains("data-node-id=\"doc_root\""));
assert!(html.contains("data-node-id=\"doc_child\""));
assert!(html.contains("data-parent-id=\"doc_root\""));
assert!(html.contains("data-active=\"true\""));
assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
assert!(html.contains("data-mnote-action=\"create-page\""));
}
#[test]
fn workspace_shell_sidebar_html_outputs_empty_state() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": []
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(&projection, None, None);
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\""));
}
}
pub fn render_workspace_shell_sidebar_html(
projection: &WorkspaceShellProjection,
sidebar_tree_html: Option<&str>,
file_tree_html: Option<&str>,
) -> String {
let starred_rows = if projection.starred_items.is_empty() {
String::new()
@@ -279,14 +363,28 @@ pub fn render_workspace_shell_sidebar_html(
.filter(|value| !value.is_empty())
.map(|html| {
format!(
r#"<div class="sidebar-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree">{html}</div></div>"#
r#"<div class="sidebar-tree-section sidebar-page-tree-section" data-testid="wolai-sidebar-page-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree" data-tree-shell-mode="page" data-workspace-id="{}">{html}</div></div>"#,
escape_html(&projection.workspace_id),
)
})
.unwrap_or_default();
let my_pages = if projected_my_pages.is_empty() {
let file_tree_panel = file_tree_html
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
format!(
r#"<section class="wolai-sidebar-section wolai-file-tree-section" aria-label="文件树" data-testid="wolai-sidebar-file-tree-section"><div class="wolai-section-title"><span>文件树</span><span class="wolai-section-caret">⌄</span></div><div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div></section>"#,
escape_html(&projection.workspace_id),
)
})
.unwrap_or_default();
let empty_state = r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-empty-state">暂无页面</div>"#;
let my_pages = if !tree_html.is_empty() {
tree_html
} else if !projected_my_pages.is_empty() {
projected_my_pages
} else {
format!("{projected_my_pages}{tree_html}")
empty_state.to_string()
};
let bottom_entries = projection
.bottom_entries
@@ -303,7 +401,8 @@ pub fn render_workspace_shell_sidebar_html(
.join("");
format!(
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="我的页面"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><span class="wolai-section-add">+</span></div>{my_pages}</section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div>{my_pages}</section>{file_tree_panel}<div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
escape_html(&projection.workspace_id),
)
}
@@ -314,10 +413,22 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
} else {
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
};
let parent_attr = item
.parent_id
.as_deref()
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let aria_current = if item.active {
r#" aria-current="page""#
} else {
""
};
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-node-id="{}"{depth_style}><span class="wolai-row-icon">{}</span>{}</a>"#,
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
escape_html(&item.href),
escape_html(&item.id),
item.depth,
item.active,
escape_html(item.icon.as_deref().unwrap_or("")),
escape_html(&item.title),
)