feat: finish rust web dual pane document shell

This commit is contained in:
lix-2026
2026-05-08 23:15:00 +08:00
parent 83805f9254
commit 3d5e0c9d5a
16 changed files with 3776 additions and 761 deletions
+228 -53
View File
@@ -7,6 +7,142 @@ 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,
}
#[component]
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">
<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>
<Show when={move || model.pane_role == "secondary"}>
<button
type="button"
class="document-pane-close"
data-mnote-pane-close="secondary"
aria-label="关闭右侧文档"
title="关闭右侧文档"
>
<span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span>
</button>
</Show>
</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 壳结构:
@@ -37,6 +173,21 @@ pub fn DocumentPage(
/// 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,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
@@ -71,62 +222,86 @@ pub fn DocumentPage(
.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,
};
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,
};
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()}>
<main
class="document-shell"
data-editor-host="leptos_tiptap_island"
data-document-id={document_id.clone()}
data-workspace-id={workspace_id.clone()}
data-page-wide-layout={page_wide_layout.to_string()}
data-page-small-text={page_small_text.to_string()}
data-layout-density={page_layout_density.clone()}
data-page-font={page_font.clone()}
data-page-show-heading-numbers={page_show_heading_numbers.to_string()}
<div
class="document-workspace"
data-testid="mnote-document-workspace"
data-has-secondary-pane={secondary_visible.to_string()}
>
<header class="document-shell-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>
<h1 class="document-title-heading">
<textarea
id="mnote-page-title-input"
class="document-title-input"
aria-label="页面标题"
data-page-title-input="true"
data-document-id={document_id.clone()}
data-workspace-id={workspace_id.clone()}
data-title-endpoint="/api/documents/title"
rows="1"
>{title.clone()}</textarea>
</h1>
<div class="document-shell-meta" aria-label="页面元信息">
<span><span aria-hidden="true">""</span>{workspace_label}</span>
<span><span aria-hidden="true">""</span>"已同步"</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={has_page_subtree.to_string()}
></section>
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island">
<div
id="mnote-leptos-tiptap-island-editor-root"
data-testid="mnote-leptos-tiptap-island-editor-root"
data-editor-host-kind="leptos_tiptap_island"
data-runtime-editor-status="booting"
></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"
></div>
</section>
</main>
<DocumentPane model={primary_model} />
<div
class="document-pane-resizer"
data-testid="mnote-secondary-pane-resizer"
data-document-pane-resizer="true"
aria-hidden="true"
hidden={!secondary_visible}
></div>
<DocumentPane model={secondary_model} />
</div>
<div class="sr-only" data-mnote-workspace-label>{workspace_label}</div>
</PageLayout>
}
}
@@ -300,7 +300,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function copyWorkspaceSourceParams(targetUrl) {
var params = new URLSearchParams(window.location.search);
['sourceKind', 'rootUri'].forEach(function(name) {
['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
var value = (params.get(name) || '').trim();
if (value) targetUrl.searchParams.set(name, value);
});
+102 -2
View File
@@ -1828,12 +1828,68 @@ body {
margin: 0 auto;
}
.document-workspace {
--mnote-secondary-pane-width: minmax(320px, 42%);
--mnote-secondary-pane-resizer-width: 6px;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
width: 100%;
min-width: 0;
}
.document-workspace[data-has-secondary-pane="true"] {
grid-template-columns: minmax(0, 1fr) var(--mnote-secondary-pane-resizer-width) var(--mnote-secondary-pane-width);
}
.document-pane {
min-width: 0;
}
.document-pane[hidden] {
display: none !important;
}
.document-pane-resizer {
display: none;
width: var(--mnote-secondary-pane-resizer-width);
min-height: calc(100vh - 40px);
cursor: col-resize;
position: relative;
}
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
display: block;
}
.document-pane-resizer::after {
content: "";
position: absolute;
left: 2px;
top: 64px;
bottom: 48px;
width: 2px;
border-radius: 999px;
background: rgba(27, 28, 28, 0.08);
}
.document-shell-header {
max-width: none;
padding: 0;
margin: 0 0 50px;
}
.document-pane-header-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.document-pane-header-row .document-title-heading {
flex: 1;
min-width: 0;
}
.document-page-icon {
display: none;
}
@@ -1886,6 +1942,26 @@ body {
text-underline-offset: 6px;
}
.document-pane-close {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: #6D6A65;
cursor: pointer;
flex: 0 0 auto;
}
.document-pane-close:hover {
background: #F4F3F3;
color: #1B1C1C;
}
.document-shell-meta {
display: none;
}
@@ -2048,6 +2124,16 @@ body {
width: min(100%, 980px);
}
.document-pane[data-pane-role="secondary"] .document-shell {
width: min(100%, 820px);
padding-left: 28px;
padding-right: 20px;
}
.document-pane[data-pane-role="secondary"] .document-shell-header {
margin-bottom: 36px;
}
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror,
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror li {
@@ -2601,6 +2687,20 @@ body {
display: none;
}
.document-workspace[data-has-secondary-pane="true"] {
grid-template-columns: minmax(0, 1fr);
}
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
display: none;
}
.document-pane[data-pane-role="secondary"] .document-shell {
padding-top: 32px;
padding-left: 0;
padding-right: 0;
}
.document-shell {
width: 100%;
padding: 48px 28px 120px;
@@ -2711,7 +2811,7 @@ mod tests {
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 菜单与本地 SVG mask 图标会增加体积,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 46000);
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 70000);
}
}