2026-04-29 12:24:44 +08:00
|
|
|
|
use crate::app::{AppConfig, AppState};
|
|
|
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
|
use crate::page_aggregate::PageAggregate;
|
|
|
|
|
|
use crate::routes::documents::{
|
|
|
|
|
|
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
|
|
|
|
|
DocumentMetaQuery,
|
|
|
|
|
|
};
|
2026-05-08 00:41:03 +08:00
|
|
|
|
use crate::routes::local_folder_source::{
|
|
|
|
|
|
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
|
|
|
|
|
resolve_local_markdown_page_aggregate,
|
|
|
|
|
|
};
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use crate::routes::query_support::execute_runtime_query_against_data;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use crate::routes::snapshot_support::{
|
|
|
|
|
|
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
|
|
|
|
|
|
use crate::ssr::pages::document::DocumentPage;
|
|
|
|
|
|
use crate::tree_shell::filetree_renderer::{
|
|
|
|
|
|
render_initial_filetree_html, FileTreeInitialRenderInput,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
|
|
|
|
|
|
use crate::workspace_shell::{
|
|
|
|
|
|
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
|
|
|
|
|
WorkspaceShellProjection,
|
|
|
|
|
|
};
|
|
|
|
|
|
use axum::body::Body;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use axum::extract::{Extension, Path, Query, State};
|
|
|
|
|
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
|
|
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
|
|
|
|
use axum::Json;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use core_protocol::KernelProjectionKind;
|
|
|
|
|
|
use serde::Deserialize;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use serde_json::json;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
use std::path::{Component, Path as FsPath, PathBuf};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
|
|
|
|
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
|
|
|
|
|
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
|
|
|
|
|
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentShellQuery {
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
pub source_kind: Option<String>,
|
|
|
|
|
|
pub root_uri: Option<String>,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
pub secondary_document_id: Option<String>,
|
|
|
|
|
|
pub secondary_source_kind: Option<String>,
|
|
|
|
|
|
pub secondary_root_uri: Option<String>,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentPageCompatQuery {
|
|
|
|
|
|
pub document_id: String,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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> {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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());
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let aggregate = build_page_aggregate_snapshot(
|
|
|
|
|
|
&state,
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&document_id,
|
|
|
|
|
|
query.workspace_id.as_deref(),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
primary_source_kind,
|
|
|
|
|
|
primary_root_uri,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let title = aggregate.head.title.as_str();
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let workspace_id = aggregate.identity.workspace_id.clone();
|
2026-05-09 06:24:50 +08:00
|
|
|
|
let requested_secondary_document_id =
|
|
|
|
|
|
normalize_optional_owned(query.secondary_document_id.as_deref());
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let secondary_source_kind = normalize_source_kind(
|
2026-05-09 06:24:50 +08:00
|
|
|
|
query
|
|
|
|
|
|
.secondary_source_kind
|
2026-05-08 23:15:00 +08:00
|
|
|
|
.as_deref()
|
|
|
|
|
|
.or(primary_source_kind),
|
|
|
|
|
|
);
|
2026-05-09 06:24:50 +08:00
|
|
|
|
let secondary_root_uri =
|
|
|
|
|
|
normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri));
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let mut secondary_requested = false;
|
|
|
|
|
|
let mut secondary_invalid = false;
|
2026-05-09 06:24:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
}
|
2026-05-09 06:24:50 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
|
|
|
|
|
let mut workspace_projection = load_workspace_shell_projection(
|
|
|
|
|
|
state.config(),
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&workspace_id,
|
|
|
|
|
|
Some(&document_id),
|
|
|
|
|
|
&default_workspace_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
apply_active_page(&mut workspace_projection, Some(&document_id));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
let is_local_folder = query
|
|
|
|
|
|
.source_kind
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
== Some("local_folder");
|
|
|
|
|
|
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
|
|
|
|
|
|
let root_uri = query.root_uri.as_deref().unwrap_or_default();
|
|
|
|
|
|
(
|
|
|
|
|
|
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
|
|
|
|
|
render_local_file_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(
|
|
|
|
|
|
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
|
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
|
|
|
|
|
&workspace_projection,
|
|
|
|
|
|
Some(sidebar_tree_html.as_str()),
|
|
|
|
|
|
Some(file_tree_html.as_str()),
|
|
|
|
|
|
);
|
|
|
|
|
|
let workspace_name = workspace_projection.workspace_name.clone();
|
|
|
|
|
|
let page_subtree_json =
|
|
|
|
|
|
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
|
2026-05-06 21:44:20 +08:00
|
|
|
|
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
|
|
|
|
|
|
.unwrap_or_else(|_| "null".to_string());
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
2026-05-09 06:24:50 +08:00
|
|
|
|
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())
|
|
|
|
|
|
});
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let body_content = crate::ssr::render_view(leptos::view! {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
<DocumentPage
|
|
|
|
|
|
title={title.to_string()}
|
|
|
|
|
|
document_id={document_id.clone()}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
workspace_id={workspace_id.clone()}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
sidebar_tree_html={sidebar_tree_html}
|
|
|
|
|
|
workspace_name={workspace_name}
|
|
|
|
|
|
workspace_sidebar_html={workspace_sidebar_html}
|
|
|
|
|
|
page_subtree_json={page_subtree_json}
|
2026-05-06 21:44:20 +08:00
|
|
|
|
page_options_json={page_options_json}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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()}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
/>
|
2026-04-29 12:24:44 +08:00
|
|
|
|
});
|
|
|
|
|
|
let html = format!(
|
|
|
|
|
|
r#"<!doctype html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<title>{}</title>
|
|
|
|
|
|
<style>{}</style>
|
|
|
|
|
|
</head>
|
2026-05-08 23:15:00 +08:00
|
|
|
|
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
|
2026-04-29 12:24:44 +08:00
|
|
|
|
{}
|
|
|
|
|
|
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
2026-04-29 14:36:24 +08:00
|
|
|
|
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
2026-05-08 23:15:00 +08:00
|
|
|
|
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
|
|
|
|
|
{}
|
|
|
|
|
|
{}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
{}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
{}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>"#,
|
|
|
|
|
|
escape_html(title),
|
|
|
|
|
|
crate::ssr::MNOTE_CSS,
|
|
|
|
|
|
escape_html(&document_id),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
secondary_requested,
|
|
|
|
|
|
secondary_invalid,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
body_content,
|
|
|
|
|
|
escape_script_json(&snapshot_json),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
escape_script_json(&bootstrap_json),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
escape_script_json(&panes_bootstrap_json),
|
|
|
|
|
|
secondary_snapshot_json
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
|
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
|
secondary_bootstrap_json
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__" type="application/json">{}</script>"#, escape_script_json(value)))
|
|
|
|
|
|
.unwrap_or_default(),
|
2026-04-30 05:46:36 +08:00
|
|
|
|
render_document_title_controller_script(),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
render_editor_island_adapter_script(),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
);
|
|
|
|
|
|
let mut response = Html(html).into_response();
|
|
|
|
|
|
stamp_shell_headers(response.headers_mut(), "document");
|
|
|
|
|
|
stamp_recent_page_cookie(response.headers_mut(), &document_id);
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn build_editor_bootstrap_json(
|
|
|
|
|
|
aggregate: &PageAggregate,
|
|
|
|
|
|
context: &RequestContext,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
source_kind: Option<&str>,
|
|
|
|
|
|
root_uri: Option<&str>,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
) -> String {
|
|
|
|
|
|
build_editor_bootstrap_json_with_ids(
|
|
|
|
|
|
aggregate,
|
|
|
|
|
|
context,
|
|
|
|
|
|
source_kind,
|
|
|
|
|
|
root_uri,
|
|
|
|
|
|
"__MNOTE_PAGE_AGGREGATE__",
|
|
|
|
|
|
"primary",
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn build_editor_bootstrap_json_with_ids(
|
|
|
|
|
|
aggregate: &PageAggregate,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
source_kind: Option<&str>,
|
|
|
|
|
|
root_uri: Option<&str>,
|
|
|
|
|
|
page_aggregate_script_id: &str,
|
|
|
|
|
|
pane_role: &str,
|
2026-04-30 16:18:54 +08:00
|
|
|
|
) -> String {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
serde_json::to_string(&json!({
|
|
|
|
|
|
"schema": "mnote.editor_bootstrap.v1",
|
|
|
|
|
|
"documentId": aggregate.identity.document_id,
|
|
|
|
|
|
"workspaceId": aggregate.identity.workspace_id,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
"paneRole": pane_role,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
"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(""),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
"pageAggregateScriptId": page_aggregate_script_id,
|
2026-04-29 14:36:24 +08:00
|
|
|
|
"saveEndpoint": "/api/documents/save",
|
2026-04-30 05:46:36 +08:00
|
|
|
|
"titleEndpoint": "/api/documents/title",
|
2026-04-29 14:36:24 +08:00
|
|
|
|
"editorHostKind": "leptos_tiptap_island",
|
|
|
|
|
|
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
|
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
|
}))
|
|
|
|
|
|
.unwrap_or_else(|_| "{}".to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn render_document_title_controller_script() -> &'static str {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
r#"<script>
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
const CONTRACT = 'mnote.document_title_controller.v1';
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const inputs = Array.from(document.querySelectorAll('[data-page-title-input="true"]')).filter((node) => node instanceof HTMLTextAreaElement);
|
|
|
|
|
|
if (!inputs.length) return;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
|
|
|
|
|
|
const cssEscape = (value) => {
|
|
|
|
|
|
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
|
|
|
|
|
return String(value).replace(/["\\]/g, '\\$&');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const autosize = (input) => {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
input.style.height = 'auto';
|
|
|
|
|
|
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const setStatus = (input, status, message) => {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
const current = document.querySelector('.wolai-breadcrumb-current');
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (current instanceof HTMLElement && isPrimaryDocument) {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let titleNode = current.querySelector('[data-page-title-current]');
|
|
|
|
|
|
if (!(titleNode instanceof HTMLElement)) {
|
|
|
|
|
|
titleNode = document.createElement('span');
|
|
|
|
|
|
titleNode.setAttribute('data-page-title-current', 'true');
|
|
|
|
|
|
current.appendChild(titleNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
titleNode.textContent = title;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!documentId) return;
|
|
|
|
|
|
const escapedId = cssEscape(documentId);
|
2026-05-06 21:44:20 +08:00
|
|
|
|
const escapedDocRowId = cssEscape(`doc:${documentId}`);
|
|
|
|
|
|
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
|
|
|
|
|
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, title);
|
2026-04-30 06:58:17 +08:00
|
|
|
|
setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
|
|
|
|
|
|
setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
|
|
|
|
|
|
setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
|
2026-04-30 05:46:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
inputs.forEach((input) => {
|
|
|
|
|
|
input.setAttribute('data-title-controller', CONTRACT);
|
|
|
|
|
|
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
|
|
|
|
|
|
const paneRole = (input.getAttribute('data-pane-role') || 'primary').trim();
|
|
|
|
|
|
const rawDocumentId = (input.getAttribute('data-document-id') || '').trim();
|
|
|
|
|
|
const documentId = rawDocumentId || (
|
|
|
|
|
|
paneRole === 'primary'
|
|
|
|
|
|
? (document.body?.dataset.documentId || '').trim()
|
|
|
|
|
|
: ''
|
|
|
|
|
|
);
|
|
|
|
|
|
const query = new URLSearchParams(window.location.search);
|
|
|
|
|
|
const paneQueryParams = paneRole === 'secondary'
|
|
|
|
|
|
? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' }
|
|
|
|
|
|
: { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' };
|
|
|
|
|
|
const sourceKindParam = paneQueryParams.sourceKindParam;
|
|
|
|
|
|
const rootUriParam = paneQueryParams.rootUriParam;
|
|
|
|
|
|
const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim();
|
|
|
|
|
|
const sourceKind = (query.get(sourceKindParam) || '').trim();
|
|
|
|
|
|
const rootUri = (query.get(rootUriParam) || '').trim();
|
|
|
|
|
|
let lastSavedTitle = input.value.trim() || '无标题';
|
|
|
|
|
|
let saving = false;
|
|
|
|
|
|
|
|
|
|
|
|
const saveTitle = async () => {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
const title = input.value.trim() || '无标题';
|
2026-05-08 23:15:00 +08:00
|
|
|
|
autosize(input);
|
2026-04-30 05:46:36 +08:00
|
|
|
|
if (!documentId || saving || title === lastSavedTitle) {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
updateVisibleTitle(input, title, documentId);
|
|
|
|
|
|
setStatus(input, 'saved');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
saving = true;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
setStatus(input, 'saving');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(endpoint, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
workspaceId: workspaceId || null,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
sourceKind: sourceKind || undefined,
|
|
|
|
|
|
rootUri: rootUri || undefined,
|
2026-04-30 05:46:36 +08:00
|
|
|
|
title,
|
|
|
|
|
|
commandName: 'page.head.updateTitle',
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
|
if (!response.ok || !payload || payload.ok !== true) {
|
|
|
|
|
|
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
lastSavedTitle = title;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
updateVisibleTitle(input, title, documentId);
|
|
|
|
|
|
setStatus(input, 'saved');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
window.dispatchEvent(new CustomEvent('tree:title-updated', {
|
|
|
|
|
|
detail: { documentId, workspaceId: workspaceId || null, title, payload },
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
setStatus(input, 'error', error instanceof Error ? error.message : String(error));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
saving = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
input.addEventListener('input', () => {
|
|
|
|
|
|
autosize(input);
|
|
|
|
|
|
setStatus(input, input.value.trim() === lastSavedTitle ? 'saved' : 'dirty');
|
|
|
|
|
|
});
|
|
|
|
|
|
input.addEventListener('keydown', (event) => {
|
|
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
input.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
input.addEventListener('blur', () => { void saveTitle(); });
|
|
|
|
|
|
autosize(input);
|
|
|
|
|
|
updateVisibleTitle(input, lastSavedTitle, documentId);
|
|
|
|
|
|
setStatus(input, 'saved');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>"#
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
r#"<script type="module">
|
|
|
|
|
|
(() => {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const PANES_BOOTSTRAP_ID = '__MNOTE_DOCUMENT_PANES_BOOTSTRAP__';
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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`;
|
2026-05-08 10:57:24 +08:00
|
|
|
|
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
|
|
|
|
|
|
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
|
2026-04-29 14:36:24 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const panesBootstrap = parseJsonScript(PANES_BOOTSTRAP_ID);
|
|
|
|
|
|
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes) || panesBootstrap.panes.length === 0) return;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-05-09 06:24:50 +08:00
|
|
|
|
const pushUrlState = (url) => {
|
|
|
|
|
|
window.history.pushState({}, '', url.pathname + url.search + url.hash);
|
|
|
|
|
|
};
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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);
|
2026-05-09 06:24:50 +08:00
|
|
|
|
if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') {
|
|
|
|
|
|
void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
sourceKind: primarySourceKind || null,
|
|
|
|
|
|
rootUri: primaryRootUri || null,
|
|
|
|
|
|
url,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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));
|
2026-05-09 06:24:50 +08:00
|
|
|
|
if (typeof window.__mnoteDocumentPaneRuntime?.closeSecondaryDocument === 'function') {
|
|
|
|
|
|
window.__mnoteDocumentPaneRuntime.closeSecondaryDocument({ url });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const raw = window.localStorage ? window.localStorage.getItem(SECONDARY_WIDTH_KEY) : '';
|
|
|
|
|
|
const width = Number(raw || 0);
|
|
|
|
|
|
if (Number.isFinite(width) && width >= 320) {
|
|
|
|
|
|
workspace.style.gridTemplateColumns = `minmax(0, 1fr) 6px minmax(320px, ${Math.round(width)}px)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
};
|
|
|
|
|
|
applyStoredSecondaryWidth();
|
|
|
|
|
|
if (workspace instanceof HTMLElement && resizer instanceof HTMLElement) {
|
|
|
|
|
|
resizer.addEventListener('pointerdown', (event) => {
|
|
|
|
|
|
if (workspace.getAttribute('data-has-secondary-pane') !== 'true') return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
const move = (nextEvent) => {
|
|
|
|
|
|
const rect = workspace.getBoundingClientRect();
|
|
|
|
|
|
const width = Math.min(Math.max(320, rect.right - nextEvent.clientX), Math.max(420, rect.width - 360));
|
|
|
|
|
|
workspace.style.gridTemplateColumns = `minmax(0, 1fr) 6px minmax(320px, ${Math.round(width)}px)`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.localStorage) window.localStorage.setItem(SECONDARY_WIDTH_KEY, String(Math.round(width)));
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
};
|
|
|
|
|
|
const up = () => {
|
|
|
|
|
|
window.removeEventListener('pointermove', move);
|
|
|
|
|
|
window.removeEventListener('pointerup', up);
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('pointermove', move);
|
|
|
|
|
|
window.addEventListener('pointerup', up);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const buildPaneRuntime = (paneDescriptor) => {
|
|
|
|
|
|
const paneRole = typeof paneDescriptor?.role === 'string' ? paneDescriptor.role : 'primary';
|
|
|
|
|
|
const paneRoot = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
|
|
|
|
|
|
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
|
|
|
|
|
|
if (!(paneRoot instanceof HTMLElement)) return null;
|
|
|
|
|
|
if (!paneDescriptor?.aggregate || !paneDescriptor?.bootstrap) return null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
paneRole,
|
|
|
|
|
|
root: paneRoot,
|
|
|
|
|
|
observability,
|
|
|
|
|
|
aggregate: paneDescriptor.aggregate,
|
|
|
|
|
|
bootstrap: paneDescriptor.bootstrap,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const paneRuntimes = panesBootstrap.panes.map(buildPaneRuntime).filter(Boolean);
|
|
|
|
|
|
if (!paneRuntimes.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
const loadRuntime = async () => {
|
|
|
|
|
|
if (window.__mnoteLeptosTiptapRuntimePromise) {
|
|
|
|
|
|
return window.__mnoteLeptosTiptapRuntimePromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
|
|
|
|
|
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
|
|
|
|
|
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
|
|
|
|
|
const manifest = await manifestResponse.json();
|
|
|
|
|
|
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
|
|
|
|
|
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
|
|
|
|
|
|
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
|
|
|
|
|
|
const runtime = await import(entryUrl);
|
|
|
|
|
|
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
|
|
|
|
|
throw new Error('island runtime 导出不完整');
|
|
|
|
|
|
}
|
|
|
|
|
|
await runtime.default(wasmUrl);
|
|
|
|
|
|
return runtime;
|
|
|
|
|
|
})();
|
|
|
|
|
|
return window.__mnoteLeptosTiptapRuntimePromise;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-09 19:05:06 +08:00
|
|
|
|
const firstNonEmptyText = (...values) => {
|
|
|
|
|
|
for (const value of values) {
|
|
|
|
|
|
const text = flattenText(value).trim();
|
|
|
|
|
|
if (text) return text;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-09 19:05:06 +08:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
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) {
|
2026-05-09 19:05:06 +08:00
|
|
|
|
const marks = mergeTiptapMarks(
|
|
|
|
|
|
legacyStylesToTiptapMarks(value.styles),
|
|
|
|
|
|
legacyMarkArrayToTiptapMarks(value.marks)
|
|
|
|
|
|
);
|
2026-05-08 00:41:03 +08:00
|
|
|
|
return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }];
|
|
|
|
|
|
}
|
|
|
|
|
|
return legacyInlineContentToTiptap(value.content || value.contentNodes);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
|
const legacyBlockToTiptap = (block, index = 0) => {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
|
2026-05-06 21:44:20 +08:00
|
|
|
|
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}`;
|
2026-05-08 00:41:03 +08:00
|
|
|
|
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
|
2026-05-02 06:25:26 +08:00
|
|
|
|
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)
|
2026-05-06 21:44:20 +08:00
|
|
|
|
? block.children.map((child, childIndex) => legacyBlockToTiptap(child, childIndex)).filter(Boolean)
|
2026-05-02 06:25:26 +08:00
|
|
|
|
: [];
|
|
|
|
|
|
const withListChildren = (itemType, listType, attrs = {}) => ({
|
|
|
|
|
|
type: listType,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
attrs: { blockId },
|
2026-05-02 06:25:26 +08:00
|
|
|
|
content: [{
|
|
|
|
|
|
type: itemType,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
attrs: { blockId, ...attrs },
|
2026-05-02 06:25:26 +08:00
|
|
|
|
content: [
|
2026-05-06 21:44:20 +08:00
|
|
|
|
{ type: 'paragraph', attrs: { blockId }, content },
|
2026-05-02 06:25:26 +08:00
|
|
|
|
...nestedChildren,
|
|
|
|
|
|
],
|
|
|
|
|
|
}],
|
|
|
|
|
|
});
|
2026-04-29 14:36:24 +08:00
|
|
|
|
if (type === 'heading') {
|
|
|
|
|
|
const level = Number(block?.props?.level || block?.level || 1) || 1;
|
2026-05-02 06:25:26 +08:00
|
|
|
|
const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {};
|
2026-05-06 21:44:20 +08:00
|
|
|
|
return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (type === 'bulletListItem' || type === 'bullet_list_item') return withListChildren('listItem', 'bulletList');
|
|
|
|
|
|
if (type === 'numberedListItem' || type === 'numbered_list_item') return withListChildren('listItem', 'orderedList');
|
2026-04-30 18:36:50 +08:00
|
|
|
|
if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') {
|
2026-05-02 06:25:26 +08:00
|
|
|
|
return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) });
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
2026-04-30 18:36:50 +08:00
|
|
|
|
if (type === 'quote' || type === 'blockquote') {
|
2026-05-06 21:44:20 +08:00
|
|
|
|
return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] };
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
2026-05-02 06:25:26 +08:00
|
|
|
|
if (type === 'codeBlock' || type === 'code_block') {
|
2026-05-06 21:44:20 +08:00
|
|
|
|
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
2026-05-09 19:05:06 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
2026-05-02 06:25:26 +08:00
|
|
|
|
if (type === 'table') {
|
|
|
|
|
|
const tableSnapshot = block?.props?.tiptapTable;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') return tableSnapshot;
|
2026-05-02 06:25:26 +08:00
|
|
|
|
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;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (tocSnapshot && typeof tocSnapshot === 'object' && tocSnapshot.type === 'tocNode') return tocSnapshot;
|
2026-05-02 06:25:26 +08:00
|
|
|
|
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;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (imageSnapshot && typeof imageSnapshot === 'object' && imageSnapshot.type === 'image') return imageSnapshot;
|
2026-05-02 06:25:26 +08:00
|
|
|
|
const attrs = {
|
|
|
|
|
|
src: String(block?.props?.src || block?.src || ''),
|
|
|
|
|
|
alt: block?.props?.alt || block?.alt || null,
|
|
|
|
|
|
title: block?.props?.title || block?.title || null,
|
|
|
|
|
|
};
|
2026-05-06 21:44:20 +08:00
|
|
|
|
return attrs.src ? { type: 'image', attrs: { blockId, ...attrs } } : null;
|
2026-05-02 06:25:26 +08:00
|
|
|
|
}
|
2026-05-06 21:44:20 +08:00
|
|
|
|
return { type: 'paragraph', attrs: withTextAlign({ blockId }), content };
|
2026-04-29 14:36:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const textToTiptapDocument = (text) => ({
|
|
|
|
|
|
type: 'doc',
|
|
|
|
|
|
content: [{
|
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
|
content: text ? [{ type: 'text', text }] : [],
|
|
|
|
|
|
}],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const isTiptapDocument = (content) => (
|
|
|
|
|
|
content &&
|
|
|
|
|
|
typeof content === 'object' &&
|
|
|
|
|
|
!Array.isArray(content) &&
|
|
|
|
|
|
content.type === 'doc'
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const toTiptapDocument = (content, fallbackText = '') => {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (isTiptapDocument(content)) return content;
|
|
|
|
|
|
const blocks = Array.isArray(content) ? content : Array.isArray(content?.blocks) ? content.blocks : [];
|
2026-04-29 14:36:24 +08:00
|
|
|
|
const nodes = blocks.map(legacyBlockToTiptap).filter(Boolean);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (nodes.length) return { type: 'doc', content: nodes };
|
2026-04-29 14:36:24 +08:00
|
|
|
|
return textToTiptapDocument(fallbackText);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
|
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 : '';
|
2026-05-08 00:41:03 +08:00
|
|
|
|
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 } : {}) }];
|
2026-05-06 21:44:20 +08:00
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (child?.type === 'hardBreak') return [{ type: 'text', text: '\n' }];
|
2026-05-06 21:44:20 +08:00
|
|
|
|
return inlineTextNodes(child);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const firstChild = (node) => Array.isArray(node?.content) ? node.content[0] : null;
|
|
|
|
|
|
const blockIdOf = (node, index) => {
|
|
|
|
|
|
const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : '';
|
|
|
|
|
|
return raw || `block-${index + 1}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tiptapNodeToEditorBlock = (node, index) => {
|
|
|
|
|
|
const blockId = blockIdOf(node, index);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
2026-05-06 21:44:20 +08:00
|
|
|
|
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: [] };
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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: [] };
|
2026-05-06 21:44:20 +08:00
|
|
|
|
if (node?.type === 'taskList') {
|
|
|
|
|
|
const taskItem = firstChild(node);
|
|
|
|
|
|
return { blockId, blockType: 'todo', props: { checked: Boolean(taskItem?.attrs?.checked) }, contentNodes: inlineTextNodes(firstChild(taskItem)), childBlockIds: [] };
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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: [] };
|
2026-05-06 21:44:20 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const editorDocumentFromTiptapDocument = (bootstrap, tiptapDocument) => {
|
2026-05-06 21:44:20 +08:00
|
|
|
|
const content = Array.isArray(tiptapDocument?.content) ? tiptapDocument.content : [];
|
|
|
|
|
|
const blocks = content.map(tiptapNodeToEditorBlock).filter(Boolean);
|
|
|
|
|
|
return {
|
|
|
|
|
|
documentId: bootstrap.documentId,
|
|
|
|
|
|
rootBlockIds: blocks.map((block) => block.blockId),
|
|
|
|
|
|
blocks,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const legacyBlocksFromEditorDocument = (editorDocument) => (
|
|
|
|
|
|
Array.isArray(editorDocument?.blocks) ? editorDocument.blocks : []
|
|
|
|
|
|
).map((block) => ({
|
|
|
|
|
|
id: block.blockId,
|
|
|
|
|
|
type: block.blockType,
|
|
|
|
|
|
props: block.blockType === 'heading'
|
|
|
|
|
|
? { level: block.props?.headingLevel || 1 }
|
|
|
|
|
|
: block.blockType === 'todo'
|
|
|
|
|
|
? { checked: Boolean(block.props?.checked) }
|
|
|
|
|
|
: block.blockType === 'code_block'
|
|
|
|
|
|
? { language: block.props?.language || null }
|
|
|
|
|
|
: block.blockType === 'image'
|
|
|
|
|
|
? { ...(block.props || {}) }
|
|
|
|
|
|
: block.blockType === 'toc'
|
|
|
|
|
|
? { ...(block.props || {}) }
|
|
|
|
|
|
: block.blockType === 'table'
|
|
|
|
|
|
? { ...(block.props || {}) }
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
content: Array.isArray(block.contentNodes)
|
2026-05-08 00:41:03 +08:00
|
|
|
|
? block.contentNodes.map((node) => {
|
|
|
|
|
|
if (!node || typeof node !== 'object') return null;
|
|
|
|
|
|
const text = typeof node.text === 'string' ? node.text : '';
|
|
|
|
|
|
if (!text) return null;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
return { type: 'text', text, ...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}) };
|
2026-05-08 00:41:03 +08:00
|
|
|
|
}).filter(Boolean)
|
2026-05-06 21:44:20 +08:00
|
|
|
|
: '',
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-08 10:57:24 +08:00
|
|
|
|
const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string'
|
|
|
|
|
|
? body.conflictDetectionKey
|
|
|
|
|
|
: typeof body?.conflict_detection_key === 'string'
|
|
|
|
|
|
? body.conflict_detection_key
|
2026-05-02 06:25:26 +08:00
|
|
|
|
: null;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
|
2026-05-02 06:25:26 +08:00
|
|
|
|
const revisionFromConflictKey = (value) => {
|
|
|
|
|
|
const match = String(value || '').match(/:(\d+)$/);
|
|
|
|
|
|
return match ? Number(match[1]) : null;
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
|
2026-05-02 06:25:26 +08:00
|
|
|
|
const normalizeBridgeValue = (value) => {
|
|
|
|
|
|
if (value instanceof Map) {
|
|
|
|
|
|
const out = {};
|
2026-05-08 23:15:00 +08:00
|
|
|
|
for (const [key, item] of value.entries()) out[key] = normalizeBridgeValue(item);
|
2026-05-02 06:25:26 +08:00
|
|
|
|
return out;
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (Array.isArray(value)) return value.map(normalizeBridgeValue);
|
2026-05-02 06:25:26 +08:00
|
|
|
|
if (value && typeof value === 'object') {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeBridgeValue(item)]));
|
2026-05-02 06:25:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
return value;
|
|
|
|
|
|
};
|
2026-05-08 23:15:00 +08:00
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
const normalizeEnvelopePayload = (event) => {
|
2026-05-02 06:25:26 +08:00
|
|
|
|
const detail = normalizeBridgeValue(event?.detail);
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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');
|
2026-05-08 10:57:24 +08:00
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (runtime.observability instanceof HTMLElement) {
|
|
|
|
|
|
runtime.observability.setAttribute('data-editor-host-status', status);
|
|
|
|
|
|
runtime.observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island');
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const pageAggregateUrl = (bootstrap) => {
|
2026-05-08 10:57:24 +08:00
|
|
|
|
const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin);
|
|
|
|
|
|
url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder');
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (bootstrap.workspaceId) url.searchParams.set('workspaceId', bootstrap.workspaceId);
|
2026-05-08 10:57:24 +08:00
|
|
|
|
if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri);
|
|
|
|
|
|
return url;
|
|
|
|
|
|
};
|
2026-05-09 06:24:50 +08:00
|
|
|
|
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',
|
|
|
|
|
|
});
|
2026-05-08 10:57:24 +08:00
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const documentSessionRegistry = new Map();
|
|
|
|
|
|
const localFolderEventRegistry = new Map();
|
2026-05-09 06:24:50 +08:00
|
|
|
|
const paneViewRegistry = new Map();
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let nextViewId = 1;
|
|
|
|
|
|
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
|
|
|
|
|
|
const SESSION_RELEASE_DELAY_MS = 1200;
|
|
|
|
|
|
|
|
|
|
|
|
const parseLocalFolderEventPayload = (event) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(String(event?.data || '{}'));
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeSessionSourceKind = (bootstrap) => {
|
|
|
|
|
|
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
|
|
|
|
|
|
return value || 'convex_workspace';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildDocumentSessionKey = (bootstrap) => {
|
|
|
|
|
|
const sourceKind = normalizeSessionSourceKind(bootstrap);
|
|
|
|
|
|
const scope = sourceKind === 'local_folder'
|
|
|
|
|
|
? String(bootstrap?.rootUri || '').trim()
|
|
|
|
|
|
: String(bootstrap?.workspaceId || '').trim();
|
|
|
|
|
|
return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sessionViews = (session) => Array.from(session.views.values());
|
|
|
|
|
|
|
|
|
|
|
|
const detachSessionFromLocalFolderChannel = (session) => {
|
|
|
|
|
|
const channel = session.localFolderChannel;
|
|
|
|
|
|
if (!channel) return;
|
|
|
|
|
|
channel.sessions.delete(session.key);
|
|
|
|
|
|
if (channel.sessions.size === 0) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
channel.eventSource.close();
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// noop
|
|
|
|
|
|
}
|
|
|
|
|
|
localFolderEventRegistry.delete(channel.rootUri);
|
|
|
|
|
|
}
|
|
|
|
|
|
session.localFolderChannel = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const releaseDocumentSession = (session) => {
|
|
|
|
|
|
if (session.saveTimer) {
|
|
|
|
|
|
window.clearTimeout(session.saveTimer);
|
|
|
|
|
|
session.saveTimer = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (session.externalRefreshTimer) {
|
|
|
|
|
|
window.clearTimeout(session.externalRefreshTimer);
|
|
|
|
|
|
session.externalRefreshTimer = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (session.releaseTimer) {
|
|
|
|
|
|
window.clearTimeout(session.releaseTimer);
|
|
|
|
|
|
session.releaseTimer = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
detachSessionFromLocalFolderChannel(session);
|
|
|
|
|
|
if (documentSessionRegistry.get(session.key) === session) {
|
|
|
|
|
|
documentSessionRegistry.delete(session.key);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const scheduleDocumentSessionRelease = (session) => {
|
|
|
|
|
|
if (session.views.size > 0) return;
|
|
|
|
|
|
if (session.releaseTimer) window.clearTimeout(session.releaseTimer);
|
|
|
|
|
|
session.releaseTimer = window.setTimeout(() => {
|
|
|
|
|
|
session.releaseTimer = 0;
|
|
|
|
|
|
if (session.views.size === 0) {
|
|
|
|
|
|
releaseDocumentSession(session);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, SESSION_RELEASE_DELAY_MS);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const cancelDocumentSessionRelease = (session) => {
|
|
|
|
|
|
if (!session.releaseTimer) return;
|
|
|
|
|
|
window.clearTimeout(session.releaseTimer);
|
|
|
|
|
|
session.releaseTimer = 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.__mnoteDebugDocumentSessions = {
|
|
|
|
|
|
snapshot: () => ({
|
|
|
|
|
|
sessionCount: documentSessionRegistry.size,
|
|
|
|
|
|
sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({
|
|
|
|
|
|
key,
|
|
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
sourceKind: session.sourceKind,
|
|
|
|
|
|
rootUri: session.rootUri,
|
|
|
|
|
|
workspaceId: session.workspaceId,
|
|
|
|
|
|
viewCount: session.views.size,
|
|
|
|
|
|
status: session.status,
|
|
|
|
|
|
})),
|
|
|
|
|
|
localFolderChannelCount: localFolderEventRegistry.size,
|
|
|
|
|
|
localFolderRoots: Array.from(localFolderEventRegistry.keys()),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const currentEditorText = (view) => {
|
|
|
|
|
|
const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
|
|
|
|
|
|
return editor?.textContent || '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const findScrollableEditorContainer = (view) => {
|
|
|
|
|
|
let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
|
|
|
|
|
|
while (current instanceof HTMLElement) {
|
|
|
|
|
|
if (current.scrollHeight > current.clientHeight + 8) {
|
|
|
|
|
|
return current;
|
|
|
|
|
|
}
|
|
|
|
|
|
current = current.parentElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const preserveViewScrollPosition = (view, operation) => {
|
|
|
|
|
|
const scrollable = findScrollableEditorContainer(view);
|
|
|
|
|
|
const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null;
|
|
|
|
|
|
const viewportTop = window.scrollY;
|
|
|
|
|
|
const viewportLeft = window.scrollX;
|
|
|
|
|
|
operation();
|
|
|
|
|
|
const restore = () => {
|
|
|
|
|
|
const current = findScrollableEditorContainer(view);
|
|
|
|
|
|
if (current instanceof HTMLElement && scrollTop != null) {
|
|
|
|
|
|
current.scrollTop = scrollTop;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) {
|
|
|
|
|
|
window.scrollTo({
|
|
|
|
|
|
top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY,
|
|
|
|
|
|
left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX,
|
|
|
|
|
|
behavior: 'auto',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
|
restore();
|
|
|
|
|
|
window.setTimeout(restore, 0);
|
|
|
|
|
|
window.setTimeout(restore, 80);
|
|
|
|
|
|
window.setTimeout(restore, 180);
|
|
|
|
|
|
window.setTimeout(restore, 320);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const dispatchRuntimeCommand = (view, payload, source) => {
|
|
|
|
|
|
view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(COMMAND_EVENT, {
|
2026-05-08 10:57:24 +08:00
|
|
|
|
bubbles: true,
|
|
|
|
|
|
detail: {
|
|
|
|
|
|
protocol: BRIDGE_PROTOCOL,
|
|
|
|
|
|
runtime: 'mnote-leptos-tiptap-spike',
|
|
|
|
|
|
version: '1.1.0',
|
2026-05-08 23:15:00 +08:00
|
|
|
|
source: source || 'mnote-web-document-session',
|
2026-05-08 10:57:24 +08:00
|
|
|
|
event: COMMAND_EVENT,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
payload,
|
2026-05-08 10:57:24 +08:00
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const clearEmbeddedLocalDraft = (runtimeDescriptor) => {
|
|
|
|
|
|
if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return;
|
2026-05-08 10:57:24 +08:00
|
|
|
|
try {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const storage = window.localStorage;
|
|
|
|
|
|
const base = 'mnote.leptos-tiptap-spike.document';
|
|
|
|
|
|
const keys = [
|
|
|
|
|
|
`${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`,
|
|
|
|
|
|
`${base}:${runtimeDescriptor.bootstrap.documentId}`,
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const key of keys) storage.removeItem(key);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn('mnote local folder 草稿清理失败', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setSessionStatus = (session, status, message) => {
|
|
|
|
|
|
session.status = status;
|
|
|
|
|
|
session.error = message || null;
|
|
|
|
|
|
sessionViews(session).forEach((view) => {
|
|
|
|
|
|
setStatus(view.runtimeDescriptor, status, message);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const dispatchSessionContentToView = (session, view, source) => {
|
|
|
|
|
|
view.suppressedSerialized = session.currentSerialized;
|
|
|
|
|
|
view.lastKnownSerialized = session.currentSerialized;
|
|
|
|
|
|
preserveViewScrollPosition(view, () => {
|
|
|
|
|
|
dispatchRuntimeCommand(view, {
|
|
|
|
|
|
command: 'replaceContent',
|
|
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
workspaceId: session.workspaceId,
|
|
|
|
|
|
title: session.title,
|
|
|
|
|
|
content: session.currentTiptapDocument,
|
|
|
|
|
|
revision: session.revision,
|
|
|
|
|
|
conflictDetectionKey: session.conflictDetectionKey,
|
|
|
|
|
|
readOnly: session.readOnly,
|
|
|
|
|
|
editable: !session.readOnly,
|
|
|
|
|
|
}, source || 'mnote-web-document-session-sync');
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const dispatchSessionMetaToView = (session, view, source) => {
|
|
|
|
|
|
dispatchRuntimeCommand(view, {
|
|
|
|
|
|
command: 'replaceContent',
|
|
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
workspaceId: session.workspaceId,
|
|
|
|
|
|
title: session.title,
|
|
|
|
|
|
revision: session.revision,
|
|
|
|
|
|
conflictDetectionKey: session.conflictDetectionKey,
|
|
|
|
|
|
readOnly: session.readOnly,
|
|
|
|
|
|
editable: !session.readOnly,
|
|
|
|
|
|
}, source || 'mnote-web-document-session-meta');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const syncSessionMetaToViews = (session) => {
|
|
|
|
|
|
sessionViews(session).forEach((view) => {
|
|
|
|
|
|
if (view.mountId != null) dispatchSessionMetaToView(session, view);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const broadcastSessionContent = (session, sourceView) => {
|
|
|
|
|
|
sessionViews(session).forEach((view) => {
|
|
|
|
|
|
if (sourceView && view.id === sourceView.id) return;
|
|
|
|
|
|
if (view.mountId == null) return;
|
|
|
|
|
|
dispatchSessionContentToView(session, view);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sessionPlainText = (session) => flattenText(session.currentTiptapDocument).replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
|
|
|
|
|
|
const sessionHasRecentExternalSignal = (session) => (
|
|
|
|
|
|
session.sourceKind === 'local_folder'
|
|
|
|
|
|
&& Number.isFinite(session.lastExternalChangeSignalAt)
|
|
|
|
|
|
&& session.lastExternalChangeSignalAt > 0
|
|
|
|
|
|
&& (Date.now() - session.lastExternalChangeSignalAt) < 1500
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const sessionHasRecentLocalInput = (session) => (
|
|
|
|
|
|
Number.isFinite(session.lastUserInputAt)
|
|
|
|
|
|
&& session.lastUserInputAt > 0
|
|
|
|
|
|
&& (Date.now() - session.lastUserInputAt) < 1500
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const markSessionExternalConflict = (session, message) => {
|
|
|
|
|
|
session.externalChangePending = false;
|
|
|
|
|
|
session.hasExternalConflict = true;
|
|
|
|
|
|
if (session.saveTimer) {
|
|
|
|
|
|
window.clearTimeout(session.saveTimer);
|
|
|
|
|
|
session.saveTimer = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const queueSessionSave = (session) => {
|
|
|
|
|
|
if (session.readOnly || session.hasExternalConflict) return;
|
|
|
|
|
|
if (session.saveTimer) window.clearTimeout(session.saveTimer);
|
|
|
|
|
|
setSessionStatus(session, 'dirty');
|
|
|
|
|
|
session.saveTimer = window.setTimeout(() => {
|
|
|
|
|
|
session.saveTimer = 0;
|
|
|
|
|
|
void persistSession(session);
|
|
|
|
|
|
}, 650);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const persistSession = async (session) => {
|
|
|
|
|
|
if (session.readOnly || session.saving || session.hasExternalConflict) return;
|
|
|
|
|
|
const serialized = session.currentSerialized;
|
|
|
|
|
|
if (!session.dirty && serialized === session.lastPersistedSerialized) {
|
|
|
|
|
|
setSessionStatus(session, 'saved');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
session.saving = true;
|
|
|
|
|
|
setSessionStatus(session, 'saving');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
|
|
|
|
|
|
const content = legacyBlocksFromEditorDocument(editorDocument);
|
|
|
|
|
|
const response = await fetch(session.saveEndpoint || '/api/documents/save', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
workspaceId: session.workspaceId,
|
|
|
|
|
|
sourceKind: session.sourceKind,
|
|
|
|
|
|
rootUri: session.rootUri,
|
|
|
|
|
|
revision: session.revision,
|
|
|
|
|
|
conflictDetectionKey: session.conflictDetectionKey,
|
|
|
|
|
|
editorDocument,
|
|
|
|
|
|
content,
|
|
|
|
|
|
tiptapDocument: session.currentTiptapDocument,
|
|
|
|
|
|
blockCount: editorDocument.blocks.length,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await response.json().catch(() => null);
|
|
|
|
|
|
if (!response.ok || !result || result.ok !== true) {
|
|
|
|
|
|
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
|
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
const saved = result.result || {};
|
|
|
|
|
|
if (Number.isInteger(saved.revision)) session.revision = saved.revision;
|
|
|
|
|
|
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
|
|
|
|
|
|
session.conflictDetectionKey = saved.conflict_detection_key.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) {
|
|
|
|
|
|
session.conflictDetectionKey = saved.conflictDetectionKey.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
|
|
|
|
|
|
session.hasExternalConflict = false;
|
|
|
|
|
|
session.externalChangePending = false;
|
|
|
|
|
|
session.lastUserInputAt = 0;
|
|
|
|
|
|
syncSessionMetaToViews(session);
|
|
|
|
|
|
session.lastPersistedSerialized = serialized;
|
|
|
|
|
|
session.saving = false;
|
|
|
|
|
|
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
|
|
|
|
|
|
const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0];
|
|
|
|
|
|
if (primaryView) {
|
|
|
|
|
|
const plainText = sessionPlainText(session);
|
|
|
|
|
|
window.__mnoteRecordPageHistorySnapshot('save', {
|
|
|
|
|
|
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
|
|
|
|
|
|
characterCount: plainText.replace(/\s/g, '').length,
|
|
|
|
|
|
blockCount: editorDocument.blocks.length,
|
|
|
|
|
|
todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length,
|
|
|
|
|
|
todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (session.currentSerialized === serialized) {
|
|
|
|
|
|
session.dirty = false;
|
|
|
|
|
|
setSessionStatus(session, 'saved');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
session.dirty = true;
|
|
|
|
|
|
setSessionStatus(session, 'dirty');
|
|
|
|
|
|
queueSessionSave(session);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
session.saving = false;
|
|
|
|
|
|
setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const scheduleSessionExternalRefresh = (session) => {
|
|
|
|
|
|
if (session.externalRefreshTimer) return;
|
|
|
|
|
|
session.externalRefreshTimer = window.setTimeout(() => {
|
|
|
|
|
|
session.externalRefreshTimer = 0;
|
|
|
|
|
|
void refreshSessionFromExternalFileChange(session);
|
|
|
|
|
|
}, 120);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const refreshSessionFromExternalFileChange = async (session) => {
|
|
|
|
|
|
if (session.sourceKind !== 'local_folder' || !session.rootUri || document.hidden) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(pageAggregateUrl({
|
|
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
sourceKind: session.sourceKind,
|
|
|
|
|
|
workspaceId: session.workspaceId,
|
|
|
|
|
|
rootUri: session.rootUri,
|
|
|
|
|
|
}).toString(), {
|
2026-05-08 10:57:24 +08:00
|
|
|
|
cache: 'no-store',
|
|
|
|
|
|
headers: { accept: 'application/json' },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!response.ok) return;
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
|
const nextAggregate = payload?.result;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const nextBody = nextAggregate?.body || {};
|
|
|
|
|
|
const nextPermissions = nextAggregate?.head?.permissions || {};
|
|
|
|
|
|
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
|
|
|
|
|
session.externalChangePending = false;
|
|
|
|
|
|
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
|
|
|
|
|
|
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
|
2026-05-08 10:57:24 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (nextConflictKey === session.lastExternalConflictDetectionKey) return;
|
|
|
|
|
|
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
|
|
|
|
|
markSessionExternalConflict(session, externalConflictMessage);
|
2026-05-08 10:57:24 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
|
|
|
|
|
session.latestAggregate = nextAggregate;
|
|
|
|
|
|
session.title = nextAggregate?.head?.title || session.title;
|
|
|
|
|
|
session.currentTiptapDocument = toTiptapDocument(nextBody.content);
|
|
|
|
|
|
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
|
|
|
|
|
session.lastPersistedSerialized = session.currentSerialized;
|
|
|
|
|
|
session.revision = nextRevision;
|
|
|
|
|
|
session.conflictDetectionKey = nextConflictKey;
|
|
|
|
|
|
session.lastExternalConflictDetectionKey = nextConflictKey || '';
|
|
|
|
|
|
session.readOnly = Boolean(nextPermissions.readOnly);
|
|
|
|
|
|
session.dirty = false;
|
|
|
|
|
|
session.hasExternalConflict = false;
|
|
|
|
|
|
session.lastUserInputAt = 0;
|
|
|
|
|
|
sessionViews(session).forEach((view) => {
|
|
|
|
|
|
if (view.mountId != null) dispatchSessionContentToView(session, view, 'mnote-web-local-folder-watch');
|
|
|
|
|
|
});
|
|
|
|
|
|
setSessionStatus(session, 'synced-external-change');
|
2026-05-08 10:57:24 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn('mnote local folder 外部更新检测失败', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const ensureLocalFolderEventChannel = (session) => {
|
|
|
|
|
|
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
|
2026-05-08 11:23:08 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let channel = localFolderEventRegistry.get(session.rootUri);
|
|
|
|
|
|
if (!channel) {
|
|
|
|
|
|
const url = new URL('/api/local-folder/events', window.location.origin);
|
|
|
|
|
|
url.searchParams.set('rootUri', session.rootUri);
|
|
|
|
|
|
const eventSource = new EventSource(url.toString());
|
|
|
|
|
|
channel = {
|
|
|
|
|
|
rootUri: session.rootUri,
|
|
|
|
|
|
eventSource,
|
|
|
|
|
|
sessions: new Map(),
|
|
|
|
|
|
};
|
|
|
|
|
|
eventSource.addEventListener('change', (event) => {
|
|
|
|
|
|
const payload = parseLocalFolderEventPayload(event);
|
|
|
|
|
|
if (!payload) return;
|
|
|
|
|
|
Array.from(channel.sessions.values()).forEach((targetSession) => {
|
|
|
|
|
|
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
|
|
|
|
|
|
if (documentId && documentId !== targetSession.documentId) return;
|
|
|
|
|
|
targetSession.lastExternalChangeSignalAt = Date.now();
|
|
|
|
|
|
targetSession.externalChangePending = true;
|
|
|
|
|
|
if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) {
|
|
|
|
|
|
markSessionExternalConflict(targetSession, externalConflictMessage);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
scheduleSessionExternalRefresh(targetSession);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
eventSource.onerror = () => {
|
|
|
|
|
|
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
|
|
|
|
|
|
};
|
|
|
|
|
|
localFolderEventRegistry.set(session.rootUri, channel);
|
|
|
|
|
|
}
|
|
|
|
|
|
channel.sessions.set(session.key, session);
|
|
|
|
|
|
session.localFolderChannel = channel;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const createDocumentSession = (runtimeDescriptor) => {
|
|
|
|
|
|
const pageBody = runtimeDescriptor.aggregate.body || {};
|
|
|
|
|
|
const permissions = runtimeDescriptor.aggregate.head?.permissions || {};
|
|
|
|
|
|
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
|
|
|
|
|
|
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
|
|
|
|
|
|
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
|
|
|
|
|
|
const tiptapDocument = toTiptapDocument(pageBody.content);
|
|
|
|
|
|
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
|
|
|
|
|
|
const session = {
|
|
|
|
|
|
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
|
|
|
|
|
|
documentId: runtimeDescriptor.bootstrap.documentId,
|
|
|
|
|
|
workspaceId: runtimeDescriptor.bootstrap.workspaceId,
|
|
|
|
|
|
sourceKind,
|
|
|
|
|
|
rootUri: runtimeDescriptor.bootstrap.rootUri,
|
|
|
|
|
|
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
|
|
|
|
|
|
latestAggregate: runtimeDescriptor.aggregate,
|
|
|
|
|
|
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
|
|
|
|
|
currentTiptapDocument: tiptapDocument,
|
|
|
|
|
|
currentSerialized: JSON.stringify(tiptapDocument),
|
|
|
|
|
|
lastPersistedSerialized: JSON.stringify(tiptapDocument),
|
|
|
|
|
|
revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision,
|
|
|
|
|
|
conflictDetectionKey,
|
|
|
|
|
|
lastExternalConflictDetectionKey: conflictDetectionKey || '',
|
|
|
|
|
|
readOnly: Boolean(permissions.readOnly),
|
|
|
|
|
|
dirty: false,
|
|
|
|
|
|
saving: false,
|
|
|
|
|
|
hasExternalConflict: false,
|
|
|
|
|
|
externalChangePending: false,
|
|
|
|
|
|
lastExternalChangeSignalAt: 0,
|
|
|
|
|
|
lastUserInputAt: 0,
|
|
|
|
|
|
status: 'booting',
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
saveTimer: 0,
|
|
|
|
|
|
externalRefreshTimer: 0,
|
|
|
|
|
|
releaseTimer: 0,
|
|
|
|
|
|
views: new Map(),
|
|
|
|
|
|
localFolderChannel: null,
|
2026-05-08 11:23:08 +08:00
|
|
|
|
};
|
2026-05-08 23:15:00 +08:00
|
|
|
|
ensureLocalFolderEventChannel(session);
|
|
|
|
|
|
return session;
|
2026-05-08 11:23:08 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const getOrCreateDocumentSession = (runtimeDescriptor) => {
|
|
|
|
|
|
const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap);
|
|
|
|
|
|
const existing = documentSessionRegistry.get(key);
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
cancelDocumentSessionRelease(existing);
|
|
|
|
|
|
return existing;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const session = createDocumentSession(runtimeDescriptor);
|
|
|
|
|
|
documentSessionRegistry.set(key, session);
|
|
|
|
|
|
return session;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-09 06:24:50 +08:00
|
|
|
|
const updatePaneChrome = (runtimeDescriptor) => {
|
|
|
|
|
|
const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]');
|
|
|
|
|
|
if (!(pane instanceof HTMLElement)) return;
|
|
|
|
|
|
const aggregate = runtimeDescriptor.aggregate || {};
|
|
|
|
|
|
const bootstrap = runtimeDescriptor.bootstrap || {};
|
|
|
|
|
|
const title = aggregate?.head?.title || '无标题';
|
|
|
|
|
|
const documentId = bootstrap.documentId || aggregate?.identity?.documentId || '';
|
|
|
|
|
|
const workspaceId = bootstrap.workspaceId || aggregate?.identity?.workspaceId || '';
|
|
|
|
|
|
pane.setAttribute('data-pane-document-id', documentId);
|
|
|
|
|
|
pane.setAttribute('data-pane-workspace-id', workspaceId);
|
|
|
|
|
|
pane.setAttribute('data-pane-visible', 'true');
|
|
|
|
|
|
pane.hidden = false;
|
|
|
|
|
|
const shell = pane.querySelector('.document-shell');
|
|
|
|
|
|
if (shell instanceof HTMLElement) {
|
|
|
|
|
|
shell.setAttribute('data-document-id', documentId);
|
|
|
|
|
|
shell.setAttribute('data-workspace-id', workspaceId);
|
|
|
|
|
|
const options = aggregate?.layout?.pageOptions || {};
|
|
|
|
|
|
shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
|
|
|
|
|
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
|
|
|
|
|
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
|
|
|
|
|
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
|
|
|
|
|
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
|
|
|
|
|
}
|
|
|
|
|
|
pane.querySelectorAll('[data-page-title-input="true"]').forEach((node) => {
|
|
|
|
|
|
if (!(node instanceof HTMLTextAreaElement)) return;
|
|
|
|
|
|
node.value = title;
|
|
|
|
|
|
node.setAttribute('data-document-id', documentId);
|
|
|
|
|
|
node.setAttribute('data-workspace-id', workspaceId);
|
|
|
|
|
|
node.setAttribute('data-title-save-status', 'saved');
|
|
|
|
|
|
node.style.height = 'auto';
|
|
|
|
|
|
node.style.height = `${Math.max(48, node.scrollHeight)}px`;
|
|
|
|
|
|
});
|
|
|
|
|
|
pane.querySelectorAll('[data-page-title-current="true"]').forEach((node) => {
|
|
|
|
|
|
if (node instanceof HTMLElement) node.textContent = title;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (runtimeDescriptor.paneRole === 'primary') {
|
|
|
|
|
|
document.body.dataset.documentId = documentId;
|
|
|
|
|
|
document.title = title;
|
|
|
|
|
|
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
|
|
|
|
|
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
|
|
|
|
|
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
|
|
|
|
|
|
if (row instanceof HTMLElement) {
|
|
|
|
|
|
row.setAttribute('data-active', 'false');
|
|
|
|
|
|
row.setAttribute('data-selected', 'false');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&');
|
|
|
|
|
|
document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => {
|
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
|
if (row.getAttribute('data-shell-mode') === 'filetree') {
|
|
|
|
|
|
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `index:${documentId}`));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row.setAttribute('data-active', 'true');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchPageAggregateForPane = async (descriptor) => {
|
|
|
|
|
|
const response = await fetch(pageAggregateUrlFromDescriptor(descriptor).toString(), {
|
|
|
|
|
|
cache: 'no-store',
|
|
|
|
|
|
headers: { accept: 'application/json' },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!response.ok) throw new Error(`page_aggregate_failed_${response.status}`);
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
|
if (!payload?.result) throw new Error('page_aggregate_missing_result');
|
|
|
|
|
|
return payload.result;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
|
|
|
|
|
|
const runtime = await loadRuntime();
|
|
|
|
|
|
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
|
|
|
|
|
|
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
|
|
|
|
|
|
if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`);
|
|
|
|
|
|
const previousView = paneViewRegistry.get(paneRole);
|
|
|
|
|
|
if (previousView) {
|
|
|
|
|
|
unmountEditorViewBinding(previousView);
|
|
|
|
|
|
paneViewRegistry.delete(paneRole);
|
|
|
|
|
|
}
|
|
|
|
|
|
const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor);
|
|
|
|
|
|
const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole);
|
|
|
|
|
|
const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap };
|
|
|
|
|
|
updatePaneChrome(runtimeDescriptor);
|
|
|
|
|
|
if (paneRole === 'secondary' && workspace instanceof HTMLElement) {
|
|
|
|
|
|
workspace.setAttribute('data-has-secondary-pane', 'true');
|
|
|
|
|
|
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
|
|
|
|
|
|
if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
|
|
|
|
|
|
applyStoredSecondaryWidth();
|
|
|
|
|
|
}
|
|
|
|
|
|
const session = getOrCreateDocumentSession(runtimeDescriptor);
|
|
|
|
|
|
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
|
|
|
|
|
|
const mountOptions = {
|
|
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
workspaceId: session.workspaceId,
|
|
|
|
|
|
title: session.title,
|
|
|
|
|
|
content: session.currentTiptapDocument,
|
|
|
|
|
|
revision: session.revision,
|
|
|
|
|
|
conflictDetectionKey: session.conflictDetectionKey,
|
|
|
|
|
|
readOnly: session.readOnly,
|
|
|
|
|
|
editable: !session.readOnly,
|
|
|
|
|
|
pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {},
|
|
|
|
|
|
};
|
|
|
|
|
|
setStatus(runtimeDescriptor, 'loading-assets');
|
|
|
|
|
|
clearEmbeddedLocalDraft(runtimeDescriptor);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
|
|
|
|
|
|
view.mountId = mountId;
|
|
|
|
|
|
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
|
|
|
|
|
|
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
|
|
|
|
|
setStatus(runtimeDescriptor, 'mounting-editor');
|
|
|
|
|
|
paneViewRegistry.set(paneRole, view);
|
|
|
|
|
|
return view;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
unmountEditorViewBinding(view);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const descriptorFromCurrentUrl = (paneRole, documentId, explicit = {}) => {
|
|
|
|
|
|
const url = currentUrl();
|
|
|
|
|
|
if (paneRole === 'secondary') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
|
|
|
|
|
|
sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace',
|
|
|
|
|
|
rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
|
|
|
|
|
|
sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace',
|
|
|
|
|
|
rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '',
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const updatePrimaryUrl = (descriptor, urlFromCaller) => {
|
|
|
|
|
|
const url = urlFromCaller instanceof URL
|
|
|
|
|
|
? urlFromCaller
|
|
|
|
|
|
: new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin);
|
|
|
|
|
|
if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId);
|
|
|
|
|
|
if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind);
|
|
|
|
|
|
if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri);
|
|
|
|
|
|
pushUrlState(url);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const closeSecondaryPane = (url) => {
|
|
|
|
|
|
const view = paneViewRegistry.get('secondary');
|
|
|
|
|
|
if (view) {
|
|
|
|
|
|
unmountEditorViewBinding(view);
|
|
|
|
|
|
paneViewRegistry.delete('secondary');
|
|
|
|
|
|
}
|
|
|
|
|
|
const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
|
|
|
|
|
|
if (pane instanceof HTMLElement) {
|
|
|
|
|
|
pane.hidden = true;
|
|
|
|
|
|
pane.setAttribute('data-pane-visible', 'false');
|
|
|
|
|
|
}
|
|
|
|
|
|
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`);
|
|
|
|
|
|
if (root instanceof HTMLElement) root.replaceChildren();
|
|
|
|
|
|
if (workspace instanceof HTMLElement) {
|
|
|
|
|
|
workspace.setAttribute('data-has-secondary-pane', 'false');
|
|
|
|
|
|
workspace.style.removeProperty('grid-template-columns');
|
|
|
|
|
|
}
|
|
|
|
|
|
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
|
|
|
|
|
|
if (resizerNode instanceof HTMLElement) resizerNode.hidden = true;
|
|
|
|
|
|
replaceUrlState(url);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const handleSessionChange = (session, view, event) => {
|
|
|
|
|
|
const payload = normalizeEnvelopePayload(event);
|
|
|
|
|
|
if (!payload) return;
|
|
|
|
|
|
const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending;
|
|
|
|
|
|
const recentExternalChange = sessionHasRecentExternalSignal(session);
|
|
|
|
|
|
const recentLocalInput = sessionHasRecentLocalInput(session);
|
|
|
|
|
|
const tiptapDocument = toTiptapDocument(
|
|
|
|
|
|
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
|
|
|
|
|
|
currentEditorText(view),
|
|
|
|
|
|
);
|
|
|
|
|
|
const serialized = JSON.stringify(tiptapDocument);
|
|
|
|
|
|
if (view.suppressedSerialized && view.suppressedSerialized === serialized) {
|
|
|
|
|
|
view.suppressedSerialized = null;
|
|
|
|
|
|
view.lastKnownSerialized = serialized;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const previousSerialized = session.currentSerialized;
|
|
|
|
|
|
session.currentTiptapDocument = tiptapDocument;
|
|
|
|
|
|
session.currentSerialized = serialized;
|
|
|
|
|
|
view.lastKnownSerialized = serialized;
|
|
|
|
|
|
if (typeof payload?.title === 'string' && payload.title.trim()) {
|
|
|
|
|
|
session.title = payload.title.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (payload?.meta && typeof payload.meta === 'object') {
|
|
|
|
|
|
if (Number.isInteger(payload.meta.revision)) session.revision = payload.meta.revision;
|
|
|
|
|
|
if (typeof payload.meta.conflictDetectionKey === 'string' && payload.meta.conflictDetectionKey.trim()) {
|
|
|
|
|
|
session.conflictDetectionKey = payload.meta.conflictDetectionKey.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof payload.meta.readOnly === 'boolean') {
|
|
|
|
|
|
session.readOnly = payload.meta.readOnly;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
session.dirty = session.currentSerialized !== session.lastPersistedSerialized;
|
|
|
|
|
|
if (serialized !== previousSerialized) {
|
|
|
|
|
|
broadcastSessionContent(session, view);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) {
|
|
|
|
|
|
markSessionExternalConflict(session, externalConflictMessage);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (session.hasExternalConflict) {
|
|
|
|
|
|
setSessionStatus(session, 'external-change-conflict', externalConflictMessage);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (session.dirty) {
|
|
|
|
|
|
queueSessionSave(session);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSessionStatus(session, 'saved');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const unmountEditorViewBinding = (view, options = {}) => {
|
|
|
|
|
|
if (!view || view.disposed) return;
|
|
|
|
|
|
view.disposed = true;
|
|
|
|
|
|
const scheduleRelease = options.scheduleRelease !== false;
|
|
|
|
|
|
const root = view.runtimeDescriptor.root;
|
|
|
|
|
|
if (typeof view.disconnectObserver === 'function') {
|
|
|
|
|
|
view.disconnectObserver();
|
|
|
|
|
|
view.disconnectObserver = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (view.onReady) root.removeEventListener(READY_EVENT, view.onReady);
|
|
|
|
|
|
if (view.onError) root.removeEventListener(ERROR_EVENT, view.onError);
|
|
|
|
|
|
if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange);
|
|
|
|
|
|
if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave);
|
|
|
|
|
|
if (view.onInput) root.removeEventListener('input', view.onInput);
|
|
|
|
|
|
if (view.mountId != null && typeof view.runtime?.unmount === 'function') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
view.runtime.unmount(view.mountId);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn('mnote editor view unmount failed', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
view.mountId = null;
|
|
|
|
|
|
root.removeAttribute('data-runtime-mount-id');
|
|
|
|
|
|
if (view.session.views.get(view.id) === view) {
|
|
|
|
|
|
view.session.views.delete(view.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (scheduleRelease) {
|
|
|
|
|
|
scheduleDocumentSessionRelease(view.session);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const observeEditorViewBinding = (view) => {
|
|
|
|
|
|
if (typeof MutationObserver !== 'function' || !(document.body instanceof HTMLElement)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const observer = new MutationObserver(() => {
|
|
|
|
|
|
if (!view.disposed && !view.runtimeDescriptor.root.isConnected) {
|
|
|
|
|
|
unmountEditorViewBinding(view);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
|
|
|
|
view.disconnectObserver = () => observer.disconnect();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const createEditorViewBinding = (session, runtime, runtimeDescriptor) => {
|
|
|
|
|
|
cancelDocumentSessionRelease(session);
|
|
|
|
|
|
const view = {
|
|
|
|
|
|
id: nextViewId++,
|
|
|
|
|
|
mountId: null,
|
|
|
|
|
|
ready: false,
|
|
|
|
|
|
disposed: false,
|
|
|
|
|
|
suppressedSerialized: null,
|
|
|
|
|
|
lastKnownSerialized: session.currentSerialized,
|
|
|
|
|
|
runtime,
|
|
|
|
|
|
runtimeDescriptor,
|
|
|
|
|
|
session,
|
|
|
|
|
|
disconnectObserver: null,
|
|
|
|
|
|
onReady: null,
|
|
|
|
|
|
onError: null,
|
|
|
|
|
|
onChange: null,
|
|
|
|
|
|
onSave: null,
|
|
|
|
|
|
onInput: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
view.onReady = () => {
|
|
|
|
|
|
view.ready = true;
|
|
|
|
|
|
if (view.lastKnownSerialized !== session.currentSerialized) {
|
|
|
|
|
|
dispatchSessionContentToView(session, view, 'mnote-web-document-session-ready-sync');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (session.status === 'dirty' || session.status === 'saving' || session.status === 'saved' || session.status === 'error' || session.status === 'external-change-conflict' || session.status === 'synced-external-change') {
|
|
|
|
|
|
setStatus(runtimeDescriptor, session.status, session.error);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setStatus(runtimeDescriptor, 'ready');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
view.onError = (event) => {
|
|
|
|
|
|
const payload = normalizeEnvelopePayload(event);
|
|
|
|
|
|
setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error');
|
|
|
|
|
|
};
|
|
|
|
|
|
view.onChange = (event) => {
|
|
|
|
|
|
handleSessionChange(session, view, event);
|
|
|
|
|
|
};
|
|
|
|
|
|
view.onSave = (event) => {
|
|
|
|
|
|
handleSessionChange(session, view, event);
|
|
|
|
|
|
};
|
|
|
|
|
|
view.onInput = (event) => {
|
|
|
|
|
|
const target = event.target;
|
|
|
|
|
|
if (!(target instanceof Element) || !target.closest('.ProseMirror')) return;
|
|
|
|
|
|
session.lastUserInputAt = Date.now();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady);
|
|
|
|
|
|
runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError);
|
|
|
|
|
|
runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange);
|
|
|
|
|
|
runtimeDescriptor.root.addEventListener(SAVE_EVENT, view.onSave);
|
|
|
|
|
|
runtimeDescriptor.root.addEventListener('input', view.onInput);
|
|
|
|
|
|
session.views.set(view.id, view);
|
|
|
|
|
|
observeEditorViewBinding(view);
|
|
|
|
|
|
return view;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const mountPane = async (runtimeDescriptor) => {
|
|
|
|
|
|
const runtime = await loadRuntime();
|
|
|
|
|
|
const session = getOrCreateDocumentSession(runtimeDescriptor);
|
|
|
|
|
|
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
|
|
|
|
|
|
|
|
|
|
|
|
const mountOptions = {
|
|
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
workspaceId: session.workspaceId,
|
|
|
|
|
|
title: session.title,
|
|
|
|
|
|
content: session.currentTiptapDocument,
|
|
|
|
|
|
revision: session.revision,
|
|
|
|
|
|
conflictDetectionKey: session.conflictDetectionKey,
|
|
|
|
|
|
readOnly: session.readOnly,
|
|
|
|
|
|
editable: !session.readOnly,
|
|
|
|
|
|
pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
setStatus(runtimeDescriptor, 'loading-assets');
|
|
|
|
|
|
clearEmbeddedLocalDraft(runtimeDescriptor);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
|
|
|
|
|
|
view.mountId = mountId;
|
|
|
|
|
|
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
|
|
|
|
|
|
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
|
|
|
|
|
setStatus(runtimeDescriptor, 'mounting-editor');
|
2026-05-09 06:24:50 +08:00
|
|
|
|
paneViewRegistry.set(runtimeDescriptor.paneRole, view);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
unmountEditorViewBinding(view);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-09 06:24:50 +08:00
|
|
|
|
window.__mnoteDocumentPaneRuntime = {
|
|
|
|
|
|
openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
|
|
|
|
|
|
const id = typeof documentId === 'string' ? documentId.trim() : '';
|
|
|
|
|
|
if (!id) return false;
|
|
|
|
|
|
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
|
|
|
|
|
|
await replacePaneDocument('primary', descriptor);
|
|
|
|
|
|
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
|
|
|
|
|
|
const id = typeof documentId === 'string' ? documentId.trim() : '';
|
|
|
|
|
|
if (!id) return false;
|
|
|
|
|
|
const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri });
|
|
|
|
|
|
await replacePaneDocument('secondary', descriptor);
|
|
|
|
|
|
if (url instanceof URL) replaceUrlState(url);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
closeSecondaryDocument: ({ url } = {}) => {
|
|
|
|
|
|
closeSecondaryPane(url instanceof URL ? url : currentUrl());
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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);
|
2026-04-29 14:36:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
|
|
</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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-02 06:25:26 +08:00
|
|
|
|
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()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
pub async fn leptos_tiptap_manifest() -> Response {
|
|
|
|
|
|
let manifest = json!({
|
|
|
|
|
|
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
|
|
|
|
|
|
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
|
|
|
|
|
|
"assetPaths": [
|
|
|
|
|
|
"mnote-leptos-tiptap-spike-island.js",
|
|
|
|
|
|
"mnote-leptos-tiptap-spike-island_bg.wasm"
|
|
|
|
|
|
],
|
|
|
|
|
|
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
|
|
|
|
|
|
});
|
|
|
|
|
|
let mut response = Json(manifest).into_response();
|
|
|
|
|
|
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
|
|
|
|
|
|
response
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
|
|
|
|
|
|
let Some(resolved) = resolve_runtime_asset_path(&asset_path) else {
|
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
|
"runtime_asset_path_invalid",
|
|
|
|
|
|
"leptos-tiptap runtime asset 路径非法",
|
|
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
let bytes = std::fs::read(&resolved).map_err(|_| {
|
|
|
|
|
|
WebError::new(
|
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
|
"runtime_asset_not_found",
|
|
|
|
|
|
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
|
|
|
|
|
|
)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let response = Response::builder()
|
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
runtime_asset_content_type(&asset_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.header(header::CACHE_CONTROL, "no-store")
|
|
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
|
.body(Body::from(bytes))
|
|
|
|
|
|
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
|
pub async fn documents_page_compat(
|
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
|
Query(query): Query<DocumentPageCompatQuery>,
|
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
|
let document_id = query.document_id.trim().to_string();
|
|
|
|
|
|
if document_id.is_empty() {
|
|
|
|
|
|
return Err(
|
|
|
|
|
|
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
|
|
|
|
|
.with_context(&context),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
let aggregate = build_page_aggregate_snapshot(
|
|
|
|
|
|
&state,
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&document_id,
|
|
|
|
|
|
query.workspace_id.as_deref(),
|
2026-05-08 00:41:03 +08:00
|
|
|
|
None,
|
|
|
|
|
|
None,
|
2026-05-06 21:44:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
let projection_owner = aggregate.source_label();
|
|
|
|
|
|
let mut response = (
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
Json(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"owner": "mnote-web",
|
|
|
|
|
|
"schema": "mnote.documents_page_compat.v1",
|
|
|
|
|
|
"page": aggregate,
|
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
|
|
|
|
|
.into_response();
|
|
|
|
|
|
stamp_shell_headers(response.headers_mut(), "documents-page-compat");
|
|
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
|
|
|
|
|
|
if let Ok(value) = HeaderValue::from_str(projection_owner) {
|
|
|
|
|
|
response.headers_mut().insert(name, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
pub async fn page_aggregate(
|
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
|
Path(document_id): Path<String>,
|
|
|
|
|
|
Query(query): Query<DocumentShellQuery>,
|
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
|
let aggregate = build_page_aggregate_snapshot(
|
|
|
|
|
|
&state,
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&document_id,
|
|
|
|
|
|
query.workspace_id.as_deref(),
|
2026-05-08 00:41:03 +08:00
|
|
|
|
query.source_kind.as_deref(),
|
|
|
|
|
|
query.root_uri.as_deref(),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let projection_owner = aggregate.source_label();
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let mut response = (
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
Json(json!({
|
|
|
|
|
|
"ok": true,
|
|
|
|
|
|
"owner": "mnote-web",
|
|
|
|
|
|
"schema": "mnote.page_aggregate.v1",
|
|
|
|
|
|
"result": aggregate,
|
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
|
|
|
|
|
.into_response();
|
|
|
|
|
|
stamp_shell_headers(response.headers_mut(), "page-aggregate");
|
2026-04-30 05:46:36 +08:00
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
|
|
|
|
|
|
if let Ok(value) = HeaderValue::from_str(projection_owner) {
|
|
|
|
|
|
response.headers_mut().insert(name, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) async fn build_page_aggregate_snapshot(
|
2026-04-29 12:24:44 +08:00
|
|
|
|
state: &AppState,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
document_id: &str,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
source_kind: Option<&str>,
|
|
|
|
|
|
root_uri: Option<&str>,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
) -> Result<PageAggregate, WebError> {
|
2026-05-08 00:41:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let meta = load_document_meta_result(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
DocumentMetaQuery {
|
|
|
|
|
|
document_id: document_id.to_string(),
|
|
|
|
|
|
workspace_id: workspace_id.map(str::to_string),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
let content = load_document_content_result(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
DocumentContentQuery {
|
|
|
|
|
|
document_id: document_id.to_string(),
|
|
|
|
|
|
workspace_id: workspace_id.map(str::to_string),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let projection = execute_runtime_query_against_data(
|
|
|
|
|
|
context,
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
RuntimeQueryEnvelopeWire {
|
|
|
|
|
|
name: "page.aggregate.get".into(),
|
|
|
|
|
|
payload: json!({
|
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"meta": meta,
|
|
|
|
|
|
"content": content,
|
|
|
|
|
|
}),
|
|
|
|
|
|
)?;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
2026-04-30 06:58:17 +08:00
|
|
|
|
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
|
|
|
|
|
|
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
|
|
|
|
|
|
})
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
|
|
|
|
|
let value = document_id
|
|
|
|
|
|
.chars()
|
|
|
|
|
|
.map(|ch| {
|
|
|
|
|
|
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
|
|
|
|
|
|
ch
|
|
|
|
|
|
} else {
|
|
|
|
|
|
'_'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect::<String>();
|
|
|
|
|
|
if value.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
|
|
|
|
|
|
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
|
|
|
|
|
headers.append(header::SET_COOKIE, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
|
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
|
|
|
|
|
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
|
|
|
|
|
|
headers.insert(name, HeaderValue::from_static(shell));
|
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn escape_html(value: &str) -> String {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
value
|
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn escape_script_json(value: &str) -> String {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
value.replace("</script", "<\\/script")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) async fn load_workspace_shell_projection(
|
|
|
|
|
|
config: &AppConfig,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
|
default_workspace_name: &str,
|
|
|
|
|
|
) -> WorkspaceShellProjection {
|
|
|
|
|
|
let spec = ProjectionSnapshotSpec {
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
root_node_id: None,
|
|
|
|
|
|
depth: Some(99),
|
|
|
|
|
|
projection: KernelProjectionKind::SidebarTree,
|
|
|
|
|
|
query: None,
|
|
|
|
|
|
max_results: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let dataset = load_projection_snapshot(config, context, &spec)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map(|snapshot| snapshot.dataset)
|
|
|
|
|
|
.unwrap_or_else(|_| {
|
|
|
|
|
|
let documents = active_document_id
|
|
|
|
|
|
.map(|document_id| {
|
|
|
|
|
|
json!([{
|
|
|
|
|
|
"id": document_id,
|
|
|
|
|
|
"workspace_id": workspace_id,
|
|
|
|
|
|
"title": "个人",
|
|
|
|
|
|
"parent_id": null,
|
|
|
|
|
|
"sort_order": 0,
|
|
|
|
|
|
"is_starred": false
|
|
|
|
|
|
}])
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_else(|| json!([]));
|
|
|
|
|
|
json!({
|
|
|
|
|
|
"active_workspace_id": workspace_id,
|
|
|
|
|
|
"active_page_id": active_document_id,
|
|
|
|
|
|
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
|
|
|
|
|
"documents": documents
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
build_workspace_shell_projection(
|
|
|
|
|
|
&dataset,
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
active_document_id,
|
|
|
|
|
|
default_workspace_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 加载侧栏页面树 HTML(SSR)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
|
|
|
|
|
/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。
|
|
|
|
|
|
/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。
|
|
|
|
|
|
pub(crate) async fn load_sidebar_tree_html(
|
|
|
|
|
|
config: &AppConfig,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
|
let spec = ProjectionSnapshotSpec {
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
root_node_id: None,
|
|
|
|
|
|
depth: Some(99),
|
|
|
|
|
|
projection: KernelProjectionKind::SidebarTree,
|
|
|
|
|
|
query: None,
|
|
|
|
|
|
max_results: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let result = match load_projection_snapshot(config, context, &spec).await {
|
|
|
|
|
|
Ok(snapshot) => Some(snapshot.projection),
|
|
|
|
|
|
Err(_) if config.allow_dev_fixtures => {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
|
|
|
|
|
|
let documents = active_document_id
|
|
|
|
|
|
.map(|document_id| {
|
|
|
|
|
|
serde_json::json!([
|
|
|
|
|
|
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
|
|
|
|
|
|
])
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
|
serde_json::json!([
|
|
|
|
|
|
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
|
|
|
|
|
|
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
|
|
|
|
|
|
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }
|
|
|
|
|
|
])
|
|
|
|
|
|
});
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let dev_dataset = serde_json::json!({
|
|
|
|
|
|
"active_workspace_id": workspace_id,
|
2026-04-29 14:36:24 +08:00
|
|
|
|
"documents": documents,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
"trashed_documents": [],
|
|
|
|
|
|
"media_assets": [],
|
|
|
|
|
|
"trashed_media_assets": [],
|
|
|
|
|
|
"mindmap_assets": [],
|
|
|
|
|
|
"trashed_mindmap_assets": [],
|
|
|
|
|
|
"table_assets": [],
|
|
|
|
|
|
"trashed_table_assets": [],
|
|
|
|
|
|
"mindmap_docs": [],
|
|
|
|
|
|
"mindmap_asset_children": {}
|
|
|
|
|
|
});
|
|
|
|
|
|
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => None,
|
|
|
|
|
|
};
|
|
|
|
|
|
result.map(|projection| {
|
|
|
|
|
|
let rows = collect_page_tree_render_rows(&projection);
|
|
|
|
|
|
render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
|
|
|
|
|
rows,
|
|
|
|
|
|
active_node_id: active_document_id.map(ToOwned::to_owned),
|
|
|
|
|
|
focused_node_id: None,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
/// 加载文件树 HTML(SSR)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 文件树与页面树共用同一份 sidebar dataset,再由 Rust kernel 输出 file_tree projection。
|
|
|
|
|
|
pub(crate) async fn load_file_tree_html(
|
|
|
|
|
|
config: &AppConfig,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
|
let spec = ProjectionSnapshotSpec {
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
root_node_id: None,
|
|
|
|
|
|
depth: Some(99),
|
|
|
|
|
|
projection: KernelProjectionKind::FileTree,
|
|
|
|
|
|
query: None,
|
|
|
|
|
|
max_results: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let result = match load_projection_snapshot(config, context, &spec).await {
|
|
|
|
|
|
Ok(snapshot) => Some(snapshot.projection),
|
|
|
|
|
|
Err(_) if config.allow_dev_fixtures => {
|
|
|
|
|
|
let documents = active_document_id
|
|
|
|
|
|
.map(|document_id| {
|
|
|
|
|
|
serde_json::json!([
|
|
|
|
|
|
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
|
|
|
|
|
|
])
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
|
serde_json::json!([
|
|
|
|
|
|
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
|
|
|
|
|
|
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }
|
|
|
|
|
|
])
|
|
|
|
|
|
});
|
|
|
|
|
|
let dev_dataset = serde_json::json!({
|
|
|
|
|
|
"active_workspace_id": workspace_id,
|
|
|
|
|
|
"documents": documents,
|
|
|
|
|
|
"trashed_documents": [],
|
|
|
|
|
|
"media_assets": [],
|
|
|
|
|
|
"trashed_media_assets": [],
|
|
|
|
|
|
"mindmap_assets": [],
|
|
|
|
|
|
"trashed_mindmap_assets": [],
|
|
|
|
|
|
"table_assets": [],
|
|
|
|
|
|
"trashed_table_assets": [],
|
|
|
|
|
|
"mindmap_docs": [],
|
|
|
|
|
|
"mindmap_asset_children": {}
|
|
|
|
|
|
});
|
|
|
|
|
|
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => None,
|
|
|
|
|
|
};
|
|
|
|
|
|
result.map(|projection| {
|
|
|
|
|
|
let rows = collect_filetree_render_rows(&projection, active_document_id);
|
|
|
|
|
|
render_initial_filetree_html(&FileTreeInitialRenderInput { rows })
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
pub(crate) fn render_local_sidebar_tree_html(
|
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
|
) -> Result<String, WebError> {
|
|
|
|
|
|
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
|
|
|
|
|
let rows = collect_page_tree_render_rows(&snapshot.projection);
|
|
|
|
|
|
Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
|
|
|
|
|
rows,
|
|
|
|
|
|
active_node_id: active_document_id.map(ToOwned::to_owned),
|
|
|
|
|
|
focused_node_id: None,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn render_local_file_tree_html(
|
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
|
) -> Result<String, WebError> {
|
|
|
|
|
|
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
|
|
|
|
|
|
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id);
|
|
|
|
|
|
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
|
|
|
|
|
rows,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use crate::app::{build_app, AppConfig, AppState};
|
|
|
|
|
|
use axum::body::{to_bytes, Body};
|
2026-05-08 00:41:03 +08:00
|
|
|
|
use axum::http::{header, Request, StatusCode};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
|
|
|
|
|
fn app() -> axum::Router {
|
|
|
|
|
|
build_app(AppState::new(AppConfig {
|
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
|
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
|
enable_legacy_next_compat: true,
|
|
|
|
|
|
enable_debug_shell_routes: false,
|
|
|
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
|
convex_url: None,
|
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
|
query_fixtures_json: Some(
|
|
|
|
|
|
r#"{
|
|
|
|
|
|
"documents:getMeta": {
|
|
|
|
|
|
"id": "doc_1",
|
|
|
|
|
|
"workspace_id": "ws_demo",
|
|
|
|
|
|
"title": "服务端页面",
|
|
|
|
|
|
"updated_at": "2026-04-18T09:30:00Z",
|
|
|
|
|
|
"can_edit": true,
|
|
|
|
|
|
"word_count": 42,
|
|
|
|
|
|
"character_count": 128,
|
|
|
|
|
|
"block_count": 3
|
|
|
|
|
|
},
|
|
|
|
|
|
"documents:getContent": {
|
|
|
|
|
|
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
|
|
|
|
|
"revision": 7,
|
|
|
|
|
|
"conflict_detection_key": "doc_1:7",
|
|
|
|
|
|
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
|
|
|
|
|
}
|
|
|
|
|
|
}"#
|
|
|
|
|
|
.into(),
|
|
|
|
|
|
),
|
|
|
|
|
|
mutation_fixtures_json: None,
|
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn document_shell_returns_page_aggregate_snapshot() {
|
|
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/documents/doc_1?workspaceId=ws_demo")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.expect("request"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-mnote-web-owner")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("mnote-web")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-mnote-web-shell")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("document")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get_all("set-cookie")
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|value| value
|
|
|
|
|
|
.to_str()
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
.contains("mnote_recent_page_id=doc_1")));
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("body");
|
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
|
|
|
|
|
assert!(html.contains("data-testid=\"wolai-sidebar\""));
|
|
|
|
|
|
assert!(html.contains("data-testid=\"wolai-topbar\""));
|
|
|
|
|
|
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
|
|
|
|
|
|
assert!(html.contains("星标置顶"));
|
|
|
|
|
|
assert!(html.contains("我的页面"));
|
|
|
|
|
|
assert!(html.contains("垃圾箱"));
|
|
|
|
|
|
assert!(html.contains("模板中心"));
|
|
|
|
|
|
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
2026-04-29 14:36:24 +08:00
|
|
|
|
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
|
|
|
|
|
|
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert!(html.contains("aria-label=\"页面标题\""));
|
|
|
|
|
|
assert!(html.contains("data-page-title-input=\"true\""));
|
|
|
|
|
|
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
|
|
|
|
|
|
assert!(html.contains("mnote.document_title_controller.v1"));
|
2026-05-06 21:44:20 +08:00
|
|
|
|
assert!(html.contains(
|
|
|
|
|
|
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
|
|
|
|
|
));
|
2026-04-30 06:58:17 +08:00
|
|
|
|
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
|
2026-05-06 21:44:20 +08:00
|
|
|
|
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\""));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
|
2026-05-08 23:15:00 +08:00
|
|
|
|
assert!(html.contains("data-testid=\"mnote-document-workspace\""));
|
|
|
|
|
|
assert!(html.contains("data-document-pane=\"true\""));
|
|
|
|
|
|
assert!(html.contains("data-pane-role=\"primary\""));
|
|
|
|
|
|
assert!(html.contains("data-document-pane-resizer=\"true\""));
|
|
|
|
|
|
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
|
|
|
|
|
|
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
|
|
|
|
|
assert!(html.contains("/api/tree/events"));
|
|
|
|
|
|
assert!(html.contains("data-mnote-tree-live-transport"));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert!(!html.contains("mnote-web-document-shell"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn page_aggregate_endpoint_returns_snapshot_contract() {
|
|
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.expect("request"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-mnote-page-aggregate-owner")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("rust-kernel")
|
|
|
|
|
|
);
|
2026-05-08 00:41:03 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get(header::CACHE_CONTROL)
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("no-store")
|
|
|
|
|
|
);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("body");
|
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
|
assert_eq!(payload["owner"], "mnote-web");
|
|
|
|
|
|
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert_eq!(payload["result"]["source"], "KernelProjection");
|
|
|
|
|
|
assert_eq!(payload["result"]["projectionVersion"], 1);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
|
|
|
|
|
assert_eq!(payload["result"]["body"]["revision"], 7);
|
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
|
|
|
|
|
|
let root =
|
|
|
|
|
|
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
std::fs::create_dir_all(&root).expect("create local root");
|
|
|
|
|
|
std::fs::write(
|
|
|
|
|
|
root.join("README.md"),
|
|
|
|
|
|
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("write local md");
|
|
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri(format!(
|
|
|
|
|
|
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
|
|
|
|
|
))
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.expect("request"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get(header::CACHE_CONTROL)
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("no-store")
|
|
|
|
|
|
);
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("body");
|
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
|
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
payload["result"]["identity"]["documentId"],
|
|
|
|
|
|
"local-md:README.md"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
|
|
|
|
|
|
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
|
|
|
|
|
|
assert_eq!(payload["result"]["body"]["revision"], 0);
|
|
|
|
|
|
assert!(payload["result"]["body"]["content"]
|
|
|
|
|
|
.to_string()
|
|
|
|
|
|
.contains("Local Heading"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
|
|
|
|
|
|
let root =
|
|
|
|
|
|
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
|
|
|
|
|
|
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
|
|
|
|
|
|
.expect("write child md");
|
|
|
|
|
|
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
|
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri(format!(
|
|
|
|
|
|
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
|
|
|
|
|
))
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.expect("request"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get(header::CACHE_CONTROL)
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("no-store")
|
|
|
|
|
|
);
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("body");
|
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
|
|
|
|
|
assert!(html.contains("Local Shell"));
|
|
|
|
|
|
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
|
|
|
|
|
assert!(html.contains("Child Page"));
|
|
|
|
|
|
assert!(html.contains("asset.png"));
|
|
|
|
|
|
assert!(html.contains("data-row-kind=\"markdown\""));
|
|
|
|
|
|
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
2026-05-08 23:15:00 +08:00
|
|
|
|
assert!(html.contains("refreshSessionFromExternalFileChange"));
|
2026-05-08 11:23:08 +08:00
|
|
|
|
assert!(html.contains("/api/local-folder/events"));
|
|
|
|
|
|
assert!(html.contains("new EventSource(url.toString())"));
|
2026-05-08 23:15:00 +08:00
|
|
|
|
assert!(html.contains("localFolderEventRegistry"));
|
2026-05-08 10:57:24 +08:00
|
|
|
|
assert!(html.contains("command: 'replaceContent'"));
|
|
|
|
|
|
assert!(html.contains("external-change-conflict"));
|
2026-05-09 06:24:50 +08:00
|
|
|
|
assert!(!html.contains(
|
|
|
|
|
|
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
|
|
|
|
|
));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 19:05:06 +08:00
|
|
|
|
#[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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
#[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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
#[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"));
|
2026-05-09 19:05:06 +08:00
|
|
|
|
assert!(html.contains("legacyMarkArrayToTiptapMarks"));
|
|
|
|
|
|
assert!(html.contains("firstNonEmptyText(block?.props?.sourcePath"));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
assert!(html.contains("marks.push({ type: 'code' })"));
|
|
|
|
|
|
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
|
|
|
|
|
assert!(html.contains("styles.link = href"));
|
|
|
|
|
|
assert!(html.contains("contentNodes.map((node) => {"));
|
|
|
|
|
|
assert!(!html.contains(
|
|
|
|
|
|
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|