2026-04-29 12:24:44 +08:00
|
|
|
|
use crate::app::{AppConfig, AppState};
|
|
|
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
|
use crate::page_aggregate::PageAggregate;
|
|
|
|
|
|
use crate::routes::documents::{
|
|
|
|
|
|
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
|
|
|
|
|
DocumentMetaQuery,
|
|
|
|
|
|
};
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use crate::routes::query_support::execute_runtime_query_against_data;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use crate::routes::snapshot_support::{
|
|
|
|
|
|
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use axum::extract::{Extension, Path, Query, State};
|
|
|
|
|
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
|
|
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
|
|
|
|
use axum::Json;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use core_protocol::KernelProjectionKind;
|
|
|
|
|
|
use serde::Deserialize;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use serde_json::json;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
use std::path::{Component, Path as FsPath, PathBuf};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
|
|
|
|
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
|
|
|
|
|
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
|
|
|
|
|
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentShellQuery {
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn document_page_shell(
|
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
|
Path(document_id): Path<String>,
|
|
|
|
|
|
Query(query): Query<DocumentShellQuery>,
|
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
|
let aggregate = build_page_aggregate_snapshot(
|
|
|
|
|
|
&state,
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&document_id,
|
|
|
|
|
|
query.workspace_id.as_deref(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let title = aggregate.head.title.as_str();
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let sidebar_tree_html =
|
2026-04-29 14:36:24 +08:00
|
|
|
|
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
2026-04-29 12:24:44 +08:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap_or_default();
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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());
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let body_content = crate::ssr::render_view(leptos::view! {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
<DocumentPage
|
|
|
|
|
|
title={title.to_string()}
|
|
|
|
|
|
document_id={document_id.clone()}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
workspace_id={workspace_id.clone()}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
sidebar_tree_html={sidebar_tree_html}
|
|
|
|
|
|
workspace_name={workspace_name}
|
|
|
|
|
|
workspace_sidebar_html={workspace_sidebar_html}
|
|
|
|
|
|
page_subtree_json={page_subtree_json}
|
|
|
|
|
|
/>
|
2026-04-29 12:24:44 +08:00
|
|
|
|
});
|
|
|
|
|
|
let html = format!(
|
|
|
|
|
|
r#"<!doctype html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<title>{}</title>
|
|
|
|
|
|
<style>{}</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
|
|
|
|
|
|
{}
|
|
|
|
|
|
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
2026-04-29 14:36:24 +08:00
|
|
|
|
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
|
|
|
|
|
{}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
{}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>"#,
|
|
|
|
|
|
escape_html(title),
|
|
|
|
|
|
crate::ssr::MNOTE_CSS,
|
|
|
|
|
|
escape_html(&document_id),
|
|
|
|
|
|
body_content,
|
|
|
|
|
|
escape_script_json(&snapshot_json),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
escape_script_json(&bootstrap_json),
|
2026-04-30 05:46:36 +08:00
|
|
|
|
render_document_title_controller_script(),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
render_editor_island_adapter_script(),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
);
|
|
|
|
|
|
let mut response = Html(html).into_response();
|
|
|
|
|
|
stamp_shell_headers(response.headers_mut(), "document");
|
|
|
|
|
|
stamp_recent_page_cookie(response.headers_mut(), &document_id);
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn build_editor_bootstrap_json(
|
|
|
|
|
|
aggregate: &PageAggregate,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
) -> String {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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",
|
2026-04-30 05:46:36 +08:00
|
|
|
|
"titleEndpoint": "/api/documents/title",
|
2026-04-29 14:36:24 +08:00
|
|
|
|
"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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn render_document_title_controller_script() -> &'static str {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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);
|
2026-04-30 06:58:17 +08:00
|
|
|
|
setText(`.tree-row[data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
|
|
|
|
|
setText(`.tree-row[data-document-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
|
|
|
|
|
setText(`.tree-row[data-doc-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
|
|
|
|
|
setText(`.wolai-page-row[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);
|
2026-04-30 05:46:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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>"#
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
pub async fn page_aggregate(
|
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
|
Path(document_id): Path<String>,
|
|
|
|
|
|
Query(query): Query<DocumentShellQuery>,
|
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
|
let aggregate = build_page_aggregate_snapshot(
|
|
|
|
|
|
&state,
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&document_id,
|
|
|
|
|
|
query.workspace_id.as_deref(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let projection_owner = aggregate.source_label();
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let mut response = (
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
Json(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"owner": "mnote-web",
|
|
|
|
|
|
"schema": "mnote.page_aggregate.v1",
|
|
|
|
|
|
"result": aggregate,
|
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
|
|
|
|
|
.into_response();
|
|
|
|
|
|
stamp_shell_headers(response.headers_mut(), "page-aggregate");
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) async fn build_page_aggregate_snapshot(
|
2026-04-29 12:24:44 +08:00
|
|
|
|
state: &AppState,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
document_id: &str,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
|
|
|
|
|
) -> Result<PageAggregate, WebError> {
|
|
|
|
|
|
let meta = load_document_meta_result(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
DocumentMetaQuery {
|
|
|
|
|
|
document_id: document_id.to_string(),
|
|
|
|
|
|
workspace_id: workspace_id.map(str::to_string),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
let content = load_document_content_result(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
DocumentContentQuery {
|
|
|
|
|
|
document_id: document_id.to_string(),
|
|
|
|
|
|
workspace_id: workspace_id.map(str::to_string),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}),
|
|
|
|
|
|
)?;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
2026-04-30 06:58:17 +08:00
|
|
|
|
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
|
|
|
|
|
|
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
|
|
|
|
|
|
})
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
|
|
|
|
|
let value = document_id
|
|
|
|
|
|
.chars()
|
|
|
|
|
|
.map(|ch| {
|
|
|
|
|
|
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
|
|
|
|
|
|
ch
|
|
|
|
|
|
} else {
|
|
|
|
|
|
'_'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect::<String>();
|
|
|
|
|
|
if value.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
|
|
|
|
|
|
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
|
|
|
|
|
headers.append(header::SET_COOKIE, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
|
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
|
|
|
|
|
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
|
|
|
|
|
|
headers.insert(name, HeaderValue::from_static(shell));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn escape_html(value: &str) -> String {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
value
|
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn escape_script_json(value: &str) -> String {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
value.replace("</script", "<\\/script")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) async fn load_workspace_shell_projection(
|
|
|
|
|
|
config: &AppConfig,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
|
default_workspace_name: &str,
|
|
|
|
|
|
) -> WorkspaceShellProjection {
|
|
|
|
|
|
let spec = ProjectionSnapshotSpec {
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
root_node_id: None,
|
|
|
|
|
|
depth: Some(99),
|
|
|
|
|
|
projection: KernelProjectionKind::SidebarTree,
|
|
|
|
|
|
query: None,
|
|
|
|
|
|
max_results: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let dataset = load_projection_snapshot(config, context, &spec)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map(|snapshot| snapshot.dataset)
|
|
|
|
|
|
.unwrap_or_else(|_| {
|
|
|
|
|
|
let documents = active_document_id
|
|
|
|
|
|
.map(|document_id| {
|
|
|
|
|
|
json!([{
|
|
|
|
|
|
"id": document_id,
|
|
|
|
|
|
"workspace_id": workspace_id,
|
|
|
|
|
|
"title": "个人",
|
|
|
|
|
|
"parent_id": null,
|
|
|
|
|
|
"sort_order": 0,
|
|
|
|
|
|
"is_starred": false
|
|
|
|
|
|
}])
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_else(|| json!([]));
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"active_workspace_id": workspace_id,
|
|
|
|
|
|
"active_page_id": active_document_id,
|
|
|
|
|
|
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
|
|
|
|
|
"documents": documents
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
build_workspace_shell_projection(
|
|
|
|
|
|
&dataset,
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
active_document_id,
|
|
|
|
|
|
default_workspace_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 加载侧栏页面树 HTML(SSR)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
|
|
|
|
|
/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。
|
|
|
|
|
|
/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。
|
|
|
|
|
|
pub(crate) async fn load_sidebar_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::SidebarTree,
|
|
|
|
|
|
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 => {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
// 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 }
|
|
|
|
|
|
])
|
|
|
|
|
|
});
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let dev_dataset = serde_json::json!({
|
|
|
|
|
|
"active_workspace_id": workspace_id,
|
2026-04-29 14:36:24 +08:00
|
|
|
|
"documents": documents,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
"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_page_tree_render_rows(&projection);
|
|
|
|
|
|
render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
|
|
|
|
|
rows,
|
|
|
|
|
|
active_node_id: active_document_id.map(ToOwned::to_owned),
|
|
|
|
|
|
focused_node_id: None,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
/// 加载文件树 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 })
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use crate::app::{build_app, AppConfig, AppState};
|
|
|
|
|
|
use axum::body::{to_bytes, Body};
|
|
|
|
|
|
use axum::http::{Request, StatusCode};
|
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
|
|
|
|
|
fn app() -> axum::Router {
|
|
|
|
|
|
build_app(AppState::new(AppConfig {
|
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
|
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
|
enable_legacy_next_compat: true,
|
|
|
|
|
|
enable_debug_shell_routes: false,
|
|
|
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
|
convex_url: None,
|
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
|
query_fixtures_json: Some(
|
|
|
|
|
|
r#"{
|
|
|
|
|
|
"documents:getMeta": {
|
|
|
|
|
|
"id": "doc_1",
|
|
|
|
|
|
"workspace_id": "ws_demo",
|
|
|
|
|
|
"title": "服务端页面",
|
|
|
|
|
|
"updated_at": "2026-04-18T09:30:00Z",
|
|
|
|
|
|
"can_edit": true,
|
|
|
|
|
|
"word_count": 42,
|
|
|
|
|
|
"character_count": 128,
|
|
|
|
|
|
"block_count": 3
|
|
|
|
|
|
},
|
|
|
|
|
|
"documents:getContent": {
|
|
|
|
|
|
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
|
|
|
|
|
"revision": 7,
|
|
|
|
|
|
"conflict_detection_key": "doc_1:7",
|
|
|
|
|
|
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
|
|
|
|
|
}
|
|
|
|
|
|
}"#
|
|
|
|
|
|
.into(),
|
|
|
|
|
|
),
|
|
|
|
|
|
mutation_fixtures_json: None,
|
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn document_shell_returns_page_aggregate_snapshot() {
|
|
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/documents/doc_1?workspaceId=ws_demo")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.expect("request"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-mnote-web-owner")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("mnote-web")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-mnote-web-shell")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("document")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get_all("set-cookie")
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|value| value
|
|
|
|
|
|
.to_str()
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
.contains("mnote_recent_page_id=doc_1")));
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("body");
|
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
|
|
|
|
|
assert!(html.contains("data-testid=\"wolai-sidebar\""));
|
|
|
|
|
|
assert!(html.contains("data-testid=\"wolai-topbar\""));
|
|
|
|
|
|
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
|
|
|
|
|
|
assert!(html.contains("星标置顶"));
|
|
|
|
|
|
assert!(html.contains("我的页面"));
|
|
|
|
|
|
assert!(html.contains("垃圾箱"));
|
|
|
|
|
|
assert!(html.contains("模板中心"));
|
|
|
|
|
|
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
2026-04-29 14:36:24 +08:00
|
|
|
|
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
|
|
|
|
|
|
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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"));
|
2026-04-30 06:58:17 +08:00
|
|
|
|
assert!(html
|
|
|
|
|
|
.contains(".tree-row[data-node-id=\"${escapedId}\"] > .tree-link > .tree-link-title"));
|
|
|
|
|
|
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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"));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert!(!html.contains("mnote-web-document-shell"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn page_aggregate_endpoint_returns_snapshot_contract() {
|
|
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.expect("request"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-mnote-page-aggregate-owner")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("rust-kernel")
|
|
|
|
|
|
);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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");
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert_eq!(payload["result"]["source"], "KernelProjection");
|
|
|
|
|
|
assert_eq!(payload["result"]["projectionVersion"], 1);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
|
|
|
|
|
assert_eq!(payload["result"]["body"]["revision"], 7);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|