Files
mnote/rust/crates/mnote-web/src/ssr/pages/document.rs
T

377 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! MNOTE 文档页面组件 (SSR)
//!
//! 提供文档编辑器的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use crate::ssr::pages::layout::PageLayout;
use leptos::prelude::*;
use serde_json::Value;
#[derive(Clone)]
pub struct DocumentPaneViewModel {
pub pane_role: &'static str,
pub title: String,
pub document_id: String,
pub workspace_id: String,
pub page_wide_layout: bool,
pub page_small_text: bool,
pub page_layout_density: String,
pub page_font: String,
pub page_show_heading_numbers: bool,
pub has_page_subtree: bool,
pub primary_legacy_ids: bool,
pub visible: bool,
pub hide_title_header: bool,
}
#[component]
pub(super) fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
let pane_hidden = !model.visible;
let pane_role = model.pane_role.to_string();
let pane_role_attr = pane_role.clone();
let pane_role_attr_two = pane_role.clone();
let pane_role_attr_three = pane_role.clone();
let pane_role_attr_four = pane_role.clone();
let pane_role_attr_five = pane_role.clone();
let pane_label = if model.pane_role == "secondary" {
"右侧文档"
} else {
"主文档"
};
let title_input_id = if model.primary_legacy_ids {
Some("mnote-page-title-input".to_string())
} else {
None
};
let editor_island_id = if model.primary_legacy_ids {
Some("mnote-editor-island".to_string())
} else {
None
};
let editor_root_id = if model.primary_legacy_ids {
Some("mnote-leptos-tiptap-island-editor-root".to_string())
} else {
None
};
view! {
<section
class="document-pane"
data-document-pane="true"
data-pane-role={pane_role_attr}
data-pane-document-id={model.document_id.clone()}
data-pane-workspace-id={model.workspace_id.clone()}
data-pane-visible={model.visible.to_string()}
aria-label={pane_label}
hidden={pane_hidden}
>
<main
class="document-shell"
data-editor-host="leptos_tiptap_island"
data-document-id={model.document_id.clone()}
data-workspace-id={model.workspace_id.clone()}
data-pane-role={pane_role_attr_two}
data-page-wide-layout={model.page_wide_layout.to_string()}
data-page-small-text={model.page_small_text.to_string()}
data-layout-density={model.page_layout_density.clone()}
data-page-font={model.page_font.clone()}
data-page-show-heading-numbers={model.page_show_heading_numbers.to_string()}
>
<header
class="document-shell-header"
data-page-title-hidden={model.hide_title_header.to_string()}
hidden={model.hide_title_header}
>
<div class="document-page-icon" aria-hidden="true">
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
</div>
<div class="document-pane-header-row">
<h1 class="document-title-heading">
<textarea
id={title_input_id}
class="document-title-input"
aria-label="页面标题"
data-page-title-input="true"
data-document-id={model.document_id.clone()}
data-workspace-id={model.workspace_id.clone()}
data-pane-role={pane_role_attr_three}
data-title-endpoint="/api/documents/title"
rows="1"
>{model.title.clone()}</textarea>
</h1>
</div>
<div class="document-shell-meta" aria-label="页面元信息">
<span data-page-title-current="true">{model.title.clone()}</span>
</div>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section
data-testid="mnote-page-subtree"
data-page-tree-source="page_aggregate.tree.pageSubtree"
data-page-subtree-present={model.has_page_subtree.to_string()}
data-pane-role={pane_role_attr_four}
></section>
<section
id={editor_island_id}
data-editor-host="leptos_tiptap_island"
data-pane-role={pane_role_attr_five}
>
<div
id={editor_root_id}
data-testid="mnote-leptos-tiptap-island-editor-root"
data-editor-host-kind="leptos_tiptap_island"
data-runtime-editor-status="booting"
data-pane-role={pane_role.clone()}
></div>
<div
class="sr-only"
data-editor-host-observability="rust-web-inline-island"
data-editor-host-active="leptos_tiptap_island"
data-editor-host-requested="leptos_tiptap_island"
data-editor-host-status="booting"
data-pane-role={pane_role}
></div>
</section>
</main>
</section>
}
}
/// MNOTE 文档页面
///
/// 渲染文档编辑器的 SSR 壳结构:
/// - `<PageLayout current_nav="documents">`
/// - 标题区域
/// - 页面聚合快照标记
/// - 编辑器占位区
#[component]
pub fn DocumentPage(
/// 文档标题
title: String,
/// 文档 id
document_id: String,
/// 工作区 id
workspace_id: 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>,
/// Page Aggregate 子树 JSON(可选)
#[prop(optional)]
page_subtree_json: Option<String>,
/// Page Aggregate 页面选项 JSON(可选)
#[prop(optional)]
page_options_json: Option<String>,
/// 右侧文档标题(可选)
#[prop(optional)]
secondary_title: String,
/// 右侧文档 id(可选)
#[prop(optional)]
secondary_document_id: String,
/// 右侧 workspace id(可选)
#[prop(optional)]
secondary_workspace_id: String,
/// 右侧 Page Aggregate 子树 JSON(可选)
#[prop(optional)]
secondary_page_subtree_json: String,
/// 右侧页面选项 JSON(可选)
#[prop(optional)]
secondary_page_options_json: String,
/// 是否隐藏主文档页头标题
#[prop(optional)]
primary_hide_title_header: bool,
/// 是否隐藏右侧文档页头标题
#[prop(optional)]
secondary_hide_title_header: bool,
/// 是否显示管理员授权入口
#[prop(optional)]
show_admin_access_policy: bool,
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "null")
.is_some();
let workspace_label = workspace_name
.clone()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人空间".to_string());
let page_options = page_options_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or(Value::Null);
let page_wide_layout = page_options
.get("wideLayout")
.and_then(Value::as_bool)
.unwrap_or(false);
let page_small_text = page_options
.get("smallText")
.and_then(Value::as_bool)
.unwrap_or(false);
let page_layout_density = page_options
.get("layoutDensity")
.and_then(Value::as_str)
.unwrap_or("normal")
.to_string();
let page_font = page_options
.get("pageFont")
.and_then(Value::as_str)
.unwrap_or("default")
.to_string();
let page_show_heading_numbers = false;
let secondary_has_page_subtree = Some(secondary_page_subtree_json.as_str())
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "null")
.is_some();
let secondary_page_options = Some(secondary_page_options_json.as_str())
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or(Value::Null);
let secondary_page_wide_layout = secondary_page_options
.get("wideLayout")
.and_then(Value::as_bool)
.unwrap_or(false);
let secondary_page_small_text = secondary_page_options
.get("smallText")
.and_then(Value::as_bool)
.unwrap_or(false);
let secondary_page_layout_density = secondary_page_options
.get("layoutDensity")
.and_then(Value::as_str)
.unwrap_or("normal")
.to_string();
let secondary_page_font = secondary_page_options
.get("pageFont")
.and_then(Value::as_str)
.unwrap_or("default")
.to_string();
let secondary_page_show_heading_numbers = false;
let secondary_visible = Some(secondary_document_id.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let primary_model = DocumentPaneViewModel {
pane_role: "primary",
title: title.clone(),
document_id: document_id.clone(),
workspace_id: workspace_id.clone(),
page_wide_layout,
page_small_text,
page_layout_density: page_layout_density.clone(),
page_font: page_font.clone(),
page_show_heading_numbers,
has_page_subtree,
primary_legacy_ids: true,
visible: true,
hide_title_header: primary_hide_title_header,
};
let secondary_model = DocumentPaneViewModel {
pane_role: "secondary",
title: secondary_title,
document_id: secondary_document_id,
workspace_id: if secondary_workspace_id.trim().is_empty() {
workspace_id.clone()
} else {
secondary_workspace_id
},
page_wide_layout: secondary_page_wide_layout,
page_small_text: secondary_page_small_text,
page_layout_density: secondary_page_layout_density,
page_font: secondary_page_font,
page_show_heading_numbers: secondary_page_show_heading_numbers,
has_page_subtree: secondary_has_page_subtree,
primary_legacy_ids: false,
visible: secondary_visible,
hide_title_header: secondary_hide_title_header,
};
view! {
<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()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
<div
class="document-workspace"
data-testid="mnote-document-workspace"
data-has-secondary-pane={secondary_visible.to_string()}
>
<section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host" data-pane-role="primary">
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="primary" role="tablist" aria-label="主编辑区标签页">
<button
type="button"
class="mnote-main-tab is-active"
data-mnote-main-tab="page"
data-mnote-tab-kind="page"
data-pane-role="primary"
role="tab"
aria-selected="true"
tabindex="0"
>
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
<span class="mnote-main-tab-title">{title.clone()}</span>
</button>
</div>
<div class="mnote-main-tab-panels">
<div data-mnote-page-tab-panel="true" data-pane-role="primary">
<DocumentPane model={primary_model} />
</div>
<section
class="mnote-resource-tab-host"
data-mnote-resource-tab-host="true"
data-pane-role="primary"
data-testid="mnote-resource-tab-host"
hidden=true
>
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="primary"></div>
</section>
</div>
</section>
<div
class="document-pane-resizer"
data-testid="mnote-secondary-pane-resizer"
data-document-pane-resizer="true"
aria-hidden="true"
hidden={!secondary_visible}
></div>
<section class="document-main-editor-group" data-testid="mnote-secondary-editor-tab-host" data-pane-role="secondary" hidden={!secondary_visible}>
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="secondary" role="tablist" aria-label="右侧编辑区标签页">
<button
type="button"
class="mnote-main-tab is-active"
data-mnote-main-tab="page"
data-mnote-tab-kind="page"
data-pane-role="secondary"
role="tab"
aria-selected="true"
tabindex="0"
>
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
<span class="mnote-main-tab-title">{secondary_model.title.clone()}</span>
<span class="mnote-main-tab-close" role="button" data-mnote-pane-close="secondary" aria-label="关闭右侧文档" title="关闭右侧文档">{"×"}</span>
</button>
</div>
<div class="mnote-main-tab-panels">
<div data-mnote-page-tab-panel="true" data-pane-role="secondary">
<DocumentPane model={secondary_model} />
</div>
<section
class="mnote-resource-tab-host"
data-mnote-resource-tab-host="true"
data-pane-role="secondary"
data-testid="mnote-secondary-resource-tab-host"
hidden=true
>
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="secondary"></div>
</section>
</div>
</section>
</div>
<div class="sr-only" data-mnote-workspace-label>{workspace_label}</div>
</PageLayout>
}
}