fix: relink rust web tree editor runtime
This commit is contained in:
@@ -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(
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载文件树 HTML(SSR)
|
||||
///
|
||||
/// 文件树与页面树共用同一份 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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user