对齐 Wolai 侧栏体验并收拢设计入库

This commit is contained in:
lix-2026
2026-04-30 16:18:54 +08:00
parent 8c895b3dc0
commit afb2a5b8a0
89 changed files with 23188 additions and 84 deletions
+63 -5
View File
@@ -2,7 +2,9 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
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,
render_document_title_controller_script, render_editor_island_adapter_script,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::workspace_shell::render_workspace_shell_sidebar_html;
@@ -140,23 +142,79 @@ pub async fn root_entry(
.active_page_title
.clone()
.unwrap_or_default();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_id={workspace_id.clone()} workspace_sidebar_html={workspace_sidebar_html} active_page_id={active_page_id} active_page_title={active_page_title} />
});
let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={workspace_name.clone()}
workspace_id={workspace_id.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
/>
})
};
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
("MNOTE".to_string(), render_workspace_entry(), String::new())
} else {
match build_page_aggregate_snapshot(
&state,
&context,
&active_page_id,
Some(workspace_id.as_str()),
)
.await
{
Ok(aggregate) => {
let title = aggregate.head.title.as_str();
let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree)
.unwrap_or_else(|_| "null".to_string());
let snapshot_json =
serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::document::DocumentPage
title={title.to_string()}
document_id={active_page_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
page_subtree_json={page_subtree_json}
/>
});
let body_extra = format!(
r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
{}
{}"#,
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
render_document_title_controller_script(),
render_editor_island_adapter_script(),
);
(title.to_string(), content, body_extra)
}
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
}
};
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MNOTE</title>
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
{}
{}
</body>
</html>"#,
escape_html(&html_title),
crate::ssr::MNOTE_CSS,
content
content,
body_extra
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
+27 -3
View File
@@ -36,7 +36,7 @@ use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -209,7 +209,7 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
}
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
projection
let mut rows: Vec<PageTreeRenderRow> = projection
.get("items")
.and_then(Value::as_array)
.map(|items| {
@@ -232,6 +232,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
node_id: node_id.to_string(),
parent_node_id: item
.get("parentNodeId")
.or_else(|| item.get("parentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -261,7 +262,30 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
})
.collect()
})
.unwrap_or_default()
.unwrap_or_default();
let parent_by_id = rows
.iter()
.map(|row| (row.node_id.clone(), row.parent_node_id.clone()))
.collect::<BTreeMap<_, _>>();
for row in &mut rows {
if row.depth == 0 && row.parent_node_id.is_some() {
let mut depth = 0_u32;
let mut cursor = row.parent_node_id.as_deref();
while let Some(parent_id) = cursor {
depth += 1;
cursor = parent_by_id
.get(parent_id)
.and_then(|parent| parent.as_deref());
if depth > 32 {
break;
}
}
row.depth = depth;
}
}
rows
}
pub(crate) fn collect_filetree_render_rows(
@@ -126,7 +126,10 @@ pub async fn document_page_shell(
Ok(response)
}
fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestContext) -> String {
pub(crate) fn build_editor_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
) -> String {
serde_json::to_string(&json!({
"schema": "mnote.editor_bootstrap.v1",
"documentId": aggregate.identity.document_id,
@@ -142,7 +145,7 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
.unwrap_or_else(|_| "{}".to_string())
}
fn render_document_title_controller_script() -> &'static str {
pub(crate) fn render_document_title_controller_script() -> &'static str {
r#"<script>
(() => {
const CONTRACT = 'mnote.document_title_controller.v1';
@@ -259,7 +262,7 @@ fn render_document_title_controller_script() -> &'static str {
</script>"#
}
fn render_editor_island_adapter_script() -> &'static str {
pub(crate) fn render_editor_island_adapter_script() -> &'static str {
r#"<script type="module">
(() => {
const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]';
@@ -586,7 +589,7 @@ pub async fn page_aggregate(
Ok(response)
}
async fn build_page_aggregate_snapshot(
pub(crate) async fn build_page_aggregate_snapshot(
state: &AppState,
context: &RequestContext,
document_id: &str,
@@ -661,7 +664,7 @@ fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
}
}
fn escape_html(value: &str) -> String {
pub(crate) fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
@@ -669,7 +672,7 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
}
fn escape_script_json(value: &str) -> String {
pub(crate) fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
+180 -17
View File
@@ -252,22 +252,28 @@ const SIDEBAR_TREE_JS: &str = r##"
return grouped;
}
function renderPageRows(parentId, grouped, activeId) {
function pageTreeChevronSvg() {
return '<svg class="tree-toggle-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false"><path d="M7.84 14.955c.206 0 .37-.07.505-.21l4.277-4.179a.79.79 0 0 0 .264-.574.78.78 0 0 0-.258-.574L8.35 5.24a.7.7 0 0 0-.51-.21.721.721 0 0 0-.498 1.247l3.814 3.721-3.814 3.709a.721.721 0 0 0 .498 1.248"></path></svg>';
}
function renderPageRows(parentId, grouped, activeId, inheritedDepth) {
var computedDepth = Number(inheritedDepth || 0);
return (grouped.get(parentId) || []).map(function(item) {
var nodeId = nodeIdOf(item);
var title = titleOf(item);
var depth = Number(item.depth || 0);
var depth = computedDepth;
var parent = parentIdOf(item);
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && item.expandedByDefault !== false;
var toggle = expandable
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '" aria-expanded="' + String(expanded) + '">' + pageTreeChevronSvg() + '</button>'
: '<span class="tree-spacer" aria-hidden="true"></span>';
var childHtml = expandable && expanded
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId) + '</ul>'
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '</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="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '" data-focused="false" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="' + escapeHtml(nodeId) + '" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"';
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="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>';
}).join('');
}
@@ -278,7 +284,7 @@ const SIDEBAR_TREE_JS: &str = r##"
return String(item.rowKind || 'document') === 'document';
});
var activeId = currentDocumentId();
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId) : '<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>') + '</ul>';
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>') + '</ul>';
return true;
}
@@ -373,7 +379,11 @@ const SIDEBAR_TREE_JS: &str = r##"
children.classList.toggle('tree-children--collapsed');
var collapsed = children.classList.contains('tree-children--collapsed');
row.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (button) button.textContent = collapsed ? '▸' : '▾';
if (button && button.getAttribute('data-testid') === 'tree-node-toggle') {
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
} else if (button) {
button.textContent = collapsed ? '▸' : '▾';
}
}
function dispatchSidebarEvent(name, detail) {
@@ -588,20 +598,20 @@ const SIDEBAR_TREE_JS: &str = r##"
{ action: 'move', icon: 'drive_file_move', label: '移动到...' },
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' }
] : [
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt + O' },
{ action: 'share', icon: 'share', label: '共享...' },
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' },
{ separator: true },
{ action: 'move', icon: 'drive_file_move', label: '移动到...' },
{ action: 'embed', icon: 'account_tree', label: '嵌入到...' },
{ separator: true },
{ action: 'copy-link', icon: 'link', label: '复制访问链接' },
{ action: 'copy-link-title', icon: 'link', label: '复制访问链接(带标题)' },
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
{ action: 'copy-id', icon: 'tag', label: '复制页面 ID' },
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
{ separator: true },
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本' },
{ separator: true },
{ action: 'rename', icon: 'edit', label: '重命名' },
{ action: 'create-child', icon: 'add', label: '新建子页面' },
{ action: 'convert-child', icon: 'subdirectory_arrow_right', label: '转为上一个子页面' },
{ action: 'delete-trash', icon: 'delete', label: '删除到垃圾桶', danger: true }
{ separator: true },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
];
items.forEach(function(item) { appendTreeContextMenuButton(menu, item, detail, trigger); });
document.body.appendChild(menu);
@@ -638,10 +648,155 @@ const SIDEBAR_TREE_JS: &str = r##"
}, x, y, trigger || row);
}
function ensureSearchModal() {
var existing = document.querySelector('[data-testid="wolai-search-modal"]');
if (existing instanceof HTMLElement) return existing;
var overlay = document.createElement('div');
overlay.className = 'wolai-search-overlay';
overlay.setAttribute('data-testid', 'wolai-search-modal');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.hidden = true;
overlay.innerHTML = '' +
'<div class="wolai-search-dialog">' +
'<div class="wolai-search-input-row">' +
'<span class="material-symbols-outlined wolai-search-input-icon" data-icon="search" aria-hidden="true"></span>' +
'<input data-testid="wolai-search-input" class="wolai-search-input" type="text" autocomplete="off" placeholder="在当前工作区中搜索" />' +
'<button type="button" class="wolai-search-close" data-testid="wolai-search-close" aria-label="关闭搜索">×</button>' +
'</div>' +
'<div class="wolai-search-options" aria-label="搜索选项">' +
'<div class="wolai-search-options-left">' +
'<span class="wolai-search-switch-control"><span>仅匹配标题</span><button type="button" class="wolai-search-switch is-on" data-search-switch="title" role="switch" aria-checked="true" aria-label="仅匹配标题"></button></span>' +
'<span class="wolai-search-switch-control"><span>精确匹配</span><button type="button" class="wolai-search-switch" data-search-switch="exact" role="switch" aria-checked="false" aria-label="精确匹配"></button></span>' +
'<span class="wolai-search-sort-control"><span>按编辑时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="updated" aria-label="按编辑时间范围">所有</button></span>' +
'<span class="wolai-search-sort-control"><span>按创建时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="created" aria-label="按创建时间范围">所有</button></span>' +
'</div>' +
'<div class="wolai-search-options-right">' +
'<span class="wolai-search-switch-control"><span>页面内搜索</span><button type="button" class="wolai-search-switch is-on" data-search-switch="page" role="switch" aria-checked="true" aria-label="页面内搜索"></button></span>' +
'</div>' +
'</div>' +
'<div class="wolai-search-result-meta" data-testid="wolai-search-result-meta"></div>' +
'<div class="wolai-search-results" data-testid="wolai-search-results"></div>' +
'</div>';
document.body.appendChild(overlay);
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
var closeButton = overlay.querySelector('[data-testid="wolai-search-close"]');
if (input) input.addEventListener('input', renderSearchResults);
overlay.querySelectorAll('[data-search-switch]').forEach(function(button) {
button.addEventListener('click', function() {
var isOn = button.getAttribute('aria-checked') !== 'true';
button.setAttribute('aria-checked', isOn ? 'true' : 'false');
button.classList.toggle('is-on', isOn);
});
});
if (closeButton) closeButton.addEventListener('click', closeSearchModal);
overlay.addEventListener('click', function(event) {
if (event.target === overlay) closeSearchModal();
});
return overlay;
}
function searchText(value) {
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
}
function collectSearchCandidates() {
var seen = Object.create(null);
var candidates = [];
function add(title, source) {
title = searchText(title);
if (!title || seen[title]) return;
seen[title] = true;
candidates.push({ title: title, source: source || '当前工作区' });
}
document.querySelectorAll('.tree-link-title, .wolai-row-title, [data-page-title-current="true"], .document-title-input, .document-read-view h1, .document-shell h1').forEach(function(node) {
add(node.textContent, '页面');
});
document.querySelectorAll('.mnote-content h1, .mnote-content h2, .mnote-content h3, .mnote-content p, .mnote-content a').forEach(function(node) {
add(node.textContent, '当前页面');
});
return candidates.slice(0, 120);
}
function highlightSearchTitle(title, query) {
var cleanTitle = searchText(title);
var cleanQuery = searchText(query);
if (!cleanQuery) return escapeHtml(cleanTitle);
var index = cleanTitle.toLowerCase().indexOf(cleanQuery.toLowerCase());
if (index < 0) return escapeHtml(cleanTitle);
return escapeHtml(cleanTitle.slice(0, index)) +
'<mark>' + escapeHtml(cleanTitle.slice(index, index + cleanQuery.length)) + '</mark>' +
escapeHtml(cleanTitle.slice(index + cleanQuery.length));
}
function renderSearchResults() {
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
if (!(overlay instanceof HTMLElement)) return;
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
var meta = overlay.querySelector('[data-testid="wolai-search-result-meta"]');
var results = overlay.querySelector('[data-testid="wolai-search-results"]');
if (!input || !meta || !results) return;
var query = searchText(input.value);
var candidates = collectSearchCandidates();
var filtered = query
? candidates.filter(function(item) { return item.title.toLowerCase().indexOf(query.toLowerCase()) >= 0; })
: candidates.slice(0, 8);
filtered = filtered.slice(0, 100);
meta.innerHTML = '<span>共 ' + filtered.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
if (!filtered.length) {
results.innerHTML = '<div class="wolai-search-empty">暂无匹配结果</div>';
return;
}
results.innerHTML = filtered.map(function(item) {
return '<button type="button" class="wolai-search-result-row">' +
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="link" aria-hidden="true"></span>' +
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(item.title, query) + '</span>' +
'<span class="wolai-search-result-snippet">' + escapeHtml(item.source) + ' · 当前工作区</span></span>' +
'</button>';
}).join('');
}
function isSearchModalOpen() {
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
return overlay instanceof HTMLElement && !overlay.hidden;
}
function openSearchModal() {
var overlay = ensureSearchModal();
overlay.hidden = false;
document.documentElement.setAttribute('data-mnote-search-modal-open', 'true');
renderSearchResults();
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
if (input) {
setTimeout(function() { input.focus(); input.select(); }, 0);
}
}
function closeSearchModal() {
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
if (overlay instanceof HTMLElement) overlay.hidden = true;
document.documentElement.removeAttribute('data-mnote-search-modal-open');
}
function toggleSearchModal() {
if (isSearchModalOpen()) closeSearchModal();
else openSearchModal();
}
document.addEventListener('click', function(e) {
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
if (activeTreeContextMenu) closeTreeContextMenu();
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
if (searchTrigger) {
e.preventDefault();
openSearchModal();
return;
}
var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]');
if (tabTrigger) {
e.preventDefault();
@@ -753,7 +908,15 @@ const SIDEBAR_TREE_JS: &str = r##"
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') closeTreeContextMenu();
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
event.preventDefault();
toggleSearchModal();
return;
}
if (event.key === 'Escape') {
closeSearchModal();
closeTreeContextMenu();
}
});
function readPageDragNodeId(event) {
@@ -1146,7 +1309,7 @@ pub fn PageLayout(
<span class="wolai-sidebar-chevron" aria-hidden="true">""</span>
</div>
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
@@ -1173,7 +1336,7 @@ pub fn PageLayout(
<button type="button" class="wolai-icon-button" title="收藏" aria-label="收藏"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="历史" aria-label="历史"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button wolai-ai-inline" title="AI" aria-label="AI"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="搜索" aria-label="搜索"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="更多" aria-label="更多"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
</div>
</header>
+109 -43
View File
@@ -910,6 +910,39 @@ a:hover {
}
/* ===== 响应式 ===== */
.wolai-search-overlay{position:fixed;inset:0;z-index:1200;display:flex;align-items:flex-start;justify-content:center;padding:92px 24px 24px;background:rgba(0,0,0,.32)}
.wolai-search-overlay[hidden]{display:none!important}
.wolai-search-dialog{width:min(680px,100%);max-height:min(720px,calc(100vh - 128px));overflow:hidden;display:flex;flex-direction:column;background:#FFF;border:1px solid rgba(27,28,28,.10);border-radius:6px;box-shadow:0 18px 48px rgba(15,23,42,.22),0 2px 8px rgba(15,23,42,.08);color:#37352F}
.wolai-search-input-row{display:flex;align-items:center;min-height:58px;padding:0 12px 0 18px;border-bottom:1px solid rgba(27,28,28,.08)}
.wolai-search-input-icon{width:22px;height:22px;color:#8B8780;margin-right:10px}
.wolai-search-input{flex:1 1 auto;min-width:0;height:56px;border:0;outline:none;background:transparent;color:#24211D;font:400 18px/1.4 var(--wolai-font-sans);letter-spacing:0}
.wolai-search-input::placeholder{color:#A29E97}
.wolai-search-close{width:28px;height:28px;border:0;border-radius:4px;background:transparent;color:#8B8780;cursor:pointer;font-size:20px;line-height:1}
.wolai-search-options{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px 8px;border-bottom:1px solid rgba(27,28,28,.06);color:#A7A39D;font-size:12px;line-height:1}
.wolai-search-options-left,.wolai-search-options-right,.wolai-search-switch-control,.wolai-search-sort-control{display:inline-flex;align-items:center}
.wolai-search-options-left{min-width:0;flex-wrap:wrap;gap:8px 12px}
.wolai-search-options-right{margin-left:auto;flex:0 0 auto}
.wolai-search-switch-control,.wolai-search-sort-control{gap:5px;white-space:nowrap}
.wolai-search-switch,.wolai-search-sort-value{border:0;background:transparent;cursor:pointer;font:inherit;letter-spacing:0}
.wolai-search-switch{position:relative;width:28px;height:16px;border-radius:999px;background:#D6D4D0;box-shadow:inset 0 0 0 1px rgba(27,28,28,.04)}
.wolai-search-switch::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:999px;background:#FFF;box-shadow:0 1px 2px rgba(15,23,42,.22)}
.wolai-search-switch.is-on{background:#C9C7C3}
.wolai-search-switch.is-on::after{transform:translateX(12px)}
.wolai-search-sort-value{display:inline-flex;align-items:center;gap:3px;color:#6D6A65}
.wolai-search-sort-value::after{content:"⌄";color:#B8B4AD;font-size:13px;line-height:1}
.wolai-search-result-meta{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 16px;color:#8B8780;font-size:12px;line-height:1.3;border-bottom:1px solid rgba(27,28,28,.06)}
.wolai-search-results{overflow:auto;padding:6px}
.wolai-search-result-row{width:100%;min-height:54px;display:flex;align-items:flex-start;gap:10px;padding:9px 10px;border:0;border-radius:4px;background:transparent;color:#37352F;cursor:pointer;text-align:left;font:inherit}
.wolai-search-result-row:hover{background:rgba(55,53,47,.08)}
.wolai-search-result-icon{width:18px;height:18px;margin-top:2px;color:#8B8780}
.wolai-search-result-main{min-width:0;display:flex;flex-direction:column;gap:3px}
.wolai-search-result-title{color:#2F2D29;font-size:14px;line-height:1.35;word-break:break-word}
.wolai-search-result-title mark{padding:0 1px;border-radius:2px;background:#FFE9E6;color:#D83A32}
.wolai-search-result-snippet,.wolai-search-empty{color:#8B8780;font-size:12px;line-height:1.35}
.wolai-search-empty{padding:28px 12px 32px;text-align:center}
@media (max-width: 768px) {
.mnote-sidebar {
width: 200px;
@@ -987,7 +1020,7 @@ a:hover {
/* ===== Stitch 260429 UI parity overrides ===== */
:root {
--atelier-surface: #FAF9F9;
--atelier-sidebar: #F4F3F3;
--atelier-sidebar: #F5F5F5;
--atelier-sidebar-hover: #ECEBE9;
--atelier-sidebar-active: #E3E2E2;
--atelier-document: #FFFFFF;
@@ -1031,26 +1064,26 @@ body {
.mnote-sidebar,
.wolai-sidebar {
width: 240px;
width: 248px;
background: var(--atelier-sidebar);
border-right: 0;
box-shadow: inset -1px 0 0 rgba(27, 28, 28, 0.06);
box-shadow: none;
overflow-x: hidden;
}
.mnote-sidebar-header,
.wolai-sidebar-header {
height: 68px;
padding: 16px 12px 8px;
gap: 10px;
height: 40px;
padding: 8px 12px;
gap: 8px;
}
.wolai-avatar {
width: 32px;
height: 32px;
width: 24px;
height: 24px;
border-radius: 5px;
background: #D6534D;
font-size: 17px;
font-size: 14px;
font-weight: 650;
}
@@ -1058,9 +1091,9 @@ body {
.sidebar-workspace-name {
max-width: 160px;
color: var(--atelier-text);
font-size: 17px;
font-size: 16px;
font-weight: 700;
line-height: 1.1;
line-height: 24px;
}
.wolai-sidebar-chevron {
@@ -1070,8 +1103,8 @@ body {
.wolai-quick-actions {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
padding: 8px 16px 26px;
gap: 10.4px;
padding: 5px 8px 11px;
}
.wolai-quick-actions a,
@@ -1080,9 +1113,9 @@ body {
}
.wolai-quick-actions a {
height: 28px;
height: 30px;
color: #3F3D39;
font-size: 19px;
font-size: 14px;
}
.wolai-quick-actions a:hover {
@@ -1099,13 +1132,14 @@ body {
.wolai-section-title,
.wolai-sidebar-tabs {
height: 30px;
height: 32px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
color: var(--atelier-text-muted);
font-size: 14px;
font-size: 16px;
line-height: 24px;
font-weight: 500;
}
@@ -1237,13 +1271,14 @@ body {
}
.sidebar-tree .tree-row {
height: 29px;
gap: 5px;
margin: 1px 4px;
padding: 0 8px;
height: 32px;
gap: 0;
margin: 0 5px;
padding: 0;
border-radius: 4px;
color: #2D2E2E;
font-size: 14px;
font-size: 16px;
line-height: 24px;
}
.sidebar-tree .tree-row:hover {
@@ -1252,8 +1287,8 @@ body {
.sidebar-tree .tree-row[data-active="true"],
.sidebar-tree .tree-row[data-selected="true"] {
background: var(--atelier-sidebar-active);
color: var(--atelier-text);
background: rgba(255, 71, 71, 0.1);
color: #E0525B;
}
.sidebar-tree .tree-row[data-drop-feedback="true"],
@@ -1265,16 +1300,44 @@ body {
.sidebar-tree .tree-toggle,
.sidebar-tree .tree-spacer {
width: 16px;
height: 22px;
width: 20px;
height: 24px;
color: #B8B5AF;
font-size: 13px;
font-size: 16px;
}
.sidebar-tree .tree-toggle {
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.sidebar-tree .tree-toggle:hover {
background: rgba(27, 28, 28, 0.06);
}
.sidebar-tree .tree-toggle-icon {
width: 20px;
height: 20px;
display: block;
fill: #878787;
transform-origin: 50% 50%;
}
.sidebar-tree .tree-toggle[aria-expanded="true"] .tree-toggle-icon {
transform: rotate(90deg);
}
.sidebar-tree .tree-row[data-active="true"] .tree-toggle-icon {
fill: #CF5659;
}
.sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] {
display: none;
}
.sidebar-tree .tree-kind-badge {
width: 20px;
height: 20px;
@@ -1295,7 +1358,7 @@ body {
}
.sidebar-tree .tree-kind-badge[data-kind="page"]::before {
content: "";
content: "";
}
.sidebar-tree[data-tree-shell-mode="filetree"] .tree-kind-badge[data-kind="page"]::before,
@@ -1333,7 +1396,7 @@ body {
.sidebar-tree .tree-link {
padding: 0 2px;
color: inherit;
font-size: 14px;
font-size: 16px;
}
.sidebar-tree .tree-link-title {
@@ -1341,17 +1404,18 @@ body {
}
.sidebar-tree .tree-row[data-active="true"] .tree-link-title {
font-weight: 500;
font-weight: 400;
}
.sidebar-tree .tree-actions {
gap: 1px;
margin-left: auto;
}
.sidebar-tree .tree-action {
width: 20px;
height: 20px;
color: #9A968F;
width: 24px;
height: 24px;
color: #B3B3B3;
border-radius: 4px;
}
@@ -1363,31 +1427,33 @@ body {
.mnote-tree-context-menu {
position: fixed;
z-index: 1000;
min-width: 236px;
width: 220px;
min-width: 220px;
max-width: min(320px, calc(100vw - 16px));
padding: 6px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 16px 32px rgba(27, 28, 28, 0.12);
border-radius: 6px;
background: #FFFFFF;
box-shadow: 0 8px 20px rgba(27, 28, 28, 0.12);
backdrop-filter: blur(24px);
color: var(--atelier-text);
}
.mnote-tree-context-menu__item {
width: 100%;
min-height: 34px;
height: 30px;
min-height: 30px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 9px;
gap: 8px;
padding: 4px 8px;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
font: inherit;
font-size: 14px;
line-height: 1.2;
line-height: 22px;
text-align: left;
cursor: pointer;
}
@@ -1439,7 +1505,7 @@ body {
.mnote-tree-context-menu__separator {
height: 1px;
margin: 5px 4px;
margin: 6px 0;
background: rgba(27, 28, 28, 0.08);
}
@@ -1468,7 +1534,7 @@ body {
}
.wolai-sidebar-footer {
border-top: 1px solid rgba(27, 28, 28, 0.08);
border-top: 0;
background: var(--atelier-sidebar);
}
@@ -45,6 +45,8 @@ pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRo
.collect()
}
const PAGE_TREE_CHEVRON_SVG: &str = r#"<svg class="tree-toggle-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false"><path d="M7.84 14.955c.206 0 .37-.07.505-.21l4.277-4.179a.79.79 0 0 0 .264-.574.78.78 0 0 0-.258-.574L8.35 5.24a.7.7 0 0 0-.51-.21.721.721 0 0 0-.498 1.247l3.814 3.721-3.814 3.709a.721.721 0 0 0 .498 1.248"></path></svg>"#;
fn escape_html(input: &str) -> String {
input
.replace('&', "&amp;")
@@ -54,6 +56,26 @@ fn escape_html(input: &str) -> String {
.replace('\'', "&#39;")
}
fn render_depth_for_node(rows: &[PageTreeRenderRow], node_id: &str, fallback_depth: u32) -> u32 {
if fallback_depth > 0 {
return fallback_depth;
}
let parent_by_id = rows
.iter()
.map(|row| (row.node_id.as_str(), row.parent_node_id.as_deref()))
.collect::<BTreeMap<_, _>>();
let mut depth = 0_u32;
let mut cursor = parent_by_id.get(node_id).and_then(|parent| *parent);
while let Some(parent_id) = cursor {
depth += 1;
cursor = parent_by_id.get(parent_id).and_then(|parent| *parent);
if depth > 32 {
break;
}
}
depth
}
fn render_page_row(
html: &mut String,
row: &PageTreeRenderRow,
@@ -80,11 +102,12 @@ fn render_page_row(
.unwrap_or(false);
let toggle_html = if row.expandable {
format!(
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}">{marker}</button>"#,
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}" aria-expanded="{expanded_state}">{marker}</button>"#,
node_id = escape_html(&row.node_id),
label = if expanded { "折叠" } else { "展开" },
title = escape_html(&row.title),
marker = if expanded { "" } else { "" },
expanded_state = if expanded { "true" } else { "false" },
marker = PAGE_TREE_CHEVRON_SVG,
)
} else {
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
@@ -96,15 +119,18 @@ fn render_page_row(
.and_then(|source| source.parent_node_id.as_deref())
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let render_depth = render_depth_for_node(&input.rows, &row.node_id, row.depth);
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="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_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="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作"></button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
aria_level = render_depth + 1,
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
test_id = row.test_id,
parent_attr = parent_attr,
depth = row.depth,
depth = render_depth,
active = active,
selected = active,
current_attr = if active { r#" aria-current="page""# } else { "" },
focused = focused,
tab_index = if focused { "0" } else { "-1" },
toggle_html = toggle_html,