Files
mnote/rust/crates/mnote-web/src/routes/web_shell.rs
T
lix-2026 d148e1ccc2 feat: 收口本地文件夹入口并推进 page aggregate rust-first
- 为 Rust Web 主入口补齐本地文件夹/云空间切换、最近目录与路径回填体验\n- 对齐 local markdown media 与 inline marks 的 Rust shell / TipTap converter 语义\n- 让 documents/page 优先消费 Rust page aggregate snapshot,并保留 TS fallback\n- 补强 tree live、local markdown 与主入口 smoke,并同步设计稿状态
2026-05-09 19:05:06 +08:00

2752 lines
112 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
use crate::routes::local_folder_source::{
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
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::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 bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::json;
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";
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 source_kind: Option<String>,
pub root_uri: Option<String>,
pub secondary_document_id: Option<String>,
pub secondary_source_kind: Option<String>,
pub secondary_root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentPageCompatQuery {
pub document_id: String,
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 primary_source_kind = normalize_source_kind(query.source_kind.as_deref());
let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref());
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
primary_source_kind,
primary_root_uri,
)
.await?;
let title = aggregate.head.title.as_str();
let workspace_id = aggregate.identity.workspace_id.clone();
let requested_secondary_document_id =
normalize_optional_owned(query.secondary_document_id.as_deref());
let secondary_source_kind = normalize_source_kind(
query
.secondary_source_kind
.as_deref()
.or(primary_source_kind),
);
let secondary_root_uri =
normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri));
let mut secondary_requested = false;
let mut secondary_invalid = false;
let secondary_aggregate =
if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() {
secondary_requested = true;
match build_page_aggregate_snapshot(
&state,
&context,
secondary_document_id,
query.workspace_id.as_deref(),
secondary_source_kind,
secondary_root_uri,
)
.await
{
Ok(aggregate) => Some(aggregate),
Err(_) => {
secondary_invalid = true;
None
}
}
} else {
None
};
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 is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
let root_uri = query.root_uri.as_deref().unwrap_or_default();
(
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
render_local_file_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
)
} else {
(
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default(),
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 page_options_json = serde_json::to_string(&aggregate.layout.page_options)
.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, primary_source_kind, primary_root_uri);
let secondary_page_subtree_json = secondary_aggregate.as_ref().map(|aggregate| {
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string())
});
let secondary_page_options_json = secondary_aggregate.as_ref().map(|aggregate| {
serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string())
});
let secondary_snapshot_json = secondary_aggregate
.as_ref()
.map(|aggregate| serde_json::to_string(aggregate).unwrap_or_else(|_| "null".to_string()));
let secondary_bootstrap_json = secondary_aggregate.as_ref().map(|aggregate| {
build_editor_bootstrap_json_with_ids(
aggregate,
&context,
secondary_source_kind,
secondary_root_uri,
"__MNOTE_SECONDARY_PAGE_AGGREGATE__",
"secondary",
)
});
let panes_bootstrap_json = build_document_panes_bootstrap_json(
&aggregate,
&context,
primary_source_kind,
primary_root_uri,
secondary_aggregate.as_ref(),
secondary_source_kind,
secondary_root_uri,
secondary_requested,
secondary_invalid,
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
document_id={document_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
page_subtree_json={page_subtree_json}
page_options_json={page_options_json}
secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
secondary_document_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.document_id.clone()).unwrap_or_default()}
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
/>
});
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="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}
{}
{}
{}
</body>
</html>"#,
escape_html(title),
crate::ssr::MNOTE_CSS,
escape_html(&document_id),
secondary_requested,
secondary_invalid,
body_content,
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
escape_script_json(&panes_bootstrap_json),
secondary_snapshot_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
.unwrap_or_default(),
secondary_bootstrap_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__" type="application/json">{}</script>"#, escape_script_json(value)))
.unwrap_or_default(),
render_document_title_controller_script(),
render_editor_island_adapter_script(),
);
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)
}
pub(crate) fn build_editor_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> String {
build_editor_bootstrap_json_with_ids(
aggregate,
context,
source_kind,
root_uri,
"__MNOTE_PAGE_AGGREGATE__",
"primary",
)
}
pub(crate) fn build_editor_bootstrap_json_with_ids(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
page_aggregate_script_id: &str,
pane_role: &str,
) -> String {
serde_json::to_string(&json!({
"schema": "mnote.editor_bootstrap.v1",
"documentId": aggregate.identity.document_id,
"workspaceId": aggregate.identity.workspace_id,
"paneRole": pane_role,
"sourceKind": source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("convex_workspace"),
"rootUri": root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(""),
"pageAggregateScriptId": page_aggregate_script_id,
"saveEndpoint": "/api/documents/save",
"titleEndpoint": "/api/documents/title",
"editorHostKind": "leptos_tiptap_island",
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn build_document_panes_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
secondary_aggregate: Option<&PageAggregate>,
secondary_source_kind: Option<&str>,
secondary_root_uri: Option<&str>,
secondary_requested: bool,
secondary_invalid: bool,
) -> String {
let primary = json!({
"role": "primary",
"aggregate": aggregate,
"bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
aggregate,
context,
source_kind,
root_uri,
"__MNOTE_PAGE_AGGREGATE__",
"primary",
)).unwrap_or_else(|_| json!({})),
});
let secondary = secondary_aggregate.map(|aggregate| {
json!({
"role": "secondary",
"aggregate": aggregate,
"bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
aggregate,
context,
secondary_source_kind,
secondary_root_uri,
"__MNOTE_SECONDARY_PAGE_AGGREGATE__",
"secondary",
)).unwrap_or_else(|_| json!({})),
})
});
serde_json::to_string(&json!({
"schema": "mnote.document_panes_bootstrap.v1",
"secondaryRequested": secondary_requested,
"secondaryInvalid": secondary_invalid,
"panes": match secondary {
Some(secondary) => vec![primary, secondary],
None => vec![primary],
},
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn normalize_optional_query_value(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn normalize_source_kind(value: Option<&str>) -> Option<&str> {
normalize_optional_query_value(value)
}
fn normalize_optional_owned(value: Option<&str>) -> Option<String> {
normalize_optional_query_value(value).map(ToOwned::to_owned)
}
pub(crate) fn render_document_title_controller_script() -> &'static str {
r#"<script>
(() => {
const CONTRACT = 'mnote.document_title_controller.v1';
const inputs = Array.from(document.querySelectorAll('[data-page-title-input="true"]')).filter((node) => node instanceof HTMLTextAreaElement);
if (!inputs.length) return;
const cssEscape = (value) => {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
};
const autosize = (input) => {
input.style.height = 'auto';
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
};
const setStatus = (input, 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 = (input, title, documentId) => {
const pane = input.closest('[data-document-pane="true"]');
const isPrimaryDocument = documentId && document.body?.dataset.documentId === documentId;
if (isPrimaryDocument) {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
}
if (documentId) {
const escapedId = cssEscape(documentId);
document.querySelectorAll(`[data-page-title-input="true"][data-document-id="${escapedId}"]`).forEach((node) => {
if (!(node instanceof HTMLTextAreaElement) || node === input) return;
node.value = title;
autosize(node);
setStatus(node, 'saved');
});
document.querySelectorAll(`[data-document-pane="true"][data-pane-document-id="${escapedId}"] [data-page-title-current="true"]`).forEach((node) => {
if (node instanceof HTMLElement && node !== input) node.textContent = title;
});
} else {
pane?.querySelectorAll('[data-page-title-current]').forEach((node) => {
if (node instanceof HTMLElement && node !== input) node.textContent = title;
});
}
const current = document.querySelector('.wolai-breadcrumb-current');
if (current instanceof HTMLElement && isPrimaryDocument) {
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);
const escapedDocRowId = cssEscape(`doc:${documentId}`);
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .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);
};
inputs.forEach((input) => {
input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
const paneRole = (input.getAttribute('data-pane-role') || 'primary').trim();
const rawDocumentId = (input.getAttribute('data-document-id') || '').trim();
const documentId = rawDocumentId || (
paneRole === 'primary'
? (document.body?.dataset.documentId || '').trim()
: ''
);
const query = new URLSearchParams(window.location.search);
const paneQueryParams = paneRole === 'secondary'
? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' }
: { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' };
const sourceKindParam = paneQueryParams.sourceKindParam;
const rootUriParam = paneQueryParams.rootUriParam;
const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim();
const sourceKind = (query.get(sourceKindParam) || '').trim();
const rootUri = (query.get(rootUriParam) || '').trim();
let lastSavedTitle = input.value.trim() || '无标题';
let saving = false;
const saveTitle = async () => {
const title = input.value.trim() || '无标题';
autosize(input);
if (!documentId || saving || title === lastSavedTitle) {
updateVisibleTitle(input, title, documentId);
setStatus(input, 'saved');
return;
}
saving = true;
setStatus(input, 'saving');
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId,
workspaceId: workspaceId || null,
sourceKind: sourceKind || undefined,
rootUri: rootUri || undefined,
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(input, title, documentId);
setStatus(input, 'saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', {
detail: { documentId, workspaceId: workspaceId || null, title, payload },
}));
} catch (error) {
setStatus(input, 'error', error instanceof Error ? error.message : String(error));
} finally {
saving = false;
}
};
input.addEventListener('input', () => {
autosize(input);
setStatus(input, 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(input);
updateVisibleTitle(input, lastSavedTitle, documentId);
setStatus(input, 'saved');
});
})();
</script>"#
}
pub(crate) fn render_editor_island_adapter_script() -> &'static str {
r#"<script type="module">
(() => {
const PANES_BOOTSTRAP_ID = '__MNOTE_DOCUMENT_PANES_BOOTSTRAP__';
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 COMMAND_EVENT = `${EVENT_PREFIX}:command`;
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
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 panesBootstrap = parseJsonScript(PANES_BOOTSTRAP_ID);
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes) || panesBootstrap.panes.length === 0) return;
const paneRouteConfig = {
primary: {
sourceKindParam: 'sourceKind',
rootUriParam: 'rootUri',
secondary: false,
},
secondary: {
documentIdParam: 'secondaryDocumentId',
sourceKindParam: 'secondarySourceKind',
rootUriParam: 'secondaryRootUri',
secondary: true,
},
};
const secondaryQueryParamNames = [
paneRouteConfig.secondary.documentIdParam,
paneRouteConfig.secondary.sourceKindParam,
paneRouteConfig.secondary.rootUriParam,
];
const currentUrl = () => new URL(window.location.href);
const replaceUrlState = (url) => {
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
};
const pushUrlState = (url) => {
window.history.pushState({}, '', url.pathname + url.search + url.hash);
};
const clearSecondaryParams = () => {
const url = currentUrl();
secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name));
replaceUrlState(url);
};
if (panesBootstrap.secondaryInvalid === true) clearSecondaryParams();
window.addEventListener('tree.page.open-right', (event) => {
const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : '';
if (!documentId) return;
const url = currentUrl();
url.searchParams.set(paneRouteConfig.secondary.documentIdParam, documentId);
const primarySourceKind = (url.searchParams.get(paneRouteConfig.primary.sourceKindParam) || '').trim();
const primaryRootUri = (url.searchParams.get(paneRouteConfig.primary.rootUriParam) || '').trim();
if (primarySourceKind) url.searchParams.set(paneRouteConfig.secondary.sourceKindParam, primarySourceKind);
else url.searchParams.delete(paneRouteConfig.secondary.sourceKindParam);
if (primaryRootUri) url.searchParams.set(paneRouteConfig.secondary.rootUriParam, primaryRootUri);
else url.searchParams.delete(paneRouteConfig.secondary.rootUriParam);
if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') {
void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({
documentId,
sourceKind: primarySourceKind || null,
rootUri: primaryRootUri || null,
url,
});
return;
}
window.location.assign(url.pathname + url.search + url.hash);
});
document.querySelectorAll('[data-mnote-pane-close="secondary"]').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const url = currentUrl();
secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name));
if (typeof window.__mnoteDocumentPaneRuntime?.closeSecondaryDocument === 'function') {
window.__mnoteDocumentPaneRuntime.closeSecondaryDocument({ url });
return;
}
window.location.assign(url.pathname + url.search + url.hash);
});
});
const workspace = document.querySelector('[data-testid="mnote-document-workspace"]');
const resizer = document.querySelector('[data-document-pane-resizer="true"]');
const SECONDARY_WIDTH_KEY = 'mnote.document.secondary.width';
const applyStoredSecondaryWidth = () => {
if (!(workspace instanceof HTMLElement)) return;
if (workspace.getAttribute('data-has-secondary-pane') !== 'true') {
workspace.style.removeProperty('grid-template-columns');
return;
}
try {
const raw = window.localStorage ? window.localStorage.getItem(SECONDARY_WIDTH_KEY) : '';
const width = Number(raw || 0);
if (Number.isFinite(width) && width >= 320) {
workspace.style.gridTemplateColumns = `minmax(0, 1fr) 6px minmax(320px, ${Math.round(width)}px)`;
}
} catch (_) {}
};
applyStoredSecondaryWidth();
if (workspace instanceof HTMLElement && resizer instanceof HTMLElement) {
resizer.addEventListener('pointerdown', (event) => {
if (workspace.getAttribute('data-has-secondary-pane') !== 'true') return;
event.preventDefault();
const move = (nextEvent) => {
const rect = workspace.getBoundingClientRect();
const width = Math.min(Math.max(320, rect.right - nextEvent.clientX), Math.max(420, rect.width - 360));
workspace.style.gridTemplateColumns = `minmax(0, 1fr) 6px minmax(320px, ${Math.round(width)}px)`;
try {
if (window.localStorage) window.localStorage.setItem(SECONDARY_WIDTH_KEY, String(Math.round(width)));
} catch (_) {}
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});
}
const buildPaneRuntime = (paneDescriptor) => {
const paneRole = typeof paneDescriptor?.role === 'string' ? paneDescriptor.role : 'primary';
const paneRoot = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
if (!(paneRoot instanceof HTMLElement)) return null;
if (!paneDescriptor?.aggregate || !paneDescriptor?.bootstrap) return null;
return {
paneRole,
root: paneRoot,
observability,
aggregate: paneDescriptor.aggregate,
bootstrap: paneDescriptor.bootstrap,
};
};
const paneRuntimes = panesBootstrap.panes.map(buildPaneRuntime).filter(Boolean);
if (!paneRuntimes.length) return;
const loadRuntime = async () => {
if (window.__mnoteLeptosTiptapRuntimePromise) {
return window.__mnoteLeptosTiptapRuntimePromise;
}
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
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' || typeof runtime.unmount !== 'function') {
throw new Error('island runtime 导出不完整');
}
await runtime.default(wasmUrl);
return runtime;
})();
return window.__mnoteLeptosTiptapRuntimePromise;
};
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 firstNonEmptyText = (...values) => {
for (const value of values) {
const text = flattenText(value).trim();
if (text) return text;
}
return '';
};
const legacyStylesToTiptapMarks = (styles) => {
if (!styles || typeof styles !== 'object') return [];
const marks = [];
if (styles.bold) marks.push({ type: 'bold' });
if (styles.italic) marks.push({ type: 'italic' });
if (styles.underline) marks.push({ type: 'underline' });
if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' });
if (styles.code || styles.inlineCode) marks.push({ type: 'code' });
const href = typeof styles.link === 'string' && styles.link.trim()
? styles.link.trim()
: typeof styles.href === 'string' && styles.href.trim()
? styles.href.trim()
: '';
if (href) marks.push({ type: 'link', attrs: { href } });
return marks;
};
const legacyMarkArrayToTiptapMarks = (inlineMarks) => {
if (!Array.isArray(inlineMarks)) return [];
return inlineMarks.flatMap((mark) => {
if (!mark || typeof mark !== 'object') return [];
if (mark.type === 'bold' || mark.type === 'italic' || mark.type === 'underline' || mark.type === 'strike' || mark.type === 'code') {
return [{ type: mark.type }];
}
if (mark.type === 'link') {
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
return href ? [{ type: 'link', attrs: { href } }] : [];
}
return [];
});
};
const mergeTiptapMarks = (...groups) => {
const seen = new Set();
return groups.flat().filter((mark) => {
const key = `${mark.type}:${JSON.stringify(mark.attrs || {})}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const legacyInlineContentToTiptap = (value) => {
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
if (value && typeof value === 'object') {
const text = typeof value.text === 'string' ? value.text : '';
if (text) {
const marks = mergeTiptapMarks(
legacyStylesToTiptapMarks(value.styles),
legacyMarkArrayToTiptapMarks(value.marks)
);
return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }];
}
return legacyInlineContentToTiptap(value.content || value.contentNodes);
}
return [];
};
const legacyBlockToTiptap = (block, index = 0) => {
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
const blockId = typeof block?.id === 'string' && block.id.trim()
? block.id.trim()
: typeof block?.blockId === 'string' && block.blockId.trim()
? block.blockId.trim()
: `block-${index + 1}`;
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
const textAlign = typeof block?.props?.textAlign === 'string'
? block.props.textAlign
: typeof block?.props?.text_align === 'string'
? block.props.text_align
: undefined;
const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs;
const nestedChildren = Array.isArray(block?.children)
? block.children.map((child, childIndex) => legacyBlockToTiptap(child, childIndex)).filter(Boolean)
: [];
const withListChildren = (itemType, listType, attrs = {}) => ({
type: listType,
attrs: { blockId },
content: [{
type: itemType,
attrs: { blockId, ...attrs },
content: [
{ type: 'paragraph', attrs: { blockId }, content },
...nestedChildren,
],
}],
});
if (type === 'heading') {
const level = Number(block?.props?.level || block?.level || 1) || 1;
const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {};
return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
}
if (type === 'bulletListItem' || type === 'bullet_list_item') return withListChildren('listItem', 'bulletList');
if (type === 'numberedListItem' || type === 'numbered_list_item') return withListChildren('listItem', 'orderedList');
if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') {
return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) });
}
if (type === 'quote' || type === 'blockquote') {
return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] };
}
if (type === 'codeBlock' || type === 'code_block') {
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
}
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
if (type === 'media') {
const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src);
const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath);
const mediaContent = name
? [{
type: 'text',
text: name,
...(sourcePath ? { marks: [{ type: 'link', attrs: { href: sourcePath } }] } : {}),
}]
: content;
return { type: 'paragraph', attrs: withTextAlign({ blockId }), content: mediaContent };
}
if (type === 'table') {
const tableSnapshot = block?.props?.tiptapTable;
if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') return tableSnapshot;
return {
type: 'table',
content: [{
type: 'tableRow',
content: [{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [{ type: 'paragraph', content }],
}],
}],
};
}
if (type === 'toc' || type === 'tocNode' || type === 'toc_node') {
const tocSnapshot = block?.props?.tiptapTocNode || block?.props?.tiptapToc;
if (tocSnapshot && typeof tocSnapshot === 'object' && tocSnapshot.type === 'tocNode') return tocSnapshot;
return {
type: 'tocNode',
attrs: {
topOffset: Number(block?.props?.topOffset || block?.props?.top_offset || 0) || 0,
maxShowCount: Number(block?.props?.maxShowCount || block?.props?.max_show_count || 20) || 20,
showTitle: block?.props?.showTitle !== false,
},
};
}
if (type === 'image') {
const imageSnapshot = block?.props?.tiptapImage;
if (imageSnapshot && typeof imageSnapshot === 'object' && imageSnapshot.type === 'image') return imageSnapshot;
const attrs = {
src: String(block?.props?.src || block?.src || ''),
alt: block?.props?.alt || block?.alt || null,
title: block?.props?.title || block?.title || null,
};
return attrs.src ? { type: 'image', attrs: { blockId, ...attrs } } : null;
}
return { type: 'paragraph', attrs: withTextAlign({ blockId }), content };
};
const textToTiptapDocument = (text) => ({
type: 'doc',
content: [{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : [],
}],
});
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 inlineTextNodes = (node) => {
if (!node || typeof node !== 'object') return [];
if (Array.isArray(node.content)) {
return node.content.flatMap((child) => {
if (child?.type === 'text') {
const text = typeof child.text === 'string' ? child.text : '';
if (!text) return [];
const styles = {};
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
if (mark?.type === 'bold') styles.bold = true;
if (mark?.type === 'italic') styles.italic = true;
if (mark?.type === 'underline') styles.underline = true;
if (mark?.type === 'strike') styles.strike = true;
if (mark?.type === 'code') styles.code = true;
if (mark?.type === 'link') {
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
if (href) styles.link = href;
}
}
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
}
if (child?.type === 'hardBreak') return [{ type: 'text', text: '\n' }];
return inlineTextNodes(child);
});
}
return [];
};
const firstChild = (node) => Array.isArray(node?.content) ? node.content[0] : null;
const blockIdOf = (node, index) => {
const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : '';
return raw || `block-${index + 1}`;
};
const tiptapNodeToEditorBlock = (node, index) => {
const blockId = blockIdOf(node, index);
if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] };
if (node?.type === 'heading') {
const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1));
return { blockId, blockType: 'heading', props: { headingLevel: level }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
}
if (node?.type === 'bulletList') return { blockId, blockType: 'bullet_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] };
if (node?.type === 'orderedList') return { blockId, blockType: 'numbered_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] };
if (node?.type === 'taskList') {
const taskItem = firstChild(node);
return { blockId, blockType: 'todo', props: { checked: Boolean(taskItem?.attrs?.checked) }, contentNodes: inlineTextNodes(firstChild(taskItem)), childBlockIds: [] };
}
if (node?.type === 'blockquote') return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] };
if (node?.type === 'codeBlock') return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
if (node?.type === 'horizontalRule') return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] };
if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] };
if (node?.type === 'tocNode') return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] };
if (node?.type === 'table') return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
return null;
};
const editorDocumentFromTiptapDocument = (bootstrap, tiptapDocument) => {
const content = Array.isArray(tiptapDocument?.content) ? tiptapDocument.content : [];
const blocks = content.map(tiptapNodeToEditorBlock).filter(Boolean);
return {
documentId: bootstrap.documentId,
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
};
const legacyBlocksFromEditorDocument = (editorDocument) => (
Array.isArray(editorDocument?.blocks) ? editorDocument.blocks : []
).map((block) => ({
id: block.blockId,
type: block.blockType,
props: block.blockType === 'heading'
? { level: block.props?.headingLevel || 1 }
: block.blockType === 'todo'
? { checked: Boolean(block.props?.checked) }
: block.blockType === 'code_block'
? { language: block.props?.language || null }
: block.blockType === 'image'
? { ...(block.props || {}) }
: block.blockType === 'toc'
? { ...(block.props || {}) }
: block.blockType === 'table'
? { ...(block.props || {}) }
: undefined,
content: Array.isArray(block.contentNodes)
? block.contentNodes.map((node) => {
if (!node || typeof node !== 'object') return null;
const text = typeof node.text === 'string' ? node.text : '';
if (!text) return null;
return { type: 'text', text, ...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}) };
}).filter(Boolean)
: '',
}));
const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string'
? body.conflictDetectionKey
: typeof body?.conflict_detection_key === 'string'
? body.conflict_detection_key
: null;
const revisionFromConflictKey = (value) => {
const match = String(value || '').match(/:(\d+)$/);
return match ? Number(match[1]) : null;
};
const normalizeBridgeValue = (value) => {
if (value instanceof Map) {
const out = {};
for (const [key, item] of value.entries()) out[key] = normalizeBridgeValue(item);
return out;
}
if (Array.isArray(value)) return value.map(normalizeBridgeValue);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeBridgeValue(item)]));
}
return value;
};
const normalizeEnvelopePayload = (event) => {
const detail = normalizeBridgeValue(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 setStatus = (runtime, status, message) => {
runtime.root.setAttribute('data-runtime-editor-status', status);
if (message) {
runtime.root.setAttribute('data-runtime-editor-error', message);
} else {
runtime.root.removeAttribute('data-runtime-editor-error');
}
if (runtime.observability instanceof HTMLElement) {
runtime.observability.setAttribute('data-editor-host-status', status);
runtime.observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island');
}
};
const pageAggregateUrl = (bootstrap) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin);
url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder');
if (bootstrap.workspaceId) url.searchParams.set('workspaceId', bootstrap.workspaceId);
if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri);
return url;
};
const pageAggregateUrlFromDescriptor = (descriptor) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(descriptor.documentId)}`, window.location.origin);
url.searchParams.set('sourceKind', descriptor.sourceKind || 'convex_workspace');
if (descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId);
if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri);
return url;
};
const buildBootstrapFromAggregate = (aggregate, descriptor, paneRole) => ({
schema: 'mnote.editor_bootstrap.v1',
documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId,
workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '',
paneRole,
sourceKind: descriptor.sourceKind || 'convex_workspace',
rootUri: descriptor.rootUri || '',
pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__',
saveEndpoint: '/api/documents/save',
titleEndpoint: '/api/documents/title',
editorHostKind: 'leptos_tiptap_island',
});
const documentSessionRegistry = new Map();
const localFolderEventRegistry = new Map();
const paneViewRegistry = new Map();
let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const SESSION_RELEASE_DELAY_MS = 1200;
const parseLocalFolderEventPayload = (event) => {
try {
return JSON.parse(String(event?.data || '{}'));
} catch (_) {
return null;
}
};
const normalizeSessionSourceKind = (bootstrap) => {
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
return value || 'convex_workspace';
};
const buildDocumentSessionKey = (bootstrap) => {
const sourceKind = normalizeSessionSourceKind(bootstrap);
const scope = sourceKind === 'local_folder'
? String(bootstrap?.rootUri || '').trim()
: String(bootstrap?.workspaceId || '').trim();
return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`;
};
const sessionViews = (session) => Array.from(session.views.values());
const detachSessionFromLocalFolderChannel = (session) => {
const channel = session.localFolderChannel;
if (!channel) return;
channel.sessions.delete(session.key);
if (channel.sessions.size === 0) {
try {
channel.eventSource.close();
} catch (_) {
// noop
}
localFolderEventRegistry.delete(channel.rootUri);
}
session.localFolderChannel = null;
};
const releaseDocumentSession = (session) => {
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
if (session.externalRefreshTimer) {
window.clearTimeout(session.externalRefreshTimer);
session.externalRefreshTimer = 0;
}
if (session.releaseTimer) {
window.clearTimeout(session.releaseTimer);
session.releaseTimer = 0;
}
detachSessionFromLocalFolderChannel(session);
if (documentSessionRegistry.get(session.key) === session) {
documentSessionRegistry.delete(session.key);
}
};
const scheduleDocumentSessionRelease = (session) => {
if (session.views.size > 0) return;
if (session.releaseTimer) window.clearTimeout(session.releaseTimer);
session.releaseTimer = window.setTimeout(() => {
session.releaseTimer = 0;
if (session.views.size === 0) {
releaseDocumentSession(session);
}
}, SESSION_RELEASE_DELAY_MS);
};
const cancelDocumentSessionRelease = (session) => {
if (!session.releaseTimer) return;
window.clearTimeout(session.releaseTimer);
session.releaseTimer = 0;
};
window.__mnoteDebugDocumentSessions = {
snapshot: () => ({
sessionCount: documentSessionRegistry.size,
sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({
key,
documentId: session.documentId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
workspaceId: session.workspaceId,
viewCount: session.views.size,
status: session.status,
})),
localFolderChannelCount: localFolderEventRegistry.size,
localFolderRoots: Array.from(localFolderEventRegistry.keys()),
}),
};
const currentEditorText = (view) => {
const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
return editor?.textContent || '';
};
const findScrollableEditorContainer = (view) => {
let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
while (current instanceof HTMLElement) {
if (current.scrollHeight > current.clientHeight + 8) {
return current;
}
current = current.parentElement;
}
return null;
};
const preserveViewScrollPosition = (view, operation) => {
const scrollable = findScrollableEditorContainer(view);
const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null;
const viewportTop = window.scrollY;
const viewportLeft = window.scrollX;
operation();
const restore = () => {
const current = findScrollableEditorContainer(view);
if (current instanceof HTMLElement && scrollTop != null) {
current.scrollTop = scrollTop;
}
if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) {
window.scrollTo({
top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY,
left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX,
behavior: 'auto',
});
}
};
window.requestAnimationFrame(() => {
restore();
window.setTimeout(restore, 0);
window.setTimeout(restore, 80);
window.setTimeout(restore, 180);
window.setTimeout(restore, 320);
});
};
const dispatchRuntimeCommand = (view, payload, source) => {
view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(COMMAND_EVENT, {
bubbles: true,
detail: {
protocol: BRIDGE_PROTOCOL,
runtime: 'mnote-leptos-tiptap-spike',
version: '1.1.0',
source: source || 'mnote-web-document-session',
event: COMMAND_EVENT,
payload,
},
}));
};
const clearEmbeddedLocalDraft = (runtimeDescriptor) => {
if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return;
try {
const storage = window.localStorage;
const base = 'mnote.leptos-tiptap-spike.document';
const keys = [
`${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`,
`${base}:${runtimeDescriptor.bootstrap.documentId}`,
];
for (const key of keys) storage.removeItem(key);
} catch (error) {
console.warn('mnote local folder 草稿清理失败', error);
}
};
const setSessionStatus = (session, status, message) => {
session.status = status;
session.error = message || null;
sessionViews(session).forEach((view) => {
setStatus(view.runtimeDescriptor, status, message);
});
};
const dispatchSessionContentToView = (session, view, source) => {
view.suppressedSerialized = session.currentSerialized;
view.lastKnownSerialized = session.currentSerialized;
preserveViewScrollPosition(view, () => {
dispatchRuntimeCommand(view, {
command: 'replaceContent',
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
}, source || 'mnote-web-document-session-sync');
});
};
const dispatchSessionMetaToView = (session, view, source) => {
dispatchRuntimeCommand(view, {
command: 'replaceContent',
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
}, source || 'mnote-web-document-session-meta');
};
const syncSessionMetaToViews = (session) => {
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionMetaToView(session, view);
});
};
const broadcastSessionContent = (session, sourceView) => {
sessionViews(session).forEach((view) => {
if (sourceView && view.id === sourceView.id) return;
if (view.mountId == null) return;
dispatchSessionContentToView(session, view);
});
};
const sessionPlainText = (session) => flattenText(session.currentTiptapDocument).replace(/\s+/g, ' ').trim();
const sessionHasRecentExternalSignal = (session) => (
session.sourceKind === 'local_folder'
&& Number.isFinite(session.lastExternalChangeSignalAt)
&& session.lastExternalChangeSignalAt > 0
&& (Date.now() - session.lastExternalChangeSignalAt) < 1500
);
const sessionHasRecentLocalInput = (session) => (
Number.isFinite(session.lastUserInputAt)
&& session.lastUserInputAt > 0
&& (Date.now() - session.lastUserInputAt) < 1500
);
const markSessionExternalConflict = (session, message) => {
session.externalChangePending = false;
session.hasExternalConflict = true;
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
};
const queueSessionSave = (session) => {
if (session.readOnly || session.hasExternalConflict) return;
if (session.saveTimer) window.clearTimeout(session.saveTimer);
setSessionStatus(session, 'dirty');
session.saveTimer = window.setTimeout(() => {
session.saveTimer = 0;
void persistSession(session);
}, 650);
};
const persistSession = async (session) => {
if (session.readOnly || session.saving || session.hasExternalConflict) return;
const serialized = session.currentSerialized;
if (!session.dirty && serialized === session.lastPersistedSerialized) {
setSessionStatus(session, 'saved');
return;
}
session.saving = true;
setSessionStatus(session, 'saving');
try {
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
const content = legacyBlocksFromEditorDocument(editorDocument);
const response = await fetch(session.saveEndpoint || '/api/documents/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: session.documentId,
workspaceId: session.workspaceId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
editorDocument,
content,
tiptapDocument: session.currentTiptapDocument,
blockCount: editorDocument.blocks.length,
}),
});
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)) session.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
session.conflictDetectionKey = saved.conflict_detection_key.trim();
}
if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) {
session.conflictDetectionKey = saved.conflictDetectionKey.trim();
}
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
session.hasExternalConflict = false;
session.externalChangePending = false;
session.lastUserInputAt = 0;
syncSessionMetaToViews(session);
session.lastPersistedSerialized = serialized;
session.saving = false;
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0];
if (primaryView) {
const plainText = sessionPlainText(session);
window.__mnoteRecordPageHistorySnapshot('save', {
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
characterCount: plainText.replace(/\s/g, '').length,
blockCount: editorDocument.blocks.length,
todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length,
todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length,
});
}
}
if (session.currentSerialized === serialized) {
session.dirty = false;
setSessionStatus(session, 'saved');
} else {
session.dirty = true;
setSessionStatus(session, 'dirty');
queueSessionSave(session);
}
} catch (error) {
session.saving = false;
setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error));
}
};
const scheduleSessionExternalRefresh = (session) => {
if (session.externalRefreshTimer) return;
session.externalRefreshTimer = window.setTimeout(() => {
session.externalRefreshTimer = 0;
void refreshSessionFromExternalFileChange(session);
}, 120);
};
const refreshSessionFromExternalFileChange = async (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || document.hidden) return;
try {
const response = await fetch(pageAggregateUrl({
documentId: session.documentId,
sourceKind: session.sourceKind,
workspaceId: session.workspaceId,
rootUri: session.rootUri,
}).toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!response.ok) return;
const payload = await response.json();
const nextAggregate = payload?.result;
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
session.externalChangePending = false;
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
return;
}
if (nextConflictKey === session.lastExternalConflictDetectionKey) return;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = toTiptapDocument(nextBody.content);
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
session.lastPersistedSerialized = session.currentSerialized;
session.revision = nextRevision;
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey || '';
session.readOnly = Boolean(nextPermissions.readOnly);
session.dirty = false;
session.hasExternalConflict = false;
session.lastUserInputAt = 0;
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, 'mnote-web-local-folder-watch');
});
setSessionStatus(session, 'synced-external-change');
} catch (error) {
console.warn('mnote local folder 外部更新检测失败', error);
}
};
const ensureLocalFolderEventChannel = (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
return;
}
let channel = localFolderEventRegistry.get(session.rootUri);
if (!channel) {
const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', session.rootUri);
const eventSource = new EventSource(url.toString());
channel = {
rootUri: session.rootUri,
eventSource,
sessions: new Map(),
};
eventSource.addEventListener('change', (event) => {
const payload = parseLocalFolderEventPayload(event);
if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => {
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
if (documentId && documentId !== targetSession.documentId) return;
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) {
markSessionExternalConflict(targetSession, externalConflictMessage);
return;
}
scheduleSessionExternalRefresh(targetSession);
});
});
eventSource.onerror = () => {
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
};
localFolderEventRegistry.set(session.rootUri, channel);
}
channel.sessions.set(session.key, session);
session.localFolderChannel = channel;
};
const createDocumentSession = (runtimeDescriptor) => {
const pageBody = runtimeDescriptor.aggregate.body || {};
const permissions = runtimeDescriptor.aggregate.head?.permissions || {};
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const tiptapDocument = toTiptapDocument(pageBody.content);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
workspaceId: runtimeDescriptor.bootstrap.workspaceId,
sourceKind,
rootUri: runtimeDescriptor.bootstrap.rootUri,
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题',
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),
revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision,
conflictDetectionKey,
lastExternalConflictDetectionKey: conflictDetectionKey || '',
readOnly: Boolean(permissions.readOnly),
dirty: false,
saving: false,
hasExternalConflict: false,
externalChangePending: false,
lastExternalChangeSignalAt: 0,
lastUserInputAt: 0,
status: 'booting',
error: null,
saveTimer: 0,
externalRefreshTimer: 0,
releaseTimer: 0,
views: new Map(),
localFolderChannel: null,
};
ensureLocalFolderEventChannel(session);
return session;
};
const getOrCreateDocumentSession = (runtimeDescriptor) => {
const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap);
const existing = documentSessionRegistry.get(key);
if (existing) {
cancelDocumentSessionRelease(existing);
return existing;
}
const session = createDocumentSession(runtimeDescriptor);
documentSessionRegistry.set(key, session);
return session;
};
const updatePaneChrome = (runtimeDescriptor) => {
const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]');
if (!(pane instanceof HTMLElement)) return;
const aggregate = runtimeDescriptor.aggregate || {};
const bootstrap = runtimeDescriptor.bootstrap || {};
const title = aggregate?.head?.title || '无标题';
const documentId = bootstrap.documentId || aggregate?.identity?.documentId || '';
const workspaceId = bootstrap.workspaceId || aggregate?.identity?.workspaceId || '';
pane.setAttribute('data-pane-document-id', documentId);
pane.setAttribute('data-pane-workspace-id', workspaceId);
pane.setAttribute('data-pane-visible', 'true');
pane.hidden = false;
const shell = pane.querySelector('.document-shell');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-document-id', documentId);
shell.setAttribute('data-workspace-id', workspaceId);
const options = aggregate?.layout?.pageOptions || {};
shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
}
pane.querySelectorAll('[data-page-title-input="true"]').forEach((node) => {
if (!(node instanceof HTMLTextAreaElement)) return;
node.value = title;
node.setAttribute('data-document-id', documentId);
node.setAttribute('data-workspace-id', workspaceId);
node.setAttribute('data-title-save-status', 'saved');
node.style.height = 'auto';
node.style.height = `${Math.max(48, node.scrollHeight)}px`;
});
pane.querySelectorAll('[data-page-title-current="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
if (runtimeDescriptor.paneRole === 'primary') {
document.body.dataset.documentId = documentId;
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'false');
row.setAttribute('data-selected', 'false');
}
});
const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&');
document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => {
if (!(row instanceof HTMLElement)) return;
if (row.getAttribute('data-shell-mode') === 'filetree') {
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `index:${documentId}`));
} else {
row.setAttribute('data-active', 'true');
}
});
}
};
const fetchPageAggregateForPane = async (descriptor) => {
const response = await fetch(pageAggregateUrlFromDescriptor(descriptor).toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!response.ok) throw new Error(`page_aggregate_failed_${response.status}`);
const payload = await response.json();
if (!payload?.result) throw new Error('page_aggregate_missing_result');
return payload.result;
};
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
const runtime = await loadRuntime();
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`);
const previousView = paneViewRegistry.get(paneRole);
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete(paneRole);
}
const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor);
const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole);
const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap };
updatePaneChrome(runtimeDescriptor);
if (paneRole === 'secondary' && workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'true');
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
applyStoredSecondaryWidth();
}
const session = getOrCreateDocumentSession(runtimeDescriptor);
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
const mountOptions = {
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {},
};
setStatus(runtimeDescriptor, 'loading-assets');
clearEmbeddedLocalDraft(runtimeDescriptor);
try {
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
view.mountId = mountId;
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
setStatus(runtimeDescriptor, 'mounting-editor');
paneViewRegistry.set(paneRole, view);
return view;
} catch (error) {
unmountEditorViewBinding(view);
throw error;
}
};
const descriptorFromCurrentUrl = (paneRole, documentId, explicit = {}) => {
const url = currentUrl();
if (paneRole === 'secondary') {
return {
documentId,
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace',
rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '',
};
}
return {
documentId,
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace',
rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '',
};
};
const updatePrimaryUrl = (descriptor, urlFromCaller) => {
const url = urlFromCaller instanceof URL
? urlFromCaller
: new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin);
if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId);
if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind);
if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri);
pushUrlState(url);
};
const closeSecondaryPane = (url) => {
const view = paneViewRegistry.get('secondary');
if (view) {
unmountEditorViewBinding(view);
paneViewRegistry.delete('secondary');
}
const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
if (pane instanceof HTMLElement) {
pane.hidden = true;
pane.setAttribute('data-pane-visible', 'false');
}
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`);
if (root instanceof HTMLElement) root.replaceChildren();
if (workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'false');
workspace.style.removeProperty('grid-template-columns');
}
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = true;
replaceUrlState(url);
};
const handleSessionChange = (session, view, event) => {
const payload = normalizeEnvelopePayload(event);
if (!payload) return;
const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending;
const recentExternalChange = sessionHasRecentExternalSignal(session);
const recentLocalInput = sessionHasRecentLocalInput(session);
const tiptapDocument = toTiptapDocument(
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
currentEditorText(view),
);
const serialized = JSON.stringify(tiptapDocument);
if (view.suppressedSerialized && view.suppressedSerialized === serialized) {
view.suppressedSerialized = null;
view.lastKnownSerialized = serialized;
return;
}
const previousSerialized = session.currentSerialized;
session.currentTiptapDocument = tiptapDocument;
session.currentSerialized = serialized;
view.lastKnownSerialized = serialized;
if (typeof payload?.title === 'string' && payload.title.trim()) {
session.title = payload.title.trim();
}
if (payload?.meta && typeof payload.meta === 'object') {
if (Number.isInteger(payload.meta.revision)) session.revision = payload.meta.revision;
if (typeof payload.meta.conflictDetectionKey === 'string' && payload.meta.conflictDetectionKey.trim()) {
session.conflictDetectionKey = payload.meta.conflictDetectionKey.trim();
}
if (typeof payload.meta.readOnly === 'boolean') {
session.readOnly = payload.meta.readOnly;
}
}
session.dirty = session.currentSerialized !== session.lastPersistedSerialized;
if (serialized !== previousSerialized) {
broadcastSessionContent(session, view);
}
if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
if (session.hasExternalConflict) {
setSessionStatus(session, 'external-change-conflict', externalConflictMessage);
return;
}
if (session.dirty) {
queueSessionSave(session);
} else {
setSessionStatus(session, 'saved');
}
};
const unmountEditorViewBinding = (view, options = {}) => {
if (!view || view.disposed) return;
view.disposed = true;
const scheduleRelease = options.scheduleRelease !== false;
const root = view.runtimeDescriptor.root;
if (typeof view.disconnectObserver === 'function') {
view.disconnectObserver();
view.disconnectObserver = null;
}
if (view.onReady) root.removeEventListener(READY_EVENT, view.onReady);
if (view.onError) root.removeEventListener(ERROR_EVENT, view.onError);
if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange);
if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave);
if (view.onInput) root.removeEventListener('input', view.onInput);
if (view.mountId != null && typeof view.runtime?.unmount === 'function') {
try {
view.runtime.unmount(view.mountId);
} catch (error) {
console.warn('mnote editor view unmount failed', error);
}
}
view.mountId = null;
root.removeAttribute('data-runtime-mount-id');
if (view.session.views.get(view.id) === view) {
view.session.views.delete(view.id);
}
if (scheduleRelease) {
scheduleDocumentSessionRelease(view.session);
}
};
const observeEditorViewBinding = (view) => {
if (typeof MutationObserver !== 'function' || !(document.body instanceof HTMLElement)) {
return;
}
const observer = new MutationObserver(() => {
if (!view.disposed && !view.runtimeDescriptor.root.isConnected) {
unmountEditorViewBinding(view);
}
});
observer.observe(document.body, { childList: true, subtree: true });
view.disconnectObserver = () => observer.disconnect();
};
const createEditorViewBinding = (session, runtime, runtimeDescriptor) => {
cancelDocumentSessionRelease(session);
const view = {
id: nextViewId++,
mountId: null,
ready: false,
disposed: false,
suppressedSerialized: null,
lastKnownSerialized: session.currentSerialized,
runtime,
runtimeDescriptor,
session,
disconnectObserver: null,
onReady: null,
onError: null,
onChange: null,
onSave: null,
onInput: null,
};
view.onReady = () => {
view.ready = true;
if (view.lastKnownSerialized !== session.currentSerialized) {
dispatchSessionContentToView(session, view, 'mnote-web-document-session-ready-sync');
}
if (session.status === 'dirty' || session.status === 'saving' || session.status === 'saved' || session.status === 'error' || session.status === 'external-change-conflict' || session.status === 'synced-external-change') {
setStatus(runtimeDescriptor, session.status, session.error);
} else {
setStatus(runtimeDescriptor, 'ready');
}
};
view.onError = (event) => {
const payload = normalizeEnvelopePayload(event);
setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error');
};
view.onChange = (event) => {
handleSessionChange(session, view, event);
};
view.onSave = (event) => {
handleSessionChange(session, view, event);
};
view.onInput = (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest('.ProseMirror')) return;
session.lastUserInputAt = Date.now();
};
runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady);
runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError);
runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange);
runtimeDescriptor.root.addEventListener(SAVE_EVENT, view.onSave);
runtimeDescriptor.root.addEventListener('input', view.onInput);
session.views.set(view.id, view);
observeEditorViewBinding(view);
return view;
};
const mountPane = async (runtimeDescriptor) => {
const runtime = await loadRuntime();
const session = getOrCreateDocumentSession(runtimeDescriptor);
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
const mountOptions = {
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {},
};
setStatus(runtimeDescriptor, 'loading-assets');
clearEmbeddedLocalDraft(runtimeDescriptor);
try {
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
view.mountId = mountId;
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
setStatus(runtimeDescriptor, 'mounting-editor');
paneViewRegistry.set(runtimeDescriptor.paneRole, view);
} catch (error) {
unmountEditorViewBinding(view);
throw error;
}
};
window.__mnoteDocumentPaneRuntime = {
openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false;
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('primary', descriptor);
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
return true;
},
openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false;
const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('secondary', descriptor);
if (url instanceof URL) replaceUrlState(url);
return true;
},
closeSecondaryDocument: ({ url } = {}) => {
closeSecondaryPane(url instanceof URL ? url : currentUrl());
return true;
},
};
window.addEventListener('pagehide', () => {
Array.from(documentSessionRegistry.values()).forEach((session) => {
sessionViews(session).forEach((view) => {
unmountEditorViewBinding(view, { scheduleRelease: false });
});
releaseDocumentSession(session);
});
}, { once: true });
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
console.error('mnote multi-pane editor mount failed', 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 editor_image_placeholder_asset() -> Response {
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="E24 image placeholder">
<rect width="640" height="360" rx="18" fill="#f3f4f6"/>
<rect x="42" y="42" width="556" height="276" rx="14" fill="#ffffff" stroke="#d1d5db" stroke-width="2"/>
<circle cx="168" cy="132" r="34" fill="#93c5fd"/>
<path d="M98 278 246 174 340 242 410 192 542 278Z" fill="#86efac"/>
<path d="M98 278 246 174 340 242 410 192 542 278" fill="none" stroke="#16a34a" stroke-width="8" stroke-linejoin="round"/>
<text x="320" y="322" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#374151">E24 Image</text>
</svg>"##;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(SVG))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
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 documents_page_compat(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentPageCompatQuery>,
) -> Result<Response, WebError> {
let document_id = query.document_id.trim().to_string();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
None,
None,
)
.await?;
let projection_owner = aggregate.source_label();
let mut response = (
StatusCode::OK,
Json(json!({
"ok": true,
"owner": "mnote-web",
"schema": "mnote.documents_page_compat.v1",
"page": aggregate,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.into_response();
stamp_shell_headers(response.headers_mut(), "documents-page-compat");
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
if let Ok(value) = HeaderValue::from_str(projection_owner) {
response.headers_mut().insert(name, value);
}
}
Ok(response)
}
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(),
query.source_kind.as_deref(),
query.root_uri.as_deref(),
)
.await?;
let projection_owner = aggregate.source_label();
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");
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
if let Ok(value) = HeaderValue::from_str(projection_owner) {
response.headers_mut().insert(name, value);
}
}
Ok(response)
}
pub(crate) async fn build_page_aggregate_snapshot(
state: &AppState,
context: &RequestContext,
document_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<PageAggregate, WebError> {
if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
let root_uri = root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
return resolve_local_markdown_page_aggregate(root_uri, document_id);
}
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?;
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,
}),
)?;
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
})
}
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
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));
}
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
pub(crate) fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub(crate) fn escape_script_json(value: &str) -> String {
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,
)
}
/// 加载侧栏页面树 HTMLSSR
///
/// 从 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 => {
// 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": 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_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,
})
})
}
/// 加载文件树 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 })
})
}
pub(crate) fn render_local_sidebar_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let rows = collect_page_tree_render_rows(&snapshot.projection);
Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
}))
}
pub(crate) fn render_local_file_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{header, 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\""));
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
assert!(html.contains("aria-label=\"页面标题\""));
assert!(html.contains("data-page-title-input=\"true\""));
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
assert!(html.contains("mnote.document_title_controller.v1"));
assert!(html.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
assert!(html.contains("data-mnote-action=\"open-page-settings\""));
assert!(html.contains("data-mnote-action=\"open-page-ai\""));
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
assert!(html.contains("data-testid=\"mnote-document-workspace\""));
assert!(html.contains("data-document-pane=\"true\""));
assert!(html.contains("data-pane-role=\"primary\""));
assert!(html.contains("data-document-pane-resizer=\"true\""));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
assert!(html.contains("/api/tree/events"));
assert!(html.contains("data-mnote-tree-live-transport"));
assert!(!html.contains("mnote-web-document-shell"));
}
#[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);
assert_eq!(
response
.headers()
.get("x-mnote-page-aggregate-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-kernel")
);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["source"], "KernelProjection");
assert_eq!(payload["result"]["projectionVersion"], 1);
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
let root =
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(
root.join("README.md"),
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
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["schema"], "mnote.page_aggregate.v1");
assert_eq!(
payload["result"]["identity"]["documentId"],
"local-md:README.md"
);
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
assert_eq!(payload["result"]["body"]["revision"], 0);
assert!(payload["result"]["body"]["content"]
.to_string()
.contains("Local Heading"));
}
#[tokio::test]
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
let root =
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
.expect("write child md");
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
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("Local Shell"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("Child Page"));
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
assert!(html.contains("refreshSessionFromExternalFileChange"));
assert!(html.contains("/api/local-folder/events"));
assert!(html.contains("new EventSource(url.toString())"));
assert!(html.contains("localFolderEventRegistry"));
assert!(html.contains("command: 'replaceContent'"));
assert!(html.contains("external-change-conflict"));
assert!(!html.contains(
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
));
}
#[tokio::test]
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-media-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
std::fs::write(
root.join("docs").join("blocks.md"),
"---\ntitle: Complex Title\n---\n[Spec](assets/spec.pdf)\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
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("Spec"));
assert!(html.contains("assets/spec.pdf"));
}
#[tokio::test]
async fn document_shell_renders_secondary_pane_contract_when_query_present() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
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-has-secondary-pane=\"true\""));
assert!(html.contains("data-pane-role=\"secondary\""));
assert!(html.contains("data-mnote-pane-close=\"secondary\""));
assert!(html.contains("__MNOTE_SECONDARY_PAGE_AGGREGATE__"));
assert!(html.contains("__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__"));
assert!(html.contains("\"paneRole\":\"secondary\""));
assert!(html.contains("\"secondaryRequested\":true"));
assert!(html.contains("\"secondaryInvalid\":false"));
}
#[tokio::test]
async fn document_shell_bootstrap_preserves_inline_mark_conversion() {
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);
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("legacyInlineContentToTiptap"));
assert!(html.contains("legacyStylesToTiptapMarks"));
assert!(html.contains("legacyMarkArrayToTiptapMarks"));
assert!(html.contains("firstNonEmptyText(block?.props?.sourcePath"));
assert!(html.contains("marks.push({ type: 'code' })"));
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
assert!(html.contains("styles.link = href"));
assert!(html.contains("contentNodes.map((node) => {"));
assert!(!html.contains(
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
}