From 85914cd4e1f4c94ff6e85155eaf7ae474a802e99 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 26 May 2026 04:17:52 +0800 Subject: [PATCH] refactor: split sidebar settings and slash menu view --- ...ime-module-maintainability-checklist-v1.md | 6 +- .../browser/sidebar-page-settings-runtime.js | 700 ++++++++++++++++++ .../mnote-web/browser/sidebar-tree-runtime.js | 693 +---------------- rust/crates/mnote-web/src/routes/mod.rs | 5 + rust/crates/mnote-web/src/routes/web_shell.rs | 14 + rust/crates/mnote-web/src/ssr/pages/layout.rs | 7 +- .../src/editor_runtime/mod.rs | 1 + .../src/editor_runtime/slash_menu_view.rs | 271 +++++++ rust/spikes/leptos-tiptap-spike/src/lib.rs | 238 ++---- 9 files changed, 1082 insertions(+), 853 deletions(-) create mode 100644 rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js create mode 100644 rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index d59caf80..a158b097 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -160,6 +160,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::web_shell - 新建:`rust/crates/mnote-web/browser/sidebar-filetree-upload-runtime.js` - 新建:`rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js` - 新建:`rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js` +- 新建:`rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js` 拆分项: @@ -170,7 +171,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::web_shell - [x] C5. `sidebar-filetree-upload-runtime.js`:local upload target plan、drop/paste preflight、readonly guard。 - [x] C6. `sidebar-attachment-open-runtime.js`:OnlyOffice/PDF/code/image open mode guard。 - [x] C7. `sidebar-tree-live-apply-runtime.js`:WS/SSE snapshot/delta/resync DOM apply。 -- [ ] C8. `sidebar-tree-runtime.js` 保留为 entrypoint,目标少于 3,000 行。 +- [x] C8. `sidebar-tree-runtime.js` 保留为 entrypoint,目标少于 3,000 行。 验收命令: @@ -183,6 +184,7 @@ node --check rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js node --check rust/crates/mnote-web/browser/sidebar-filetree-upload-runtime.js node --check rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js node --check rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js +node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib ssr::pages::layout -- --test-threads=1 ``` @@ -247,7 +249,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tree -- -- - [x] E2. `mount.rs`:迁出 mount context、mounted handles、`mount_app_into` 周边可独立部分;`#[wasm_bindgen]` wrapper 可暂留根文件。 - [x] E3. `runtime_bridge.rs`:迁出 `dispatch_runtime_event`、`dispatch_*_to_target`、host command listener install。 - [x] E4. `block_transform.rs`:迁出 `run_block_turn_into_action`、`top_level_block_matches_action`、page/block transform helpers。 -- [ ] E5. `slash_menu_view.rs`:迁出 slash menu Leptos view 和 click handling。 +- [x] E5. `slash_menu_view.rs`:迁出 slash menu Leptos view 和 click handling。 - [ ] E6. `block_handle_menu_view.rs`:迁出 block handle menu 和 submenu view,作为最后一刀。 验收标准: diff --git a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js new file mode 100644 index 00000000..dc15b1de --- /dev/null +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -0,0 +1,700 @@ +export function createSidebarPageSettingsRuntime(context) { + const { + currentDocumentId, + currentPageAggregate, + currentRootUri, + currentSourceKind, + currentWorkspaceSourcePayload, + escapeHtml, + pageUiState, + resolveWorkspaceId, + searchText, + } = context; + const MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = context.globalShowHeadingNumbersKey || 'mnote.global.showHeadingNumbers'; + + function defaultPageOptions() { + return { + wideLayout: false, + smallText: false, + layoutDensity: 'normal', + showHeadingNumbers: false, + showToc: false, + showStructure: false, + protectEditing: false, + showWordCount: true, + collapseBacklinks: false, + pageFont: 'default', + hideChildPages: false, + showBlockRefCount: false, + hideTitleHeader: 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 readGlobalShowHeadingNumbers() { + try { + var raw = window.localStorage ? window.localStorage.getItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY) : ''; + return raw === 'true' || raw === '1'; + } catch (_) { + return false; + } + } + + function writeGlobalShowHeadingNumbers(value) { + try { + if (window.localStorage) { + window.localStorage.setItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, value ? 'true' : 'false'); + } + } catch (_) {} + document.documentElement.setAttribute('data-global-show-heading-numbers', String(Boolean(value))); + } + + function effectiveShowHeadingNumbers(options) { + return readGlobalShowHeadingNumbers(); + } + + function pageOptionIsSupported(name) { + return name === 'wideLayout' + || name === 'smallText' + || name === 'layoutDensity' + || name === 'pageFont' + || name === 'showHeadingNumbers' + || (name === 'hideTitleHeader' && currentSourceKind() === 'local_folder'); + } + + function pageOptionDescription(name) { + if (name === 'wideLayout') return '自适应宽度'; + if (name === 'smallText') return '小字体'; + if (name === 'hideTitleHeader') return '隐藏本地 Markdown 文件标题'; + 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 === 'hideTitleHeader') return '已接通:本地 Markdown 页头标题立即隐藏或显示'; + 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 globalShowHeadingNumbers = readGlobalShowHeadingNumbers(); + var showHeadingNumbers = effectiveShowHeadingNumbers(options); + 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(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(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(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-global-show-heading-numbers', String(globalShowHeadingNumbers)); + document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers)); + document.documentElement.setAttribute('data-page-hide-title-header', String(Boolean(options.hideTitleHeader))); + var titleHeader = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header') || document.querySelector('.document-shell-header'); + if (titleHeader instanceof HTMLElement) { + var hidden = Boolean(options.hideTitleHeader); + titleHeader.hidden = hidden; + titleHeader.setAttribute('data-page-title-hidden', String(hidden)); + } + } + + 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 createPageOptionRow(key, type) { + var inputType = type || 'checkbox'; + var supported = pageOptionIsSupported(key); + if (inputType === 'checkbox') { + return '' + + ''; + } + return '' + + ''; + } + + function createGlobalHeadingNumbersRow() { + return '' + + ''; + } + + function renderGlobalOptions(popover) { + var globalHeadingNumbers = readGlobalShowHeadingNumbers(); + popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) { + input.checked = globalHeadingNumbers; + }); + } + + function createPageFontRow() { + return '' + + ''; + } + + 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 = '' + + '
' + + '
' + + '
页面历史
当前会话内最近保存的 15 个快照。
' + + '' + + '
' + + '
' + + '
'; + 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 '' + + '
' + + '
' + + '
' + escapeHtml(label) + '
' + + '
字数 ' + Number(stats.wordCount || 0) + ' · 字符 ' + Number(stats.characterCount || 0) + ' · 块数 ' + Number(stats.blockCount || 0) + '
' + + '
' + + '' + + '
'; + }).join('') + : '
尚未产生历史快照,编辑后会自动生成。
'; + } + + 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 = '' + + ''; + 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 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 = '' + + ''; + document.body.appendChild(popover); + return popover; + } + + function pageSettingsLocalIndexScopeKey() { + return [ + currentSourceKind(), + currentRootUri(), + resolveWorkspaceId(document.body), + currentDocumentId() + ].join('|'); + } + + function pageSettingsLocalIndexIsAvailable() { + return currentSourceKind() === 'local_folder' && Boolean(currentRootUri()) && Boolean(currentDocumentId()); + } + + function pageSettingsLocalIndexEmpty(message) { + return '
' + escapeHtml(message) + '
'; + } + + function renderPageSettingsLocalIndexList(items, kind) { + var rows = Array.isArray(items) ? items : []; + if (!rows.length) { + return pageSettingsLocalIndexEmpty(kind === 'backlinks' ? '暂无反链' : '暂无标签'); + } + if (kind === 'backlinks') { + return rows.slice(0, 12).map(function(item) { + var title = searchText(item && item.title) || searchText(item && item.path) || '未命名页面'; + var path = searchText(item && item.path); + var snippet = searchText(item && item.snippet); + return '' + + '
' + + '
' + escapeHtml(title) + '
' + + (path ? '
' + escapeHtml(path) + '
' : '') + + (snippet ? '
' + escapeHtml(snippet) + '
' : '') + + '
'; + }).join(''); + } + return rows.slice(0, 16).map(function(item) { + var tag = searchText(item && item.tag) || 'untagged'; + var count = Number(item && item.count || 0); + return '' + + '
' + + '
#' + escapeHtml(tag) + '
' + + '
' + count + ' 个页面
' + + '
'; + }).join(''); + } + + function renderPageSettingsLocalIndex(popover) { + popover = popover || ensurePageSettingsPopover(); + var statusNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-status"]'); + var backlinksNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]'); + var tagsNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-tags"]'); + if (!(statusNode instanceof HTMLElement) || !(backlinksNode instanceof HTMLElement) || !(tagsNode instanceof HTMLElement)) return; + + if (!pageSettingsLocalIndexIsAvailable()) { + statusNode.textContent = '本地索引仅在本地工作区页面可用'; + backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root'); + tagsNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root'); + return; + } + + var summary = pageUiState.localIndexSummary || {}; + if (summary.loading) { + statusNode.textContent = '正在读取本地索引...'; + backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('加载中'); + tagsNode.innerHTML = pageSettingsLocalIndexEmpty('加载中'); + return; + } + if (summary.error) { + statusNode.textContent = '本地索引读取失败'; + backlinksNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error); + tagsNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error); + return; + } + if (summary.scopeKey !== pageSettingsLocalIndexScopeKey()) { + statusNode.textContent = '切换到索引页签后读取本地索引'; + backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取'); + tagsNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取'); + return; + } + + statusNode.textContent = '来自当前授权 root 的 .mnote/index/search-index.json'; + backlinksNode.innerHTML = renderPageSettingsLocalIndexList(summary.backlinks, 'backlinks'); + tagsNode.innerHTML = renderPageSettingsLocalIndexList(summary.tags, 'tags'); + } + + async function loadPageSettingsLocalIndex(force) { + if (!pageSettingsLocalIndexIsAvailable()) { + renderPageSettingsLocalIndex(); + return; + } + var scopeKey = pageSettingsLocalIndexScopeKey(); + var current = pageUiState.localIndexSummary || {}; + if (!force && current.scopeKey === scopeKey && !current.error && !current.loading) { + renderPageSettingsLocalIndex(); + return; + } + pageUiState.localIndexSummary = { + scopeKey: scopeKey, + loading: true, + error: '', + backlinks: null, + tags: null + }; + renderPageSettingsLocalIndex(); + try { + var baseParams = new URLSearchParams(); + baseParams.set('workspaceId', resolveWorkspaceId(document.body)); + baseParams.set('rootUri', currentRootUri()); + var backlinksParams = new URLSearchParams(baseParams); + backlinksParams.set('documentId', currentDocumentId()); + var backlinksUrl = '/api/search/local-index/backlinks?' + backlinksParams.toString(); + var tagsUrl = '/api/search/local-index/tags?' + baseParams.toString(); + var responses = await Promise.all([ + fetch(backlinksUrl, { headers: { accept: 'application/json' } }), + fetch(tagsUrl, { headers: { accept: 'application/json' } }) + ]); + var backlinksPayload = await responses[0].json().catch(function(){ return null; }); + var tagsPayload = await responses[1].json().catch(function(){ return null; }); + if (!responses[0].ok || !backlinksPayload || backlinksPayload.ok !== true) { + throw new Error('backlinks_' + responses[0].status); + } + if (!responses[1].ok || !tagsPayload || tagsPayload.ok !== true) { + throw new Error('tags_' + responses[1].status); + } + pageUiState.localIndexSummary = { + scopeKey: scopeKey, + loading: false, + error: '', + backlinks: backlinksPayload.result && Array.isArray(backlinksPayload.result.backlinks) ? backlinksPayload.result.backlinks : [], + tags: tagsPayload.result && Array.isArray(tagsPayload.result.tags) ? tagsPayload.result.tags : [] + }; + } catch (error) { + pageUiState.localIndexSummary = { + scopeKey: scopeKey, + loading: false, + error: error instanceof Error ? error.message : String(error), + backlinks: [], + tags: [] + }; + } + renderPageSettingsLocalIndex(); + } + + 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; + }); + renderGlobalOptions(popover); + var statsNode = popover.querySelector('[data-testid="wolai-page-settings-stats"]'); + if (statsNode instanceof HTMLElement) { + var stats = computeLivePageStats(); + statsNode.innerHTML = '' + + '字数 ' + Number(stats.wordCount || 0) + '' + + '字符 ' + Number(stats.characterCount || 0) + '' + + '块数 ' + Number(stats.blockCount || 0) + '' + + '待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + ''; + } + renderPageSettingsLocalIndex(popover); + } + + 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; + }); + if (tabName === 'index') void loadPageSettingsLocalIndex(false); + } + + 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), + ...currentWorkspaceSourcePayload(), + 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(); + } + + return { + applyPageOptionsToShell, + closePageHistoryDrawer, + closePageSettingsPopover, + closePageShareDialog, + currentPageOptions, + ensureHistorySnapshotsSeeded, + ensurePageHistoryDrawer, + ensurePageSettingsPopover, + ensurePageShareDialog, + isPageSettingsOpen, + openPageHistoryDrawer, + openPageSettingsPopover, + openPageShareDialog, + pageOptionIsSupported, + persistPageOptionsPatch, + recordPageHistorySnapshot, + renderPageSettingsPopover, + setActivePageSettingsTab, + togglePageSettingsPopover, + updatePageSettingsTriggerState, + writeGlobalShowHeadingNumbers, + }; +} diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 6d322941..e75b545d 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -6,6 +6,7 @@ import { createSidebarAttachmentOpenRuntime } from './sidebar-attachment-open-ru import { createSidebarFileTreeCommandRuntime } from './sidebar-filetree-command-runtime.js'; import { createSidebarFileTreeUploadRuntime } from './sidebar-filetree-upload-runtime.js'; import { createSidebarPageAiRuntime } from './sidebar-page-ai-runtime.js'; +import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtime.js'; (function(){ if (window.__mnoteSidebarTreeRuntimeStarted) return; @@ -191,195 +192,6 @@ import { createSidebarPageAiRuntime } from './sidebar-page-ai-runtime.js'; return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {}; } - function defaultPageOptions() { - return { - wideLayout: false, - smallText: false, - layoutDensity: 'normal', - showHeadingNumbers: false, - showToc: false, - showStructure: false, - protectEditing: false, - showWordCount: true, - collapseBacklinks: false, - pageFont: 'default', - hideChildPages: false, - showBlockRefCount: false, - hideTitleHeader: 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 readGlobalShowHeadingNumbers() { - try { - var raw = window.localStorage ? window.localStorage.getItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY) : ''; - return raw === 'true' || raw === '1'; - } catch (_) { - return false; - } - } - - function writeGlobalShowHeadingNumbers(value) { - try { - if (window.localStorage) { - window.localStorage.setItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, value ? 'true' : 'false'); - } - } catch (_) {} - document.documentElement.setAttribute('data-global-show-heading-numbers', String(Boolean(value))); - } - - function effectiveShowHeadingNumbers(options) { - return readGlobalShowHeadingNumbers(); - } - - function pageOptionIsSupported(name) { - return name === 'wideLayout' - || name === 'smallText' - || name === 'layoutDensity' - || name === 'pageFont' - || name === 'showHeadingNumbers' - || (name === 'hideTitleHeader' && currentSourceKind() === 'local_folder'); - } - - function pageOptionDescription(name) { - if (name === 'wideLayout') return '自适应宽度'; - if (name === 'smallText') return '小字体'; - if (name === 'hideTitleHeader') return '隐藏本地 Markdown 文件标题'; - 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 === 'hideTitleHeader') return '已接通:本地 Markdown 页头标题立即隐藏或显示'; - 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 globalShowHeadingNumbers = readGlobalShowHeadingNumbers(); - var showHeadingNumbers = effectiveShowHeadingNumbers(options); - 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(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(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(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-global-show-heading-numbers', String(globalShowHeadingNumbers)); - document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers)); - document.documentElement.setAttribute('data-page-hide-title-header', String(Boolean(options.hideTitleHeader))); - var titleHeader = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header') || document.querySelector('.document-shell-header'); - if (titleHeader instanceof HTMLElement) { - var hidden = Boolean(options.hideTitleHeader); - titleHeader.hidden = hidden; - titleHeader.setAttribute('data-page-title-hidden', String(hidden)); - } - } - const sidebarWorkspace = createSidebarWorkspaceRuntime({ cssEscape, currentDocumentId, @@ -409,6 +221,36 @@ import { createSidebarPageAiRuntime } from './sidebar-page-ai-runtime.js'; const openAccountMenu = (...args) => sidebarWorkspace.openAccountMenu(...args); sidebarWorkspace.autoOpenRecentLocalRootOnHome(); + const sidebarPageSettings = createSidebarPageSettingsRuntime({ + currentDocumentId, + currentPageAggregate, + currentRootUri, + currentSourceKind, + currentWorkspaceSourcePayload, + escapeHtml, + globalShowHeadingNumbersKey: MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, + pageUiState, + resolveWorkspaceId, + searchText, + }); + const applyPageOptionsToShell = (...args) => sidebarPageSettings.applyPageOptionsToShell(...args); + const closePageHistoryDrawer = (...args) => sidebarPageSettings.closePageHistoryDrawer(...args); + const closePageSettingsPopover = (...args) => sidebarPageSettings.closePageSettingsPopover(...args); + const closePageShareDialog = (...args) => sidebarPageSettings.closePageShareDialog(...args); + const currentPageOptions = (...args) => sidebarPageSettings.currentPageOptions(...args); + const ensureHistorySnapshotsSeeded = (...args) => sidebarPageSettings.ensureHistorySnapshotsSeeded(...args); + const isPageSettingsOpen = (...args) => sidebarPageSettings.isPageSettingsOpen(...args); + const openPageHistoryDrawer = (...args) => sidebarPageSettings.openPageHistoryDrawer(...args); + const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args); + const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args); + const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args); + const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args); + const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args); + const setActivePageSettingsTab = (...args) => sidebarPageSettings.setActivePageSettingsTab(...args); + const togglePageSettingsPopover = (...args) => sidebarPageSettings.togglePageSettingsPopover(...args); + const updatePageSettingsTriggerState = (...args) => sidebarPageSettings.updatePageSettingsTriggerState(...args); + const writeGlobalShowHeadingNumbers = (...args) => sidebarPageSettings.writeGlobalShowHeadingNumbers(...args); + function setCommandPending(trigger, pending) { if (!(trigger instanceof HTMLElement)) return; trigger.setAttribute('data-pending', pending ? 'true' : 'false'); @@ -1994,180 +1836,6 @@ import { createSidebarPageAiRuntime } from './sidebar-page-ai-runtime.js'; 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 createPageOptionRow(key, type) { - var inputType = type || 'checkbox'; - var supported = pageOptionIsSupported(key); - if (inputType === 'checkbox') { - return '' + - ''; - } - return '' + - ''; - } - - function createGlobalHeadingNumbersRow() { - return '' + - ''; - } - - function renderGlobalOptions(popover) { - var globalHeadingNumbers = readGlobalShowHeadingNumbers(); - popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) { - input.checked = globalHeadingNumbers; - }); - } - - function createPageFontRow() { - return '' + - ''; - } - - 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 = '' + - '
' + - '
' + - '
页面历史
当前会话内最近保存的 15 个快照。
' + - '' + - '
' + - '
' + - '
'; - 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 '' + - '
' + - '
' + - '
' + escapeHtml(label) + '
' + - '
字数 ' + Number(stats.wordCount || 0) + ' · 字符 ' + Number(stats.characterCount || 0) + ' · 块数 ' + Number(stats.blockCount || 0) + '
' + - '
' + - '' + - '
'; - }).join('') - : '
尚未产生历史快照,编辑后会自动生成。
'; - } - - 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 = '' + - ''; - 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'); - } - const sidebarPageAi = createSidebarPageAiRuntime({ buildLocalFileOpenUrl, currentDocumentId, @@ -2216,305 +1884,6 @@ import { createSidebarPageAiRuntime } from './sidebar-page-ai-runtime.js'; const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args); const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args); - 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 = '' + - ''; - document.body.appendChild(popover); - return popover; - } - - function pageSettingsLocalIndexScopeKey() { - return [ - currentSourceKind(), - currentRootUri(), - resolveWorkspaceId(document.body), - currentDocumentId() - ].join('|'); - } - - function pageSettingsLocalIndexIsAvailable() { - return currentSourceKind() === 'local_folder' && Boolean(currentRootUri()) && Boolean(currentDocumentId()); - } - - function pageSettingsLocalIndexEmpty(message) { - return '
' + escapeHtml(message) + '
'; - } - - function renderPageSettingsLocalIndexList(items, kind) { - var rows = Array.isArray(items) ? items : []; - if (!rows.length) { - return pageSettingsLocalIndexEmpty(kind === 'backlinks' ? '暂无反链' : '暂无标签'); - } - if (kind === 'backlinks') { - return rows.slice(0, 12).map(function(item) { - var title = searchText(item && item.title) || searchText(item && item.path) || '未命名页面'; - var path = searchText(item && item.path); - var snippet = searchText(item && item.snippet); - return '' + - '
' + - '
' + escapeHtml(title) + '
' + - (path ? '
' + escapeHtml(path) + '
' : '') + - (snippet ? '
' + escapeHtml(snippet) + '
' : '') + - '
'; - }).join(''); - } - return rows.slice(0, 16).map(function(item) { - var tag = searchText(item && item.tag) || 'untagged'; - var count = Number(item && item.count || 0); - return '' + - '
' + - '
#' + escapeHtml(tag) + '
' + - '
' + count + ' 个页面
' + - '
'; - }).join(''); - } - - function renderPageSettingsLocalIndex(popover) { - popover = popover || ensurePageSettingsPopover(); - var statusNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-status"]'); - var backlinksNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]'); - var tagsNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-tags"]'); - if (!(statusNode instanceof HTMLElement) || !(backlinksNode instanceof HTMLElement) || !(tagsNode instanceof HTMLElement)) return; - - if (!pageSettingsLocalIndexIsAvailable()) { - statusNode.textContent = '本地索引仅在本地工作区页面可用'; - backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root'); - tagsNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root'); - return; - } - - var summary = pageUiState.localIndexSummary || {}; - if (summary.loading) { - statusNode.textContent = '正在读取本地索引...'; - backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('加载中'); - tagsNode.innerHTML = pageSettingsLocalIndexEmpty('加载中'); - return; - } - if (summary.error) { - statusNode.textContent = '本地索引读取失败'; - backlinksNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error); - tagsNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error); - return; - } - if (summary.scopeKey !== pageSettingsLocalIndexScopeKey()) { - statusNode.textContent = '切换到索引页签后读取本地索引'; - backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取'); - tagsNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取'); - return; - } - - statusNode.textContent = '来自当前授权 root 的 .mnote/index/search-index.json'; - backlinksNode.innerHTML = renderPageSettingsLocalIndexList(summary.backlinks, 'backlinks'); - tagsNode.innerHTML = renderPageSettingsLocalIndexList(summary.tags, 'tags'); - } - - async function loadPageSettingsLocalIndex(force) { - if (!pageSettingsLocalIndexIsAvailable()) { - renderPageSettingsLocalIndex(); - return; - } - var scopeKey = pageSettingsLocalIndexScopeKey(); - var current = pageUiState.localIndexSummary || {}; - if (!force && current.scopeKey === scopeKey && !current.error && !current.loading) { - renderPageSettingsLocalIndex(); - return; - } - pageUiState.localIndexSummary = { - scopeKey: scopeKey, - loading: true, - error: '', - backlinks: null, - tags: null - }; - renderPageSettingsLocalIndex(); - try { - var baseParams = new URLSearchParams(); - baseParams.set('workspaceId', resolveWorkspaceId(document.body)); - baseParams.set('rootUri', currentRootUri()); - var backlinksParams = new URLSearchParams(baseParams); - backlinksParams.set('documentId', currentDocumentId()); - var backlinksUrl = '/api/search/local-index/backlinks?' + backlinksParams.toString(); - var tagsUrl = '/api/search/local-index/tags?' + baseParams.toString(); - var responses = await Promise.all([ - fetch(backlinksUrl, { headers: { accept: 'application/json' } }), - fetch(tagsUrl, { headers: { accept: 'application/json' } }) - ]); - var backlinksPayload = await responses[0].json().catch(function(){ return null; }); - var tagsPayload = await responses[1].json().catch(function(){ return null; }); - if (!responses[0].ok || !backlinksPayload || backlinksPayload.ok !== true) { - throw new Error('backlinks_' + responses[0].status); - } - if (!responses[1].ok || !tagsPayload || tagsPayload.ok !== true) { - throw new Error('tags_' + responses[1].status); - } - pageUiState.localIndexSummary = { - scopeKey: scopeKey, - loading: false, - error: '', - backlinks: backlinksPayload.result && Array.isArray(backlinksPayload.result.backlinks) ? backlinksPayload.result.backlinks : [], - tags: tagsPayload.result && Array.isArray(tagsPayload.result.tags) ? tagsPayload.result.tags : [] - }; - } catch (error) { - pageUiState.localIndexSummary = { - scopeKey: scopeKey, - loading: false, - error: error instanceof Error ? error.message : String(error), - backlinks: [], - tags: [] - }; - } - renderPageSettingsLocalIndex(); - } - - 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; - }); - renderGlobalOptions(popover); - var statsNode = popover.querySelector('[data-testid="wolai-page-settings-stats"]'); - if (statsNode instanceof HTMLElement) { - var stats = computeLivePageStats(); - statsNode.innerHTML = '' + - '字数 ' + Number(stats.wordCount || 0) + '' + - '字符 ' + Number(stats.characterCount || 0) + '' + - '块数 ' + Number(stats.blockCount || 0) + '' + - '待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + ''; - } - renderPageSettingsLocalIndex(popover); - } - - 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; - }); - if (tabName === 'index') void loadPageSettingsLocalIndex(false); - } - - 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), - ...currentWorkspaceSourcePayload(), - 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(); - } - const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({ attachmentClassForFileName: (...args) => attachmentClassForFileName(...args), buildLocalFileOpenUrl, diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 5c393d98..2b72371e 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -158,6 +158,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/sidebar-page-ai-runtime.js", get(web_shell::sidebar_page_ai_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/sidebar-page-settings-runtime.js", + get(web_shell::sidebar_page_settings_runtime_asset), + ) .route( "/api/mnote-browser-runtime/sidebar-tree-runtime.js", get(web_shell::sidebar_tree_runtime_asset), @@ -655,6 +659,7 @@ mod tests { "/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js", "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js", "/api/mnote-browser-runtime/sidebar-page-ai-runtime.js", + "/api/mnote-browser-runtime/sidebar-page-settings-runtime.js", "/api/mnote-browser-runtime/sidebar-tree-runtime.js", "/api/mnote-browser-runtime/tree-live-controller.js", "/api/mnote-browser-runtime/tree-shell-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 71ec971d..016886ff 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -742,6 +742,20 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn sidebar_page_settings_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn sidebar_shell_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-shell-runtime.js"); Response::builder() diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 10524ae9..9f31d438 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -217,6 +217,8 @@ mod tests { include_str!("../../../browser/sidebar-page-tree-runtime.js"); const SIDEBAR_PAGE_AI_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-page-ai-runtime.js"); + const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str = + include_str!("../../../browser/sidebar-page-settings-runtime.js"); const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str = include_str!("../../../browser/filetree-context-menu-runtime.js"); const FILETREE_DND_RUNTIME_JS: &str = include_str!("../../../browser/filetree-dnd-runtime.js"); @@ -255,9 +257,8 @@ mod tests { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_SIDEBAR_TREE_MODE_KEY")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY")); - assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\"") - ); + assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS + .contains("data-global-option-checkbox=\"showHeadingNumbers\"")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("treeView")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree:local-command")); diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs index 5948425d..daefff81 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod overlays; pub(crate) mod persistence; pub(crate) mod runtime_bridge; pub(crate) mod slash_actions; +pub(crate) mod slash_menu_view; pub(crate) mod style; pub(crate) mod table_commands; pub(crate) mod table_toolbar_view; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs new file mode 100644 index 00000000..f59ce24b --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/slash_menu_view.rs @@ -0,0 +1,271 @@ +use crate::editor_runtime::{ + block_hover_state::HoveredBlockState, + block_transform::run_slash_action, + bridge_events::{ChangeMetaPayload, ChangePayload}, + command_sync::read_editor_snapshot, + overlays::toolbar_overlay_locked, + persistence::{persist_document_state, persisted_document_identity, PersistedDocumentIdentity}, + runtime_bridge::dispatch_change_event_to_target, + slash_actions::{SlashActionKind, SLASH_ACTIONS}, +}; +use leptos::prelude::*; +use leptos_tiptap::TiptapEditorHandle; +use serde_json::Value; +use std::sync::Arc; +use web_sys::EventTarget; + +pub(crate) struct SlashMenuViewProps { + pub(crate) editor: TiptapEditorHandle, + pub(crate) signals: SlashMenuSignals, + pub(crate) runtime: SlashMenuRuntimeContext, + pub(crate) anchor_style: Arc String + Send + Sync>, +} + +#[derive(Clone, Copy)] +pub(crate) struct SlashMenuSignals { + pub(crate) slash_open: ReadSignal, + pub(crate) set_slash_open: WriteSignal, + pub(crate) slash_index: ReadSignal, + pub(crate) slash_query: ReadSignal, + pub(crate) dirty_count: ReadSignal, + pub(crate) set_dirty_count: WriteSignal, + pub(crate) document_json: WriteSignal, + pub(crate) html_output: WriteSignal, + pub(crate) json_output: WriteSignal, + pub(crate) document_id: ReadSignal>, + pub(crate) workspace_id: ReadSignal>, + pub(crate) title: ReadSignal, + pub(crate) editor_focused: ReadSignal, + pub(crate) hovered_block: ReadSignal>, + pub(crate) turn_into_open: ReadSignal, + pub(crate) color_menu_open: ReadSignal, + pub(crate) more_menu_open: ReadSignal, + pub(crate) revision: ReadSignal>, + pub(crate) conflict_detection_key: ReadSignal>, + pub(crate) read_only: ReadSignal, + pub(crate) command_feedback: WriteSignal, +} + +#[derive(Clone)] +pub(crate) struct SlashMenuRuntimeContext { + pub(crate) change_event_target: Option, + pub(crate) dispatch_runtime_state: Arc, +} + +fn runtime_persisted_identity( + document_id: ReadSignal>, + workspace_id: ReadSignal>, +) -> PersistedDocumentIdentity { + persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked()) +} + +fn filtered_slash_intro_view( + query: &str, + command_feedback: WriteSignal, +) -> impl IntoView { + let show_turn_into = !query.is_empty() + && ("zhw".contains(query) || "转换为".contains(query)); + let show_page = !query.is_empty() && ("ym".contains(query) || "页面".contains(query)); + + if show_turn_into || show_page { + view! { + <> + {if show_turn_into { + view! { + <> + + + + }.into_any() + } else { + ().into_any() + }} + {if show_page { + view! { + <> + + + + }.into_any() + } else { + ().into_any() + }} + + } + .into_any() + } else { + ().into_any() + } +} + +fn handle_slash_item_click( + editor: TiptapEditorHandle, + kind: SlashActionKind, + signals: SlashMenuSignals, + runtime: &SlashMenuRuntimeContext, +) { + match run_slash_action(editor, kind) { + Ok(message) => { + signals.set_slash_open.set(false); + signals.set_dirty_count.update(|count| *count += 1); + let (html, snapshot, json_text) = read_editor_snapshot(editor); + signals.html_output.set(html.clone()); + signals.document_json.set(snapshot.clone()); + signals.json_output.set(json_text); + if let Some(target) = runtime.change_event_target.as_ref() { + dispatch_change_event_to_target( + target, + &ChangePayload { + document_id: signals.document_id.get_untracked(), + workspace_id: signals.workspace_id.get_untracked(), + title: signals.title.get_untracked(), + content: snapshot.clone(), + meta: ChangeMetaPayload { + dirty_count: signals.dirty_count.get_untracked(), + editor_focused: signals.editor_focused.get_untracked(), + slash_open: signals.slash_open.get_untracked(), + toolbar_open: toolbar_overlay_locked( + signals.turn_into_open.get_untracked(), + signals.color_menu_open.get_untracked(), + signals.more_menu_open.get_untracked(), + ), + selected_block_index: signals + .hovered_block + .get_untracked() + .map(|block| block.index), + revision: signals.revision.get_untracked(), + conflict_detection_key: signals.conflict_detection_key.get_untracked(), + read_only: signals.read_only.get_untracked(), + }, + }, + ); + (runtime.dispatch_runtime_state)(target); + } + match persist_document_state( + &runtime_persisted_identity(signals.document_id, signals.workspace_id), + &signals.title.get_untracked(), + &snapshot, + Some(html), + ) { + Ok(()) => signals + .command_feedback + .set(format!("{message},并已写入本地草稿")), + Err(err) => signals + .command_feedback + .set(format!("{message},但本地保存失败:{err}")), + } + } + Err(err) => { + signals.set_slash_open.set(false); + signals + .command_feedback + .set(format!("命令执行失败:{err}")); + } + } +} + +pub(crate) fn slash_menu_view(props: SlashMenuViewProps) -> impl IntoView { + let SlashMenuViewProps { + editor, + signals, + runtime, + anchor_style, + } = props; + + view! { + {move || { + if signals.slash_open.get() { + let anchor_style = anchor_style.clone(); + let runtime = runtime.clone(); + view! { +
+
+ {move || { + let query = signals.slash_query.get().trim().to_ascii_lowercase(); + filtered_slash_intro_view(&query, signals.command_feedback) + }} + {SLASH_ACTIONS.iter().enumerate().map(|(index, action)| { + let is_selected = move || signals.slash_index.get() == index; + let kind = action.kind; + let label = action.label; + let description = action.description; + let shortcut = action.shortcut; + let icon = action.icon; + let category = action.category; + let show_category = index == 0 + || SLASH_ACTIONS[index - 1].category != category; + let testid = format!("slash-item-{}", action.id); + let runtime = runtime.clone(); + view! { + <> + {if show_category { + view! { }.into_any() + } else { + ().into_any() + }} + + + } + }).collect_view()} +
+
+ } + .into_any() + } else { + ().into_any() + } + }} + } +} diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 083b2e9a..7dff2007 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -80,6 +80,9 @@ use editor_runtime::runtime_bridge::{ dispatch_status_event_to_target, register_runtime_listener, take_unmount_handle, }; use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS}; +use editor_runtime::slash_menu_view::{ + slash_menu_view, SlashMenuRuntimeContext, SlashMenuSignals, SlashMenuViewProps, +}; use editor_runtime::style::SPIKE_STYLE; use editor_runtime::table_toolbar_view::table_toolbar_view; @@ -2240,6 +2243,54 @@ fn App(mount_options: MountOptions) -> impl IntoView { } }; + let slash_menu_runtime = SlashMenuRuntimeContext { + change_event_target: slash_change_event_target.clone(), + dispatch_runtime_state: std::sync::Arc::new(move |target: &EventTarget| { + dispatch_runtime_state_to_target( + target, + document_id.get_untracked(), + workspace_id.get_untracked(), + title.get_untracked(), + dirty_count.get_untracked(), + hovered_block.get_untracked(), + editor_focused.get_untracked(), + slash_open.get_untracked(), + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + read_only.get_untracked(), + ); + }), + }; + let slash_menu_props = SlashMenuViewProps { + editor, + signals: SlashMenuSignals { + slash_open, + set_slash_open, + slash_index, + slash_query, + dirty_count, + set_dirty_count, + document_json: set_document_json, + html_output: set_html_output, + json_output: set_json_output, + document_id, + workspace_id, + title, + editor_focused, + hovered_block, + turn_into_open, + color_menu_open, + more_menu_open, + revision, + conflict_detection_key, + read_only, + command_feedback: set_command_feedback, + }, + runtime: slash_menu_runtime, + anchor_style: std::sync::Arc::new(slash_menu_anchor_style), + }; + view! {
impl IntoView { } }} - {move || { - if slash_open.get() { - view! { -
-
- {move || { - let query = slash_query.get().trim().to_ascii_lowercase(); - let show_turn_into = !query.is_empty() - && ("zhw".contains(query.as_str()) - || "转换为".contains(query.as_str())); - let show_page = !query.is_empty() - && ("ym".contains(query.as_str()) - || "页面".contains(query.as_str())); - if show_turn_into || show_page { - view! { - <> - {if show_turn_into { - view! { - <> - - - - }.into_any() - } else { - ().into_any() - }} - {if show_page { - view! { - <> - - - - }.into_any() - } else { - ().into_any() - }} - - }.into_any() - } else { - ().into_any() - } - }} - {SLASH_ACTIONS.iter().enumerate().map(|(index, action)| { - let is_selected = move || slash_index.get() == index; - let kind = action.kind; - let label = action.label; - let description = action.description; - let shortcut = action.shortcut; - let icon = action.icon; - let category = action.category; - let show_category = index == 0 - || SLASH_ACTIONS[index - 1].category != category; - let testid = format!("slash-item-{}", action.id); - let slash_change_event_target = slash_change_event_target.clone(); - view! { - <> - {if show_category { - view! { }.into_any() - } else { - ().into_any() - }} - - - } - }).collect_view()} -
-
- } - .into_any() - } else { - ().into_any() - } - }} + {slash_menu_view(slash_menu_props)}