收口 Rust Web 入口与 AI 写入链

- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。

- 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。

- 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。

- 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
lix-2026
2026-05-06 21:44:20 +08:00
parent 98b6360595
commit e8ba12e461
86 changed files with 8872 additions and 1716 deletions
+209 -10
View File
@@ -1,21 +1,220 @@
//! MNOTE 登录页面组件
use super::layout::PageLayout;
use leptos::prelude::*;
const TEST_ACCOUNT_EMAIL: &str = "mnote.e2e@example.com";
const TEST_ACCOUNT_PASSWORD: &str = "MnoteE2E123!";
const TEST_ACCOUNT_NAME: &str = "mnote-e2e";
/// MNOTE 登录页面
///
/// 当 legacy compat 关闭时由 `auth_entry` 路由使用。
#[component]
pub fn AuthPage() -> impl IntoView {
view! {
<PageLayout current_nav="home">
<section class="mnote-auth">
<h1>MNOTE </h1>
<p>
"此页面由 mnote-web gateway 提供。"
</p>
<main class="mnote-auth" data-testid="mnote-auth-page">
<a class="mnote-auth-brand" href="/auth" aria-label="MNOTE">
<span class="mnote-auth-brand-mark" aria-hidden="true">"M"</span>
<span>"MNOTE"</span>
</a>
<section class="mnote-auth-panel" aria-labelledby="mnote-auth-title">
<div class="mnote-auth-heading">
<h1 id="mnote-auth-title">"邮箱登录"</h1>
<p>"登录后进入你的工作区"</p>
</div>
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="convex-password">
<input type="hidden" name="action" value="auth:signIn" />
<input type="hidden" name="provider" value="password" />
<input type="hidden" name="flow" value="signIn" data-auth-flow />
<label class="mnote-auth-field">
<span>"邮箱"</span>
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="请输入邮箱"
required
/>
</label>
<label class="mnote-auth-field mnote-auth-username" hidden>
<span>"用户名"</span>
<input
id="username"
name="username"
type="text"
autocomplete="username"
placeholder="用于显示的用户名"
/>
</label>
<label class="mnote-auth-field">
<span>"密码"</span>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
placeholder="请输入密码"
minlength="8"
required
/>
</label>
<p class="mnote-auth-message" role="status" aria-live="polite" data-auth-message></p>
<button class="mnote-auth-submit" type="submit" data-auth-submit>"登录"</button>
<button
class="mnote-auth-quick-login"
type="button"
data-auth-test-login
data-test-email=TEST_ACCOUNT_EMAIL
data-test-password=TEST_ACCOUNT_PASSWORD
data-test-name=TEST_ACCOUNT_NAME
>
"测试账号快速登录"
</button>
</form>
<button class="mnote-auth-switch" type="button" data-auth-switch data-flow="signIn">
"没有账号?注册"
</button>
</section>
</PageLayout>
<script>{AUTH_SCRIPT}</script>
</main>
}
}
const AUTH_SCRIPT: &str = r#"
(function () {
var root = document.querySelector('[data-testid="mnote-auth-page"]');
if (!root) return;
var form = root.querySelector('form[data-auth-mode="convex-password"]');
var flowInput = root.querySelector('[data-auth-flow]');
var usernameField = root.querySelector('.mnote-auth-username');
var usernameInput = root.querySelector('#username');
var title = root.querySelector('#mnote-auth-title');
var subtitle = root.querySelector('.mnote-auth-heading p');
var submit = root.querySelector('[data-auth-submit]');
var quickLogin = root.querySelector('[data-auth-test-login]');
var switcher = root.querySelector('[data-auth-switch]');
var message = root.querySelector('[data-auth-message]');
if (!form || !flowInput || !submit || !quickLogin || !switcher || !message) return;
function setMessage(text, type) {
message.textContent = text || '';
message.dataset.type = type || '';
}
function setBusy(isBusy) {
submit.disabled = !!isBusy;
quickLogin.disabled = !!isBusy;
switcher.disabled = !!isBusy;
}
function setFlow(nextFlow) {
var isSignUp = nextFlow === 'signUp';
flowInput.value = nextFlow;
switcher.dataset.flow = nextFlow;
if (title) title.textContent = isSignUp ? '创建账号' : '邮箱登录';
if (subtitle) subtitle.textContent = isSignUp ? '创建账号后进入你的工作区' : '登录后进入你的工作区';
submit.textContent = isSignUp ? '注册并登录' : '登录';
switcher.textContent = isSignUp ? '已有账号?登录' : '没有账号?注册';
if (usernameField) usernameField.hidden = !isSignUp;
if (usernameInput) usernameInput.required = isSignUp;
quickLogin.hidden = isSignUp;
var passwordInput = root.querySelector('#password');
if (passwordInput) passwordInput.autocomplete = isSignUp ? 'new-password' : 'current-password';
setMessage('', '');
}
function buildPayload(flow, email, password, username) {
var payload = {
action: 'auth:signIn',
args: {
provider: 'password',
params: {
email: email,
password: password,
flow: flow
}
}
};
if (flow === 'signUp' && username) {
payload.args.params.name = username;
}
return payload;
}
async function requestAuth(flow, email, password, username) {
var response = await fetch('/api/auth', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify(buildPayload(flow, email, password, username))
});
var data = await response.json().catch(function () { return {}; });
if (!response.ok || data.error) {
throw new Error(data.error || '登录失败,请重试');
}
return data;
}
switcher.addEventListener('click', function () {
setFlow(flowInput.value === 'signIn' ? 'signUp' : 'signIn');
});
form.addEventListener('submit', async function (event) {
event.preventDefault();
var email = String((root.querySelector('#email') || {}).value || '').trim();
var password = String((root.querySelector('#password') || {}).value || '');
var username = String((usernameInput || {}).value || '').trim();
var flow = flowInput.value === 'signUp' ? 'signUp' : 'signIn';
if (!email || !password || (flow === 'signUp' && !username)) {
setMessage('请完整填写信息', 'error');
return;
}
setBusy(true);
setMessage(flow === 'signUp' ? '正在创建账号...' : '正在登录...', 'info');
try {
await requestAuth(flow, email, password, username);
setMessage('登录成功,正在进入工作区...', 'success');
window.location.assign('/');
} catch (error) {
setMessage(error && error.message ? error.message : '登录失败,请重试', 'error');
setBusy(false);
}
});
quickLogin.addEventListener('click', async function () {
var email = String(quickLogin.dataset.testEmail || '').trim();
var password = String(quickLogin.dataset.testPassword || '');
var username = String(quickLogin.dataset.testName || '').trim();
if (!email || !password || !username) {
setMessage('测试账号配置缺失,请联系开发者', 'error');
return;
}
var emailInput = root.querySelector('#email');
var passwordInput = root.querySelector('#password');
if (emailInput) emailInput.value = email;
if (passwordInput) passwordInput.value = password;
setBusy(true);
setMessage('正在登录测试账号...', 'info');
try {
await requestAuth('signIn', email, password, username);
setMessage('登录成功,正在进入工作区...', 'success');
window.location.assign('/');
return;
} catch (_signInError) {
setMessage('测试账号不存在,正在自动创建...', 'info');
}
try {
await requestAuth('signUp', email, password, username);
setMessage('测试账号已创建,正在进入工作区...', 'success');
window.location.assign('/');
} catch (error) {
setMessage(error && error.message ? error.message : '测试账号登录失败,请重试', 'error');
setBusy(false);
}
});
setFlow('signIn');
})();
"#;
@@ -5,6 +5,7 @@
use crate::ssr::pages::layout::PageLayout;
use leptos::prelude::*;
use serde_json::Value;
/// MNOTE 文档页面
///
@@ -33,6 +34,9 @@ pub fn DocumentPage(
/// Page Aggregate 子树 JSON(可选)
#[prop(optional)]
page_subtree_json: Option<String>,
/// Page Aggregate 页面选项 JSON(可选)
#[prop(optional)]
page_options_json: Option<String>,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
@@ -44,9 +48,45 @@ pub fn DocumentPage(
.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 = page_options
.get("showHeadingNumbers")
.and_then(Value::as_bool)
.unwrap_or(true);
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()}>
<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()}
>
<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>
+859 -12
View File
@@ -17,6 +17,15 @@ const SIDEBAR_TREE_JS: &str = r##"
var activeFileTreeDropRow = null;
var projectionRefreshTimer = 0;
var activeTreeContextMenu = null;
var pageUiState = {
pageOptions: null,
historySnapshots: [],
pageSettingsOpen: false,
pageAiOpen: false,
pageAiBusy: false,
pageAiMessages: [],
pageAiSuggestionIndex: 0
};
function closestAction(target, selector) {
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
@@ -36,11 +45,178 @@ const SIDEBAR_TREE_JS: &str = r##"
return String(value).replace(/["\\]/g, '\\$&');
}
function parseJsonScript(id) {
var node = document.getElementById(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || 'null');
} catch (_) {
return null;
}
}
function currentDocumentId() {
var match = window.location.pathname.match(/^\/documents\/([^\/]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function currentPageAggregate() {
return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {};
}
function defaultPageOptions() {
return {
wideLayout: false,
smallText: false,
layoutDensity: 'normal',
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: 'default',
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null
};
}
function currentPageOptions() {
if (pageUiState.pageOptions) return pageUiState.pageOptions;
var aggregate = currentPageAggregate();
var current = aggregate && aggregate.layout && aggregate.layout.pageOptions && typeof aggregate.layout.pageOptions === 'object'
? aggregate.layout.pageOptions
: null;
if (!current) {
return defaultPageOptions();
}
pageUiState.pageOptions = Object.assign(defaultPageOptions(), current);
return pageUiState.pageOptions;
}
function pageOptionIsSupported(name) {
return name === 'wideLayout'
|| name === 'smallText'
|| name === 'layoutDensity'
|| name === 'pageFont'
|| name === 'showHeadingNumbers';
}
function pageOptionDescription(name) {
if (name === 'wideLayout') return '自适应宽度';
if (name === 'smallText') return '小字体';
if (name === 'showToc') return '标题目录';
if (name === 'showHeadingNumbers') return '标题自动编号';
if (name === 'protectEditing') return '编辑保护';
if (name === 'collapseBacklinks') return '折叠反向引用';
if (name === 'hideChildPages') return '隐藏子页面';
if (name === 'showBlockRefCount') return '显示块引用数字';
return name;
}
function pageOptionHint(name) {
if (name === 'wideLayout') return '已接通:主内容列宽度立即变化';
if (name === 'smallText') return '已接通:正文排版会更紧凑';
if (name === 'showHeadingNumbers') return '已接通:标题前显示顺序编号';
if (name === 'layoutDensity') return '已接通:段落与列表间距会变化';
if (name === 'pageFont') return '已接通:当前页面字体会切换';
if (name === 'showToc') return '待接线:当前 Rust 壳还没有正式目录面板';
if (name === 'protectEditing') return '待接线:当前主编辑器只显示降级说明';
if (name === 'collapseBacklinks') return '待接线:当前 Rust 壳未挂回链面板';
if (name === 'hideChildPages') return '待接线:当前页面壳还没有子页面块显隐';
if (name === 'showBlockRefCount') return '待接线:当前页面壳未显示块引用计数';
return '待接线';
}
function ensureHistorySnapshotsSeeded() {
if (pageUiState.historySnapshots.length > 0) return;
var aggregate = currentPageAggregate();
var stats = aggregate && aggregate.stats && typeof aggregate.stats === 'object' ? aggregate.stats : {};
pageUiState.historySnapshots = [{
id: 'snapshot-initial',
timestamp: Date.now(),
stats: {
wordCount: Number(stats.wordCount || 0),
characterCount: Number(stats.characterCount || 0),
blockCount: Number(stats.blockCount || 0),
todoTotal: Number(stats.todoTotal || 0),
todoDone: Number(stats.todoDone || 0)
}
}];
}
function computeLivePageStats() {
var aggregate = currentPageAggregate();
var fallbackStats = aggregate && aggregate.stats && typeof aggregate.stats === 'object' ? aggregate.stats : {};
var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) {
return {
wordCount: Number(fallbackStats.wordCount || 0),
characterCount: Number(fallbackStats.characterCount || 0),
blockCount: Number(fallbackStats.blockCount || 0),
todoTotal: Number(fallbackStats.todoTotal || 0),
todoDone: Number(fallbackStats.todoDone || 0)
};
}
var text = (editor.textContent || '').trim();
var compact = text.replace(/\s+/g, ' ').trim();
var wordCount = compact ? compact.split(' ').filter(Boolean).length : 0;
var characterCount = text.replace(/\s/g, '').length;
var blockCount = editor.querySelectorAll(':scope > *').length;
var todos = Array.from(editor.querySelectorAll('input[type="checkbox"]'));
return {
wordCount: wordCount || Number(fallbackStats.wordCount || 0),
characterCount: characterCount || Number(fallbackStats.characterCount || 0),
blockCount: blockCount || Number(fallbackStats.blockCount || 0),
todoTotal: todos.length || Number(fallbackStats.todoTotal || 0),
todoDone: todos.filter(function(node){ return node.checked; }).length || Number(fallbackStats.todoDone || 0)
};
}
function recordPageHistorySnapshot(reason, stats) {
ensureHistorySnapshotsSeeded();
pageUiState.historySnapshots = [{
id: 'snapshot-' + Date.now(),
timestamp: Date.now(),
reason: reason || 'save',
stats: stats || computeLivePageStats()
}].concat(pageUiState.historySnapshots).slice(0, 15);
renderPageHistoryDrawer();
}
function applyPageOptionsToShell() {
var options = currentPageOptions();
var shell = document.querySelector('.document-shell');
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
shell.style.width = '100%';
shell.style.maxWidth = options.wideLayout ? '980px' : '760px';
}
if (editorRoot instanceof HTMLElement) {
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
editorRoot.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
}
if (editorSurface instanceof HTMLElement) {
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
editorSurface.setAttribute('data-page-font', String(options.pageFont || 'default'));
editorSurface.setAttribute('data-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
}
document.documentElement.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
document.documentElement.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default'));
document.documentElement.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
}
function normalizeSidebarTreeMode(value) {
var mode = String(value || '').trim();
return mode === 'filetree' ? 'filetree' : 'page';
@@ -131,8 +307,11 @@ const SIDEBAR_TREE_JS: &str = r##"
});
document.querySelectorAll('.tree-row[data-node-id="' + cssEscape(nodeId) + '"], .tree-row[data-document-id="' + cssEscape(nodeId) + '"], .tree-row[data-doc-id="' + cssEscape(nodeId) + '"]').forEach(function(row) {
if (row instanceof HTMLElement) {
if (row.getAttribute('data-shell-mode') === 'filetree') row.setAttribute('data-selected', 'true');
else row.setAttribute('data-active', 'true');
if (row.getAttribute('data-shell-mode') === 'filetree') {
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === 'index:' + nodeId));
} else {
row.setAttribute('data-active', 'true');
}
}
});
return;
@@ -195,10 +374,10 @@ const SIDEBAR_TREE_JS: &str = r##"
function updateTitleEverywhere(documentId, title) {
if (!documentId) return;
var escaped = cssEscape(documentId);
var escapedDocRowId = cssEscape('doc:' + documentId);
var selectors = [
'.tree-row[data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-doc-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title',
'.wolai-page-row[data-node-id="' + escaped + '"] > .wolai-row-title',
'a[href="/documents/' + escaped + '"] > .wolai-row-title',
'a[href^="/documents/' + escaped + '?"] > .wolai-row-title'
@@ -322,7 +501,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && item.expandedByDefault !== false;
var selected = rowId === 'doc:' + activeId || rowId === 'index:' + activeId || documentId === activeId;
var selected = rowId === 'index:' + activeId;
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
var toggle = expandable
? '<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="' + escapeHtml(rowId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
@@ -846,10 +1025,624 @@ const SIDEBAR_TREE_JS: &str = r##"
else openSearchModal();
}
function updatePageSettingsTriggerState() {
var trigger = document.querySelector('[data-testid="wolai-page-settings-trigger"]');
if (!(trigger instanceof HTMLElement)) return;
trigger.setAttribute('data-state', pageUiState.pageSettingsOpen ? 'open' : 'closed');
trigger.setAttribute('aria-expanded', pageUiState.pageSettingsOpen ? 'true' : 'false');
}
function updatePageAiTriggerState() {
var trigger = document.querySelector('[data-testid="wolai-floating-ai"]');
if (!(trigger instanceof HTMLElement)) return;
trigger.setAttribute('data-state', pageUiState.pageAiOpen ? 'open' : 'closed');
trigger.setAttribute('aria-expanded', pageUiState.pageAiOpen ? 'true' : 'false');
}
function createPageOptionRow(key, type) {
var inputType = type || 'checkbox';
var supported = pageOptionIsSupported(key);
if (inputType === 'checkbox') {
return '' +
'<label class="wolai-page-setting-row' + (supported ? '' : ' is-pending') + '" data-page-setting-row="' + key + '">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">' + escapeHtml(pageOptionDescription(key)) + '</span>' +
'<span class="wolai-page-setting-hint">' + escapeHtml(pageOptionHint(key)) + '</span>' +
'</span>' +
'<input type="checkbox" class="wolai-page-setting-checkbox" data-page-option-checkbox="' + key + '"' + (supported ? '' : ' data-setting-pending="true"') + ' />' +
'</label>';
}
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="' + key + '">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">' + escapeHtml(pageOptionDescription(key)) + '</span>' +
'<span class="wolai-page-setting-hint">' + escapeHtml(pageOptionHint(key)) + '</span>' +
'</span>' +
'<select class="wolai-page-setting-select" data-page-option-select="' + key + '">' +
'<option value="compact">紧凑</option>' +
'<option value="normal">默认</option>' +
'<option value="spacious">宽松</option>' +
'</select>' +
'</label>';
}
function createPageFontRow() {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="pageFont">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">页面字体</span>' +
'<span class="wolai-page-setting-hint">已接通:仅对当前页面生效</span>' +
'</span>' +
'<select class="wolai-page-setting-select" data-page-option-select="pageFont">' +
'<option value="default">默认</option>' +
'<option value="song">宋体</option>' +
'<option value="kai">楷体</option>' +
'</select>' +
'</label>';
}
function ensurePageHistoryDrawer() {
var existing = document.querySelector('[data-testid="wolai-page-history-drawer"]');
if (existing instanceof HTMLElement) return existing;
var drawer = document.createElement('aside');
drawer.className = 'wolai-page-history-drawer';
drawer.setAttribute('data-testid', 'wolai-page-history-drawer');
drawer.setAttribute('data-mnote-surface', 'page-history');
drawer.hidden = true;
drawer.innerHTML = '' +
'<div class="wolai-page-history-panel">' +
'<div class="wolai-page-history-header">' +
'<div><div class="wolai-page-history-title">页面历史</div><div class="wolai-page-history-subtitle">当前会话内最近保存的 15 个快照。</div></div>' +
'<button type="button" class="wolai-surface-close" data-page-history-action="close" aria-label="关闭页面历史">×</button>' +
'</div>' +
'<div class="wolai-page-history-list" data-page-history-list></div>' +
'</div>';
drawer.addEventListener('click', function(event) {
if (event.target === drawer) closePageHistoryDrawer();
});
document.body.appendChild(drawer);
return drawer;
}
function renderPageHistoryDrawer() {
var drawer = ensurePageHistoryDrawer();
var list = drawer.querySelector('[data-page-history-list]');
if (!(list instanceof HTMLElement)) return;
ensureHistorySnapshotsSeeded();
list.innerHTML = pageUiState.historySnapshots.length
? pageUiState.historySnapshots.map(function(snapshot) {
var stats = snapshot.stats || {};
var label = new Date(snapshot.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return '' +
'<div class="wolai-page-history-item">' +
'<div class="wolai-page-history-item-copy">' +
'<div class="wolai-page-history-item-title">' + escapeHtml(label) + '</div>' +
'<div class="wolai-page-history-item-meta">字数 ' + Number(stats.wordCount || 0) + ' · 字符 ' + Number(stats.characterCount || 0) + ' · 块数 ' + Number(stats.blockCount || 0) + '</div>' +
'</div>' +
'<button type="button" class="wolai-page-history-item-ghost" data-page-history-action="noop">仅查看</button>' +
'</div>';
}).join('')
: '<div class="wolai-page-history-empty">尚未产生历史快照,编辑后会自动生成。</div>';
}
function openPageHistoryDrawer() {
renderPageHistoryDrawer();
var drawer = ensurePageHistoryDrawer();
drawer.hidden = false;
document.documentElement.setAttribute('data-mnote-page-history-open', 'true');
}
function closePageHistoryDrawer() {
var drawer = document.querySelector('[data-testid="wolai-page-history-drawer"]');
if (drawer instanceof HTMLElement) drawer.hidden = true;
document.documentElement.removeAttribute('data-mnote-page-history-open');
}
function ensurePageShareDialog() {
var existing = document.querySelector('[data-testid="wolai-page-share-dialog"]');
if (existing instanceof HTMLElement) return existing;
var dialog = document.createElement('div');
dialog.className = 'wolai-page-share-dialog';
dialog.setAttribute('data-testid', 'wolai-page-share-dialog');
dialog.setAttribute('data-mnote-surface', 'page-share');
dialog.hidden = true;
dialog.innerHTML = '' +
'<div class="wolai-page-share-card" role="dialog" aria-modal="true">' +
'<div class="wolai-page-share-header">' +
'<div><div class="wolai-page-share-title">公开分享页面</div><div class="wolai-page-share-subtitle">当前 3000 公开入口由 mnote-web 持有。</div></div>' +
'<button type="button" class="wolai-surface-close" data-page-share-action="close" aria-label="关闭公开分享页面">×</button>' +
'</div>' +
'<div class="wolai-page-share-state">' +
'<span class="wolai-public-pill wolai-public-pill--inline">全网公开</span>' +
'<span class="wolai-page-share-copy">任何拥有链接的人都可以访问当前页面。</span>' +
'</div>' +
'<div class="wolai-page-share-url-row">' +
'<input class="wolai-page-share-url" type="text" readonly data-page-share-url value="" />' +
'<button type="button" class="wolai-page-share-copy-button" data-page-share-action="copy-link">复制链接</button>' +
'</div>' +
'<div class="wolai-page-share-footer">更多共享者、群组公开和权限策略仍待接线。</div>' +
'</div>';
dialog.addEventListener('click', function(event) {
if (event.target === dialog) closePageShareDialog();
});
document.body.appendChild(dialog);
return dialog;
}
function openPageShareDialog() {
var dialog = ensurePageShareDialog();
var input = dialog.querySelector('[data-page-share-url]');
if (input instanceof HTMLInputElement) input.value = window.location.href;
dialog.hidden = false;
document.documentElement.setAttribute('data-mnote-page-share-open', 'true');
}
function closePageShareDialog() {
var dialog = document.querySelector('[data-testid="wolai-page-share-dialog"]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
document.documentElement.removeAttribute('data-mnote-page-share-open');
}
function pageAiSuggestions() {
var title = searchText(document.querySelector('[data-page-title-current="true"]')?.textContent) || '当前页面';
return [
'帮我总结《' + title + '》当前内容',
'把当前页面改写得更简洁一些',
'提炼当前页的关键待办和行动项',
'基于当前页内容生成一个三段式摘要'
];
}
function ensurePageAiDrawer() {
var existing = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
if (existing instanceof HTMLElement) return existing;
var drawer = document.createElement('aside');
drawer.className = 'wolai-page-ai-drawer';
drawer.setAttribute('data-testid', 'wolai-page-ai-drawer');
drawer.setAttribute('data-mnote-surface', 'page-ai');
drawer.hidden = true;
drawer.innerHTML = '' +
'<div class="wolai-page-ai-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-ai-header">' +
'<div class="wolai-page-ai-header-copy">' +
'<h2 class="wolai-page-ai-title" data-title="page-ai-title">智能问答</h2>' +
'<div class="wolai-page-ai-subtitle">当前页面上下文优先 · mnote-cli</div>' +
'</div>' +
'<button type="button" class="wolai-surface-close" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
'</div>' +
'<div class="wolai-page-ai-body">' +
'<div class="wolai-page-ai-suggestions">' +
'<div class="wolai-page-ai-suggestions-header">' +
'<span>推荐问题</span>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
'</div>' +
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
'</div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'</div>' +
'<div class="wolai-page-ai-footer">' +
'<div class="wolai-page-ai-toolbar">' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话">新会话</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-action="model">mnote-cli</button>' +
'</div>' +
'<div class="wolai-page-ai-input-row">' +
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
'<button type="button" class="wolai-page-ai-send" data-page-ai-action="send" aria-label="发送">↑</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(drawer);
return drawer;
}
function renderPageAiSuggestions() {
var drawer = ensurePageAiDrawer();
var list = drawer.querySelector('[data-page-ai-suggestion-list]');
if (!(list instanceof HTMLElement)) return;
var suggestions = pageAiSuggestions();
var offset = pageUiState.pageAiSuggestionIndex % suggestions.length;
var ordered = suggestions.slice(offset).concat(suggestions.slice(0, offset)).slice(0, 3);
list.innerHTML = ordered.map(function(text) {
return '<button type="button" class="wolai-page-ai-suggestion" data-page-ai-suggestion="' + escapeHtml(text) + '">' + escapeHtml(text) + '</button>';
}).join('');
}
function renderPageAiConversation() {
var drawer = ensurePageAiDrawer();
var conversation = drawer.querySelector('[data-page-ai-conversation]');
if (!(conversation instanceof HTMLElement)) return;
if (!pageUiState.pageAiMessages.length) {
conversation.innerHTML = '<div class="wolai-page-ai-empty">围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。</div>';
return;
}
conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) {
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '">' +
'<div class="wolai-page-ai-message-role">' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '</div>' +
'<div class="wolai-page-ai-message-text">' + escapeHtml(item.content || '') + '</div>' +
'</div>';
}).join('');
conversation.scrollTop = conversation.scrollHeight;
}
function openPageAiDrawer() {
renderPageAiSuggestions();
renderPageAiConversation();
var drawer = ensurePageAiDrawer();
drawer.hidden = false;
pageUiState.pageAiOpen = true;
updatePageAiTriggerState();
}
function closePageAiDrawer() {
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
if (drawer instanceof HTMLElement) drawer.hidden = true;
pageUiState.pageAiOpen = false;
updatePageAiTriggerState();
}
function isPageAiDrawerOpen() {
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
return drawer instanceof HTMLElement && !drawer.hidden;
}
async function streamPageAiResponse(response, onEvent) {
if (!response.body || typeof response.body.getReader !== 'function') return;
var reader = response.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
var frames = buffer.split('\n\n');
buffer = frames.pop() || '';
frames.forEach(function(frame) {
var eventName = '';
var dataLines = [];
frame.split('\n').forEach(function(line) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
});
if (eventName) onEvent(eventName, dataLines.join('\n'));
});
}
}
async function sendPageAiMessage(text) {
if (pageUiState.pageAiBusy) return;
var prompt = searchText(text);
if (!prompt) return;
var aggregate = currentPageAggregate();
var body = aggregate.body || {};
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
var outline = subtree && subtree.outline ? subtree.outline : null;
pageUiState.pageAiBusy = true;
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
renderPageAiConversation();
try {
var response = await fetch('/api/ai-agent/run', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
stream: true,
maxSteps: 8,
scope: 'document',
messages: [{ role: 'user', content: prompt }],
toolChoice: {
mode: 'auto',
toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write']
},
context: {
documentId: currentDocumentId(),
documentBlocks: body.content || null,
node: {
documentId: currentDocumentId(),
title: aggregate.head && aggregate.head.title ? aggregate.head.title : ''
},
subtree: subtree,
outline: outline,
evidence: null,
pageOptions: currentPageOptions()
},
options: {
searxng: true,
ai: { provider: 'hermes' }
}
})
});
if (!response.ok) {
throw new Error('page_ai_failed_' + response.status);
}
var assistantText = '';
await streamPageAiResponse(response, function(eventName, payloadText) {
if (eventName === 'assistant_message') {
try {
var payload = JSON.parse(payloadText || 'null');
assistantText = searchText(payload && payload.text);
} catch (_) {}
}
});
pageUiState.pageAiMessages.push({
role: 'assistant',
content: assistantText || '当前页面 AI 已接通 `mnote-cli`,但这次没有返回文本结果。'
});
} catch (error) {
pageUiState.pageAiMessages.push({
role: 'assistant',
content: '当前页面 AI 抽屉已接到 `mnote-cli` 主链,但本次请求失败:' + (error instanceof Error ? error.message : String(error))
});
} finally {
pageUiState.pageAiBusy = false;
renderPageAiConversation();
}
}
function ensurePageSettingsPopover() {
var existing = document.querySelector('[data-testid="wolai-page-settings-popover"]');
if (existing instanceof HTMLElement) return existing;
var popover = document.createElement('div');
popover.className = 'wolai-page-settings-popover';
popover.setAttribute('data-testid', 'wolai-page-settings-popover');
popover.setAttribute('data-mnote-surface', 'page-settings');
popover.hidden = true;
popover.innerHTML = '' +
'<div class="wolai-page-settings-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page">页面选项</button>' +
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom">自定义页面</button>' +
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global">全局选项</button>' +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
createPageOptionRow('wideLayout', 'checkbox') +
createPageOptionRow('smallText', 'checkbox') +
createPageOptionRow('showToc', 'checkbox') +
createPageOptionRow('showHeadingNumbers', 'checkbox') +
createPageOptionRow('protectEditing', 'checkbox') +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="custom" hidden>' +
createPageFontRow() +
createPageOptionRow('layoutDensity', 'select') +
createPageOptionRow('collapseBacklinks', 'checkbox') +
createPageOptionRow('hideChildPages', 'checkbox') +
createPageOptionRow('showBlockRefCount', 'checkbox') +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
'<div class="wolai-page-settings-global-note">当前 Rust Web 壳仅保留最小全局设置入口,真正的全局偏好仍待后续统一收口。</div>' +
'</div>' +
'<div class="wolai-page-settings-actions">' +
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="history">页面历史...</button>' +
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="share">公开分享页面...</button>' +
'<button type="button" class="wolai-page-settings-action is-disabled" data-page-settings-action="move" disabled>移动到...</button>' +
'<button type="button" class="wolai-page-settings-action is-disabled" data-page-settings-action="embed" disabled>嵌入到...</button>' +
'<button type="button" class="wolai-page-settings-action is-danger is-disabled" data-page-settings-action="delete" disabled>删除页面</button>' +
'</div>' +
'<div class="wolai-page-settings-stats" data-testid="wolai-page-settings-stats"></div>' +
'</div>';
document.body.appendChild(popover);
return popover;
}
function renderPageSettingsPopover() {
var popover = ensurePageSettingsPopover();
var options = currentPageOptions();
popover.querySelectorAll('[data-page-option-checkbox]').forEach(function(input) {
var key = input.getAttribute('data-page-option-checkbox');
input.checked = Boolean(options[key]);
input.disabled = !pageOptionIsSupported(key);
});
popover.querySelectorAll('[data-page-option-select]').forEach(function(select) {
var key = select.getAttribute('data-page-option-select');
var value = key === 'layoutDensity' ? String(options.layoutDensity || 'normal') : String(options.pageFont || 'default');
select.value = value;
});
var statsNode = popover.querySelector('[data-testid="wolai-page-settings-stats"]');
if (statsNode instanceof HTMLElement) {
var stats = computeLivePageStats();
statsNode.innerHTML = '' +
'<span>字数 ' + Number(stats.wordCount || 0) + '</span>' +
'<span>字符 ' + Number(stats.characterCount || 0) + '</span>' +
'<span>块数 ' + Number(stats.blockCount || 0) + '</span>' +
'<span>待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
}
}
function setActivePageSettingsTab(tabName) {
var popover = ensurePageSettingsPopover();
popover.querySelectorAll('[data-page-settings-tab]').forEach(function(tab) {
var active = tab.getAttribute('data-page-settings-tab') === tabName;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
});
}
async function persistPageOptionsPatch(patch) {
var previous = Object.assign({}, currentPageOptions());
pageUiState.pageOptions = Object.assign({}, previous, patch);
var nextOptions = Object.assign({}, pageUiState.pageOptions);
applyPageOptionsToShell();
renderPageSettingsPopover();
try {
var response = await fetch('/api/documents/options', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: currentDocumentId(),
workspaceId: resolveWorkspaceId(document.body),
options: nextOptions,
commandName: 'page.layout.updateOptions'
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'page_settings_save_failed_' + response.status);
}
document.documentElement.setAttribute('data-mnote-page-options-saved', 'true');
} catch (error) {
pageUiState.pageOptions = previous;
applyPageOptionsToShell();
renderPageSettingsPopover();
document.documentElement.setAttribute('data-mnote-page-options-error', error instanceof Error ? error.message : String(error));
}
}
function isPageSettingsOpen() {
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden;
}
function openPageSettingsPopover() {
if (!currentDocumentId()) return;
var popover = ensurePageSettingsPopover();
renderPageSettingsPopover();
setActivePageSettingsTab('page');
popover.hidden = false;
pageUiState.pageSettingsOpen = true;
updatePageSettingsTriggerState();
}
function closePageSettingsPopover() {
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
pageUiState.pageSettingsOpen = false;
updatePageSettingsTriggerState();
}
function togglePageSettingsPopover() {
if (isPageSettingsOpen()) closePageSettingsPopover();
else openPageSettingsPopover();
}
document.addEventListener('click', function(e) {
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
if (activeTreeContextMenu) closeTreeContextMenu();
var historyClose = closestAction(e.target, '[data-page-history-action="close"]');
if (historyClose) {
e.preventDefault();
closePageHistoryDrawer();
return;
}
var shareClose = closestAction(e.target, '[data-page-share-action="close"]');
if (shareClose) {
e.preventDefault();
closePageShareDialog();
return;
}
var shareCopy = closestAction(e.target, '[data-page-share-action="copy-link"]');
if (shareCopy) {
e.preventDefault();
void copyTreeContextValue(window.location.href, 'page-share-link');
return;
}
var pageHistoryTrigger = closestAction(e.target, '[data-mnote-action="open-page-history"]');
if (pageHistoryTrigger) {
e.preventDefault();
openPageHistoryDrawer();
return;
}
var pageSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-page-settings"]');
if (pageSettingsTrigger) {
e.preventDefault();
togglePageSettingsPopover();
return;
}
var pageSettingsPanel = closestAction(e.target, '.wolai-page-settings-panel');
if (isPageSettingsOpen() && !pageSettingsPanel) {
closePageSettingsPopover();
}
var pageSettingsTab = closestAction(e.target, '[data-page-settings-tab]');
if (pageSettingsTab) {
e.preventDefault();
setActivePageSettingsTab(pageSettingsTab.getAttribute('data-page-settings-tab') || 'page');
return;
}
var pageSettingsAction = closestAction(e.target, '[data-page-settings-action]');
if (pageSettingsAction) {
e.preventDefault();
var actionName = pageSettingsAction.getAttribute('data-page-settings-action') || '';
if (actionName === 'history') {
openPageHistoryDrawer();
return;
}
if (actionName === 'share') {
openPageShareDialog();
return;
}
return;
}
var pageAiTrigger = closestAction(e.target, '[data-mnote-action="open-page-ai"]');
if (pageAiTrigger) {
e.preventDefault();
openPageAiDrawer();
return;
}
var pageAiClose = closestAction(e.target, '[data-page-ai-action="close"]');
if (pageAiClose) {
e.preventDefault();
closePageAiDrawer();
return;
}
var pageAiRotate = closestAction(e.target, '[data-page-ai-action="rotate"]');
if (pageAiRotate) {
e.preventDefault();
pageUiState.pageAiSuggestionIndex += 1;
renderPageAiSuggestions();
return;
}
var pageAiSuggestion = closestAction(e.target, '[data-page-ai-suggestion]');
if (pageAiSuggestion) {
e.preventDefault();
var text = pageAiSuggestion.getAttribute('data-page-ai-suggestion') || '';
var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
if (inputNode instanceof HTMLTextAreaElement) {
inputNode.value = text;
inputNode.focus();
}
return;
}
var pageAiNewSession = closestAction(e.target, '[data-page-ai-action="new-session"]');
if (pageAiNewSession) {
e.preventDefault();
pageUiState.pageAiMessages = [];
renderPageAiConversation();
return;
}
var pageAiSend = closestAction(e.target, '[data-page-ai-action="send"]');
if (pageAiSend) {
e.preventDefault();
var input = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
if (input instanceof HTMLTextAreaElement) {
var message = input.value;
input.value = '';
void sendPageAiMessage(message);
}
return;
}
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
if (searchTrigger) {
e.preventDefault();
@@ -973,6 +1766,11 @@ const SIDEBAR_TREE_JS: &str = r##"
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && isPageSettingsOpen()) {
event.preventDefault();
closePageSettingsPopover();
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
event.preventDefault();
toggleSearchModal();
@@ -982,8 +1780,51 @@ const SIDEBAR_TREE_JS: &str = r##"
closeSearchModal();
closeTreeContextMenu();
}
if (event.key === 'Enter' && !event.shiftKey) {
var aiInput = closestAction(event.target, '[data-page-ai-input]');
if (aiInput instanceof HTMLTextAreaElement) {
event.preventDefault();
var text = aiInput.value;
aiInput.value = '';
void sendPageAiMessage(text);
}
}
});
document.addEventListener('change', function(event) {
var checkbox = closestAction(event.target, '[data-page-option-checkbox]');
if (checkbox instanceof HTMLInputElement) {
var key = checkbox.getAttribute('data-page-option-checkbox') || '';
if (!pageOptionIsSupported(key)) return;
var patch = {};
patch[key] = checkbox.checked;
void persistPageOptionsPatch(patch);
return;
}
var select = closestAction(event.target, '[data-page-option-select]');
if (select instanceof HTMLSelectElement) {
var selectKey = select.getAttribute('data-page-option-select') || '';
var next = {};
if (selectKey === 'layoutDensity') next.layoutDensity = select.value;
if (selectKey === 'pageFont') next.pageFont = select.value;
if (Object.keys(next).length) {
void persistPageOptionsPatch(next);
}
}
});
function initializePageUiSurfaces() {
pageUiState.pageOptions = null;
applyPageOptionsToShell();
updatePageSettingsTriggerState();
updatePageAiTriggerState();
ensureHistorySnapshotsSeeded();
}
window.__mnoteRecordPageHistorySnapshot = recordPageHistorySnapshot;
window.__mnoteApplyPageOptionsToShell = applyPageOptionsToShell;
setTimeout(initializePageUiSurfaces, 0);
function readPageDragNodeId(event) {
var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : '';
return (fromTransfer || draggingPageNodeId || '').trim();
@@ -1403,8 +2244,8 @@ pub fn PageLayout(
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" 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" title="更多" aria-label="更多"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="页面历史" aria-label="历史" data-mnote-action="open-page-history"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="页面选项和全局选项" aria-label="更多" data-testid="wolai-page-settings-trigger" data-mnote-action="open-page-settings"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
</div>
</header>
<article class="mnote-content">
@@ -1412,7 +2253,7 @@ pub fn PageLayout(
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button wolai-floating-button--help" aria-label="帮助"><span class="material-symbols-outlined" data-icon="help" aria-hidden="true"></span></button>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
</div>
</div>
</div>
@@ -1446,13 +2287,19 @@ mod tests {
fn sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates() {
assert!(SIDEBAR_TREE_JS.contains("openTreeContextMenu"));
assert!(SIDEBAR_TREE_JS.contains("mnote-tree-context-menu"));
assert!(SIDEBAR_TREE_JS.contains("复制访问链接(带标题)"));
assert!(SIDEBAR_TREE_JS.contains("复制访问链接"));
assert!(SIDEBAR_TREE_JS.contains("删除到垃圾桶"));
assert!(SIDEBAR_TREE_JS.contains(
".tree-row[data-node-id=\"' + escaped + '\"] > .tree-link > .tree-link-title"
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
assert!(SIDEBAR_TREE_JS.contains(
r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"#
));
assert!(!SIDEBAR_TREE_JS.contains(
r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
assert!(!SIDEBAR_TREE_JS
.contains("[data-node-id=\"' + cssEscape(documentId) + '\"] .tree-link-title"));
.contains(r#"[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title"#));
}
#[test]