Files
mnote/rust/crates/mnote-web/src/routes/web_shell.rs
T
lix-2026 f292c6710a feat: EditorRuntimeActor - 三层缓存/delta/事件架构
Phase A — EditorRuntimeActor 内存缓存层
- 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init
- block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径
- editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启)
- bridge-runtime 三个核心函数公开化
- rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞)

Phase B — 编辑器增量 delta channel
- BlockDelta/DeltaOperation 类型 + actor.build_block_delta()
- leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch
- DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent
- 工具响应含 blockDelta 字段供前端消费

Phase C — 事件 stream delta
- broadcast channel 在 AppState/actor/SSE 三层贯通
- tree_events SSE 端点发 block.delta 事件
- 旧客户端降级兼容

环境修复
- rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出)
- run-convex-deploy.js(封装 Convex function 部署到本地后端 3210)

ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
2026-05-16 22:03:30 +08:00

3496 lines
146 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::fs;
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>,
}
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),
None,
)
.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 hermes_settings_config_script = render_hermes_settings_config_script();
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),
hermes_settings_config_script,
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",
)
}
fn render_hermes_settings_config_script() -> String {
let Some(base_url) = [
"MNOTE_WEB_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_API_BASE_URL",
]
.into_iter()
.find_map(env_or_dotenv)
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()) else {
return String::new();
};
let settings_url = format!("{base_url}/hermes/settings");
let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string());
format!(
r#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
escape_script_json(&encoded)
)
}
fn env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
if cfg!(test) {
return None;
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((candidate_key, value)) = line.split_once('=') else {
continue;
};
if candidate_key.trim() != key {
continue;
}
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
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())
}
pub(crate) 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 fileTreePageTitle = (value) => {
const normalized = String(value || '无标题').trim() || '无标题';
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
};
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`, fileTreePageTitle(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 resolveTitleTarget = (targetInput) => {
const paneRole = (targetInput.getAttribute('data-pane-role') || 'primary').trim();
const rawDocumentId = (targetInput.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' };
return {
documentId,
workspaceId: (targetInput.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(),
sourceKind: (query.get(paneQueryParams.sourceKindParam) || '').trim(),
rootUri: (query.get(paneQueryParams.rootUriParam) || '').trim(),
};
};
const readLastSavedTitle = () => input.getAttribute('data-title-last-saved') || '无标题';
const writeLastSavedTitle = (title) => {
input.setAttribute('data-title-last-saved', title || '无标题');
};
writeLastSavedTitle(input.value.trim() || '无标题');
let saving = false;
const saveTitle = async () => {
const title = input.value.trim() || '无标题';
const currentTarget = resolveTitleTarget(input);
autosize(input);
if (!currentTarget.documentId || saving || title === readLastSavedTitle()) {
updateVisibleTitle(input, title, currentTarget.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: currentTarget.documentId,
workspaceId: currentTarget.workspaceId || null,
sourceKind: currentTarget.sourceKind || undefined,
rootUri: currentTarget.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}`);
}
writeLastSavedTitle(title);
updateVisibleTitle(input, title, currentTarget.documentId);
setStatus(input, 'saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', {
detail: { documentId: currentTarget.documentId, workspaceId: currentTarget.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() || '无标题') === readLastSavedTitle() ? '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, readLastSavedTitle(), resolveTitleTarget(input).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);
if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') {
window.__MNOTE_MINDMAP_RUST_SHELL__ = {
mount: runtime.mount_mindmap_shell,
unmount: runtime.unmount_mindmap_shell,
};
}
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 === 'mindmap') {
const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
const mindmapId = firstNonEmptyText(
block?.props?.mindmapId,
block?.props?.mindmap_id,
block?.mindmapId,
block?.mindmap_id,
data?.mindmapId,
data?.mindmap_id,
data?.id
);
const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
return {
type: 'paragraph',
attrs: withTextAlign({
blockId,
mnoteBlockType: 'mindmap',
mindmapId,
rootNodeId,
}),
};
}
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 mindmapDomDescriptors = (root) => {
if (!(root instanceof HTMLElement)) return [];
return Array.from(root.querySelectorAll('[data-testid="mnote-mindmap-editor-root"]'))
.flatMap((node) => {
if (!(node instanceof HTMLElement)) return [];
const mindmapId = typeof node.dataset.mnoteMindmapId === 'string' ? node.dataset.mnoteMindmapId.trim() : '';
if (!mindmapId) return [];
const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim()
? node.dataset.mnoteRootNodeId.trim()
: 'root';
return [{ mindmapId, rootNodeId }];
});
};
const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => {
if (!isTiptapDocument(tiptapDocument) || !Array.isArray(tiptapDocument.content)) return tiptapDocument;
const descriptors = mindmapDomDescriptors(root);
if (!descriptors.length) return tiptapDocument;
let index = 0;
for (const node of tiptapDocument.content) {
if (node?.type !== 'paragraph' || node?.attrs?.mnoteBlockType !== 'mindmap') continue;
const descriptor = descriptors[index];
index += 1;
if (!descriptor) continue;
node.attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {};
if (typeof node.attrs.mindmapId !== 'string' || !node.attrs.mindmapId.trim()) {
node.attrs.mindmapId = descriptor.mindmapId;
}
if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) {
node.attrs.rootNodeId = descriptor.rootNodeId;
}
}
return tiptapDocument;
};
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 mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => {
const data = attrs?.data && typeof attrs.data === 'object' ? attrs.data : {};
const mindmapId = firstNonEmptyText(
attrs?.mindmapId,
attrs?.mindmap_id,
data?.mindmapId,
data?.mindmap_id,
data?.id,
fallbackMindmapId
);
const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version);
return {
mindmapId,
rootNodeId,
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
};
};
const tiptapNodeToEditorBlock = (node, index) => {
const blockId = blockIdOf(node, index);
if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') {
return {
blockId,
blockType: 'mindmap',
props: mindmapPropsFromAttrs(node?.attrs, blockId),
contentNodes: [],
childBlockIds: [],
};
}
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 === 'mindmap'
? mindmapPropsFromAttrs(block.props || {}, block.blockId)
: block.blockType === 'image'
? { ...(block.props || {}) }
: block.blockType === 'toc'
? { ...(block.props || {}) }
: block.blockType === 'table'
? { ...(block.props || {}) }
: undefined,
content: block.blockType === 'mindmap'
? ''
: 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();
const mindmapPaneViewRegistry = new Map();
let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
const SESSION_RELEASE_DELAY_MS = 1200;
const unmountMindmapPane = (paneRole) => {
const view = mindmapPaneViewRegistry.get(paneRole);
if (!view) return;
mindmapPaneViewRegistry.delete(paneRole);
if (view.mountId != null && view.runtime && typeof view.runtime.unmount === 'function') {
try {
view.runtime.unmount(view.mountId);
} catch (error) {
console.warn('mnote mindmap pane unmount failed', error);
}
}
if (view.root instanceof HTMLElement) {
view.root.removeAttribute('data-runtime-mount-id');
view.root.removeAttribute('data-mnote-object-editor');
view.root.removeAttribute('data-mnote-object-identity');
view.root.removeAttribute('data-mnote-mindmap-id');
view.root.replaceChildren();
}
};
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 hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
if (hydrateView) {
hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root);
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
}
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, source) => {
if (session.externalRefreshTimer) return;
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
session.externalRefreshTimer = window.setTimeout(() => {
const refreshSource = session.externalRefreshSource || 'mnote-web-external-change';
session.externalRefreshSource = '';
session.externalRefreshTimer = 0;
void refreshSessionFromExternalChange(session, refreshSource);
}, 120);
};
const refreshSessionFromExternalChange = async (session, source) => {
if (document.hidden) return;
if (session.sourceKind === 'local_folder' && !session.rootUri) 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) {
if (session.sourceKind === 'local_folder') {
markSessionExternalConflict(session, externalConflictMessage);
}
return;
}
const payload = await response.json();
const nextAggregate = payload?.result;
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = toTiptapDocument(nextBody.content);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
}
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) 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 = nextTiptapDocument;
session.currentSerialized = nextSerialized;
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, source || 'mnote-web-external-change');
});
setSessionStatus(session, 'synced-external-change');
} catch (error) {
console.warn('mnote 页面外部更新检测失败', error);
}
};
const refreshSessionFromExternalFileChange = async (session) => {
if (session.sourceKind !== 'local_folder') return;
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
};
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() : '';
const eventKind = String(payload.eventKind || '');
const mayAffectMissingDocument = eventKind.includes('Remove') || eventKind.includes('Name');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument && !mayAffectMissingDocument) return;
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
markSessionExternalConflict(targetSession, externalConflictMessage);
return;
}
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
eventSource.onerror = () => {
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
};
localFolderEventRegistry.set(session.rootUri, channel);
}
channel.sessions.set(session.key, session);
session.localFolderChannel = channel;
};
const readTreePayloadData = (payload) => (
payload && typeof payload === 'object'
? (payload.data || payload.delta || payload)
: null
);
const readTreePayloadOverview = (payload) => (
payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object'
? payload.overview
: null
);
const readTreePayloadCursor = (payload) => {
const raw = String(payload?.cursor || payload?.revision || '').trim();
if (!raw) return { id: '', createdAt: '', raw: '' };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(),
createdAt: String(parsed.createdAt || parsed.created_at || '').trim(),
raw,
};
}
} catch (_) {}
return { id: raw, createdAt: '', raw };
};
const treeRecordMatchesPayloadCursor = (record, payload) => {
if (!record || typeof record !== 'object') return false;
const cursor = readTreePayloadCursor(payload);
if (!cursor.id && !cursor.createdAt && !cursor.raw) return false;
const ids = [
record.id,
record._id,
record.command_log_id,
record.commandLogId,
record.domain_event_id,
record.domainEventId,
record.command_id,
record.commandId,
].map((value) => String(value || '').trim()).filter(Boolean);
if (cursor.id && ids.includes(cursor.id)) return true;
const createdAt = String(record.created_at || record.createdAt || '').trim();
return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt);
};
const treeRecordTargetsDocument = (record, documentId) => {
if (!record || typeof record !== 'object' || !documentId) return false;
const targetPageId = String(record.target_page_id || record.targetPageId || '').trim();
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
if (targetPageId === documentId || aggregateId === documentId) return true;
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
if (!payload) return false;
const streamDelta = payload.streamDelta || payload.stream_delta || null;
const deltaDocumentId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim()
: '';
return deltaDocumentId === documentId;
};
const collectMindmapIdsFromTreeRecord = (record, documentId, out) => {
if (!record || typeof record !== 'object' || !documentId) return;
if (!treeRecordTargetsDocument(record, documentId)) return;
const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim();
if (targetBlockId) out.add(targetBlockId);
const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim();
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
if (aggregateType === 'block' && aggregateId) out.add(aggregateId);
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null;
const blockId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
: '';
if (blockId) out.add(blockId);
};
const collectMindmapIdsFromTreePayload = (payload, session) => {
const ids = new Set();
if (!payload || typeof payload !== 'object' || !session?.documentId) return [];
const kind = String(payload.kind || '').trim();
const data = readTreePayloadData(payload);
if (kind === 'delta' && data && typeof data === 'object') {
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
if (!documentId || documentId === session.documentId) {
const blockId = String(data.blockId || data.block_id || '').trim();
if (blockId) ids.add(blockId);
const streamDelta = data.streamDelta || data.stream_delta || null;
const streamBlockId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
: '';
if (streamBlockId) ids.add(streamBlockId);
}
}
if (kind === 'resync') {
const overview = readTreePayloadOverview(payload);
if (overview) {
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
commandLogs
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
domainEvents
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
}
}
return Array.from(ids);
};
const refreshMindmapRuntimesFromTreePayload = (payload, session) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => {
const bridge = registry[mindmapId];
if (bridge && typeof bridge.refreshProjection === 'function') {
void bridge.refreshProjection('mnote-web-tree-live');
}
});
};
const treePayloadTargetsDocument = (payload, session) => {
if (!payload || typeof payload !== 'object' || !session?.documentId) return false;
if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false;
const data = readTreePayloadData(payload);
if (data && typeof data === 'object') {
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
if (documentId === session.documentId) return true;
const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : [];
if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true;
}
const overview = readTreePayloadOverview(payload);
if (!overview) return false;
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId))
|| domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId));
};
const handleTreeExternalChange = (event) => {
const payload = event?.detail?.payload || event?.detail || null;
if (!payload) return;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (session.sourceKind === 'local_folder') return;
if (!treePayloadTargetsDocument(payload, session)) return;
refreshMindmapRuntimesFromTreePayload(payload, session);
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, treeExternalConflictMessage);
return;
}
scheduleSessionExternalRefresh(session, 'mnote-web-tree-live');
});
};
const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => {
if (!session) return false;
const doc = String(documentId || '').trim();
const workspace = String(workspaceId || '').trim();
if (doc && session.documentId !== doc) return false;
if (workspace && session.workspaceId && session.workspaceId !== workspace) return false;
return true;
};
const refreshDocumentSessionsFromExternalWrite = (detail, source) => {
const documentId = String(detail?.documentId || '').trim();
const workspaceId = String(detail?.workspaceId || '').trim();
let scheduled = 0;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, treeExternalConflictMessage);
return;
}
scheduled += 1;
scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write');
});
return scheduled;
};
window.addEventListener('tree:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange);
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
});
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,
externalRefreshSource: '',
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-last-saved', title);
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.body.dataset.mnoteShell = 'document';
delete document.body.dataset.mindmapId;
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') === `doc:${documentId}`));
} else {
row.setAttribute('data-active', 'true');
}
});
}
runtimeDescriptor.root.removeAttribute('data-mnote-object-editor');
runtimeDescriptor.root.removeAttribute('data-mnote-object-identity');
runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id');
};
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 = {}) => {
unmountMindmapPane(paneRole);
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 parseJsonScriptFromDocument = (doc, id) => {
const node = doc?.getElementById?.(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || 'null');
} catch (error) {
console.warn(`mnote mindmap shell JSON 解析失败: ${id}`, error);
return null;
}
};
const fetchMindmapShellBootstrap = async (url) => {
const response = await fetch(url.toString(), {
cache: 'no-store',
credentials: 'include',
headers: { accept: 'text/html' },
});
if (!response.ok) throw new Error(`mindmap_shell_failed_${response.status}`);
const html = await response.text();
const parsed = new DOMParser().parseFromString(html, 'text/html');
const bootstrap = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__');
if (!bootstrap || typeof bootstrap !== 'object') throw new Error('mindmap_shell_missing_bootstrap');
const contract = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_SHELL__') || {};
const title = String(bootstrap.title || parsed.querySelector('title')?.textContent || '思维导图').trim() || '思维导图';
return { bootstrap, contract, title };
};
const setPrimaryMindmapSelection = (documentId, mindmapId) => {
const cssEscape = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape : (value) => String(value).replace(/["\\]/g, '\\$&');
const escapedDocId = cssEscape(documentId);
const escapedMindmapId = cssEscape(mindmapId);
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
row.setAttribute('data-active', 'false');
row.setAttribute('data-selected', 'false');
});
document.querySelectorAll(`.tree-row[data-shell-mode="page"][data-node-id="${escapedDocId}"]`).forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-active', 'true');
});
document.querySelectorAll(`.tree-row[data-shell-mode="filetree"][data-asset-id="${escapedMindmapId}"]`).forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'true');
});
};
const updatePrimaryMindmapChrome = ({ documentId, mindmapId, workspaceId, title, root }) => {
const pane = root.closest('[data-document-pane="true"]');
if (pane instanceof HTMLElement) {
pane.setAttribute('data-pane-document-id', `__mindmap_object__:${documentId}:${mindmapId}`);
pane.setAttribute('data-pane-workspace-id', workspaceId || '');
pane.setAttribute('data-pane-visible', 'true');
pane.hidden = false;
}
const shell = root.closest('.document-shell');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-editor-host', 'mindmap_object');
shell.setAttribute('data-document-id', documentId);
shell.setAttribute('data-workspace-id', workspaceId || '');
shell.setAttribute('data-mindmap-id', mindmapId);
}
document.body.dataset.documentId = documentId;
document.body.dataset.mindmapId = mindmapId;
document.body.dataset.mnoteShell = 'mindmap';
document.title = title;
document.querySelectorAll('[data-page-title-input="true"][data-pane-role="primary"]').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-last-saved', title);
node.setAttribute('data-title-save-status', 'saved');
node.style.height = 'auto';
node.style.height = `${Math.max(48, node.scrollHeight)}px`;
});
document.querySelectorAll('[data-page-title-current="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
root.setAttribute('data-mnote-object-editor', 'mindmap');
root.setAttribute('data-mnote-object-identity', `resource:mindmap:${documentId}:${mindmapId}`);
root.setAttribute('data-mnote-mindmap-id', mindmapId);
setPrimaryMindmapSelection(documentId, mindmapId);
};
const replacePrimaryPaneMindmap = async ({ documentId, mindmapId, workspaceId, url }) => {
const targetUrl = url instanceof URL
? url
: new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin);
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="primary"]`);
const observability = document.querySelector('[data-editor-host-observability][data-pane-role="primary"]');
if (!(root instanceof HTMLElement)) throw new Error('primary_pane_root_missing');
const runtime = await loadRuntime();
const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl);
const previousView = paneViewRegistry.get('primary');
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete('primary');
}
unmountMindmapPane('primary');
if (observability instanceof HTMLElement) {
observability.setAttribute('data-editor-host-active', 'mindmap_object');
observability.setAttribute('data-editor-host-status', 'mounting');
}
root.replaceChildren();
root.setAttribute('data-runtime-editor-status', 'booting');
updatePrimaryMindmapChrome({ documentId, mindmapId, workspaceId, title, root });
const mountId = runtime.mount(root, bootstrap);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'mindmap_object');
if (observability instanceof HTMLElement) {
observability.setAttribute('data-editor-host-status', 'mounted');
}
mindmapPaneViewRegistry.set('primary', { paneRole: 'primary', root, runtime, mountId });
pushUrlState(targetUrl);
return true;
};
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 = hydrateMindmapAttrsFromDom(toTiptapDocument(
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
currentEditorText(view),
), view.runtimeDescriptor.root);
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;
},
openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => {
const docId = typeof documentId === 'string' ? documentId.trim() : '';
const mapId = typeof mindmapId === 'string' ? mindmapId.trim() : '';
if (!docId || !mapId) return false;
await replacePrimaryPaneMindmap({
documentId: docId,
mindmapId: mapId,
workspaceId: typeof workspaceId === 'string' ? workspaceId.trim() : '',
url,
});
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;
},
refreshDocument: async ({ documentId, workspaceId, source } = {}) => {
const targets = Array.from(documentSessionRegistry.values()).filter((session) => (
sessionMatchesDocumentWorkspace(session, documentId, workspaceId)
));
await Promise.all(targets.map((session) => refreshSessionFromExternalChange(
session,
source || 'mnote-web-programmatic-refresh',
)));
return targets.length;
},
refreshPrimaryDocument: async ({ documentId, workspaceId, source } = {}) => {
const primary = paneViewRegistry.get('primary');
if (!primary?.session) return 0;
if (!sessionMatchesDocumentWorkspace(primary.session, documentId, workspaceId)) return 0;
await refreshSessionFromExternalChange(primary.session, source || 'mnote-web-programmatic-refresh');
return 1;
},
};
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 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 = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => snapshot.dataset,
Err(_) if config.allow_dev_fixtures => {
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,
"dev_fixture": true
})
}
Err(_) => json!({
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
"degraded": true,
"degraded_reason": "projection_unavailable"
}),
};
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, false)),
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()
.map(|projection| (projection, true))
}
Err(_) => None,
};
result.map(|(projection, dev_fixture)| {
let rows = collect_page_tree_render_rows(&projection);
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
});
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
})
}
/// 加载文件树 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>,
active_row_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, false)),
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()
.map(|projection| (projection, true))
}
Err(_) => None,
};
result.map(|(projection, dev_fixture)| {
let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id);
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
mark_dev_fixture_html(html, dev_fixture, "file-tree")
})
}
fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String {
if !dev_fixture {
return html;
}
format!(
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="{kind}"></span>{html}"#
)
}
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, None);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
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,
enable_editor_actor: true,
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(),
}))
}
fn app_with_unreachable_convex_without_fixture() -> 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: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:9".into()),
convex_admin_key: Some("test-admin-key".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
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-mnote-dev-fixture-kind=\"workspace-shell\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\""));
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("const currentTarget = resolveTitleTarget(input);"));
assert!(html.contains("documentId: currentTarget.documentId"));
assert!(html.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
assert!(html.contains("const fileTreePageTitle = (value) => {"));
assert!(html.contains(
"return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;"
));
assert!(html.contains(
r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"#
));
assert!(html.contains("row.getAttribute('data-row-id') === `doc:${documentId}`"));
assert!(!html.contains("row.getAttribute('data-row-id') === `index:${documentId}`"));
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("openPrimaryMindmap"));
assert!(html.contains("replacePrimaryPaneMindmap"));
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
assert!(html.contains("data-mnote-object-identity"));
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("compat-join")
);
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"], "CompatMetaContentJoin");
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_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
assert_eq!(
response
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("query_send")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex")
);
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["ok"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(payload.get("schema").is_none());
assert!(payload.get("result").is_none());
}
#[tokio::test]
async fn document_shell_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(!text.contains("mnote.page_aggregate.v1"));
assert!(!text.contains("data-mnote-dev-fixture"));
assert!(!text.contains("data-page-aggregate-snapshot"));
}
#[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-page-openable=\"false\""));
assert!(html.contains("fileAction === 'open' && rowKind === 'folder'"));
assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'"));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
assert!(html.contains("refreshSessionFromExternalFileChange"));
assert!(html.contains("refreshSessionFromExternalChange"));
assert!(html.contains("treeExternalConflictMessage"));
assert!(html.contains("tree:delta"));
assert!(html.contains("tree:resync"));
assert!(html.contains("mnote-web-tree-live"));
assert!(html.contains("refreshMindmapRuntimesFromTreePayload"));
assert!(html.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__"));
assert!(html.contains("/api/local-folder/events"));
assert!(html.contains("new EventSource(url.toString())"));
assert!(html.contains("localFolderEventRegistry"));
assert!(html
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
assert!(html.contains("mayAffectMissingDocument"));
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
assert!(html.contains("command: 'replaceContent'"));
assert!(html.contains("external-change-conflict"));
assert!(!html.contains(
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
));
}
#[tokio::test]
async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() {
let config = 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,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
};
let headers = HeaderMap::new();
let context = RequestContext::from_http_parts(
&Method::GET,
&"/documents/doc_1?workspaceId=ws_demo"
.parse::<Uri>()
.expect("uri"),
&headers,
);
let sidebar_html =
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
let filetree_html =
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
let workspace_projection = super::load_workspace_shell_projection(
&config,
&context,
"ws_demo",
Some("doc_1"),
"个人空间",
)
.await;
assert!(sidebar_html.is_none());
assert!(filetree_html.is_none());
assert!(workspace_projection.degraded);
assert!(workspace_projection.my_page_items.is_empty());
assert!(!workspace_projection.dev_fixture);
}
#[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("node?.attrs?.mnoteBlockType === 'mindmap'"));
assert!(html.contains("blockType: 'mindmap'"));
assert!(html.contains("props: mindmapPropsFromAttrs(node?.attrs, blockId)"));
assert!(html.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)"));
assert!(html.contains("mnoteBlockType: 'mindmap'"));
assert!(html.contains("block.blockType === 'mindmap'"));
assert!(html.contains("content: block.blockType === 'mindmap'"));
assert!(html.contains("? ''"));
assert!(!html.contains(
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
}