20260513 mindmap优化01
This commit is contained in:
@@ -13,10 +13,11 @@ use core_protocol::{
|
||||
DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand,
|
||||
EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand,
|
||||
GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge,
|
||||
KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
|
||||
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode,
|
||||
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
|
||||
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
|
||||
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
|
||||
KernelNodeType, KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
@@ -5547,7 +5548,7 @@ fn build_mindmap_kernel_projection_result(
|
||||
associative_lines: collect_mindmap_associative_lines(data),
|
||||
layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"])
|
||||
.unwrap_or_else(|| json!("logicalStructure")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("classic")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")),
|
||||
view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})),
|
||||
capabilities: MindmapKernelCapabilities {
|
||||
can_edit: true,
|
||||
@@ -5564,6 +5565,45 @@ fn build_mindmap_kernel_projection_result(
|
||||
})
|
||||
}
|
||||
|
||||
fn default_mindmap_theme_config() -> Value {
|
||||
json!({
|
||||
"lineColor": "#7aa2ff",
|
||||
"lineStyle": "curve",
|
||||
"rootLineKeepSameInCurve": true,
|
||||
"rootLineStartPositionKeepSameInCurve": true,
|
||||
"generalizationLineColor": "#ef6a5b",
|
||||
"backgroundColor": "#f6f8fc",
|
||||
"root": {
|
||||
"fillColor": "#e25563",
|
||||
"color": "#ffffff",
|
||||
"fontWeight": "bold",
|
||||
"borderColor": "transparent",
|
||||
"borderWidth": 0,
|
||||
"borderRadius": 8
|
||||
},
|
||||
"second": {
|
||||
"fillColor": "#4f7df3",
|
||||
"color": "#ffffff",
|
||||
"borderColor": "transparent",
|
||||
"borderWidth": 0,
|
||||
"borderRadius": 8
|
||||
},
|
||||
"node": {
|
||||
"fillColor": "transparent",
|
||||
"color": "#315aa9",
|
||||
"borderColor": "transparent",
|
||||
"borderWidth": 0
|
||||
},
|
||||
"generalization": {
|
||||
"fillColor": "#ffffff",
|
||||
"color": "#ef6a5b",
|
||||
"borderColor": "#ef6a5b",
|
||||
"borderWidth": 1,
|
||||
"borderRadius": 8
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_mindmap_adapter_projection_result(
|
||||
data: &Value,
|
||||
mindmap_id: &str,
|
||||
@@ -5577,9 +5617,9 @@ fn build_mindmap_adapter_projection_result(
|
||||
})?,
|
||||
layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"])
|
||||
.unwrap_or_else(|| json!("logicalStructure")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("classic")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")),
|
||||
theme_config: read_mindmap_value_field(data, &["themeConfig", "theme_config"])
|
||||
.unwrap_or_else(|| json!({})),
|
||||
.unwrap_or_else(default_mindmap_theme_config),
|
||||
view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})),
|
||||
config: read_mindmap_value_field(data, &["config"]).unwrap_or_else(|| json!({})),
|
||||
compat_payload: read_mindmap_value_field(data, &["compatPayload", "compat_payload"])
|
||||
@@ -7075,6 +7115,33 @@ fn is_book_asset(file_name: &str, mime_type: &str) -> bool {
|
||||
.any(|suffix| lowered_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
fn is_onlyoffice_asset(file_name: &str, mime_type: &str) -> bool {
|
||||
let lowered_mime = mime_type.trim().to_ascii_lowercase();
|
||||
let lowered_name = file_name.trim().to_ascii_lowercase();
|
||||
lowered_mime.contains("officedocument")
|
||||
|| lowered_mime.contains("msword")
|
||||
|| lowered_mime.contains("ms-excel")
|
||||
|| lowered_mime.contains("ms-powerpoint")
|
||||
|| [
|
||||
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp",
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| lowered_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
fn is_code_asset(file_name: &str, mime_type: &str) -> bool {
|
||||
let lowered_mime = mime_type.trim().to_ascii_lowercase();
|
||||
let lowered_name = file_name.trim().to_ascii_lowercase();
|
||||
lowered_mime.starts_with("text/")
|
||||
&& [
|
||||
".c", ".cc", ".cpp", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js",
|
||||
".jsx", ".json", ".kt", ".lua", ".md", ".py", ".rs", ".sh", ".sql", ".toml",
|
||||
".ts", ".tsx", ".xml", ".yaml", ".yml",
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| lowered_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
fn classify_asset_kind(
|
||||
asset_type: &str,
|
||||
file_name: &str,
|
||||
@@ -7111,7 +7178,17 @@ fn classify_asset_kind(
|
||||
|
||||
fn classify_asset_resource_kind(
|
||||
asset_kind: &KernelProjectionAssetKind,
|
||||
file_name: &str,
|
||||
mime_type: &str,
|
||||
) -> KernelProjectionResourceKind {
|
||||
if matches!(asset_kind, KernelProjectionAssetKind::File) {
|
||||
if is_onlyoffice_asset(file_name, mime_type) {
|
||||
return KernelProjectionResourceKind::OnlyOffice;
|
||||
}
|
||||
if is_code_asset(file_name, mime_type) {
|
||||
return KernelProjectionResourceKind::Code;
|
||||
}
|
||||
}
|
||||
match asset_kind {
|
||||
KernelProjectionAssetKind::Mindmap => KernelProjectionResourceKind::Mindmap,
|
||||
KernelProjectionAssetKind::Table => KernelProjectionResourceKind::Table,
|
||||
@@ -7403,6 +7480,7 @@ fn make_projection_resource_meta(
|
||||
resource_kind: KernelProjectionResourceKind,
|
||||
document_id: Option<String>,
|
||||
asset_id: Option<String>,
|
||||
block_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
asset_kind: Option<KernelProjectionAssetKind>,
|
||||
icon_hint: &str,
|
||||
@@ -7418,6 +7496,34 @@ fn make_projection_resource_meta(
|
||||
) {
|
||||
extra.insert("source".into(), source);
|
||||
}
|
||||
let object_kind = match (&resource_kind, &asset_kind, asset_id.as_ref()) {
|
||||
(KernelProjectionResourceKind::Document, _, _) => KernelObjectKind::Page,
|
||||
(KernelProjectionResourceKind::Index, _, _) => KernelObjectKind::Index,
|
||||
(KernelProjectionResourceKind::Mindmap, _, _) => KernelObjectKind::Mindmap,
|
||||
(KernelProjectionResourceKind::OnlyOffice, _, _) => KernelObjectKind::OnlyOffice,
|
||||
(KernelProjectionResourceKind::Code, _, _) => KernelObjectKind::Code,
|
||||
(_, Some(KernelProjectionAssetKind::Mindmap), _) => KernelObjectKind::Mindmap,
|
||||
(_, _, Some(_)) => KernelObjectKind::Attachment,
|
||||
_ => KernelObjectKind::Page,
|
||||
};
|
||||
let object_identity = Some(KernelObjectIdentity {
|
||||
object_kind,
|
||||
document_id: document_id.clone(),
|
||||
block_id: block_id.clone(),
|
||||
asset_id: asset_id.clone(),
|
||||
});
|
||||
let block_asset_relation =
|
||||
match (document_id.clone(), block_id, asset_id.clone(), asset_kind.clone()) {
|
||||
(Some(document_id), Some(block_id), Some(asset_id), Some(asset_kind)) => {
|
||||
Some(KernelBlockAssetRelation {
|
||||
document_id,
|
||||
block_id,
|
||||
asset_id,
|
||||
asset_kind,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
KernelProjectionResourceMeta {
|
||||
resource_kind: Some(resource_kind),
|
||||
document_id,
|
||||
@@ -7425,6 +7531,8 @@ fn make_projection_resource_meta(
|
||||
workspace_id,
|
||||
asset_kind,
|
||||
icon_hint: Some(icon_hint.into()),
|
||||
object_identity,
|
||||
block_asset_relation,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
@@ -7433,6 +7541,7 @@ fn make_projection_resource_meta(
|
||||
struct NormalizedFileTreeAsset {
|
||||
id: String,
|
||||
document_id: String,
|
||||
block_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
title: String,
|
||||
resource_kind: KernelProjectionResourceKind,
|
||||
@@ -7451,7 +7560,7 @@ fn infer_file_tree_asset_shape(
|
||||
) {
|
||||
let asset_kind = classify_asset_kind(asset_type, file_name, mime_type);
|
||||
(
|
||||
classify_asset_resource_kind(&asset_kind),
|
||||
classify_asset_resource_kind(&asset_kind, file_name, mime_type),
|
||||
asset_kind.clone(),
|
||||
classify_asset_icon_hint(&asset_kind),
|
||||
)
|
||||
@@ -7472,6 +7581,7 @@ fn normalize_file_tree_asset(value: &Value) -> Option<NormalizedFileTreeAsset> {
|
||||
Some(NormalizedFileTreeAsset {
|
||||
id,
|
||||
document_id,
|
||||
block_id: string_field(value, "block_id").or_else(|| string_field(value, "blockId")),
|
||||
workspace_id: string_field(value, "workspace_id")
|
||||
.or_else(|| string_field(value, "workspaceId")),
|
||||
title: file_name,
|
||||
@@ -7651,6 +7761,7 @@ fn build_file_tree_projection_result(
|
||||
KernelProjectionResourceKind::Document,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
None,
|
||||
workspace_id.clone(),
|
||||
None,
|
||||
"page",
|
||||
@@ -7682,6 +7793,7 @@ fn build_file_tree_projection_result(
|
||||
KernelProjectionResourceKind::Index,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
None,
|
||||
workspace_id.clone(),
|
||||
None,
|
||||
"index",
|
||||
@@ -7728,6 +7840,7 @@ fn build_file_tree_projection_result(
|
||||
asset.resource_kind.clone(),
|
||||
Some(asset.document_id.clone()),
|
||||
Some(asset.id.clone()),
|
||||
asset.block_id.clone(),
|
||||
asset.workspace_id.clone(),
|
||||
Some(KernelProjectionAssetKind::Mindmap),
|
||||
"mindmap",
|
||||
@@ -7768,6 +7881,7 @@ fn build_file_tree_projection_result(
|
||||
child_asset.resource_kind.clone(),
|
||||
Some(child_asset.document_id.clone()),
|
||||
Some(child_asset.id.clone()),
|
||||
child_asset.block_id.clone(),
|
||||
child_asset.workspace_id.clone(),
|
||||
Some(child_asset.asset_kind.clone()),
|
||||
child_asset.icon_hint,
|
||||
@@ -7808,6 +7922,7 @@ fn build_file_tree_projection_result(
|
||||
asset.resource_kind.clone(),
|
||||
Some(asset.document_id.clone()),
|
||||
Some(asset.id.clone()),
|
||||
asset.block_id.clone(),
|
||||
asset.workspace_id.clone(),
|
||||
Some(asset.asset_kind.clone()),
|
||||
asset.icon_hint,
|
||||
@@ -8141,6 +8256,7 @@ fn build_kernel_projection_result(
|
||||
KernelProjectionResourceKind::Document,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
None,
|
||||
node.workspace_id.clone(),
|
||||
None,
|
||||
"page",
|
||||
@@ -9189,10 +9305,24 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"docId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"docId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"data": payload.data,
|
||||
"createOnly": payload.create_only.unwrap_or(false),
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.put",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.put"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.put",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.put",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -9229,11 +9359,25 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"documentId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"documentId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"commands": payload.commands,
|
||||
"projectionRevision": payload.projection_revision,
|
||||
"canonicalCommand": "mindmap.command.apply",
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.command.apply",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.updated"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.updated",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.command.apply",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -9265,8 +9409,22 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"docId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"docId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.delete",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.deleted"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.deleted",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.delete",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -9299,8 +9457,22 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"docId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"docId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.restore",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.restored"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.restored",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.restore",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -10036,6 +10208,7 @@ where
|
||||
|
||||
fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
|
||||
match raw_type {
|
||||
"mindmap" => EditorBlockType::Mindmap,
|
||||
"heading" => EditorBlockType::Heading,
|
||||
"bullet_list_item" | "bullet_list" | "bullet-list" => EditorBlockType::BulletListItem,
|
||||
"numbered_list_item" | "ordered_list" | "ordered-list" => EditorBlockType::NumberedListItem,
|
||||
@@ -10058,11 +10231,9 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
|
||||
let raw_type = read_trimmed_string_field(block, &["blockType", "type"])
|
||||
.unwrap_or_else(|| "paragraph".into())
|
||||
.to_lowercase();
|
||||
let normalized_block_type = normalize_editor_block_type_for_save(&raw_type);
|
||||
let mut props = BlockProps::default();
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::Heading
|
||||
) {
|
||||
if matches!(normalized_block_type, EditorBlockType::Heading) {
|
||||
props.heading_level = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
@@ -10076,20 +10247,14 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
|
||||
.and_then(|map| map.get("collapsed"))
|
||||
.and_then(Value::as_bool);
|
||||
}
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::Todo
|
||||
) {
|
||||
if matches!(normalized_block_type, EditorBlockType::Todo) {
|
||||
props.checked = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|map| map.get("checked"))
|
||||
.and_then(Value::as_bool);
|
||||
}
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::CodeBlock
|
||||
) {
|
||||
if matches!(normalized_block_type, EditorBlockType::CodeBlock) {
|
||||
props.language = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
@@ -10121,15 +10286,72 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
|
||||
{
|
||||
props.extra.insert("tiptapTocNode".into(), tiptap_toc);
|
||||
}
|
||||
if matches!(normalized_block_type, EditorBlockType::Mindmap) {
|
||||
let props_map = block.get("props").and_then(Value::as_object);
|
||||
let legacy_data = props_map.and_then(|map| map.get("data"));
|
||||
let data_object = legacy_data.and_then(Value::as_object);
|
||||
if let Some(mindmap_id) = props_map
|
||||
.and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id")))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
data_object.and_then(|map| {
|
||||
map.get("mindmapId")
|
||||
.or_else(|| map.get("mindmap_id"))
|
||||
.or_else(|| map.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
|
||||
} else {
|
||||
props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(block_id.clone()));
|
||||
}
|
||||
if let Some(root_node_id) = props_map
|
||||
.and_then(|map| map.get("rootNodeId").or_else(|| map.get("root_node_id")))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
data_object.and_then(|map| {
|
||||
map.get("rootNodeId")
|
||||
.or_else(|| map.get("root_node_id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("rootNodeId".into(), Value::String(root_node_id.to_string()));
|
||||
}
|
||||
if let Some(projection_version) = props_map
|
||||
.and_then(|map| map.get("projectionVersion"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
props.extra.insert(
|
||||
"projectionVersion".into(),
|
||||
Value::Number(projection_version.into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
let text = read_trimmed_string_field(block, &["content"])
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| extract_inline_text(block));
|
||||
|
||||
EditorBlock {
|
||||
block_id,
|
||||
block_type: normalize_editor_block_type_for_save(&raw_type),
|
||||
block_type: normalized_block_type.clone(),
|
||||
props,
|
||||
content_nodes: build_text_content_nodes(&text),
|
||||
content_nodes: if matches!(normalized_block_type, EditorBlockType::Mindmap) {
|
||||
vec![]
|
||||
} else {
|
||||
build_text_content_nodes(&text)
|
||||
},
|
||||
child_block_ids: vec![],
|
||||
}
|
||||
}
|
||||
@@ -10156,6 +10378,7 @@ fn normalize_save_editor_document(
|
||||
) -> Result<EditorBlockDocument, BridgeError> {
|
||||
if let Some(editor_document) = payload.editor_document.clone() {
|
||||
if let Ok(mut parsed) = serde_json::from_value::<EditorBlockDocument>(editor_document) {
|
||||
hydrate_editor_document_props_from_raw(&mut parsed, payload.editor_document.as_ref());
|
||||
if parsed.document_id.trim().is_empty() {
|
||||
parsed.document_id = payload.document_id.clone();
|
||||
}
|
||||
@@ -10189,6 +10412,80 @@ fn normalize_save_editor_document(
|
||||
))
|
||||
}
|
||||
|
||||
fn hydrate_editor_document_props_from_raw(parsed: &mut EditorBlockDocument, raw: Option<&Value>) {
|
||||
let Some(raw_blocks) = raw
|
||||
.and_then(|value| value.get("blocks"))
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (index, block) in parsed.blocks.iter_mut().enumerate() {
|
||||
if !matches!(block.block_type, EditorBlockType::Mindmap) {
|
||||
continue;
|
||||
}
|
||||
let Some(raw_block) = raw_blocks
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
read_trimmed_string_field(candidate, &["blockId", "block_id"]).as_deref()
|
||||
== Some(block.block_id.as_str())
|
||||
})
|
||||
.or_else(|| raw_blocks.get(index))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
hydrate_mindmap_block_props_from_raw(block, raw_block);
|
||||
}
|
||||
}
|
||||
|
||||
fn hydrate_mindmap_block_props_from_raw(block: &mut EditorBlock, raw_block: &Value) {
|
||||
let props = raw_block.get("props").and_then(Value::as_object);
|
||||
let legacy_data = props
|
||||
.and_then(|map| map.get("data"))
|
||||
.and_then(Value::as_object);
|
||||
if let Some(mindmap_id) = props
|
||||
.and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id")))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
legacy_data.and_then(|map| {
|
||||
map.get("mindmapId")
|
||||
.or_else(|| map.get("mindmap_id"))
|
||||
.or_else(|| map.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
block
|
||||
.props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
|
||||
}
|
||||
for (raw_key, canonical_key) in [("rootNodeId", "rootNodeId"), ("root_node_id", "rootNodeId")] {
|
||||
if let Some(value) = props
|
||||
.and_then(|map| map.get(raw_key))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| legacy_data.and_then(|map| map.get(raw_key).and_then(Value::as_str)))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
block
|
||||
.props
|
||||
.extra
|
||||
.insert(canonical_key.into(), Value::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(projection_version) = props
|
||||
.and_then(|map| map.get("projectionVersion"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
block.props.extra.insert(
|
||||
"projectionVersion".into(),
|
||||
Value::Number(projection_version.into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
let mut props = serde_json::Map::new();
|
||||
match block.block_type {
|
||||
@@ -10232,6 +10529,23 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
props.insert("tiptapTocNode".into(), tiptap_toc.clone());
|
||||
}
|
||||
}
|
||||
EditorBlockType::Mindmap => {
|
||||
if let Some(mindmap_id) = block.props.extra.get("mindmapId").and_then(Value::as_str) {
|
||||
props.insert("mindmapId".into(), json!(mindmap_id));
|
||||
}
|
||||
if let Some(root_node_id) = block.props.extra.get("rootNodeId").and_then(Value::as_str)
|
||||
{
|
||||
props.insert("rootNodeId".into(), json!(root_node_id));
|
||||
}
|
||||
if let Some(projection_version) = block
|
||||
.props
|
||||
.extra
|
||||
.get("projectionVersion")
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
props.insert("projectionVersion".into(), json!(projection_version));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(text_align) = block
|
||||
@@ -10255,6 +10569,7 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
|
||||
match block.block_type {
|
||||
EditorBlockType::Paragraph => "paragraph",
|
||||
EditorBlockType::Mindmap => "mindmap",
|
||||
EditorBlockType::Heading => "heading",
|
||||
EditorBlockType::BulletListItem => "bullet_list_item",
|
||||
EditorBlockType::NumberedListItem => "numbered_list_item",
|
||||
@@ -10302,6 +10617,8 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
|
||||
"props": legacy_props_from_editor_block(block),
|
||||
"content": if matches!(block.block_type, EditorBlockType::Divider) {
|
||||
Value::Array(Vec::new())
|
||||
} else if matches!(block.block_type, EditorBlockType::Mindmap) {
|
||||
Value::String(String::new())
|
||||
} else {
|
||||
Value::String(legacy_text_from_editor_block(block))
|
||||
},
|
||||
@@ -11429,6 +11746,14 @@ mod tests {
|
||||
"text": "新标题"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"]["eventType"],
|
||||
json!("tree.resource.mindmap.updated")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"]["kind"],
|
||||
json!("resync_required")
|
||||
);
|
||||
}
|
||||
RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => {
|
||||
panic!("expected command plan")
|
||||
@@ -11726,16 +12051,44 @@ mod tests {
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(plan.function_name, "mindmaps:put");
|
||||
assert_eq!(plan.args_json["docId"], json!("doc_1"));
|
||||
assert_eq!(plan.args_json["mindmapId"], json!("mind_1"));
|
||||
assert_eq!(
|
||||
plan.args_json,
|
||||
plan.args_json["data"],
|
||||
json!({
|
||||
"docId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"data": {
|
||||
"data": {"text": "中心主题"},
|
||||
"children": [],
|
||||
},
|
||||
"createOnly": true,
|
||||
"data": {"text": "中心主题"},
|
||||
"children": [],
|
||||
})
|
||||
);
|
||||
assert_eq!(plan.args_json["createOnly"], json!(true));
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "mindmap.put",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "mind_1",
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "tree.resource.mindmap.put",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "mindmap.put",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "mind_1",
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -12317,6 +12670,178 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_preserves_mindmap_placeholder() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "documents.save".into(),
|
||||
command_id: "cmd_save_mindmap".into(),
|
||||
idempotency_key: Some("idem_save_mindmap".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 6,
|
||||
"content": [],
|
||||
"tiptapDocument": {
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"blockId": "mind_1",
|
||||
"mnoteBlockType": "mindmap",
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
}
|
||||
}]
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:6"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存导图占位".into()),
|
||||
refs: vec!["phase6-mindmap".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("documents.save mindmap plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
|
||||
Some(&json!("mindmap"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/type"),
|
||||
Some(&json!("mindmap"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/id"),
|
||||
Some(&json!("mind_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/mindmapId"),
|
||||
Some(&json!("mind_1"))
|
||||
);
|
||||
assert_eq!(plan.args_json.pointer("/content/0/props/data"), None);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/content"),
|
||||
Some(&json!(""))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_preserves_mindmap_props_from_editor_document() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "page.body.save".into(),
|
||||
command_id: "cmd_save_mindmap_editor_document".into(),
|
||||
idempotency_key: Some("idem_save_mindmap_editor_document".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 7,
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["block-1"],
|
||||
"blocks": [{
|
||||
"blockId": "block-1",
|
||||
"blockType": "mindmap",
|
||||
"props": {
|
||||
"data": null,
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
},
|
||||
"contentNodes": [],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"content": [],
|
||||
"tiptapDocument": {
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"blockId": "block-1",
|
||||
"mnoteBlockType": "mindmap",
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
}
|
||||
}]
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:7"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存 editorDocument 导图占位".into()),
|
||||
refs: vec!["task169-mindmap-realtime-smoke".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("page.body.save mindmap editorDocument plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/mindmapId"),
|
||||
Some(&json!("mind_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/rootNodeId"),
|
||||
Some(&json!("root"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/projectionVersion"),
|
||||
Some(&json!(1))
|
||||
);
|
||||
assert_eq!(plan.args_json.pointer("/content/0/props/data"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_preserves_tiptap_image() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
@@ -16219,6 +16744,7 @@ mod tests {
|
||||
"id": "mind_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_mind_1",
|
||||
"asset_type": "mindmap",
|
||||
"file_name": "roadmap.json",
|
||||
"mime_type": "application/json",
|
||||
@@ -16293,6 +16819,15 @@ mod tests {
|
||||
item_by_row_id["index:page_root"]["resourceMeta"]["resourceKind"],
|
||||
json!("index")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["index:page_root"]["resourceMeta"]["objectIdentity"],
|
||||
json!({
|
||||
"objectKind": "index",
|
||||
"documentId": "page_root",
|
||||
"blockId": null,
|
||||
"assetId": null
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["index:page_root"]["resourceMeta"]["extra"]["source"],
|
||||
json!({
|
||||
@@ -16333,6 +16868,24 @@ mod tests {
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["assetKind"],
|
||||
json!("mindmap")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["objectIdentity"],
|
||||
json!({
|
||||
"objectKind": "mindmap",
|
||||
"documentId": "page_root",
|
||||
"blockId": "block_mind_1",
|
||||
"assetId": "mind_1"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["blockAssetRelation"],
|
||||
json!({
|
||||
"documentId": "page_root",
|
||||
"blockId": "block_mind_1",
|
||||
"assetId": "mind_1",
|
||||
"assetKind": "mindmap"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:asset_ref_1"]["parentNodeId"],
|
||||
json!("asset-folder:mind_1")
|
||||
@@ -16566,6 +17119,108 @@ mod tests {
|
||||
assert!(!row_ids.contains(&"asset:asset_rust_child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_file_tree_projection_separates_attachment_object_identities() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.project_view".into(),
|
||||
payload: json!({
|
||||
"projection": "file_tree",
|
||||
"workspaceId": "ws_1",
|
||||
"rootNodeId": "page_root",
|
||||
"depth": 2,
|
||||
"includeEdges": true,
|
||||
"nodeTypes": ["page"],
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "根页面",
|
||||
"parent_id": null,
|
||||
"sort_order": 0,
|
||||
"is_starred": true,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"media_assets": [
|
||||
{
|
||||
"id": "office_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_office_1",
|
||||
"asset_type": "file",
|
||||
"file_name": "contract.docx",
|
||||
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
},
|
||||
{
|
||||
"id": "code_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_code_1",
|
||||
"asset_type": "file",
|
||||
"file_name": "main.rs",
|
||||
"mime_type": "text/rust"
|
||||
},
|
||||
{
|
||||
"id": "image_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_image_1",
|
||||
"asset_type": "file",
|
||||
"file_name": "cover.png",
|
||||
"mime_type": "image/png"
|
||||
}
|
||||
],
|
||||
"mindmap_assets": [],
|
||||
"table_assets": [],
|
||||
"mindmap_asset_children": {}
|
||||
})),
|
||||
})
|
||||
.expect("file_tree object identity projection should build");
|
||||
|
||||
let items = result["items"].as_array().expect("items should be array");
|
||||
let item_by_row_id = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.get("rowId")
|
||||
.and_then(Value::as_str)
|
||||
.map(|row_id| (row_id.to_string(), item))
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:office_1"]["resourceMeta"]["resourceKind"],
|
||||
json!("only_office")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:office_1"]["resourceMeta"]["objectIdentity"]["objectKind"],
|
||||
json!("only_office")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:code_1"]["resourceMeta"]["resourceKind"],
|
||||
json!("code")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:code_1"]["resourceMeta"]["objectIdentity"]["objectKind"],
|
||||
json!("code")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:image_1"]["resourceMeta"]["objectIdentity"],
|
||||
json!({
|
||||
"objectKind": "attachment",
|
||||
"documentId": "page_root",
|
||||
"blockId": "block_image_1",
|
||||
"assetId": "image_1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_file_tree_projection_query_matches_index_resource() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
|
||||
@@ -54,6 +54,10 @@ mod tests {
|
||||
serde_json::to_string(&EditorBlockType::PageReference).unwrap(),
|
||||
"\"page_reference\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorBlockType::Mindmap).unwrap(),
|
||||
"\"mindmap\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorBlockType::BlockReference).unwrap(),
|
||||
"\"block_reference\""
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::BTreeMap;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorBlockType {
|
||||
Paragraph,
|
||||
Mindmap,
|
||||
Heading,
|
||||
BulletListItem,
|
||||
NumberedListItem,
|
||||
|
||||
@@ -58,6 +58,16 @@ pub struct TiptapParagraphAttrs {
|
||||
pub block_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_align: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mnote_block_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mindmap_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub root_node_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub projection_version: Option<u64>,
|
||||
#[serde(flatten, default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -371,6 +381,54 @@ fn props_with_text_align(text_align: Option<String>) -> BlockProps {
|
||||
props
|
||||
}
|
||||
|
||||
fn props_with_mindmap_attrs(attrs: &TiptapParagraphAttrs) -> BlockProps {
|
||||
let mut props = props_with_text_align(attrs.text_align.clone());
|
||||
if let Some(mindmap_id) = attrs
|
||||
.mindmap_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
|
||||
}
|
||||
if let Some(root_node_id) = attrs
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("rootNodeId".into(), Value::String(root_node_id.to_string()));
|
||||
}
|
||||
if let Some(projection_version) = attrs.projection_version {
|
||||
props.extra.insert(
|
||||
"projectionVersion".into(),
|
||||
Value::Number(projection_version.into()),
|
||||
);
|
||||
}
|
||||
for (key, value) in &attrs.extra {
|
||||
props.extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn read_extra_string(props: &BlockProps, key: &str) -> Option<String> {
|
||||
props
|
||||
.extra
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_extra_u64(props: &BlockProps, key: &str) -> Option<u64> {
|
||||
props.extra.get(key).and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
impl EditorBlockDocumentTiptapBridge {
|
||||
pub fn to_tiptap_doc(
|
||||
document: &EditorBlockDocument,
|
||||
@@ -474,6 +532,11 @@ fn list_item_content_from_block(
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: None,
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
content: inline_content,
|
||||
}];
|
||||
@@ -491,9 +554,40 @@ fn block_to_tiptap_node(
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: None,
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
EditorBlockType::Mindmap => {
|
||||
let mut extra = block.props.extra.clone();
|
||||
extra.remove("textAlign");
|
||||
extra.remove("text_align");
|
||||
extra.remove("mindmapId");
|
||||
extra.remove("rootNodeId");
|
||||
extra.remove("projectionVersion");
|
||||
if !extra.contains_key("mnoteMindmapData") {
|
||||
if let Some(data) = block.props.extra.get("data") {
|
||||
extra.insert("mnoteMindmapData".into(), data.clone());
|
||||
}
|
||||
}
|
||||
Ok(TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: Some("mindmap".into()),
|
||||
mindmap_id: read_extra_string(&block.props, "mindmapId")
|
||||
.or_else(|| Some(block.block_id.clone())),
|
||||
root_node_id: read_extra_string(&block.props, "rootNodeId"),
|
||||
projection_version: read_extra_u64(&block.props, "projectionVersion"),
|
||||
extra,
|
||||
},
|
||||
content: Vec::new(),
|
||||
})
|
||||
}
|
||||
EditorBlockType::Heading => Ok(TiptapNode::Heading {
|
||||
attrs: TiptapHeadingAttrs {
|
||||
level: block.props.heading_level.unwrap_or(1),
|
||||
@@ -546,6 +640,11 @@ fn block_to_tiptap_node(
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: None,
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
@@ -584,6 +683,21 @@ fn node_to_block(
|
||||
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
|
||||
let fallback_block_id = format!("block_{}", index + 1);
|
||||
match node {
|
||||
TiptapNode::Paragraph { attrs, .. }
|
||||
if attrs.mnote_block_type.as_deref() == Some("mindmap") =>
|
||||
{
|
||||
Ok(EditorBlock {
|
||||
block_id: attrs
|
||||
.block_id
|
||||
.clone()
|
||||
.or_else(|| attrs.mindmap_id.clone())
|
||||
.unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Mindmap,
|
||||
props: props_with_mindmap_attrs(attrs),
|
||||
content_nodes: vec![],
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Paragraph,
|
||||
@@ -1118,6 +1232,7 @@ mod tests {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("align_1".into()),
|
||||
text_align: Some("center".into()),
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "E20 center".into(),
|
||||
@@ -1143,4 +1258,34 @@ mod tests {
|
||||
};
|
||||
assert_eq!(attrs.text_align.as_deref(), Some("center"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_mindmap_paragraph_placeholder() {
|
||||
let doc = TiptapNode::Doc {
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("mind_1".into()),
|
||||
mnote_block_type: Some("mindmap".into()),
|
||||
mindmap_id: Some("mind_1".into()),
|
||||
root_node_id: Some("root".into()),
|
||||
projection_version: Some(1),
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let parsed = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_1", &doc)
|
||||
.expect("mindmap placeholder should convert to mindmap block");
|
||||
assert_eq!(parsed.root_block_ids, vec!["mind_1"]);
|
||||
assert_eq!(parsed.blocks[0].block_type, EditorBlockType::Mindmap);
|
||||
assert_eq!(
|
||||
parsed.blocks[0].props.extra.get("mindmapId"),
|
||||
Some(&serde_json::json!("mind_1"))
|
||||
);
|
||||
|
||||
let restored = EditorBlockDocumentTiptapBridge::to_tiptap_doc(&parsed)
|
||||
.expect("mindmap block should restore to paragraph placeholder");
|
||||
assert_eq!(restored, doc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ pub enum KernelProjectionResourceKind {
|
||||
Asset,
|
||||
AssetFolder,
|
||||
Mindmap,
|
||||
Attachment,
|
||||
OnlyOffice,
|
||||
Code,
|
||||
Table,
|
||||
Book,
|
||||
Pdf,
|
||||
@@ -131,6 +134,35 @@ pub enum KernelProjectionAssetKind {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelObjectKind {
|
||||
Page,
|
||||
Index,
|
||||
Mindmap,
|
||||
Attachment,
|
||||
OnlyOffice,
|
||||
Code,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelObjectIdentity {
|
||||
pub object_kind: KernelObjectKind,
|
||||
pub document_id: Option<String>,
|
||||
pub block_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelBlockAssetRelation {
|
||||
pub document_id: String,
|
||||
pub block_id: String,
|
||||
pub asset_id: String,
|
||||
pub asset_kind: KernelProjectionAssetKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelGraphDirection {
|
||||
@@ -367,6 +399,10 @@ pub struct KernelProjectionResourceMeta {
|
||||
pub workspace_id: Option<String>,
|
||||
pub asset_kind: Option<KernelProjectionAssetKind>,
|
||||
pub icon_hint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub object_identity: Option<KernelObjectIdentity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub block_asset_relation: Option<KernelBlockAssetRelation>,
|
||||
#[serde(default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
@@ -649,6 +685,8 @@ mod tests {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
asset_kind: Some(KernelProjectionAssetKind::File),
|
||||
icon_hint: Some("page".into()),
|
||||
object_identity: None,
|
||||
block_asset_relation: None,
|
||||
extra: BTreeMap::new(),
|
||||
}),
|
||||
icon_hint: Some("page".into()),
|
||||
|
||||
@@ -40,10 +40,11 @@ pub use kernel::{
|
||||
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
||||
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
||||
DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
|
||||
KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult,
|
||||
KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge,
|
||||
KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree,
|
||||
KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren,
|
||||
KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType,
|
||||
KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
|
||||
@@ -150,6 +150,7 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("p-1".into()),
|
||||
text_align: None,
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "Hello".into(),
|
||||
@@ -183,6 +184,7 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("t-1".into()),
|
||||
text_align: None,
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "todo".into(),
|
||||
@@ -335,3 +337,41 @@ fn tiptap_table_round_trip_preserves_table_node() {
|
||||
Some(&serde_json::json!("A1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiptap_mindmap_placeholder_round_trip_preserves_mindmap_attrs() {
|
||||
let doc: TiptapNode = serde_json::from_value(serde_json::json!({
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"blockId": "mind_1",
|
||||
"mnoteBlockType": "mindmap",
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
}
|
||||
}]
|
||||
}))
|
||||
.expect("mindmap placeholder JSON should parse");
|
||||
|
||||
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_mindmap", &doc)
|
||||
.expect("mindmap placeholder should import");
|
||||
assert_eq!(imported.blocks[0].block_type, EditorBlockType::Mindmap);
|
||||
assert_eq!(
|
||||
imported.blocks[0].props.extra.get("mindmapId"),
|
||||
Some(&serde_json::json!("mind_1"))
|
||||
);
|
||||
|
||||
let exported =
|
||||
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&imported).expect("mindmap should export");
|
||||
let exported_value = serde_json::to_value(exported).expect("exported doc should serialize");
|
||||
assert_eq!(
|
||||
exported_value.pointer("/content/0/attrs/mnoteBlockType"),
|
||||
Some(&serde_json::json!("mindmap"))
|
||||
);
|
||||
assert_eq!(
|
||||
exported_value.pointer("/content/0/attrs/mindmapId"),
|
||||
Some(&serde_json::json!("mind_1"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use core_protocol::{
|
||||
KernelBlockAssetRelation, KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind,
|
||||
KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn object_identity_separates_index_body_from_mindmap_asset() {
|
||||
let index_identity = KernelObjectIdentity {
|
||||
object_kind: KernelObjectKind::Index,
|
||||
document_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
asset_id: None,
|
||||
};
|
||||
let mindmap_identity = KernelObjectIdentity {
|
||||
object_kind: KernelObjectKind::Mindmap,
|
||||
document_id: Some("doc_1".into()),
|
||||
block_id: Some("block_mindmap_1".into()),
|
||||
asset_id: Some("mind_1".into()),
|
||||
};
|
||||
|
||||
assert_ne!(index_identity, mindmap_identity);
|
||||
|
||||
let index_json = serde_json::to_value(&index_identity).expect("index identity 序列化");
|
||||
let mindmap_json = serde_json::to_value(&mindmap_identity).expect("mindmap identity 序列化");
|
||||
|
||||
assert_eq!(index_json["objectKind"], "index");
|
||||
assert_eq!(index_json["documentId"], "doc_1");
|
||||
assert_eq!(mindmap_json["objectKind"], "mindmap");
|
||||
assert_eq!(mindmap_json["assetId"], "mind_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_meta_can_describe_asset_object_kinds() {
|
||||
let cases = [
|
||||
(
|
||||
KernelObjectKind::Mindmap,
|
||||
KernelProjectionResourceKind::Mindmap,
|
||||
KernelProjectionAssetKind::Mindmap,
|
||||
"mind_1",
|
||||
),
|
||||
(
|
||||
KernelObjectKind::OnlyOffice,
|
||||
KernelProjectionResourceKind::OnlyOffice,
|
||||
KernelProjectionAssetKind::File,
|
||||
"office_1",
|
||||
),
|
||||
(
|
||||
KernelObjectKind::Attachment,
|
||||
KernelProjectionResourceKind::Attachment,
|
||||
KernelProjectionAssetKind::Image,
|
||||
"image_1",
|
||||
),
|
||||
(
|
||||
KernelObjectKind::Code,
|
||||
KernelProjectionResourceKind::Code,
|
||||
KernelProjectionAssetKind::File,
|
||||
"code_1",
|
||||
),
|
||||
];
|
||||
|
||||
for (object_kind, resource_kind, asset_kind, asset_id) in cases {
|
||||
let meta = KernelProjectionResourceMeta {
|
||||
resource_kind: Some(resource_kind),
|
||||
document_id: Some("doc_1".into()),
|
||||
asset_id: Some(asset_id.into()),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
asset_kind: Some(asset_kind.clone()),
|
||||
icon_hint: None,
|
||||
object_identity: Some(KernelObjectIdentity {
|
||||
object_kind,
|
||||
document_id: Some("doc_1".into()),
|
||||
block_id: Some("block_1".into()),
|
||||
asset_id: Some(asset_id.into()),
|
||||
}),
|
||||
block_asset_relation: Some(KernelBlockAssetRelation {
|
||||
document_id: "doc_1".into(),
|
||||
block_id: "block_1".into(),
|
||||
asset_id: asset_id.into(),
|
||||
asset_kind,
|
||||
}),
|
||||
extra: Default::default(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&meta).expect("resource meta 序列化");
|
||||
assert_eq!(json["objectIdentity"]["documentId"], "doc_1");
|
||||
assert_eq!(json["blockAssetRelation"]["blockId"], "block_1");
|
||||
assert_eq!(json["blockAssetRelation"]["assetId"], asset_id);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
|
||||
};
|
||||
@@ -574,17 +576,21 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let mut result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let mut result = execution.result;
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert("executedCommand".into(), json!("page.body.save"));
|
||||
map.insert("canonicalCommand".into(), json!("page.body.save"));
|
||||
map.insert("compatRoute".into(), json!("/api/documents/save"));
|
||||
if let Some(artifact_error) = execution.artifact_error {
|
||||
map.insert("artifactError".into(), json!(artifact_error));
|
||||
}
|
||||
}
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
|
||||
use crate::routes::web_shell::{
|
||||
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
|
||||
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
||||
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
|
||||
load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
render_document_title_controller_script, render_editor_island_adapter_script,
|
||||
render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
};
|
||||
@@ -336,6 +337,17 @@ pub async fn root_entry(
|
||||
active_source_kind.as_deref(),
|
||||
active_root_uri.as_deref(),
|
||||
);
|
||||
let panes_bootstrap_json = build_document_panes_bootstrap_json(
|
||||
&aggregate,
|
||||
&context,
|
||||
active_source_kind.as_deref(),
|
||||
active_root_uri.as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::document::DocumentPage
|
||||
title={title.to_string()}
|
||||
@@ -350,10 +362,12 @@ pub async fn root_entry(
|
||||
let body_extra = format!(
|
||||
r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}
|
||||
{}"#,
|
||||
escape_script_json(&snapshot_json),
|
||||
escape_script_json(&bootstrap_json),
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
render_document_title_controller_script(),
|
||||
render_editor_island_adapter_script(),
|
||||
);
|
||||
@@ -953,6 +967,20 @@ mod tests {
|
||||
legacy_next_base_url: String,
|
||||
enable_legacy_next_compat: bool,
|
||||
convex_url: Option<String>,
|
||||
) -> axum::Router {
|
||||
app_with_query_fixtures(
|
||||
legacy_next_base_url,
|
||||
enable_legacy_next_compat,
|
||||
convex_url,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn app_with_query_fixtures(
|
||||
legacy_next_base_url: String,
|
||||
enable_legacy_next_compat: bool,
|
||||
convex_url: Option<String>,
|
||||
query_fixtures_json: Option<String>,
|
||||
) -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -967,7 +995,7 @@ mod tests {
|
||||
convex_url,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
query_fixtures_json,
|
||||
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -1221,6 +1249,55 @@ mod tests {
|
||||
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_active_page_includes_document_panes_bootstrap() {
|
||||
let response = app_with_query_fixtures(
|
||||
"http://127.0.0.1:3100".into(),
|
||||
false,
|
||||
None,
|
||||
Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"updated_at": "2026-04-18T09:30:00Z",
|
||||
"can_edit": true,
|
||||
"word_count": 42,
|
||||
"character_count": 128,
|
||||
"block_count": 1
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/?pageId=doc_1&workspaceId=ws_demo")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnote.document_panes_bootstrap.v1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_folder_without_debug_tree_route() {
|
||||
let root =
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex, fetch_query_data_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -35,6 +36,15 @@ pub struct MindmapCommandRequest {
|
||||
pub commands: Vec<Value>,
|
||||
pub projection_revision: Option<u64>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub data: Option<Value>,
|
||||
pub create_only: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_mindmap_data() -> Value {
|
||||
json!({
|
||||
"data": {"text": "中心主题"},
|
||||
"children": [],
|
||||
})
|
||||
}
|
||||
|
||||
fn response_headers() -> HeaderMap {
|
||||
@@ -62,6 +72,41 @@ fn resolve_query_name(params: &MindmapQueryParams) -> &'static str {
|
||||
"mindmap.projection.get"
|
||||
}
|
||||
|
||||
fn read_workspace_id_from_meta(meta: &Value) -> Option<String> {
|
||||
meta.get("workspace_id")
|
||||
.or_else(|| meta.get("workspaceId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
async fn resolve_mindmap_workspace_id(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
explicit_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Option<String>, WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, explicit_workspace_id, false)?;
|
||||
if effective_workspace_id.is_some() {
|
||||
return Ok(effective_workspace_id);
|
||||
}
|
||||
|
||||
// 思维导图 runtime 的历史请求体不一定带 workspaceId;
|
||||
// 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。
|
||||
let meta = fetch_documents_meta_via_convex(state.config(), context, None, document_id).await?;
|
||||
Ok(read_workspace_id_from_meta(&meta))
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub async fn get_mindmap(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -114,15 +159,79 @@ pub async fn apply_mindmap_command(
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
if body.command_name.as_deref() != Some("mindmap.command.apply") {
|
||||
let command_name = body.command_name.as_deref();
|
||||
if !matches!(
|
||||
command_name,
|
||||
None | Some("mindmaps.put") | Some("mindmap.command.apply")
|
||||
) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mindmap_command_required",
|
||||
"仅支持 mindmap.command.apply",
|
||||
"仅支持 mindmaps.put 或 mindmap.command.apply",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
resolve_mindmap_workspace_id(&state, &context, body.workspace_id.as_deref(), document_id)
|
||||
.await?;
|
||||
|
||||
if command_name != Some("mindmap.command.apply") {
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "mindmaps.put".into(),
|
||||
command_id: format!("mindmap_put_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: Some(mindmap_id.to_string()),
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"data": body.data.unwrap_or_else(default_mindmap_data),
|
||||
"createOnly": body.create_only.unwrap_or(false),
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web mindmap put via kernel projection".into()),
|
||||
refs: vec!["task168-mindmap-put-validator-smoke".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
response_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"commandName": "mindmaps.put",
|
||||
"result": execution.result,
|
||||
"artifacts": execution_artifacts_json(&execution),
|
||||
"artifactError": execution.artifact_error,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let current = fetch_query_data_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
@@ -184,7 +293,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -201,7 +310,96 @@ pub async fn apply_mindmap_command(
|
||||
"applied": applied.applied,
|
||||
"errors": applied.errors,
|
||||
"projectionRevision": body.projection_revision,
|
||||
"result": result,
|
||||
"result": execution.result,
|
||||
"artifacts": execution_artifacts_json(&execution),
|
||||
"artifactError": execution.artifact_error,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{"documents:getMeta":{"id":"doc_1","workspace_id":"ws_demo","title":"页面"},"mindmaps:get":{"data":{"data":{"text":"KMIND","uid":"root"},"children":[]},"revision":1}}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{"mindmaps:put":{"ok":true,"document_id":"doc_1","mindmap_id":"mind_1","updated_at":"2026-05-12T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mindmap_put_derives_workspace_and_returns_tree_artifacts() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/mindmap/doc_1/mind_1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"data": {
|
||||
"data": {"text": "KMIND", "uid": "root"},
|
||||
"children": []
|
||||
},
|
||||
"createOnly": true
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(payload["artifacts"]["commandLog"]["workspaceId"], "ws_demo");
|
||||
assert_eq!(payload["artifacts"]["commandLog"]["targetPageId"], "doc_1");
|
||||
assert_eq!(
|
||||
payload["artifacts"]["commandLog"]["targetBlockId"],
|
||||
"mind_1"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["eventType"],
|
||||
"tree.resource.mindmap.put"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["payload"]["streamDelta"],
|
||||
json!({
|
||||
"op": "resync_required",
|
||||
"reason": "mindmap.put",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "mind_1"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::web_shell::{
|
||||
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
|
||||
load_workspace_shell_projection,
|
||||
};
|
||||
use crate::ssr::pages::mindmap::MindmapPage;
|
||||
use axum::extract::{Extension, Path};
|
||||
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
@@ -10,9 +16,92 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||
|
||||
pub async fn mindmap_object_shell(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path((doc_id, mindmap_id)): Path<(String, String)>,
|
||||
) -> Result<Response, WebError> {
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let aggregate = build_page_aggregate_snapshot(&state, &context, &doc_id, None, None, None)
|
||||
.await
|
||||
.ok();
|
||||
let workspace_id = aggregate
|
||||
.as_ref()
|
||||
.map(|value| value.identity.workspace_id.clone())
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let title = aggregate
|
||||
.as_ref()
|
||||
.map(|value| value.head.title.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "思维导图".to_string());
|
||||
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) =
|
||||
if let Some(workspace_id) = workspace_id.as_deref() {
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
Some(&doc_id),
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html =
|
||||
load_file_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
Some(sidebar_tree_html),
|
||||
Some(workspace_sidebar_html),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
let editor_bootstrap = json!({
|
||||
"documentId": format!("__mindmap_object__:{doc_id}:{mindmap_id}"),
|
||||
"workspaceId": workspace_id
|
||||
.as_ref()
|
||||
.map(|value| format!("__mindmap_object__:{value}")),
|
||||
"title": title.clone(),
|
||||
"content": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"mindmapId": mindmap_id,
|
||||
"mnoteBlockType": "mindmap",
|
||||
"projectionVersion": 1,
|
||||
"rootNodeId": "root",
|
||||
"mnoteMindmapData": serde_json::Value::Null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"readOnly": false,
|
||||
"editable": true,
|
||||
"standaloneObject": {
|
||||
"kind": "mindmap",
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id
|
||||
},
|
||||
"revision": serde_json::Value::Null,
|
||||
"conflictDetectionKey": serde_json::Value::Null,
|
||||
"pageOptions": {
|
||||
"pageWidth": "full",
|
||||
"smallText": false,
|
||||
"showHeadingNumbers": false,
|
||||
"fontFamily": "sans"
|
||||
}
|
||||
});
|
||||
let contract = json!({
|
||||
"schema": "mnote.mindmap_shell.v1",
|
||||
"owner": "mnote-web",
|
||||
@@ -35,11 +124,17 @@ pub async fn mindmap_object_shell(
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id
|
||||
});
|
||||
let editor_bootstrap_json =
|
||||
serde_json::to_string(&editor_bootstrap).unwrap_or_else(|_| "null".to_string());
|
||||
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<MindmapPage
|
||||
document_id={doc_id.clone()}
|
||||
mindmap_id={mindmap_id.clone()}
|
||||
title={title.clone()}
|
||||
sidebar_tree_html={sidebar_tree_html.unwrap_or_default()}
|
||||
workspace_name={workspace_name.unwrap_or_default()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}
|
||||
/>
|
||||
});
|
||||
let html = format!(
|
||||
@@ -47,19 +142,24 @@ pub async fn mindmap_object_shell(
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>思维导图</title>
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
|
||||
{}
|
||||
<script id="__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(&title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&doc_id),
|
||||
escape_html(&mindmap_id),
|
||||
body_content,
|
||||
escape_script_json(&editor_bootstrap_json),
|
||||
escape_script_json(&contract_json),
|
||||
render_mindmap_standalone_bootstrap_script(),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "mindmap");
|
||||
@@ -87,6 +187,80 @@ fn escape_script_json(value: &str) -> String {
|
||||
value.replace("</script", "<\\/script")
|
||||
}
|
||||
|
||||
fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
||||
r#"<script type="module">
|
||||
(() => {
|
||||
const BOOTSTRAP_ID = '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__';
|
||||
const MOUNT_ID = 'mnote-mindmap-island';
|
||||
|
||||
const parseJsonScript = (id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent || 'null');
|
||||
} catch (error) {
|
||||
console.warn(`mnote mindmap bootstrap JSON 解析失败: ${id}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuntime = async () => {
|
||||
if (window.__mnoteLeptosTiptapRuntimePromise) {
|
||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||
}
|
||||
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
||||
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||
const manifest = await manifestResponse.json();
|
||||
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
|
||||
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
|
||||
const runtime = await import(entryUrl);
|
||||
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
||||
throw new Error('island runtime 导出不完整');
|
||||
}
|
||||
await runtime.default(wasmUrl);
|
||||
if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') {
|
||||
window.__MNOTE_MINDMAP_RUST_SHELL__ = {
|
||||
mount: runtime.mount_mindmap_shell,
|
||||
unmount: runtime.unmount_mindmap_shell,
|
||||
};
|
||||
}
|
||||
return runtime;
|
||||
})();
|
||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||
};
|
||||
|
||||
const bootstrap = parseJsonScript(BOOTSTRAP_ID);
|
||||
const mountTarget = document.getElementById(MOUNT_ID);
|
||||
if (!bootstrap || !(mountTarget instanceof HTMLElement)) return;
|
||||
|
||||
let mountId = null;
|
||||
let runtimeModule = null;
|
||||
|
||||
const mountStandaloneMindmap = async () => {
|
||||
runtimeModule = await loadRuntime();
|
||||
mountId = runtimeModule.mount(mountTarget, bootstrap);
|
||||
mountTarget.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
};
|
||||
|
||||
void mountStandaloneMindmap().catch((error) => {
|
||||
console.error('mnote standalone mindmap mount failed', error);
|
||||
mountTarget.setAttribute('data-runtime-editor-status', 'error');
|
||||
mountTarget.setAttribute('data-runtime-editor-error', error instanceof Error ? error.message : 'unknown');
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (mountId != null && runtimeModule && typeof runtimeModule.unmount === 'function') {
|
||||
try {
|
||||
runtimeModule.unmount(mountId);
|
||||
} catch (_error) {}
|
||||
}
|
||||
}, { once: true });
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
@@ -189,9 +363,21 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("mnote.mindmap_shell.v1"));
|
||||
assert!(html.contains("data-mnote-object-editor=\"mindmap\""));
|
||||
assert!(html.contains(
|
||||
"data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\""
|
||||
));
|
||||
assert!(html.contains("data-leptos-mindmap-island=\"standalone\""));
|
||||
assert!(html.contains("mindmap.simple_mind_map_scene.get"));
|
||||
assert!(html.contains("mindmap.command.apply"));
|
||||
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnoteBlockType"));
|
||||
assert!(html.contains("__mindmap_object__:doc_1:mind_1"));
|
||||
assert!(html.contains("\"standaloneObject\""));
|
||||
assert!(html.contains("\"documentId\":\"doc_1\""));
|
||||
assert!(html.contains("\"mindmapId\":\"mind_1\""));
|
||||
assert!(html.contains("runtimeModule.mount(mountTarget, bootstrap)"));
|
||||
assert!(html.contains("/api/leptos-tiptap-runtime/manifest.json"));
|
||||
assert!(!html.contains("react_mindmap_runtime"));
|
||||
assert!(!html.contains("next-app-router"));
|
||||
}
|
||||
|
||||
@@ -57,8 +57,9 @@ pub async fn events(
|
||||
state.polls += 1;
|
||||
sleep(Duration::from_millis(poll_ms)).await;
|
||||
|
||||
let poll_query = live_poll_query(&state.query);
|
||||
let Ok((workspace_id, overview)) =
|
||||
load_stream_overview(state.app_state.config(), &state.context, &state.query)
|
||||
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
@@ -76,7 +77,7 @@ pub async fn events(
|
||||
let Ok(payload) = build_stream_delta_payload(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&state.query,
|
||||
&poll_query,
|
||||
&workspace_id,
|
||||
&overview,
|
||||
change.cursor,
|
||||
@@ -91,18 +92,15 @@ pub async fn events(
|
||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||
}
|
||||
StreamChangeKind::Resync => {
|
||||
let mut next_query = state.query.clone();
|
||||
next_query.cursor = change.cursor;
|
||||
let Ok(snapshot_payload) = load_stream_snapshot(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&next_query,
|
||||
&poll_query,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
state.query = next_query;
|
||||
state.current_cursor = read_stream_cursor_from_payload(&snapshot_payload);
|
||||
return Some((
|
||||
Ok(stream_event(
|
||||
@@ -157,6 +155,14 @@ struct StreamPollState {
|
||||
initial_emitted: bool,
|
||||
}
|
||||
|
||||
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
|
||||
let mut next = query.clone();
|
||||
// Convex bridgeLogs 的 cursor 是“向更旧记录翻页”,不是 live tail 的起点;
|
||||
// 实时轮询必须始终查最新窗口,再用 current_cursor 在 Rust 侧比较增量。
|
||||
next.cursor = None;
|
||||
next
|
||||
}
|
||||
|
||||
fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
let event_id = payload
|
||||
.get("revision")
|
||||
@@ -183,6 +189,7 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
@@ -289,4 +296,20 @@ mod tests {
|
||||
assert!(text.contains("id: "));
|
||||
assert!(text.contains("\"revision\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_poll_query_drops_bridge_pagination_cursor() {
|
||||
let query = StreamSnapshotQuery {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
cursor: Some(r#"{"createdAt":"2026-05-12T00:00:00Z","id":"clog_1"}"#.into()),
|
||||
poll_ms: Some(250),
|
||||
..StreamSnapshotQuery::default()
|
||||
};
|
||||
|
||||
let live_query = super::live_poll_query(&query);
|
||||
|
||||
assert_eq!(live_query.workspace_id, Some("ws_demo".into()));
|
||||
assert_eq!(live_query.poll_ms, Some(250));
|
||||
assert_eq!(live_query.cursor, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +540,9 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
object_identity: resource_meta
|
||||
.and_then(|meta| meta.get("objectIdentity"))
|
||||
.and_then(|value| serde_json::to_string(value).ok()),
|
||||
selected,
|
||||
})
|
||||
})
|
||||
@@ -1686,13 +1689,25 @@ fn build_tree_shell_html(
|
||||
documentId: "",
|
||||
assetId: "",
|
||||
assetKind: "",
|
||||
objectIdentity: null,
|
||||
blockAssetRelation: null,
|
||||
};
|
||||
}
|
||||
const objectIdentity =
|
||||
value?.objectIdentity && typeof value.objectIdentity === "object"
|
||||
? value.objectIdentity
|
||||
: null;
|
||||
const blockAssetRelation =
|
||||
value?.blockAssetRelation && typeof value.blockAssetRelation === "object"
|
||||
? value.blockAssetRelation
|
||||
: null;
|
||||
return {
|
||||
resourceKind: normalizeText(value?.resourceKind),
|
||||
documentId: normalizeText(value?.documentId),
|
||||
assetId: normalizeText(value?.assetId),
|
||||
assetKind: normalizeText(value?.assetKind),
|
||||
objectIdentity,
|
||||
blockAssetRelation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4690,12 +4705,14 @@ fn build_tree_shell_html(
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -4857,6 +4874,10 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
: "";
|
||||
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
||||
row.dataset.shellMode = "filetree";
|
||||
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
||||
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
||||
@@ -5308,12 +5329,14 @@ fn build_tree_shell_html(
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -5382,6 +5405,10 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
: "";
|
||||
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
||||
row.dataset.shellMode = "filetree";
|
||||
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
||||
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
||||
|
||||
@@ -288,7 +288,7 @@ pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn build_document_panes_bootstrap_json(
|
||||
pub(crate) fn build_document_panes_bootstrap_json(
|
||||
aggregate: &PageAggregate,
|
||||
context: &RequestContext,
|
||||
source_kind: Option<&str>,
|
||||
@@ -815,6 +815,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
|
||||
}
|
||||
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
||||
if (type === 'mindmap') {
|
||||
const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
|
||||
const mindmapId = firstNonEmptyText(
|
||||
block?.props?.mindmapId,
|
||||
block?.props?.mindmap_id,
|
||||
block?.mindmapId,
|
||||
block?.mindmap_id,
|
||||
data?.mindmapId,
|
||||
data?.mindmap_id,
|
||||
data?.id
|
||||
);
|
||||
const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||
return {
|
||||
type: 'paragraph',
|
||||
attrs: withTextAlign({
|
||||
blockId,
|
||||
mnoteBlockType: 'mindmap',
|
||||
mindmapId,
|
||||
rootNodeId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (type === 'media') {
|
||||
const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src);
|
||||
const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath);
|
||||
@@ -882,6 +904,41 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
content.type === 'doc'
|
||||
);
|
||||
|
||||
const mindmapDomDescriptors = (root) => {
|
||||
if (!(root instanceof HTMLElement)) return [];
|
||||
return Array.from(root.querySelectorAll('[data-testid="mnote-mindmap-editor-root"]'))
|
||||
.flatMap((node) => {
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const mindmapId = typeof node.dataset.mnoteMindmapId === 'string' ? node.dataset.mnoteMindmapId.trim() : '';
|
||||
if (!mindmapId) return [];
|
||||
const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim()
|
||||
? node.dataset.mnoteRootNodeId.trim()
|
||||
: 'root';
|
||||
return [{ mindmapId, rootNodeId }];
|
||||
});
|
||||
};
|
||||
|
||||
const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => {
|
||||
if (!isTiptapDocument(tiptapDocument) || !Array.isArray(tiptapDocument.content)) return tiptapDocument;
|
||||
const descriptors = mindmapDomDescriptors(root);
|
||||
if (!descriptors.length) return tiptapDocument;
|
||||
let index = 0;
|
||||
for (const node of tiptapDocument.content) {
|
||||
if (node?.type !== 'paragraph' || node?.attrs?.mnoteBlockType !== 'mindmap') continue;
|
||||
const descriptor = descriptors[index];
|
||||
index += 1;
|
||||
if (!descriptor) continue;
|
||||
node.attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {};
|
||||
if (typeof node.attrs.mindmapId !== 'string' || !node.attrs.mindmapId.trim()) {
|
||||
node.attrs.mindmapId = descriptor.mindmapId;
|
||||
}
|
||||
if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) {
|
||||
node.attrs.rootNodeId = descriptor.rootNodeId;
|
||||
}
|
||||
}
|
||||
return tiptapDocument;
|
||||
};
|
||||
|
||||
const toTiptapDocument = (content, fallbackText = '') => {
|
||||
if (isTiptapDocument(content)) return content;
|
||||
const blocks = Array.isArray(content) ? content : Array.isArray(content?.blocks) ? content.blocks : [];
|
||||
@@ -923,9 +980,36 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : '';
|
||||
return raw || `block-${index + 1}`;
|
||||
};
|
||||
const mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => {
|
||||
const data = attrs?.data && typeof attrs.data === 'object' ? attrs.data : {};
|
||||
const mindmapId = firstNonEmptyText(
|
||||
attrs?.mindmapId,
|
||||
attrs?.mindmap_id,
|
||||
data?.mindmapId,
|
||||
data?.mindmap_id,
|
||||
data?.id,
|
||||
fallbackMindmapId
|
||||
);
|
||||
const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||
const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version);
|
||||
return {
|
||||
mindmapId,
|
||||
rootNodeId,
|
||||
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const tiptapNodeToEditorBlock = (node, index) => {
|
||||
const blockId = blockIdOf(node, index);
|
||||
if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') {
|
||||
return {
|
||||
blockId,
|
||||
blockType: 'mindmap',
|
||||
props: mindmapPropsFromAttrs(node?.attrs, blockId),
|
||||
contentNodes: [],
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||
if (node?.type === 'heading') {
|
||||
const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1));
|
||||
@@ -967,6 +1051,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
? { checked: Boolean(block.props?.checked) }
|
||||
: block.blockType === 'code_block'
|
||||
? { language: block.props?.language || null }
|
||||
: block.blockType === 'mindmap'
|
||||
? mindmapPropsFromAttrs(block.props || {}, block.blockId)
|
||||
: block.blockType === 'image'
|
||||
? { ...(block.props || {}) }
|
||||
: block.blockType === 'toc'
|
||||
@@ -974,7 +1060,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: block.blockType === 'table'
|
||||
? { ...(block.props || {}) }
|
||||
: undefined,
|
||||
content: Array.isArray(block.contentNodes)
|
||||
content: block.blockType === 'mindmap'
|
||||
? ''
|
||||
: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const text = typeof node.text === 'string' ? node.text : '';
|
||||
@@ -1060,6 +1148,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const paneViewRegistry = new Map();
|
||||
let nextViewId = 1;
|
||||
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
|
||||
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
|
||||
const SESSION_RELEASE_DELAY_MS = 1200;
|
||||
|
||||
const parseLocalFolderEventPayload = (event) => {
|
||||
@@ -1316,6 +1405,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const persistSession = async (session) => {
|
||||
if (session.readOnly || session.saving || session.hasExternalConflict) return;
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
if (hydrateView) {
|
||||
hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root);
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
}
|
||||
const serialized = session.currentSerialized;
|
||||
if (!session.dirty && serialized === session.lastPersistedSerialized) {
|
||||
setSessionStatus(session, 'saved');
|
||||
@@ -1389,16 +1483,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSessionExternalRefresh = (session) => {
|
||||
const scheduleSessionExternalRefresh = (session, source) => {
|
||||
if (session.externalRefreshTimer) return;
|
||||
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
|
||||
session.externalRefreshTimer = window.setTimeout(() => {
|
||||
const refreshSource = session.externalRefreshSource || 'mnote-web-external-change';
|
||||
session.externalRefreshSource = '';
|
||||
session.externalRefreshTimer = 0;
|
||||
void refreshSessionFromExternalFileChange(session);
|
||||
void refreshSessionFromExternalChange(session, refreshSource);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const refreshSessionFromExternalFileChange = async (session) => {
|
||||
if (session.sourceKind !== 'local_folder' || !session.rootUri || document.hidden) return;
|
||||
const refreshSessionFromExternalChange = async (session, source) => {
|
||||
if (document.hidden) return;
|
||||
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
|
||||
try {
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
documentId: session.documentId,
|
||||
@@ -1439,14 +1537,19 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
session.hasExternalConflict = false;
|
||||
session.lastUserInputAt = 0;
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, 'mnote-web-local-folder-watch');
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
} catch (error) {
|
||||
console.warn('mnote local folder 外部更新检测失败', error);
|
||||
console.warn('mnote 页面外部更新检测失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessionFromExternalFileChange = async (session) => {
|
||||
if (session.sourceKind !== 'local_folder') return;
|
||||
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
|
||||
};
|
||||
|
||||
const ensureLocalFolderEventChannel = (session) => {
|
||||
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
|
||||
return;
|
||||
@@ -1473,7 +1576,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
markSessionExternalConflict(targetSession, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduleSessionExternalRefresh(targetSession);
|
||||
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
|
||||
});
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
@@ -1485,6 +1588,164 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
session.localFolderChannel = channel;
|
||||
};
|
||||
|
||||
const readTreePayloadData = (payload) => (
|
||||
payload && typeof payload === 'object'
|
||||
? (payload.data || payload.delta || payload)
|
||||
: null
|
||||
);
|
||||
|
||||
const readTreePayloadOverview = (payload) => (
|
||||
payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object'
|
||||
? payload.overview
|
||||
: null
|
||||
);
|
||||
|
||||
const readTreePayloadCursor = (payload) => {
|
||||
const raw = String(payload?.cursor || payload?.revision || '').trim();
|
||||
if (!raw) return { id: '', createdAt: '', raw: '' };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return {
|
||||
id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(),
|
||||
createdAt: String(parsed.createdAt || parsed.created_at || '').trim(),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
return { id: raw, createdAt: '', raw };
|
||||
};
|
||||
|
||||
const treeRecordMatchesPayloadCursor = (record, payload) => {
|
||||
if (!record || typeof record !== 'object') return false;
|
||||
const cursor = readTreePayloadCursor(payload);
|
||||
if (!cursor.id && !cursor.createdAt && !cursor.raw) return false;
|
||||
const ids = [
|
||||
record.id,
|
||||
record._id,
|
||||
record.command_log_id,
|
||||
record.commandLogId,
|
||||
record.domain_event_id,
|
||||
record.domainEventId,
|
||||
record.command_id,
|
||||
record.commandId,
|
||||
].map((value) => String(value || '').trim()).filter(Boolean);
|
||||
if (cursor.id && ids.includes(cursor.id)) return true;
|
||||
const createdAt = String(record.created_at || record.createdAt || '').trim();
|
||||
return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt);
|
||||
};
|
||||
|
||||
const treeRecordTargetsDocument = (record, documentId) => {
|
||||
if (!record || typeof record !== 'object' || !documentId) return false;
|
||||
const targetPageId = String(record.target_page_id || record.targetPageId || '').trim();
|
||||
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
|
||||
if (targetPageId === documentId || aggregateId === documentId) return true;
|
||||
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
|
||||
if (!payload) return false;
|
||||
const streamDelta = payload.streamDelta || payload.stream_delta || null;
|
||||
const deltaDocumentId = streamDelta && typeof streamDelta === 'object'
|
||||
? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim()
|
||||
: '';
|
||||
return deltaDocumentId === documentId;
|
||||
};
|
||||
|
||||
const collectMindmapIdsFromTreeRecord = (record, documentId, out) => {
|
||||
if (!record || typeof record !== 'object' || !documentId) return;
|
||||
if (!treeRecordTargetsDocument(record, documentId)) return;
|
||||
const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim();
|
||||
if (targetBlockId) out.add(targetBlockId);
|
||||
const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim();
|
||||
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
|
||||
if (aggregateType === 'block' && aggregateId) out.add(aggregateId);
|
||||
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
|
||||
const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null;
|
||||
const blockId = streamDelta && typeof streamDelta === 'object'
|
||||
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
|
||||
: '';
|
||||
if (blockId) out.add(blockId);
|
||||
};
|
||||
|
||||
const collectMindmapIdsFromTreePayload = (payload, session) => {
|
||||
const ids = new Set();
|
||||
if (!payload || typeof payload !== 'object' || !session?.documentId) return [];
|
||||
const kind = String(payload.kind || '').trim();
|
||||
const data = readTreePayloadData(payload);
|
||||
if (kind === 'delta' && data && typeof data === 'object') {
|
||||
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
|
||||
if (!documentId || documentId === session.documentId) {
|
||||
const blockId = String(data.blockId || data.block_id || '').trim();
|
||||
if (blockId) ids.add(blockId);
|
||||
const streamDelta = data.streamDelta || data.stream_delta || null;
|
||||
const streamBlockId = streamDelta && typeof streamDelta === 'object'
|
||||
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
|
||||
: '';
|
||||
if (streamBlockId) ids.add(streamBlockId);
|
||||
}
|
||||
}
|
||||
if (kind === 'resync') {
|
||||
const overview = readTreePayloadOverview(payload);
|
||||
if (overview) {
|
||||
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
|
||||
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
|
||||
commandLogs
|
||||
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
|
||||
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
|
||||
domainEvents
|
||||
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
|
||||
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
};
|
||||
|
||||
const refreshMindmapRuntimesFromTreePayload = (payload, session) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => {
|
||||
const bridge = registry[mindmapId];
|
||||
if (bridge && typeof bridge.refreshProjection === 'function') {
|
||||
void bridge.refreshProjection('mnote-web-tree-live');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const treePayloadTargetsDocument = (payload, session) => {
|
||||
if (!payload || typeof payload !== 'object' || !session?.documentId) return false;
|
||||
if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false;
|
||||
const data = readTreePayloadData(payload);
|
||||
if (data && typeof data === 'object') {
|
||||
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
|
||||
if (documentId === session.documentId) return true;
|
||||
const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : [];
|
||||
if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true;
|
||||
}
|
||||
const overview = readTreePayloadOverview(payload);
|
||||
if (!overview) return false;
|
||||
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
|
||||
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
|
||||
return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId))
|
||||
|| domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId));
|
||||
};
|
||||
|
||||
const handleTreeExternalChange = (event) => {
|
||||
const payload = event?.detail?.payload || event?.detail || null;
|
||||
if (!payload) return;
|
||||
Array.from(documentSessionRegistry.values()).forEach((session) => {
|
||||
if (session.sourceKind === 'local_folder') return;
|
||||
if (!treePayloadTargetsDocument(payload, session)) return;
|
||||
refreshMindmapRuntimesFromTreePayload(payload, session);
|
||||
session.lastExternalChangeSignalAt = Date.now();
|
||||
session.externalChangePending = true;
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
markSessionExternalConflict(session, treeExternalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduleSessionExternalRefresh(session, 'mnote-web-tree-live');
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('tree:delta', handleTreeExternalChange);
|
||||
window.addEventListener('tree:resync', handleTreeExternalChange);
|
||||
|
||||
const createDocumentSession = (runtimeDescriptor) => {
|
||||
const pageBody = runtimeDescriptor.aggregate.body || {};
|
||||
const permissions = runtimeDescriptor.aggregate.head?.permissions || {};
|
||||
@@ -1513,6 +1774,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
saving: false,
|
||||
hasExternalConflict: false,
|
||||
externalChangePending: false,
|
||||
externalRefreshSource: '',
|
||||
lastExternalChangeSignalAt: 0,
|
||||
lastUserInputAt: 0,
|
||||
status: 'booting',
|
||||
@@ -1714,10 +1976,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending;
|
||||
const recentExternalChange = sessionHasRecentExternalSignal(session);
|
||||
const recentLocalInput = sessionHasRecentLocalInput(session);
|
||||
const tiptapDocument = toTiptapDocument(
|
||||
const tiptapDocument = hydrateMindmapAttrsFromDom(toTiptapDocument(
|
||||
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
|
||||
currentEditorText(view),
|
||||
);
|
||||
), view.runtimeDescriptor.root);
|
||||
const serialized = JSON.stringify(tiptapDocument);
|
||||
if (view.suppressedSerialized && view.suppressedSerialized === serialized) {
|
||||
view.suppressedSerialized = null;
|
||||
@@ -2614,6 +2876,13 @@ mod tests {
|
||||
assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'"));
|
||||
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
||||
assert!(html.contains("refreshSessionFromExternalFileChange"));
|
||||
assert!(html.contains("refreshSessionFromExternalChange"));
|
||||
assert!(html.contains("treeExternalConflictMessage"));
|
||||
assert!(html.contains("tree:delta"));
|
||||
assert!(html.contains("tree:resync"));
|
||||
assert!(html.contains("mnote-web-tree-live"));
|
||||
assert!(html.contains("refreshMindmapRuntimesFromTreePayload"));
|
||||
assert!(html.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__"));
|
||||
assert!(html.contains("/api/local-folder/events"));
|
||||
assert!(html.contains("new EventSource(url.toString())"));
|
||||
assert!(html.contains("localFolderEventRegistry"));
|
||||
@@ -2714,6 +2983,14 @@ mod tests {
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
assert!(html.contains("contentNodes.map((node) => {"));
|
||||
assert!(html.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
|
||||
assert!(html.contains("blockType: 'mindmap'"));
|
||||
assert!(html.contains("props: mindmapPropsFromAttrs(node?.attrs, blockId)"));
|
||||
assert!(html.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)"));
|
||||
assert!(html.contains("mnoteBlockType: 'mindmap'"));
|
||||
assert!(html.contains("block.blockType === 'mindmap'"));
|
||||
assert!(html.contains("content: block.blockType === 'mindmap'"));
|
||||
assert!(html.contains("? ''"));
|
||||
assert!(!html.contains(
|
||||
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
|
||||
));
|
||||
|
||||
@@ -962,6 +962,25 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '';
|
||||
}
|
||||
|
||||
function fileObjectIdentity(item) {
|
||||
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
||||
if (meta.objectIdentity && typeof meta.objectIdentity === 'object') return meta.objectIdentity;
|
||||
var rowKind = String(item && item.rowKind || '');
|
||||
var documentId = fileDocumentId(item) || null;
|
||||
var assetId = fileAssetId(item) || null;
|
||||
var iconKind = iconKindOf(item);
|
||||
var objectKind = rowKind === 'document' ? 'page' : rowKind === 'index' ? 'index' : iconKind === 'mindmap' ? 'mindmap' : 'attachment';
|
||||
return { objectKind: objectKind, documentId: documentId, blockId: null, assetId: assetId };
|
||||
}
|
||||
|
||||
function objectIdentityAttr(identity) {
|
||||
try {
|
||||
return JSON.stringify(identity || {});
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function iconKindOf(item) {
|
||||
return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file';
|
||||
}
|
||||
@@ -976,6 +995,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var parent = parentIdOf(item);
|
||||
var documentId = fileDocumentId(item);
|
||||
var assetId = fileAssetId(item);
|
||||
var objectIdentity = fileObjectIdentity(item);
|
||||
var children = grouped.get(nodeId) || [];
|
||||
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
||||
var expanded = expandable && item.expandedByDefault !== false;
|
||||
@@ -990,7 +1010,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var childHtml = expandable
|
||||
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -1066,11 +1086,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return ext;
|
||||
if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return ext;
|
||||
if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return ext;
|
||||
if (ext === 'pdf') return ext;
|
||||
if (isNonOfficeAttachmentName(name, ext)) return '';
|
||||
if (mt.indexOf('wordprocessingml') >= 0) return 'docx';
|
||||
if (mt.indexOf('presentationml') >= 0) return 'pptx';
|
||||
if (mt.indexOf('spreadsheetml') >= 0) return 'xlsx';
|
||||
if (mt.indexOf('pdf') >= 0) return 'pdf';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1098,6 +1117,32 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '/onlyoffice?' + params.toString();
|
||||
}
|
||||
|
||||
function buildMindmapOpenPath(documentId, assetId) {
|
||||
var doc = String(documentId || '').trim();
|
||||
var map = String(assetId || '').trim();
|
||||
if (!doc || !map) return '';
|
||||
return '/mindmap/' + encodeURIComponent(doc) + '/' + encodeURIComponent(map);
|
||||
}
|
||||
|
||||
function isMindmapAssetDetail(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
var assetType = String(detail && detail.assetType || '').trim();
|
||||
if (assetType === 'mindmap') return true;
|
||||
return assetId.indexOf('mindmap_') === 0 || assetId.indexOf('mindmap-') === 0;
|
||||
}
|
||||
|
||||
function readFileTreeObjectIdentity(row) {
|
||||
if (!row) return null;
|
||||
var raw = row.getAttribute('data-object-identity') || '';
|
||||
if (!raw) return null;
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentOnlyOfficeUserId() {
|
||||
try {
|
||||
var response = await fetch('/api/auth/whoami', {
|
||||
@@ -1115,6 +1160,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function openConvexAssetFromFileTree(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (!assetId) return;
|
||||
var documentId = String(detail && detail.documentId || '').trim();
|
||||
if (isMindmapAssetDetail(detail) && documentId) {
|
||||
var mindmapPath = buildMindmapOpenPath(documentId, assetId);
|
||||
if (mindmapPath) {
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-object-shell');
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
||||
window.location.assign(mindmapPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
|
||||
method: 'GET',
|
||||
@@ -1142,6 +1197,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}), '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
|
||||
await openCodeEditorAttachment({
|
||||
href: fileUrl,
|
||||
fileUrl: fileUrl,
|
||||
fileName: fileName,
|
||||
assetId: assetId,
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
fileSize: uploadedFileSize(asset)
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(fileUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
window.alert(error && error.message ? error.message : '打开附件失败');
|
||||
@@ -1243,6 +1309,67 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function isNonOfficeAttachmentName(name, ext) {
|
||||
var codeFileNames = [
|
||||
'.dockerignore', '.editorconfig', '.env', '.eslintrc', '.gitattributes', '.gitignore', '.npmrc', '.prettierrc',
|
||||
'dockerfile', 'makefile', 'cmakelists.txt', 'gemfile', 'rakefile', 'procfile'
|
||||
];
|
||||
return [
|
||||
'pdf', 'toml', 'json', 'yaml', 'yml', 'md', 'markdown', 'txt', 'ini', 'env', 'xml', 'html', 'htm', 'css', 'scss',
|
||||
'less', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'rs', 'go', 'java', 'c', 'cpp', 'h', 'hpp', 'cs',
|
||||
'php', 'rb', 'sh', 'bash', 'zsh', 'sql', 'lock', 'log', 'vue', 'svelte', 'astro', 'jsonc', 'json5',
|
||||
'mts', 'cts', 'lua', 'dart', 'kt', 'kts', 'swift', 'scala', 'gradle', 'groovy', 'clj',
|
||||
'ex', 'exs', 'erl', 'hrl', 'fs', 'fsx', 'r', 'jl', 'm', 'mm', 'pl', 'pm', 'ps1', 'bat', 'cmd',
|
||||
'psm1', 'psd1', 'dockerfile', 'containerfile', 'proto', 'graphql', 'gql', 'prisma', 'tf', 'tfvars',
|
||||
'hcl', 'nix', 'cmake', 'bazel', 'bzl', 'properties', 'conf', 'cfg', 'config', 'service', 'desktop',
|
||||
'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'prettierrc', 'eslintrc'
|
||||
].indexOf(ext) >= 0 || codeFileNames.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
function attachmentExtensionFromFileName(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
return name.indexOf('.') >= 0 ? name.split('.').pop() : '';
|
||||
}
|
||||
|
||||
function isPdfAttachmentFileName(fileName) {
|
||||
return attachmentExtensionFromFileName(fileName) === 'pdf';
|
||||
}
|
||||
|
||||
function isCodeAttachmentFileName(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
var ext = attachmentExtensionFromFileName(name);
|
||||
return ext !== 'pdf' && isNonOfficeAttachmentName(name, ext);
|
||||
}
|
||||
|
||||
function inferCodeAttachmentLanguage(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
var ext = attachmentExtensionFromFileName(name);
|
||||
var byName = {
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'cmakelists.txt': 'cmake',
|
||||
'.gitignore': 'gitignore',
|
||||
'.gitattributes': 'gitattributes',
|
||||
'.editorconfig': 'ini',
|
||||
'.env': 'dotenv'
|
||||
};
|
||||
if (byName[name]) return byName[name];
|
||||
var byExt = {
|
||||
bash: 'bash', bat: 'batch', c: 'c', cjs: 'javascript', cmd: 'batch', conf: 'text', cpp: 'cpp',
|
||||
cs: 'csharp', css: 'css', cts: 'typescript', dart: 'dart', dockerfile: 'dockerfile', env: 'dotenv',
|
||||
go: 'go', gql: 'graphql', gradle: 'groovy', graphql: 'graphql', h: 'c', hcl: 'hcl', hpp: 'cpp',
|
||||
htm: 'html', html: 'html', ini: 'ini', java: 'java', js: 'javascript', json: 'json', json5: 'json',
|
||||
jsonc: 'jsonc', jsx: 'javascript', kt: 'kotlin', kts: 'kotlin', less: 'less', log: 'text',
|
||||
lua: 'lua', m: 'objective-c', markdown: 'markdown', md: 'markdown', mjs: 'javascript',
|
||||
mts: 'typescript', nix: 'nix', php: 'php', pl: 'perl', pm: 'perl', prisma: 'prisma',
|
||||
proto: 'protobuf', ps1: 'powershell', py: 'python', r: 'r', rb: 'ruby', rs: 'rust',
|
||||
scss: 'scss', sh: 'bash', sql: 'sql', svelte: 'svelte', swift: 'swift', tf: 'terraform',
|
||||
tfvars: 'terraform', toml: 'toml', ts: 'typescript', tsx: 'typescript', txt: 'text',
|
||||
vue: 'vue', xml: 'xml', yaml: 'yaml', yml: 'yaml', zsh: 'bash'
|
||||
};
|
||||
return byExt[ext] || 'text';
|
||||
}
|
||||
|
||||
function attachmentClassForFileName(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : '';
|
||||
@@ -1250,6 +1377,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt';
|
||||
if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet';
|
||||
if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf';
|
||||
if (isNonOfficeAttachmentName(name, ext)) {
|
||||
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-code';
|
||||
}
|
||||
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file';
|
||||
}
|
||||
|
||||
@@ -3050,9 +3180,99 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function openEditorAttachmentDetail(detail) {
|
||||
if (!detail || !detail.href) return;
|
||||
if (isPdfAttachmentFileName(detail.fileName)) {
|
||||
void openPdfEditorAttachment(detail);
|
||||
return;
|
||||
}
|
||||
if (isCodeAttachmentFileName(detail.fileName)) {
|
||||
void openCodeEditorAttachment(detail);
|
||||
return;
|
||||
}
|
||||
window.open(detail.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
async function resolveEditorAttachmentUrl(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (assetId) {
|
||||
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
|
||||
}
|
||||
var signedUrl = String(payload && payload.signedUrl || '').trim();
|
||||
if (!signedUrl) throw new Error('附件链接不可用');
|
||||
return {
|
||||
url: signedUrl,
|
||||
asset: payload.asset && typeof payload.asset === 'object' ? payload.asset : {}
|
||||
};
|
||||
}
|
||||
var url = String(detail && (detail.fileUrl || detail.href) || '').trim();
|
||||
if (!url) throw new Error('附件链接不可用');
|
||||
return { url: url, asset: {} };
|
||||
}
|
||||
|
||||
async function openPdfEditorAttachment(detail) {
|
||||
try {
|
||||
var resolved = await resolveEditorAttachmentUrl(detail);
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
window.alert(error && error.message ? error.message : '打开 PDF 失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function openCodeEditorAttachment(detail) {
|
||||
var resolved = null;
|
||||
try {
|
||||
resolved = await resolveEditorAttachmentUrl(detail);
|
||||
var size = Number(resolved.asset && (resolved.asset.file_size || resolved.asset.fileSize) || 0);
|
||||
if (Number.isFinite(size) && size > 1024 * 1024) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var response = await fetch(resolved.url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!response.ok) throw new Error('读取附件内容失败');
|
||||
var text = await response.text();
|
||||
if (text.length > 1024 * 1024) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var editorRoot = document.querySelector('.editor-surface .ProseMirror');
|
||||
var editor = editorRoot && editorRoot.editor;
|
||||
if (!editor || !editor.chain) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var title = String(detail.fileName || resolved.asset.file_name || '附件').trim() || '附件';
|
||||
var language = inferCodeAttachmentLanguage(title);
|
||||
editor.chain().focus().insertContent([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: title }]
|
||||
},
|
||||
{
|
||||
type: 'codeBlock',
|
||||
attrs: { language: language },
|
||||
content: text ? [{ type: 'text', text: text.replace(/\r\n?/g, '\n') }] : []
|
||||
}
|
||||
]).run();
|
||||
} catch (error) {
|
||||
console.warn('[mnote attachment] open code attachment failed', error);
|
||||
if (resolved && resolved.url) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
window.alert(error && error.message ? error.message : '打开代码附件失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditorAttachmentDownload(detail) {
|
||||
if (!detail) return;
|
||||
if (detail.assetId) {
|
||||
@@ -3330,6 +3550,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var rowKind = fileRow.getAttribute('data-row-kind') || '';
|
||||
var documentId = fileRow.getAttribute('data-document-id') || fileRow.getAttribute('data-doc-id') || '';
|
||||
var assetId = fileRow.getAttribute('data-asset-id') || '';
|
||||
var assetType = '';
|
||||
var kindBadge = fileRow.querySelector('.tree-kind-badge');
|
||||
if (kindBadge instanceof HTMLElement) {
|
||||
assetType = kindBadge.getAttribute('data-kind') || '';
|
||||
}
|
||||
if (fileAction === 'toggle') {
|
||||
e.preventDefault();
|
||||
toggleChildren(fileRow, fileBtn);
|
||||
@@ -3356,14 +3581,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
e.preventDefault();
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
var objectIdentity = readFileTreeObjectIdentity(fileRow);
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' });
|
||||
} else if (assetId) {
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId });
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3988,6 +4214,17 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.asset.open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("openConvexAssetFromFileTree"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildMindmapOpenPath"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isMindmapAssetDetail"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("openMindmapAssetInDocumentShell"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-mindmap-asset-open-mode"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mindmap-object-shell"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("window.location.assign(mindmapPath)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("assetType: assetType || null"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-object-identity"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("readFileTreeObjectIdentity"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("objectIdentity: objectIdentity"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("workspaceId: resolveWorkspaceId(fileRow)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/media/sign?assetId="));
|
||||
assert!(SIDEBAR_TREE_JS.contains("fetchCurrentOnlyOfficeUserId"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami"));
|
||||
@@ -4031,6 +4268,40 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isNonOfficeAttachmentName(name, ext)"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("if (ext === 'pdf') return ext;"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("mt.indexOf('pdf') >= 0"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-uploaded-attachment-pdf"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-uploaded-attachment-code"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'toml'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'json'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'yaml'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'md'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'vue'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'svelte'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'proto'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'dockerfile'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'.gitignore'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function openPdfEditorAttachment"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function openCodeEditorAttachment"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function resolveEditorAttachmentUrl"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("type: 'codeBlock'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("attrs: { language: language }"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("inferCodeAttachmentLanguage"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (isPdfAttachmentFileName(detail.fileName))"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (isCodeAttachmentFileName(detail.fileName))"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("await openCodeEditorAttachment({"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
|
||||
|
||||
@@ -18,17 +18,37 @@ pub fn MindmapPage(
|
||||
document_id: String,
|
||||
/// 思维导图 ID
|
||||
mindmap_id: String,
|
||||
/// 页面标题
|
||||
title: String,
|
||||
/// 侧栏页面树 HTML
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
/// 工作区名称
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
/// workspace shell 侧栏 sections HTML
|
||||
#[prop(optional)]
|
||||
workspace_sidebar_html: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let object_identity = format!("resource:mindmap:{document_id}:{mindmap_id}");
|
||||
view! {
|
||||
<PageLayout current_nav="documents">
|
||||
<PageLayout
|
||||
current_nav="documents"
|
||||
sidebar_tree_html={sidebar_tree_html.unwrap_or_default()}
|
||||
workspace_name={workspace_name.unwrap_or_default()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}
|
||||
topbar_title={title.clone()}
|
||||
>
|
||||
<main
|
||||
id="mnote-mindmap-shell"
|
||||
data-object-shell="mindmap"
|
||||
data-mnote-object-editor="mindmap"
|
||||
data-mnote-object-identity={object_identity}
|
||||
data-document-id={document_id}
|
||||
data-mindmap-id={mindmap_id}
|
||||
>
|
||||
<header>
|
||||
<h1>{"思维导图"}</h1>
|
||||
<h1>{title}</h1>
|
||||
</header>
|
||||
<section
|
||||
id="mnote-mindmap-island"
|
||||
|
||||
@@ -8,6 +8,7 @@ use bridge_runtime::{
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
|
||||
@@ -19,6 +20,32 @@ const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
|
||||
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
|
||||
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
||||
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
static CONVEX_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
|
||||
fn convex_http_client(context: &RequestContext) -> Result<&'static reqwest::Client, WebError> {
|
||||
if let Some(client) = CONVEX_HTTP_CLIENT.get() {
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.pool_max_idle_per_host(16)
|
||||
.pool_idle_timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let _ = CONVEX_HTTP_CLIENT.set(client);
|
||||
CONVEX_HTTP_CLIENT.get().ok_or_else(|| {
|
||||
WebError::internal("Convex HTTP 客户端初始化失败")
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
@@ -232,15 +259,7 @@ pub async fn execute_convex_query_plan(
|
||||
"args": plan.args_json,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let client = convex_http_client(context)?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/query", convex_url(config, context)?))
|
||||
@@ -357,6 +376,18 @@ pub async fn execute_convex_query_by_name(
|
||||
|
||||
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
let mut args = plan.args_json.clone();
|
||||
if matches!(plan.command_name.as_str(), "mindmaps.put")
|
||||
|| matches!(plan.function_name.as_str(), "mindmaps:put")
|
||||
{
|
||||
if let Value::Object(map) = &mut args {
|
||||
// Rust plan 保留 tree domain event / stream hint 作为正式契约;
|
||||
// Convex mindmaps.put legacy validator 仍只接收真实写入字段。
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
"documents.save" | "page.body.save"
|
||||
@@ -414,15 +445,7 @@ pub async fn execute_convex_command_plan(
|
||||
"args": [convex_command_args_for_plan(plan)],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let client = convex_http_client(context)?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/mutation", convex_url(config, context)?))
|
||||
@@ -550,15 +573,7 @@ pub async fn execute_convex_mutation_by_name(
|
||||
"args": [args],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let client = convex_http_client(context)?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/mutation", convex_url(config, context)?))
|
||||
@@ -847,6 +862,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_mindmap_put_bridge_artifacts_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "mindmaps.put".into(),
|
||||
command_id: "cmd_mindmap_put_1".into(),
|
||||
function_name: "mindmaps:put".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"docId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"data": {"data": {"text": "KMIND"}, "children": []},
|
||||
"createOnly": false,
|
||||
"streamDeltaHint": {"family": "tree"},
|
||||
"domainEventHint": {"eventType": "tree.resource.mindmap.put"},
|
||||
"domainEventPlan": {"eventType": "tree.resource.mindmap.put"},
|
||||
"domainEventPlans": [{"eventType": "tree.resource.mindmap.put"}],
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
json!({
|
||||
"docId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"data": {"data": {"text": "KMIND"}, "children": []},
|
||||
"createOnly": false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct FileTreeRenderRow {
|
||||
pub icon_kind: String,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub object_identity: Option<String>,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ fn render_filetree_row(
|
||||
String::new()
|
||||
};
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
@@ -93,6 +94,7 @@ fn render_filetree_row(
|
||||
parent_attr = parent_attr,
|
||||
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
toggle_html = toggle_html,
|
||||
icon_kind = escape_html(&row.icon_kind),
|
||||
@@ -174,6 +176,9 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
||||
),
|
||||
selected: true,
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
@@ -188,6 +193,9 @@ mod tests {
|
||||
icon_kind: "index".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"index","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
||||
),
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
@@ -198,6 +206,7 @@ mod tests {
|
||||
assert!(html.contains("data-testid=\"filetree-doc-row\""));
|
||||
assert!(html.contains("data-testid=\"filetree-index-row\""));
|
||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
||||
assert!(html.contains("data-object-identity=\"{"objectKind":"index""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-selected=\"true\""));
|
||||
|
||||
@@ -133,6 +133,7 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
@@ -147,6 +148,7 @@ mod tests {
|
||||
icon_kind: "file".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: Some("asset_1".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
]);
|
||||
|
||||
+24
-8
@@ -319,6 +319,9 @@ function __wbg_get_imports() {
|
||||
__wbg_assign_d4fed0f8abb71719: function() { return handleError(function (arg0, arg1, arg2) {
|
||||
arg0.assign(getStringFromWasm0(arg1, arg2));
|
||||
}, arguments); },
|
||||
__wbg_blur_583010b6b4026c5d: function() { return handleError(function (arg0) {
|
||||
arg0.blur();
|
||||
}, arguments); },
|
||||
__wbg_body_c7b35a55457167ba: function(arg0) {
|
||||
const ret = arg0.body;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
@@ -646,6 +649,16 @@ function __wbg_get_imports() {
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_instanceof_HtmlInputElement_8dc30e795ec4f2a5: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof HTMLInputElement;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_instanceof_Map_1b76fd4635be43eb: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
@@ -1145,6 +1158,9 @@ function __wbg_get_imports() {
|
||||
__wbg_set_scrollTop_e931da7f2ad87c86: function(arg0, arg1) {
|
||||
arg0.scrollTop = arg1;
|
||||
},
|
||||
__wbg_set_value_d84be184846d017b: function(arg0, arg1, arg2) {
|
||||
arg0.value = getStringFromWasm0(arg1, arg2);
|
||||
},
|
||||
__wbg_shiftKey_e483c13c966878f6: function(arg0) {
|
||||
const ret = arg0.shiftKey;
|
||||
return ret;
|
||||
@@ -1260,42 +1276,42 @@ function __wbg_get_imports() {
|
||||
}
|
||||
}, arguments); },
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1585, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1889, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1824, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1915, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2227, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1741, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2050, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1826, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2137, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1740, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2049, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1762, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2071, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1825, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
|
||||
return ret;
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
+83
-83
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user