Implement Rust web sidebar title and tree interactions
This commit is contained in:
@@ -19,6 +19,8 @@ pub fn DocumentPage(
|
||||
title: String,
|
||||
/// 文档 id
|
||||
document_id: String,
|
||||
/// 工作区 id
|
||||
workspace_id: String,
|
||||
/// 侧栏页面树 HTML(可选)
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
@@ -44,10 +46,27 @@ pub fn DocumentPage(
|
||||
.unwrap_or_else(|| "个人空间".to_string());
|
||||
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()}>
|
||||
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()} data-workspace-id={workspace_id.clone()}>
|
||||
<header class="document-shell-header">
|
||||
<div class="document-page-icon" aria-hidden="true">"⌂"</div>
|
||||
<h1>{title}</h1>
|
||||
<div class="document-page-icon" aria-hidden="true">
|
||||
<svg class="mnote-symbol mnote-symbol--document" data-icon="home" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M4 10.5 12 3l8 7.5"></path>
|
||||
<path d="M6.5 9.5V21h11V9.5"></path>
|
||||
<path d="M9.5 21v-6h5v6"></path>
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
@@ -4,20 +4,63 @@ use leptos::prelude::*;
|
||||
|
||||
const SIDEBAR_TREE_JS: &str = r##"
|
||||
(function(){
|
||||
if (window.__mnoteSidebarTreeRuntimeStarted) return;
|
||||
window.__mnoteSidebarTreeRuntimeStarted = true;
|
||||
|
||||
var PAGE_DRAG_MIME = 'application/x-mnote-page-tree-node';
|
||||
var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids';
|
||||
var mnoteNavigationInFlight = '';
|
||||
var draggingPageNodeId = '';
|
||||
var activePageDropRow = null;
|
||||
var draggingFileTreeRowIds = [];
|
||||
var activeFileTreeDropRow = null;
|
||||
var projectionRefreshTimer = 0;
|
||||
|
||||
function closestAction(target, selector) {
|
||||
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function currentDocumentId() {
|
||||
var match = window.location.pathname.match(/^\/documents\/([^\/]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
function resolveWorkspaceId(trigger) {
|
||||
var direct = (trigger.getAttribute('data-workspace-id') || '').trim();
|
||||
if (direct) return direct;
|
||||
var root = trigger.closest('[data-workspace-id]');
|
||||
return root ? (root.getAttribute('data-workspace-id') || '').trim() : '';
|
||||
if (root) {
|
||||
var value = (root.getAttribute('data-workspace-id') || '').trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
function setCommandPending(trigger, pending) {
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('data-pending', pending ? 'true' : 'false');
|
||||
if ('disabled' in trigger) {
|
||||
if (pending) trigger.setAttribute('disabled', 'disabled');
|
||||
else trigger.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchTreeCommand(trigger, body) {
|
||||
trigger.setAttribute('data-pending', 'true');
|
||||
trigger.setAttribute('disabled', 'disabled');
|
||||
setCommandPending(trigger, true);
|
||||
try {
|
||||
var response = await fetch('/api/tree/commands', {
|
||||
method: 'POST',
|
||||
@@ -28,15 +71,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (!response.ok || !payload || !payload.result) {
|
||||
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: body, result: payload.result } }));
|
||||
return payload.result;
|
||||
} catch (error) {
|
||||
trigger.removeAttribute('disabled');
|
||||
trigger.setAttribute('data-pending', 'false');
|
||||
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
|
||||
setCommandPending(trigger, false);
|
||||
if (trigger instanceof HTMLElement) {
|
||||
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToDocument(nodeId, workspaceId) {
|
||||
if (!nodeId) return;
|
||||
var url = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
|
||||
if (mnoteNavigationInFlight === url) return;
|
||||
mnoteNavigationInFlight = url;
|
||||
document.documentElement.setAttribute('data-mnote-navigation-pending', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-navigation-target', nodeId);
|
||||
if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') {
|
||||
window.__mnoteTreeLiveEventSource.close();
|
||||
}
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
async function createPage(trigger, parentId) {
|
||||
var workspaceId = resolveWorkspaceId(trigger);
|
||||
var effectiveParentId = (parentId || trigger.getAttribute('data-parent-id') || '').trim();
|
||||
@@ -48,7 +106,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
title: '新页面'
|
||||
});
|
||||
var nextWorkspaceId = result.workspaceId || workspaceId;
|
||||
window.location.href = '/documents/' + encodeURIComponent(result.documentId) + '?workspaceId=' + encodeURIComponent(nextWorkspaceId);
|
||||
navigateToDocument(result.documentId, nextWorkspaceId);
|
||||
}
|
||||
|
||||
function switchSidebarTreeTab(trigger) {
|
||||
@@ -69,6 +127,192 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function updateTitleEverywhere(documentId, title) {
|
||||
if (!documentId) return;
|
||||
var selectors = [
|
||||
'[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title',
|
||||
'[data-document-id="' + cssEscape(documentId) + '"] .tree-link-title',
|
||||
'[data-doc-id="' + cssEscape(documentId) + '"] .tree-link-title',
|
||||
'[data-node-id="' + cssEscape(documentId) + '"] .wolai-row-title'
|
||||
];
|
||||
selectors.forEach(function(selector) {
|
||||
document.querySelectorAll(selector).forEach(function(node) {
|
||||
if (node instanceof HTMLElement) node.textContent = title;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readProjection(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
if (value.result && typeof value.result === 'object') return value.result;
|
||||
if (value.snapshot && value.snapshot.tree) return value.snapshot.tree;
|
||||
if (value.data && value.data.tree) return value.data.tree;
|
||||
if (value.tree && typeof value.tree === 'object') return value.tree;
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectionItems(projection) {
|
||||
var resolved = readProjection(projection);
|
||||
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
|
||||
}
|
||||
|
||||
function nodeIdOf(item) {
|
||||
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
|
||||
}
|
||||
|
||||
function rowIdOf(item) {
|
||||
return String(item && (item.rowId || item.nodeId || item.id) || '').trim();
|
||||
}
|
||||
|
||||
function parentIdOf(item) {
|
||||
return String(item && (item.parentNodeId || item.parentId || '') || '').trim();
|
||||
}
|
||||
|
||||
function titleOf(item) {
|
||||
return String(item && item.title || '无标题').trim() || '无标题';
|
||||
}
|
||||
|
||||
function groupRowsByParent(rows) {
|
||||
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
|
||||
var grouped = new Map();
|
||||
rows.forEach(function(item) {
|
||||
var parentId = parentIdOf(item);
|
||||
if (!ids.has(parentId)) parentId = '';
|
||||
if (!grouped.has(parentId)) grouped.set(parentId, []);
|
||||
grouped.get(parentId).push(item);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function renderPageRows(parentId, grouped, activeId) {
|
||||
return (grouped.get(parentId) || []).map(function(item) {
|
||||
var nodeId = nodeIdOf(item);
|
||||
var title = titleOf(item);
|
||||
var depth = Number(item.depth || 0);
|
||||
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>'
|
||||
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
||||
var childHtml = expandable && expanded
|
||||
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="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>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderPageProjection(projection) {
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
if (!tree) return false;
|
||||
var rows = projectionItems(projection).filter(function(item) {
|
||||
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>';
|
||||
return true;
|
||||
}
|
||||
|
||||
function fileDocumentId(item) {
|
||||
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
||||
if (meta.documentId) return String(meta.documentId).trim();
|
||||
if (item && item.documentId) return String(item.documentId).trim();
|
||||
if (item && item.rowKind === 'document') return nodeIdOf(item);
|
||||
if (item && item.rowKind === 'index') return nodeIdOf(item).replace(/^index:/, '');
|
||||
return '';
|
||||
}
|
||||
|
||||
function fileAssetId(item) {
|
||||
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
||||
if (meta.assetId) return String(meta.assetId).trim();
|
||||
if (item && item.assetId) return String(item.assetId).trim();
|
||||
if (item && item.rowKind === 'asset') return nodeIdOf(item).replace(/^asset:/, '');
|
||||
return '';
|
||||
}
|
||||
|
||||
function iconKindOf(item) {
|
||||
return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file';
|
||||
}
|
||||
|
||||
function renderFileRows(parentId, grouped, activeId) {
|
||||
return (grouped.get(parentId) || []).map(function(item) {
|
||||
var nodeId = nodeIdOf(item);
|
||||
var rowId = rowIdOf(item);
|
||||
var rowKind = String(item.rowKind || 'document');
|
||||
var title = titleOf(item);
|
||||
var depth = Number(item.depth || 0);
|
||||
var parent = parentIdOf(item);
|
||||
var documentId = fileDocumentId(item);
|
||||
var assetId = fileAssetId(item);
|
||||
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 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>'
|
||||
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
||||
var createAction = rowKind === 'document'
|
||||
? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>'
|
||||
: '';
|
||||
var childHtml = expandable && expanded
|
||||
? '<ul class="tree-children">' + renderFileRows(nodeId, grouped, activeId) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderFileProjection(projection) {
|
||||
var tree = document.getElementById('sidebar-file-tree-root');
|
||||
if (!tree) return false;
|
||||
var rows = projectionItems(projection);
|
||||
var activeId = currentDocumentId();
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId) : '<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>') + '</ul>';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchProjection(path, workspaceId) {
|
||||
var url = new URL(path, window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId || 'default');
|
||||
url.searchParams.set('depth', '99');
|
||||
var response = await fetch(url.toString(), { cache: 'no-store' });
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || !payload.result) throw new Error('tree_projection_failed_' + response.status);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function scheduleProjectionRefresh(workspaceId) {
|
||||
if (projectionRefreshTimer) window.clearTimeout(projectionRefreshTimer);
|
||||
projectionRefreshTimer = window.setTimeout(function() {
|
||||
projectionRefreshTimer = 0;
|
||||
var resolvedWorkspaceId = workspaceId || resolveWorkspaceId(document.body);
|
||||
Promise.all([
|
||||
fetchProjection('/api/tree/projections/sidebar', resolvedWorkspaceId).then(renderPageProjection),
|
||||
fetchProjection('/api/tree/projections/file', resolvedWorkspaceId).then(renderFileProjection)
|
||||
]).then(function() {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'true');
|
||||
}).catch(function(error) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-apply-error', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function toggleChildren(row, button) {
|
||||
var li = row && row.parentElement;
|
||||
var children = li ? li.querySelector(':scope > .tree-children') : null;
|
||||
if (!children) return;
|
||||
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 ? '▸' : '▾';
|
||||
}
|
||||
|
||||
function dispatchSidebarEvent(name, detail) {
|
||||
document.documentElement.setAttribute('data-mnote-last-tree-action', name);
|
||||
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]');
|
||||
if (tabTrigger) {
|
||||
@@ -84,6 +328,43 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileTree && fileTree.contains(e.target)) {
|
||||
var fileBtn = closestAction(e.target, '[data-rust-action]');
|
||||
var fileRow = closestAction(e.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
if (!fileRow) return;
|
||||
var fileAction = fileBtn ? fileBtn.getAttribute('data-rust-action') : 'open';
|
||||
var rowId = fileRow.getAttribute('data-row-id') || '';
|
||||
var rowKind = fileRow.getAttribute('data-row-kind') || '';
|
||||
var documentId = fileRow.getAttribute('data-document-id') || fileRow.getAttribute('data-doc-id') || '';
|
||||
var assetId = fileRow.getAttribute('data-asset-id') || '';
|
||||
if (fileAction === 'toggle') {
|
||||
e.preventDefault();
|
||||
toggleChildren(fileRow, fileBtn);
|
||||
return;
|
||||
}
|
||||
if (fileAction === 'create') {
|
||||
e.preventDefault();
|
||||
void createPage(fileBtn || fileRow, documentId || fileRow.getAttribute('data-node-id'));
|
||||
return;
|
||||
}
|
||||
if (fileAction === 'menu') {
|
||||
e.preventDefault();
|
||||
dispatchSidebarEvent('tree.filetree.context-menu', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
fileTree.querySelectorAll('.tree-row[data-selected="true"]').forEach(function(row) {
|
||||
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'false');
|
||||
});
|
||||
fileRow.setAttribute('data-selected', 'true');
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
if ((rowKind === 'document' || rowKind === 'index') && documentId) {
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
if (!tree || !tree.contains(e.target)) return;
|
||||
var btn = closestAction(e.target, '[data-rust-action]');
|
||||
@@ -94,18 +375,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (action === 'toggle') {
|
||||
var row = btn.closest('.tree-row');
|
||||
if (!row) return;
|
||||
var li = row.parentElement;
|
||||
var children = li.querySelector(':scope > .tree-children');
|
||||
if (children) {
|
||||
children.classList.toggle('tree-children--collapsed');
|
||||
var isCollapsed = children.classList.contains('tree-children--collapsed');
|
||||
row.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
||||
btn.textContent = isCollapsed ? '▸' : '▾';
|
||||
}
|
||||
toggleChildren(row, btn);
|
||||
e.preventDefault();
|
||||
} else if (action === 'open') {
|
||||
var workspaceId = resolveWorkspaceId(btn);
|
||||
window.location.href = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
|
||||
navigateToDocument(nodeId, workspaceId);
|
||||
e.preventDefault();
|
||||
} else if (action === 'create') {
|
||||
e.preventDefault();
|
||||
@@ -119,23 +393,323 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
workspaceId: resolveWorkspaceId(btn),
|
||||
documentId: nodeId,
|
||||
title: title.trim()
|
||||
}).then(function(){ window.location.reload(); });
|
||||
}).then(function(){
|
||||
updateTitleEverywhere(nodeId, title.trim());
|
||||
scheduleProjectionRefresh(resolveWorkspaceId(btn));
|
||||
});
|
||||
}
|
||||
} else if (action === 'menu') {
|
||||
e.preventDefault();
|
||||
dispatchSidebarEvent('tree.page.context-menu', { documentId: nodeId, target: { documentId: nodeId } });
|
||||
}
|
||||
});
|
||||
|
||||
function readPageDragNodeId(event) {
|
||||
var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : '';
|
||||
return (fromTransfer || draggingPageNodeId || '').trim();
|
||||
}
|
||||
|
||||
function clearPageDropFeedback() {
|
||||
if (activePageDropRow instanceof HTMLElement) {
|
||||
activePageDropRow.setAttribute('data-drop-feedback', 'false');
|
||||
}
|
||||
activePageDropRow = null;
|
||||
}
|
||||
|
||||
function canDropPage(sourceNodeId, targetRow) {
|
||||
if (!sourceNodeId || !(targetRow instanceof HTMLElement)) return false;
|
||||
var targetNodeId = targetRow.getAttribute('data-node-id') || '';
|
||||
if (!targetNodeId || targetNodeId === sourceNodeId) return false;
|
||||
var sourceNode = document.querySelector('#sidebar-tree-root .tree-node[data-node-id="' + cssEscape(sourceNodeId) + '"]');
|
||||
return !(sourceNode instanceof HTMLElement && sourceNode.contains(targetRow));
|
||||
}
|
||||
|
||||
function pageDropPosition(event, row) {
|
||||
var rect = row.getBoundingClientRect();
|
||||
var ratio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||
if (ratio < 0.25) return 'before';
|
||||
if (ratio > 0.75) return 'after';
|
||||
return 'inside';
|
||||
}
|
||||
|
||||
function resolvePageMoveTarget(targetRow, position) {
|
||||
var targetNodeId = targetRow.getAttribute('data-node-id') || '';
|
||||
var parentId = targetRow.getAttribute('data-parent-id') || null;
|
||||
if (position === 'inside') {
|
||||
var children = targetRow.parentElement ? targetRow.parentElement.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="page"]') : [];
|
||||
return { parentId: targetNodeId, sortOrder: children.length };
|
||||
}
|
||||
var siblings = Array.from(document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]')).filter(function(row) {
|
||||
return (row.getAttribute('data-parent-id') || '') === (parentId || '');
|
||||
});
|
||||
var index = siblings.indexOf(targetRow);
|
||||
return { parentId: parentId, sortOrder: Math.max(0, index + (position === 'after' ? 1 : 0)) };
|
||||
}
|
||||
|
||||
document.addEventListener('dragstart', function(event) {
|
||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"][draggable="true"]');
|
||||
if (pageRow) {
|
||||
draggingPageNodeId = pageRow.getAttribute('data-node-id') || '';
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(PAGE_DRAG_MIME, draggingPageNodeId);
|
||||
event.dataTransfer.setData('text/plain', draggingPageNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"][draggable="true"]');
|
||||
if (fileRow) {
|
||||
var rowId = fileRow.getAttribute('data-row-id') || '';
|
||||
draggingFileTreeRowIds = rowId ? [rowId] : [];
|
||||
if (event.dataTransfer) {
|
||||
var payload = JSON.stringify({ type: 'mnote-file-tree-dnd', version: 1, rowIds: draggingFileTreeRowIds });
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
|
||||
event.dataTransfer.setData('application/x-mnote-file-tree', payload);
|
||||
event.dataTransfer.setData('text/plain', payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
if (!tree) return;
|
||||
var currentPath = window.location.pathname;
|
||||
var match = currentPath.match(/^\/documents\/([^\/]+)/);
|
||||
if (match) {
|
||||
var activeId = match[1];
|
||||
var links = tree.querySelectorAll('[data-rust-action="open"]');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
if (links[i].getAttribute('data-node-id') === activeId) {
|
||||
links[i].closest('.tree-row').setAttribute('data-active', 'true');
|
||||
}
|
||||
document.addEventListener('dragover', function(event) {
|
||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"]');
|
||||
var sourceNodeId = readPageDragNodeId(event);
|
||||
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
|
||||
event.preventDefault();
|
||||
clearPageDropFeedback();
|
||||
pageRow.setAttribute('data-drop-feedback', 'true');
|
||||
pageRow.setAttribute('data-drop-position', pageDropPosition(event, pageRow));
|
||||
activePageDropRow = pageRow;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileTree && fileTree.contains(event.target)) {
|
||||
var hasFiles = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], 'Files') >= 0;
|
||||
var hasInternal = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], FILETREE_DRAG_MIME) >= 0;
|
||||
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return;
|
||||
event.preventDefault();
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles || event.altKey ? 'copy' : 'move';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('drop', function(event) {
|
||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"]');
|
||||
var sourceNodeId = readPageDragNodeId(event);
|
||||
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
|
||||
event.preventDefault();
|
||||
var position = pageDropPosition(event, pageRow);
|
||||
var target = resolvePageMoveTarget(pageRow, position);
|
||||
clearPageDropFeedback();
|
||||
draggingPageNodeId = '';
|
||||
void dispatchTreeCommand(pageRow, {
|
||||
action: 'move',
|
||||
workspaceId: resolveWorkspaceId(pageRow),
|
||||
documentId: sourceNodeId,
|
||||
parentId: target.parentId,
|
||||
sortOrder: target.sortOrder
|
||||
}).then(function(){ scheduleProjectionRefresh(resolveWorkspaceId(pageRow)); });
|
||||
return;
|
||||
}
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileTree && fileTree.contains(event.target)) {
|
||||
var targetRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
var files = event.dataTransfer ? Array.from(event.dataTransfer.files || []) : [];
|
||||
var raw = event.dataTransfer ? event.dataTransfer.getData(FILETREE_DRAG_MIME) || event.dataTransfer.getData('application/x-mnote-file-tree') || '' : '';
|
||||
var rowIds = draggingFileTreeRowIds.slice();
|
||||
if (raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed.rowIds)) rowIds = parsed.rowIds;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!files.length && !rowIds.length) return;
|
||||
event.preventDefault();
|
||||
var detail = {
|
||||
workspaceId: resolveWorkspaceId(targetRow || fileTree),
|
||||
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
|
||||
targetRowKind: targetRow ? targetRow.getAttribute('data-row-kind') : 'root',
|
||||
documentId: targetRow ? targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id') : null,
|
||||
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null
|
||||
};
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow = null;
|
||||
if (files.length) {
|
||||
dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files }));
|
||||
} else {
|
||||
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true }));
|
||||
}
|
||||
draggingFileTreeRowIds = [];
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function() {
|
||||
draggingPageNodeId = '';
|
||||
draggingFileTreeRowIds = [];
|
||||
clearPageDropFeedback();
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow = null;
|
||||
});
|
||||
|
||||
window.addEventListener('tree:title-updated', function(event) {
|
||||
var detail = event.detail || {};
|
||||
updateTitleEverywhere(detail.documentId, detail.title);
|
||||
scheduleProjectionRefresh(detail.workspaceId);
|
||||
});
|
||||
|
||||
window.addEventListener('tree:snapshot', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
if (renderPageProjection(payload)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot');
|
||||
}
|
||||
scheduleProjectionRefresh(payload && payload.workspaceId);
|
||||
});
|
||||
|
||||
window.addEventListener('tree:delta', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
var data = payload && (payload.data || payload.delta || payload);
|
||||
var documents = data && (data.upsertDocuments || data.upsert_documents);
|
||||
if (Array.isArray(documents)) {
|
||||
documents.forEach(function(doc) {
|
||||
updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题');
|
||||
});
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
||||
scheduleProjectionRefresh(payload && payload.workspaceId);
|
||||
});
|
||||
|
||||
window.addEventListener('tree:resync', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
|
||||
scheduleProjectionRefresh(payload && payload.workspaceId);
|
||||
});
|
||||
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
var activeId = currentDocumentId();
|
||||
if (tree && activeId) {
|
||||
var activeRow = tree.querySelector('.tree-row[data-node-id="' + cssEscape(activeId) + '"]');
|
||||
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'true');
|
||||
}
|
||||
})();
|
||||
"##;
|
||||
|
||||
const TREE_LIVE_CONTROLLER_JS: &str = r##"
|
||||
(function(){
|
||||
if (window.__mnoteTreeLiveControllerStarted) return;
|
||||
window.__mnoteTreeLiveControllerStarted = true;
|
||||
|
||||
function readBootstrap() {
|
||||
var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__');
|
||||
var fallback = {
|
||||
schema: 'mnote.tree_live_bootstrap.v1',
|
||||
transport: 'convex-command-log-sse',
|
||||
endpoint: '/api/tree/events',
|
||||
resyncEndpoint: '/api/tree/projections/sidebar',
|
||||
rootIds: [],
|
||||
initialRevision: null
|
||||
};
|
||||
if (!script || !script.textContent) return fallback;
|
||||
try {
|
||||
return Object.assign(fallback, JSON.parse(script.textContent));
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceId() {
|
||||
var withWorkspace = document.querySelector('[data-workspace-id]');
|
||||
if (withWorkspace) {
|
||||
var value = (withWorkspace.getAttribute('data-workspace-id') || '').trim();
|
||||
if (value) return value;
|
||||
}
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
return (params.get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
function dispatchTreeEvent(name, detail) {
|
||||
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
||||
}
|
||||
|
||||
function applyStatus(status) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-status', status);
|
||||
}
|
||||
|
||||
function applyTransport(transport) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-transport', transport || 'convex-command-log-sse');
|
||||
}
|
||||
|
||||
function closeActiveSource() {
|
||||
var source = window.__mnoteTreeLiveEventSource;
|
||||
if (source && typeof source.close === 'function') {
|
||||
source.close();
|
||||
window.__mnoteTreeLiveEventSource = null;
|
||||
applyStatus('closed');
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!('EventSource' in window)) {
|
||||
applyStatus('unsupported');
|
||||
return;
|
||||
}
|
||||
var bootstrap = readBootstrap();
|
||||
applyTransport(bootstrap.transport || 'convex-command-log-sse');
|
||||
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
|
||||
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
|
||||
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
|
||||
}
|
||||
var failures = 0;
|
||||
var source = new EventSource(url.toString());
|
||||
window.__mnoteTreeLiveEventSource = source;
|
||||
applyStatus('connecting');
|
||||
|
||||
source.addEventListener('open', function(){
|
||||
failures = 0;
|
||||
applyStatus('connected');
|
||||
});
|
||||
|
||||
source.addEventListener('snapshot', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
var revision = payload.revision || payload.cursor || event.lastEventId || null;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
|
||||
dispatchTreeEvent('tree:snapshot', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('delta', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
var revision = payload.revision || payload.cursor || event.lastEventId || null;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
|
||||
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('resync', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
var revision = payload.revision || payload.cursor || event.lastEventId || null;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
|
||||
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.onerror = function(){
|
||||
failures += 1;
|
||||
applyStatus(failures >= 3 ? 'resync-pending' : 'reconnecting');
|
||||
if (failures >= 3) {
|
||||
dispatchTreeEvent('tree:resync-requested', { endpoint: bootstrap.resyncEndpoint, workspaceId: workspaceId });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.__mnoteTreeLiveClose = closeActiveSource;
|
||||
window.addEventListener('pagehide', closeActiveSource, { once: true });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
"##;
|
||||
@@ -198,6 +772,17 @@ pub fn PageLayout(
|
||||
None,
|
||||
)
|
||||
});
|
||||
let tree_live_bootstrap = serde_json::json!({
|
||||
"schema": "mnote.tree_live_bootstrap.v1",
|
||||
"transport": "convex-command-log-sse",
|
||||
"workspaceId": null,
|
||||
"rootIds": [],
|
||||
"initialRevision": null,
|
||||
"endpoint": "/api/tree/events",
|
||||
"resyncEndpoint": "/api/tree/projections/sidebar",
|
||||
"views": ["page-tree", "file-tree"]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
view! {
|
||||
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
@@ -208,15 +793,17 @@ 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="搜索"><span class="nav-icon">"⌕"</span></a>
|
||||
<a href="/graph" title="关系图"><span class="nav-icon">"⌘"</span></a>
|
||||
<a href="/actions" title="快捷动作"><span class="nav-icon">"↯"</span></a>
|
||||
<a href="/help" title="帮助"><span class="nav-icon">"?"</span></a>
|
||||
<a href="/files" title="文件"><span class="nav-icon">"▣"</span></a>
|
||||
<a href="/more" title="更多"><span class="nav-icon">"…"</span></a>
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索"><svg class="mnote-symbol nav-icon" data-icon="search" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="5.5"></circle><path d="m15 15 5 5"></path></svg></a>
|
||||
<a href="/graph" title="关系图" aria-label="关系图"><svg class="mnote-symbol nav-icon" data-icon="account_tree" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h5v5H6z"></path><path d="M15 3h5v5h-5z"></path><path d="M15 16h5v5h-5z"></path><path d="M11 8.5h2.5a4 4 0 0 0 4-4"></path><path d="M11 8.5h2.5a4 4 0 0 1 4 4V16"></path></svg></a>
|
||||
<a href="/actions" title="快捷动作" aria-label="快捷动作"><svg class="mnote-symbol nav-icon" data-icon="bolt" viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 14h6l-1 8 9-13h-6z"></path></svg></a>
|
||||
<a href="/help" title="帮助" aria-label="帮助"><svg class="mnote-symbol nav-icon" data-icon="help" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M9.8 9a2.4 2.4 0 0 1 4.5 1.2c0 1.7-2.3 2-2.3 3.8"></path><path d="M12 17.5h.01"></path></svg></a>
|
||||
<a href="/files" title="文件" aria-label="文件"><svg class="mnote-symbol nav-icon" data-icon="inventory_2" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v4H4z"></path><path d="M6 10v9h12v-9"></path><path d="M9 14h6"></path></svg></a>
|
||||
<a href="/more" title="更多" aria-label="更多"><svg class="mnote-symbol nav-icon" data-icon="more_horiz" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h.01"></path><path d="M12 12h.01"></path><path d="M19 12h.01"></path></svg></a>
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
|
||||
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
||||
<script id="mnote-tree-live-controller" inner_html={TREE_LIVE_CONTROLLER_JS.to_string()}></script>
|
||||
</aside>
|
||||
<div class="mnote-main">
|
||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||
@@ -225,7 +812,7 @@ pub fn PageLayout(
|
||||
<nav class="wolai-breadcrumb" aria-label="页面路径">
|
||||
<span class="wolai-breadcrumb-root">"The Digital Atelier"</span>
|
||||
<span class="wolai-breadcrumb-separator" aria-hidden="true">"/"</span>
|
||||
<span class="wolai-breadcrumb-current"><span class="wolai-home-icon" aria-hidden="true">"⌂"</span>{topbar_title}</span>
|
||||
<span class="wolai-breadcrumb-current"><svg class="mnote-symbol wolai-home-icon" data-icon="home" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5 12 3l8 7.5"></path><path d="M6.5 9.5V21h11V9.5"></path><path d="M9.5 21v-6h5v6"></path></svg><span data-page-title-current="true">{topbar_title}</span></span>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="wolai-topbar-actions" aria-label="页面操作">
|
||||
@@ -248,3 +835,34 @@ pub fn PageLayout(
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS};
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("dragstart"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-drop-feedback"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("action: 'move'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use leptos::prelude::*;
|
||||
/// MNOTE 搜索页面
|
||||
///
|
||||
/// 渲染搜索页面的 SSR 壳结构:
|
||||
/// - `<main id="mnote-search-shell" data-island-host="react_search_palette">`
|
||||
/// - `<main id="mnote-search-shell" data-island-host="search_interaction_island">`
|
||||
/// - 搜索标题区域
|
||||
/// - 搜索岛占位区
|
||||
#[component]
|
||||
@@ -26,10 +26,14 @@ pub fn SearchPage(
|
||||
/// 工作区名称(可选)
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
/// 服务端首批搜索结果 HTML(可选)
|
||||
#[prop(optional)]
|
||||
initial_results_html: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let initial_results_html = initial_results_html.unwrap_or_default();
|
||||
view! {
|
||||
<PageLayout current_nav="search" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
|
||||
<main id="mnote-search-shell" data-island-host="react_search_palette">
|
||||
<main id="mnote-search-shell" data-island-host="search_interaction_island">
|
||||
<header class="search-header">
|
||||
<h1>{"搜索"}</h1>
|
||||
<p class="search-meta">
|
||||
@@ -43,7 +47,8 @@ pub fn SearchPage(
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
<section id="mnote-search-island" data-react-island="search_palette"></section>
|
||||
<section id="mnote-search-results" data-search-results-owner="rust-kernel" inner_html={initial_results_html}></section>
|
||||
<section id="mnote-search-island" data-react-island="search_interaction"></section>
|
||||
</main>
|
||||
</PageLayout>
|
||||
}
|
||||
@@ -60,9 +65,9 @@ mod tests {
|
||||
<SearchPage workspace_id="ws_demo" search_query="Rust" />
|
||||
});
|
||||
assert!(html.contains("mnote-search-shell"));
|
||||
assert!(html.contains("data-island-host=\"react_search_palette\""));
|
||||
assert!(html.contains("data-island-host=\"search_interaction_island\""));
|
||||
assert!(html.contains("mnote-search-island"));
|
||||
assert!(html.contains("data-react-island=\"search_palette\""));
|
||||
assert!(html.contains("mnote-search-results"));
|
||||
assert!(html.contains("搜索"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user