20260513 mindmap优化01

This commit is contained in:
lix-2026
2026-05-13 22:43:16 +08:00
parent 17c003976b
commit b4a452a8b7
89 changed files with 11557 additions and 707 deletions
@@ -1,7 +1,9 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::command_support::{
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
};
use crate::routes::local_folder_source::{
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
};
@@ -574,17 +576,21 @@ pub async fn save(
dry_run: false,
validate_only: false,
};
let mut result = execute_runtime_command_via_convex(
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
let mut result = execution.result;
if let Value::Object(map) = &mut result {
map.insert("executedCommand".into(), json!("page.body.save"));
map.insert("canonicalCommand".into(), json!("page.body.save"));
map.insert("compatRoute".into(), json!("/api/documents/save"));
if let Some(artifact_error) = execution.artifact_error {
map.insert("artifactError".into(), json!(artifact_error));
}
}
Ok(ok_response(&context, result))
}
+80 -3
View File
@@ -3,8 +3,9 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
use crate::routes::web_shell::{
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_local_file_tree_html, render_local_sidebar_tree_html,
};
@@ -336,6 +337,17 @@ pub async fn root_entry(
active_source_kind.as_deref(),
active_root_uri.as_deref(),
);
let panes_bootstrap_json = build_document_panes_bootstrap_json(
&aggregate,
&context,
active_source_kind.as_deref(),
active_root_uri.as_deref(),
None,
None,
None,
false,
false,
);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::document::DocumentPage
title={title.to_string()}
@@ -350,10 +362,12 @@ pub async fn root_entry(
let body_extra = format!(
r#"<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>
{}
{}"#,
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
escape_script_json(&panes_bootstrap_json),
render_document_title_controller_script(),
render_editor_island_adapter_script(),
);
@@ -953,6 +967,20 @@ mod tests {
legacy_next_base_url: String,
enable_legacy_next_compat: bool,
convex_url: Option<String>,
) -> axum::Router {
app_with_query_fixtures(
legacy_next_base_url,
enable_legacy_next_compat,
convex_url,
None,
)
}
fn app_with_query_fixtures(
legacy_next_base_url: String,
enable_legacy_next_compat: bool,
convex_url: Option<String>,
query_fixtures_json: Option<String>,
) -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -967,7 +995,7 @@ mod tests {
convex_url,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
query_fixtures_json,
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
@@ -1221,6 +1249,55 @@ mod tests {
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
}
#[tokio::test]
async fn root_entry_active_page_includes_document_panes_bootstrap() {
let response = app_with_query_fixtures(
"http://127.0.0.1:3100".into(),
false,
None,
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": 1
},
"documents:getContent": {
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
"revision": 7,
"conflict_detection_key": "doc_1:7",
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
}
}"#
.into(),
),
)
.oneshot(
Request::builder()
.uri("/?pageId=doc_1&workspaceId=ws_demo")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.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("utf8");
assert!(html.contains("__MNOTE_PAGE_AGGREGATE__"));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains("mnote.document_panes_bootstrap.v1"));
}
#[tokio::test]
async fn root_entry_renders_local_folder_without_debug_tree_route() {
let root =
+205 -7
View File
@@ -1,9 +1,10 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
execute_runtime_query_via_convex, fetch_documents_meta_via_convex, fetch_query_data_via_convex,
resolve_effective_workspace_id,
};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
@@ -35,6 +36,15 @@ pub struct MindmapCommandRequest {
pub commands: Vec<Value>,
pub projection_revision: Option<u64>,
pub workspace_id: Option<String>,
pub data: Option<Value>,
pub create_only: Option<bool>,
}
fn default_mindmap_data() -> Value {
json!({
"data": {"text": "中心主题"},
"children": [],
})
}
fn response_headers() -> HeaderMap {
@@ -62,6 +72,41 @@ fn resolve_query_name(params: &MindmapQueryParams) -> &'static str {
"mindmap.projection.get"
}
fn read_workspace_id_from_meta(meta: &Value) -> Option<String> {
meta.get("workspace_id")
.or_else(|| meta.get("workspaceId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
async fn resolve_mindmap_workspace_id(
state: &AppState,
context: &RequestContext,
explicit_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Option<String>, WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(context, explicit_workspace_id, false)?;
if effective_workspace_id.is_some() {
return Ok(effective_workspace_id);
}
// 思维导图 runtime 的历史请求体不一定带 workspaceId
// 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。
let meta = fetch_documents_meta_via_convex(state.config(), context, None, document_id).await?;
Ok(read_workspace_id_from_meta(&meta))
}
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
execution
.artifacts
.as_ref()
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
.unwrap_or(Value::Null)
}
pub async fn get_mindmap(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -114,15 +159,79 @@ pub async fn apply_mindmap_command(
)
.with_context(&context));
}
if body.command_name.as_deref() != Some("mindmap.command.apply") {
let command_name = body.command_name.as_deref();
if !matches!(
command_name,
None | Some("mindmaps.put") | Some("mindmap.command.apply")
) {
return Err(WebError::bad_request_code(
"mindmap_command_required",
"仅支持 mindmap.command.apply",
"仅支持 mindmaps.put 或 mindmap.command.apply",
)
.with_context(&context));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
resolve_mindmap_workspace_id(&state, &context, body.workspace_id.as_deref(), document_id)
.await?;
if command_name != Some("mindmap.command.apply") {
let command = RuntimeCommandEnvelopeWire {
name: "mindmaps.put".into(),
command_id: format!("mindmap_put_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
page_id: Some(document_id.to_string()),
block_id: Some(mindmap_id.to_string()),
}),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"workspaceId": effective_workspace_id,
"data": body.data.unwrap_or_else(default_mindmap_data),
"createOnly": body.create_only.unwrap_or(false),
}),
preflight_data: None,
reason: Some("mnote-web mindmap put via kernel projection".into()),
refs: vec!["task168-mindmap-put-validator-smoke".into()],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
return Ok((
StatusCode::OK,
response_headers(),
Json(json!({
"ok": true,
"commandName": "mindmaps.put",
"result": execution.result,
"artifacts": execution_artifacts_json(&execution),
"artifactError": execution.artifact_error,
})),
));
}
let current = fetch_query_data_via_convex(
state.config(),
&context,
@@ -184,7 +293,7 @@ pub async fn apply_mindmap_command(
validate_only: false,
};
let result = execute_runtime_command_via_convex(
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
&context,
effective_workspace_id.as_deref(),
@@ -201,7 +310,96 @@ pub async fn apply_mindmap_command(
"applied": applied.applied,
"errors": applied.errors,
"projectionRevision": body.projection_revision,
"result": result,
"result": execution.result,
"artifacts": execution_artifacts_json(&execution),
"artifactError": execution.artifact_error,
})),
))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(
r#"{"documents:getMeta":{"id":"doc_1","workspace_id":"ws_demo","title":"页面"},"mindmaps:get":{"data":{"data":{"text":"KMIND","uid":"root"},"children":[]},"revision":1}}"#
.into(),
),
mutation_fixtures_json: Some(
r#"{"mindmaps:put":{"ok":true,"document_id":"doc_1","mindmap_id":"mind_1","updated_at":"2026-05-12T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#
.into(),
),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn mindmap_put_derives_workspace_and_returns_tree_artifacts() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/mindmap/doc_1/mind_1")
.header("content-type", "application/json")
.body(Body::from(
json!({
"data": {
"data": {"text": "KMIND", "uid": "root"},
"children": []
},
"createOnly": true
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
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["artifacts"]["commandLog"]["workspaceId"], "ws_demo");
assert_eq!(payload["artifacts"]["commandLog"]["targetPageId"], "doc_1");
assert_eq!(
payload["artifacts"]["commandLog"]["targetBlockId"],
"mind_1"
);
assert_eq!(
payload["artifacts"]["domainEvent"]["eventType"],
"tree.resource.mindmap.put"
);
assert_eq!(
payload["artifacts"]["domainEvent"]["payload"]["streamDelta"],
json!({
"op": "resync_required",
"reason": "mindmap.put",
"documentId": "doc_1",
"blockId": "mind_1"
})
);
}
}
@@ -1,7 +1,13 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::web_shell::{
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
load_workspace_shell_projection,
};
use crate::ssr::pages::mindmap::MindmapPage;
use axum::extract::{Extension, Path};
use crate::workspace_shell::render_workspace_shell_sidebar_html;
use axum::extract::{Extension, Path, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::{Html, IntoResponse, Response};
use serde_json::json;
@@ -10,9 +16,92 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
pub async fn mindmap_object_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>,
) -> Result<Response, WebError> {
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let aggregate = build_page_aggregate_snapshot(&state, &context, &doc_id, None, None, None)
.await
.ok();
let workspace_id = aggregate
.as_ref()
.map(|value| value.identity.workspace_id.clone())
.filter(|value| !value.trim().is_empty());
let title = aggregate
.as_ref()
.map(|value| value.head.title.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "思维导图".to_string());
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) =
if let Some(workspace_id) = workspace_id.as_deref() {
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
workspace_id,
Some(&doc_id),
&default_workspace_name,
)
.await;
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
.await
.unwrap_or_default();
let file_tree_html =
load_file_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
.await
.unwrap_or_default();
let workspace_name = workspace_projection.workspace_name.clone();
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
(
Some(workspace_name),
Some(sidebar_tree_html),
Some(workspace_sidebar_html),
)
} else {
(None, None, None)
};
let editor_bootstrap = json!({
"documentId": format!("__mindmap_object__:{doc_id}:{mindmap_id}"),
"workspaceId": workspace_id
.as_ref()
.map(|value| format!("__mindmap_object__:{value}")),
"title": title.clone(),
"content": {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": {
"mindmapId": mindmap_id,
"mnoteBlockType": "mindmap",
"projectionVersion": 1,
"rootNodeId": "root",
"mnoteMindmapData": serde_json::Value::Null
}
}
]
},
"readOnly": false,
"editable": true,
"standaloneObject": {
"kind": "mindmap",
"documentId": doc_id,
"mindmapId": mindmap_id
},
"revision": serde_json::Value::Null,
"conflictDetectionKey": serde_json::Value::Null,
"pageOptions": {
"pageWidth": "full",
"smallText": false,
"showHeadingNumbers": false,
"fontFamily": "sans"
}
});
let contract = json!({
"schema": "mnote.mindmap_shell.v1",
"owner": "mnote-web",
@@ -35,11 +124,17 @@ pub async fn mindmap_object_shell(
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id
});
let editor_bootstrap_json =
serde_json::to_string(&editor_bootstrap).unwrap_or_else(|_| "null".to_string());
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
let body_content = crate::ssr::render_view(leptos::view! {
<MindmapPage
document_id={doc_id.clone()}
mindmap_id={mindmap_id.clone()}
title={title.clone()}
sidebar_tree_html={sidebar_tree_html.unwrap_or_default()}
workspace_name={workspace_name.unwrap_or_default()}
workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}
/>
});
let html = format!(
@@ -47,19 +142,24 @@ pub async fn mindmap_object_shell(
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>思维导图</title>
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
{}
<script id="__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
{}
</body>
</html>"#,
escape_html(&title),
crate::ssr::MNOTE_CSS,
escape_html(&doc_id),
escape_html(&mindmap_id),
body_content,
escape_script_json(&editor_bootstrap_json),
escape_script_json(&contract_json),
render_mindmap_standalone_bootstrap_script(),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "mindmap");
@@ -87,6 +187,80 @@ fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn render_mindmap_standalone_bootstrap_script() -> &'static str {
r#"<script type="module">
(() => {
const BOOTSTRAP_ID = '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__';
const MOUNT_ID = 'mnote-mindmap-island';
const parseJsonScript = (id) => {
const node = document.getElementById(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || 'null');
} catch (error) {
console.warn(`mnote mindmap bootstrap JSON 解析失败: ${id}`, error);
return null;
}
};
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 bootstrap = parseJsonScript(BOOTSTRAP_ID);
const mountTarget = document.getElementById(MOUNT_ID);
if (!bootstrap || !(mountTarget instanceof HTMLElement)) return;
let mountId = null;
let runtimeModule = null;
const mountStandaloneMindmap = async () => {
runtimeModule = await loadRuntime();
mountId = runtimeModule.mount(mountTarget, bootstrap);
mountTarget.setAttribute('data-runtime-mount-id', String(mountId));
};
void mountStandaloneMindmap().catch((error) => {
console.error('mnote standalone mindmap mount failed', error);
mountTarget.setAttribute('data-runtime-editor-status', 'error');
mountTarget.setAttribute('data-runtime-editor-error', error instanceof Error ? error.message : 'unknown');
});
window.addEventListener('beforeunload', () => {
if (mountId != null && runtimeModule && typeof runtimeModule.unmount === 'function') {
try {
runtimeModule.unmount(mountId);
} catch (_error) {}
}
}, { once: true });
})();
</script>"#
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
@@ -189,9 +363,21 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("mnote.mindmap_shell.v1"));
assert!(html.contains("data-mnote-object-editor=\"mindmap\""));
assert!(html.contains(
"data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\""
));
assert!(html.contains("data-leptos-mindmap-island=\"standalone\""));
assert!(html.contains("mindmap.simple_mind_map_scene.get"));
assert!(html.contains("mindmap.command.apply"));
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
assert!(html.contains("mnoteBlockType"));
assert!(html.contains("__mindmap_object__:doc_1:mind_1"));
assert!(html.contains("\"standaloneObject\""));
assert!(html.contains("\"documentId\":\"doc_1\""));
assert!(html.contains("\"mindmapId\":\"mind_1\""));
assert!(html.contains("runtimeModule.mount(mountTarget, bootstrap)"));
assert!(html.contains("/api/leptos-tiptap-runtime/manifest.json"));
assert!(!html.contains("react_mindmap_runtime"));
assert!(!html.contains("next-app-router"));
}
+29 -6
View File
@@ -57,8 +57,9 @@ pub async fn events(
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
let poll_query = live_poll_query(&state.query);
let Ok((workspace_id, overview)) =
load_stream_overview(state.app_state.config(), &state.context, &state.query)
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
.await
else {
return None;
@@ -76,7 +77,7 @@ pub async fn events(
let Ok(payload) = build_stream_delta_payload(
state.app_state.config(),
&state.context,
&state.query,
&poll_query,
&workspace_id,
&overview,
change.cursor,
@@ -91,18 +92,15 @@ pub async fn events(
return Some((Ok(stream_event("delta", &payload)), Some(state)));
}
StreamChangeKind::Resync => {
let mut next_query = state.query.clone();
next_query.cursor = change.cursor;
let Ok(snapshot_payload) = load_stream_snapshot(
state.app_state.config(),
&state.context,
&next_query,
&poll_query,
)
.await
else {
return None;
};
state.query = next_query;
state.current_cursor = read_stream_cursor_from_payload(&snapshot_payload);
return Some((
Ok(stream_event(
@@ -157,6 +155,14 @@ struct StreamPollState {
initial_emitted: bool,
}
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
let mut next = query.clone();
// Convex bridgeLogs 的 cursor 是“向更旧记录翻页”,不是 live tail 的起点;
// 实时轮询必须始终查最新窗口,再用 current_cursor 在 Rust 侧比较增量。
next.cursor = None;
next
}
fn stream_event(event_name: &str, payload: &Value) -> Event {
let event_id = payload
.get("revision")
@@ -183,6 +189,7 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::stream_support::StreamSnapshotQuery;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -289,4 +296,20 @@ mod tests {
assert!(text.contains("id: "));
assert!(text.contains("\"revision\""));
}
#[test]
fn live_poll_query_drops_bridge_pagination_cursor() {
let query = StreamSnapshotQuery {
workspace_id: Some("ws_demo".into()),
cursor: Some(r#"{"createdAt":"2026-05-12T00:00:00Z","id":"clog_1"}"#.into()),
poll_ms: Some(250),
..StreamSnapshotQuery::default()
};
let live_query = super::live_poll_query(&query);
assert_eq!(live_query.workspace_id, Some("ws_demo".into()));
assert_eq!(live_query.poll_ms, Some(250));
assert_eq!(live_query.cursor, None);
}
}
+27
View File
@@ -540,6 +540,9 @@ pub(crate) fn collect_filetree_render_rows(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
object_identity: resource_meta
.and_then(|meta| meta.get("objectIdentity"))
.and_then(|value| serde_json::to_string(value).ok()),
selected,
})
})
@@ -1686,13 +1689,25 @@ fn build_tree_shell_html(
documentId: "",
assetId: "",
assetKind: "",
objectIdentity: null,
blockAssetRelation: null,
};
}
const objectIdentity =
value?.objectIdentity && typeof value.objectIdentity === "object"
? value.objectIdentity
: null;
const blockAssetRelation =
value?.blockAssetRelation && typeof value.blockAssetRelation === "object"
? value.blockAssetRelation
: null;
return {
resourceKind: normalizeText(value?.resourceKind),
documentId: normalizeText(value?.documentId),
assetId: normalizeText(value?.assetId),
assetKind: normalizeText(value?.assetKind),
objectIdentity,
blockAssetRelation,
};
};
@@ -4690,12 +4705,14 @@ fn build_tree_shell_html(
postToHost("tree.asset.open", {
documentId: documentId || null,
assetId: assetId || null,
objectIdentity: item.resourceMeta?.objectIdentity || null,
target: { documentId: documentId || null },
payload: {
documentId: documentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
objectIdentity: item.resourceMeta?.objectIdentity || null,
},
});
};
@@ -4857,6 +4874,10 @@ fn build_tree_shell_html(
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.assetId = assetId || "";
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
? JSON.stringify(item.resourceMeta.objectIdentity)
: "";
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
@@ -5308,12 +5329,14 @@ fn build_tree_shell_html(
postToHost("tree.asset.open", {
documentId: documentId || null,
assetId: assetId || null,
objectIdentity: item.resourceMeta?.objectIdentity || null,
target: { documentId: documentId || null },
payload: {
documentId: documentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
objectIdentity: item.resourceMeta?.objectIdentity || null,
},
});
};
@@ -5382,6 +5405,10 @@ fn build_tree_shell_html(
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.assetId = assetId || "";
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
? JSON.stringify(item.resourceMeta.objectIdentity)
: "";
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
+288 -11
View File
@@ -288,7 +288,7 @@ pub(crate) fn build_editor_bootstrap_json_with_ids(
.unwrap_or_else(|_| "{}".to_string())
}
fn build_document_panes_bootstrap_json(
pub(crate) fn build_document_panes_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
@@ -815,6 +815,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
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);
@@ -882,6 +904,41 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
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 : [];
@@ -923,9 +980,36 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
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));
@@ -967,6 +1051,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
? { 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'
@@ -974,7 +1060,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
: block.blockType === 'table'
? { ...(block.props || {}) }
: undefined,
content: Array.isArray(block.contentNodes)
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 : '';
@@ -1060,6 +1148,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const paneViewRegistry = new Map();
let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
const SESSION_RELEASE_DELAY_MS = 1200;
const parseLocalFolderEventPayload = (event) => {
@@ -1316,6 +1405,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
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');
@@ -1389,16 +1483,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
};
const scheduleSessionExternalRefresh = (session) => {
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 refreshSessionFromExternalFileChange(session);
void refreshSessionFromExternalChange(session, refreshSource);
}, 120);
};
const refreshSessionFromExternalFileChange = async (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || document.hidden) return;
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,
@@ -1439,14 +1537,19 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
session.hasExternalConflict = false;
session.lastUserInputAt = 0;
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, 'mnote-web-local-folder-watch');
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change');
});
setSessionStatus(session, 'synced-external-change');
} catch (error) {
console.warn('mnote local folder 外部更新检测失败', 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;
@@ -1473,7 +1576,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
markSessionExternalConflict(targetSession, externalConflictMessage);
return;
}
scheduleSessionExternalRefresh(targetSession);
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
eventSource.onerror = () => {
@@ -1485,6 +1588,164 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
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');
});
};
window.addEventListener('tree:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange);
const createDocumentSession = (runtimeDescriptor) => {
const pageBody = runtimeDescriptor.aggregate.body || {};
const permissions = runtimeDescriptor.aggregate.head?.permissions || {};
@@ -1513,6 +1774,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
saving: false,
hasExternalConflict: false,
externalChangePending: false,
externalRefreshSource: '',
lastExternalChangeSignalAt: 0,
lastUserInputAt: 0,
status: 'booting',
@@ -1714,10 +1976,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending;
const recentExternalChange = sessionHasRecentExternalSignal(session);
const recentLocalInput = sessionHasRecentLocalInput(session);
const tiptapDocument = toTiptapDocument(
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;
@@ -2614,6 +2876,13 @@ mod tests {
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"));
@@ -2714,6 +2983,14 @@ mod tests {
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('')"
));