From 4c62ea7e357e859037fbfbbbfde51150e26623e6 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Wed, 20 May 2026 17:47:09 +0800 Subject: [PATCH] fix: repair resource tab and slash menu regressions --- .../crates/mnote-web/src/routes/onlyoffice.rs | 39 +++- rust/crates/mnote-web/src/routes/web_shell.rs | 180 +++++++++++++++++- .../mnote-web/src/ssr/pages/document.rs | 2 +- rust/crates/mnote-web/src/ssr/styles.rs | 36 +++- rust/spikes/leptos-tiptap-spike/src/lib.rs | 72 ++++++- ...4-rust-onlyoffice-attachment-open-smoke.js | 20 ++ ...ask455-local-folder-mindmap-clean-smoke.js | 45 +++++ .../task457-main-editor-resource-tab-smoke.js | 27 +++ 8 files changed, 404 insertions(+), 17 deletions(-) diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index a2ae1d88..5ef904d0 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -223,6 +223,13 @@ fn onlyoffice_internal_candidates() -> Vec { candidates } +fn onlyoffice_document_url_base_js() -> String { + env_or_dotenv("ONLYOFFICE_DOCUMENT_URL_BASE") + .or_else(|| env_or_dotenv("MNOTE_WEB_ONLYOFFICE_INTERNAL_BASE_URL")) + .and_then(|value| normalize_http_origin(&value)) + .unwrap_or_else(|| "http://host.docker.internal:3000".into()) +} + async fn resolve_onlyoffice_internal_url() -> String { let client = reqwest::Client::builder() .timeout(Duration::from_millis(2_500)) @@ -349,6 +356,7 @@ pub async fn page(Query(query): Query) -> Result "view", _ => "edit", }; + let document_url_base = onlyoffice_document_url_base_js(); let html = format!( r#" @@ -384,7 +392,8 @@ pub async fn page(Query(query): Query) -> Result) -> Result) -> Result) -> AppState { AppState::new(AppConfig { service_name: "mnote-web".into(), diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index f82c73ed..837bc504 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -613,6 +613,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const CHANGE_EVENT = `${EVENT_PREFIX}:change`; const SAVE_EVENT = `${EVENT_PREFIX}:save-request`; const READY_EVENT = `${EVENT_PREFIX}:ready`; + const STATE_EVENT = `${EVENT_PREFIX}:state`; const ERROR_EVENT = `${EVENT_PREFIX}:error`; const COMMAND_EVENT = `${EVENT_PREFIX}:command`; const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1'; @@ -2898,7 +2899,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (view.onError) root.removeEventListener(ERROR_EVENT, view.onError); if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange); if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave); + if (view.onState) root.removeEventListener(STATE_EVENT, view.onState); + if (view.onKeydown) root.removeEventListener('keydown', view.onKeydown, true); if (view.onInput) root.removeEventListener('input', view.onInput); + if (view.disconnectSlashObserver) { + view.disconnectSlashObserver(); + view.disconnectSlashObserver = null; + } if (view.mountId != null && typeof view.runtime?.unmount === 'function') { try { view.runtime.unmount(view.mountId); @@ -2929,6 +2936,135 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { view.disconnectObserver = () => observer.disconnect(); }; + const activeEditorRootForSlashMenu = () => { + const selection = window.getSelection(); + const anchorNode = selection?.anchorNode || null; + const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE + ? anchorNode + : anchorNode?.parentElement || null; + const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => node instanceof HTMLElement); + if (anchorElement instanceof Element) { + const activeRoot = roots.find((root) => root.contains(anchorElement)); + if (activeRoot) return activeRoot; + } + return roots.find((root) => root.offsetParent !== null) || roots[0] || null; + }; + + const slashMenuAnchorFromSelection = (root) => { + const currentBlockFromSelection = () => { + try { + const selection = window.getSelection(); + const anchorNode = selection?.anchorNode || null; + const editor = root.querySelector('.ProseMirror'); + const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE + ? anchorNode + : anchorNode?.parentElement || null; + if (!(editor instanceof HTMLElement) || !(anchorElement instanceof Element) || !editor.contains(anchorElement)) return null; + let current = anchorElement; + while (current && current.parentElement && current.parentElement !== editor) { + current = current.parentElement; + } + return current instanceof HTMLElement && current.parentElement === editor ? current : null; + } catch (_) { + return null; + } + }; + const fallback = () => { + const editor = root.querySelector('.ProseMirror'); + if (editor instanceof HTMLElement) { + const block = currentBlockFromSelection() + || Array.from(editor.children).find((node) => node instanceof HTMLElement && node.matches(':focus-within, .ProseMirror-selectednode')) + || Array.from(editor.children).find((node) => node instanceof HTMLElement && node.getBoundingClientRect().height > 0); + if (block instanceof HTMLElement) { + const rect = block.getBoundingClientRect(); + return { top: rect.bottom + 8, left: rect.left, anchorTop: rect.top }; + } + } + const rect = root.getBoundingClientRect(); + return { top: rect.top + 24, left: rect.left + 24, anchorTop: rect.top + 24 }; + }; + try { + const selection = window.getSelection(); + if (!selection || selection.rangeCount <= 0) return fallback(); + const anchorNode = selection.anchorNode; + if (anchorNode && !root.contains(anchorNode.nodeType === Node.ELEMENT_NODE ? anchorNode : anchorNode.parentElement)) { + return fallback(); + } + const rect = selection.getRangeAt(0).getBoundingClientRect(); + if (rect && (rect.top > 0 || rect.left > 0 || rect.height > 0)) { + return { top: rect.bottom + 8, left: rect.left, anchorTop: rect.top }; + } + } catch (_) {} + return fallback(); + }; + + const positionSlashMenuForRoot = (root) => { + if (!(root instanceof HTMLElement)) root = activeEditorRootForSlashMenu(); + if (!(root instanceof HTMLElement)) return; + const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]') + || document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); + if (!(menu instanceof HTMLElement)) return; + const anchor = slashMenuAnchorFromSelection(root); + const gap = 8; + const menuWidth = Math.min(316, Math.max(160, window.innerWidth - 16)); + const menuHeight = Math.min(430, Math.max(120, window.innerHeight - 16)); + const left = Math.min(Math.max(gap, anchor.left), Math.max(gap, window.innerWidth - menuWidth - gap)); + const belowTop = Math.min(Math.max(gap, anchor.top), Math.max(gap, window.innerHeight - 120)); + let top = belowTop + menuHeight > window.innerHeight - gap + ? Math.min(Math.max(gap, anchor.anchorTop - menuHeight - gap), Math.max(gap, window.innerHeight - 120)) + : belowTop; + const mindmap = document.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + if (mindmap instanceof HTMLElement) { + const mindmapRect = mindmap.getBoundingClientRect(); + const overlapsMindmap = top < mindmapRect.bottom && top + menuHeight > mindmapRect.top; + if (overlapsMindmap && mindmapRect.bottom > window.innerHeight - 48) { + top = Math.max(top, Math.max(gap, window.innerHeight - menuHeight - gap)); + } else if (overlapsMindmap && mindmapRect.bottom + menuHeight + gap <= window.innerHeight) { + top = mindmapRect.bottom + gap; + } + } + menu.style.position = 'fixed'; + menu.style.zIndex = '130'; + menu.style.left = `${Math.round(left)}px`; + menu.style.top = `${Math.round(top)}px`; + menu.style.width = `min(316px, calc(100vw - 16px))`; + menu.style.maxHeight = `min(430px, calc(100vh - 16px))`; + menu.setAttribute('data-mnote-slash-positioned', 'host'); + }; + + const scheduleSlashMenuPosition = (root) => { + window.requestAnimationFrame(() => positionSlashMenuForRoot(root)); + }; + + const scheduleGlobalSlashMenuPosition = () => { + scheduleSlashMenuPosition(activeEditorRootForSlashMenu()); + }; + + const installGlobalSlashMenuPositioning = () => { + if (window.__MNOTE_SLASH_MENU_POSITIONING_INSTALLED__) return; + window.__MNOTE_SLASH_MENU_POSITIONING_INSTALLED__ = true; + if (typeof MutationObserver === 'function' && document.body instanceof HTMLElement) { + const observer = new MutationObserver(() => scheduleGlobalSlashMenuPosition()); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden'] }); + } + document.addEventListener('keydown', (event) => { + if (event.key === '/') scheduleGlobalSlashMenuPosition(); + }, true); + document.addEventListener('selectionchange', () => scheduleGlobalSlashMenuPosition(), true); + window.addEventListener('resize', scheduleGlobalSlashMenuPosition); + window.addEventListener('scroll', scheduleGlobalSlashMenuPosition, true); + }; + + installGlobalSlashMenuPositioning(); + + const observeSlashMenuPosition = (view) => { + const root = view.runtimeDescriptor.root; + if (!(root instanceof HTMLElement) || typeof MutationObserver !== 'function') return; + const observer = new MutationObserver(() => scheduleSlashMenuPosition(root)); + observer.observe(document.body, { childList: true, subtree: true }); + view.disconnectSlashObserver = () => observer.disconnect(); + }; + const createEditorViewBinding = (session, runtime, runtimeDescriptor) => { cancelDocumentSessionRelease(session); const view = { @@ -2946,7 +3082,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { onError: null, onChange: null, onSave: null, + onState: null, + onKeydown: null, onInput: null, + disconnectSlashObserver: null, }; view.onReady = () => { @@ -2965,23 +3104,34 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error'); }; view.onChange = (event) => { + scheduleSlashMenuPosition(runtimeDescriptor.root); handleSessionChange(session, view, event); }; view.onSave = (event) => { handleSessionChange(session, view, event); }; + view.onState = () => { + scheduleSlashMenuPosition(runtimeDescriptor.root); + }; + view.onKeydown = (event) => { + if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root); + }; view.onInput = (event) => { const target = event.target; if (!(target instanceof Element) || !target.closest('.ProseMirror')) return; session.lastUserInputAt = Date.now(); + scheduleSlashMenuPosition(runtimeDescriptor.root); }; runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady); runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError); runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange); runtimeDescriptor.root.addEventListener(SAVE_EVENT, view.onSave); + runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState); + runtimeDescriptor.root.addEventListener('keydown', view.onKeydown, true); runtimeDescriptor.root.addEventListener('input', view.onInput); session.views.set(view.id, view); + observeSlashMenuPosition(view); observeEditorViewBinding(view); return view; }; @@ -2994,13 +3144,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { panelRoot: document.querySelector('[data-mnote-resource-tab-panel-root]'), }); - const resourceIconForKind = (kind) => { - if (kind === 'office') return 'article'; - if (kind === 'pdf') return 'picture_as_pdf'; + const resourceTabBadgeKind = (input, kind) => { + const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); + if (kind === 'office') { + if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt'; + if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet'; + return 'word'; + } + if (kind === 'pdf') return 'ppt'; + if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code'; if (kind === 'image') return 'image'; - if (kind === 'markdown') return 'notes'; - if (kind === 'code') return 'code'; - return 'draft'; + return 'file'; }; const currentWebShellWorkspaceId = () => { @@ -3078,7 +3232,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { tab.setAttribute('role', 'tab'); tab.setAttribute('data-mnote-main-tab', objectIdentity); tab.setAttribute('data-mnote-tab-kind', kind); - tab.innerHTML = '×'; + tab.setAttribute('data-mnote-tab-badge-kind', resourceTabBadgeKind(input, kind)); + tab.innerHTML = '×'; const titleNode = tab.querySelector('.mnote-main-tab-title'); if (titleNode) titleNode.textContent = title; tab.addEventListener('click', (event) => { @@ -3985,6 +4140,17 @@ mod tests { assert!(html.contains("data-pane-role=\"primary\"")); assert!(html.contains("data-document-pane-resizer=\"true\"")); assert!(html.contains("data-mnote-main-tab-strip")); + assert!(html.contains("class=\"mnote-main-tab-badge\"")); + assert!(!html.contains(">description - + {title.clone()} diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index b5c97f21..f61875d7 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -2417,9 +2417,34 @@ body { box-shadow: inset 0 1px 0 #E9E9E8, inset 1px 0 0 #E9E9E8, inset -1px 0 0 #E9E9E8; } -.mnote-main-tab .material-symbols-outlined { - font-size: 16px; - flex: 0 0 auto; +.mnote-main-tab-badge { + width: 14px; + height: 14px; + flex: 0 0 14px; + border-radius: 2px; + background: #6b7280; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12); +} + +.mnote-main-tab[data-mnote-tab-kind="page"] .mnote-main-tab-badge, +.mnote-main-tab[data-mnote-tab-badge-kind="code"] .mnote-main-tab-badge { + background: #111111; +} + +.mnote-main-tab[data-mnote-tab-badge-kind="word"] .mnote-main-tab-badge { + background: #4f7df3; +} + +.mnote-main-tab[data-mnote-tab-badge-kind="ppt"] .mnote-main-tab-badge { + background: #d94841; +} + +.mnote-main-tab[data-mnote-tab-badge-kind="sheet"] .mnote-main-tab-badge { + background: #2f9e44; +} + +.mnote-main-tab[data-mnote-tab-badge-kind="image"] .mnote-main-tab-badge { + background: #5b6cf0; } .mnote-main-tab-title { @@ -4195,6 +4220,11 @@ mod tests { assert!(MNOTE_CSS.contains("background: #2f9e44")); assert!(MNOTE_CSS.contains("a.mnote-uploaded-attachment-code::before")); assert!(MNOTE_CSS.contains("background: #111111")); + assert!(MNOTE_CSS.contains(".mnote-main-tab-badge")); + assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"word\"]")); + assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"ppt\"]")); + assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"sheet\"]")); + assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"code\"]")); } #[test] diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 08827c1a..4d7dcd9b 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -1899,12 +1899,12 @@ const SPIKE_STYLE: &str = r#" } .slash-menu { - position: absolute; + position: fixed; top: 24px; left: 24px; - z-index: 11; - width: min(316px, calc(100% - 48px)); - max-height: min(430px, calc(100vh - 180px)); + z-index: 130; + width: min(316px, calc(100vw - 16px)); + max-height: min(430px, calc(100vh - 16px)); display: grid; gap: 0; padding: 8px; @@ -3381,6 +3381,14 @@ mod tests { Some(5) ); } + + #[test] + fn slash_menu_css_uses_viewport_overlay_layer() { + assert!(STYLE.contains(".slash-menu")); + assert!(STYLE.contains("position: fixed;")); + assert!(STYLE.contains("z-index: 130;")); + assert!(!STYLE.contains("z-index: 11;")); + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -5480,6 +5488,56 @@ fn floating_toolbar_anchor_from_selection() -> Option { }) } +fn slash_menu_anchor_style() -> String { + const MENU_WIDTH: f64 = 316.0; + const MENU_HEIGHT: f64 = 430.0; + const GAP: f64 = 8.0; + + let viewport_width = window() + .and_then(|win| win.inner_width().ok()) + .and_then(|value| value.as_f64()) + .unwrap_or(1024.0) + .max(320.0); + let viewport_height = window() + .and_then(|win| win.inner_height().ok()) + .and_then(|value| value.as_f64()) + .unwrap_or(768.0) + .max(240.0); + + let selection_rect = window() + .and_then(|win| win.get_selection().ok().flatten()) + .and_then(|selection| selection.get_range_at(0).ok()) + .map(|range| range.get_bounding_client_rect()) + .filter(|rect| rect.top() > 0.0 || rect.left() > 0.0 || rect.height() > 0.0); + + let (raw_top, raw_left, raw_anchor_top) = if let Some(rect) = selection_rect { + (rect.bottom() + GAP, rect.left(), rect.top()) + } else if let Some(block) = hovered_block_from_selection() { + if let Some(stage) = editor_stage_element() { + let stage_rect = stage.get_bounding_client_rect(); + ( + stage_rect.top() + block.top + block.height + GAP, + stage_rect.left() + content_text_left(stage_rect.width()), + stage_rect.top() + block.top, + ) + } else { + (24.0, 24.0, 24.0) + } + } else { + (24.0, 24.0, 24.0) + }; + + let clamped_left = raw_left.clamp(GAP, (viewport_width - MENU_WIDTH - GAP).max(GAP)); + let below_limit = viewport_height - GAP; + let clamped_top = if raw_top + MENU_HEIGHT > below_limit { + (raw_anchor_top - MENU_HEIGHT - GAP).clamp(GAP, below_limit - 120.0) + } else { + raw_top.clamp(GAP, below_limit - 120.0) + }; + + format!("top:{clamped_top:.1}px;left:{clamped_left:.1}px;") +} + fn image_toolbar_anchor_from_image(image: &Element) -> Option { let stage = editor_stage_element()?; let rect = image.get_bounding_client_rect(); @@ -10762,7 +10820,11 @@ fn App(mount_options: MountOptions) -> impl IntoView { {move || { if slash_open.get() { view! { -
+
{move || { let query = slash_query.get().trim().to_ascii_lowercase(); diff --git a/scripts/task174-rust-onlyoffice-attachment-open-smoke.js b/scripts/task174-rust-onlyoffice-attachment-open-smoke.js index 79bcebfc..76c90a60 100644 --- a/scripts/task174-rust-onlyoffice-attachment-open-smoke.js +++ b/scripts/task174-rust-onlyoffice-attachment-open-smoke.js @@ -183,6 +183,24 @@ async function readOnlyOfficeDebug(page) { }); } +async function assertOnlyOfficeDocumentUrlReachableFromContainer(debug) { + const resolvedFileUrl = String(debug && debug.resolvedFileUrl || ""); + assert(resolvedFileUrl.includes("/api/onlyoffice/proxy"), `OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(debug)}`); + assert( + /^(http:\/\/host\.docker\.internal:3000|http:\/\/172\.17\.0\.1:3000)/.test(resolvedFileUrl), + `OnlyOffice resolvedFileUrl 应使用 DocumentServer 可回源的 mnote-web 地址:${resolvedFileUrl}`, + ); + const { execFile } = require("node:child_process"); + const { promisify } = require("node:util"); + const execFileAsync = promisify(execFile); + const response = await execFileAsync( + "docker", + ["exec", "mnote-onlyoffice-documentserver", "curl", "-fsSI", "--max-time", "10", resolvedFileUrl], + { timeout: 15_000, maxBuffer: 1024 * 1024 }, + ); + assert(/HTTP\/[0-9.]+ 200/.test(response.stdout), `DocumentServer 容器无法回源读取文档:${response.stdout}\n${response.stderr}`); +} + async function main() { const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); const context = await browser.newContext(); @@ -217,6 +235,8 @@ async function main() { const firstDebug = await readOnlyOfficeDebug(popup); assert(firstDebug.ready, "OnlyOffice ready flag 应为 true"); assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas"); + assert(firstDebug.debug && firstDebug.debug.documentUrlBase, `OnlyOffice debug 应暴露 documentUrlBase:${JSON.stringify(firstDebug)}`); + await assertOnlyOfficeDocumentUrlReachableFromContainer(firstDebug.debug); assert( /^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey), `OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`, diff --git a/scripts/task455-local-folder-mindmap-clean-smoke.js b/scripts/task455-local-folder-mindmap-clean-smoke.js index f44ec0a7..f271b93e 100644 --- a/scripts/task455-local-folder-mindmap-clean-smoke.js +++ b/scripts/task455-local-folder-mindmap-clean-smoke.js @@ -78,6 +78,50 @@ async function insertMindmapThroughSlash(page) { }); } +async function assertSlashMenuAnchorsAfterMindmap(page) { + const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror[contenteditable="true"]').first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End"); + await page.keyboard.type("/"); + await page.getByTestId("mnote-leptos-tiptap-slash-menu").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')?.getAttribute("data-mnote-slash-positioned") === "host", + null, + { timeout: UI_TIMEOUT_MS }, + ); + const state = await page.evaluate(() => { + const menu = document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); + const mindmap = document.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + const menuRect = menu?.getBoundingClientRect(); + const mindmapRect = mindmap?.getBoundingClientRect(); + const pointNode = menuRect + ? document.elementFromPoint(menuRect.left + 24, menuRect.top + 24) + : null; + return { + menuRect: menuRect ? { top: menuRect.top, bottom: menuRect.bottom, left: menuRect.left, right: menuRect.right } : null, + mindmapRect: mindmapRect ? { top: mindmapRect.top, bottom: mindmapRect.bottom, left: mindmapRect.left, right: mindmapRect.right } : null, + menuPosition: menu instanceof HTMLElement ? getComputedStyle(menu).position : "", + menuZIndex: menu instanceof HTMLElement ? getComputedStyle(menu).zIndex : "", + hostPositioned: menu instanceof HTMLElement ? menu.getAttribute("data-mnote-slash-positioned") || "" : "", + hitInsideMenu: Boolean(pointNode && menu && menu.contains(pointNode)), + viewportHeight: window.innerHeight, + }; + }); + assert(state.menuRect, `slash 菜单应可见: ${JSON.stringify(state)}`); + assert.equal(state.menuPosition, "fixed", `slash 菜单应由宿主定位到 viewport 层: ${JSON.stringify(state)}`); + assert(Number(state.menuZIndex) >= 120, `slash 菜单层级应高于 mindmap/块工具: ${JSON.stringify(state)}`); + assert(state.hitInsideMenu, `slash 菜单不应被思维导图或其它层遮挡: ${JSON.stringify(state)}`); + assert(state.menuRect.top >= 0 && state.menuRect.bottom <= state.viewportHeight, `slash 菜单不应超出视口: ${JSON.stringify(state)}`); + if (state.mindmapRect) { + assert( + state.menuRect.top > state.mindmapRect.top - 80, + `mindmap 后输入 / 时菜单不应回退到编辑器左上固定旧位置: ${JSON.stringify(state)}`, + ); + } + await page.keyboard.press("Escape"); + return state; +} + async function readState(page) { return page.evaluate(() => { const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); @@ -285,6 +329,7 @@ async function main() { { timeout: UI_TIMEOUT_MS }, ); result.saveAfterInsert = await waitForStableEditorSave(page, networkRecords, "after-insert"); + result.slashMenuAfterMindmap = await assertSlashMenuAnchorsAfterMindmap(page); result.diskAfterInsert = fs.readdirSync(pageDir).sort(); assert(result.diskAfterInsert.includes("CleanPage.md"), `页面 Markdown 应存在: ${result.diskAfterInsert.join(",")}`); diff --git a/scripts/task457-main-editor-resource-tab-smoke.js b/scripts/task457-main-editor-resource-tab-smoke.js index f9aaa6d7..17613b06 100644 --- a/scripts/task457-main-editor-resource-tab-smoke.js +++ b/scripts/task457-main-editor-resource-tab-smoke.js @@ -154,6 +154,21 @@ async function main() { state: "visible", timeout: UI_TIMEOUT_MS, }); + const markdownTabState = await page.evaluate(() => { + const pageTab = document.querySelector('[data-mnote-main-tab="page"]'); + const tab = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]'); + const badge = tab?.querySelector(".mnote-main-tab-badge"); + return { + pageText: pageTab?.textContent || "", + resourceText: tab?.textContent || "", + badgeKind: tab?.getAttribute("data-mnote-tab-badge-kind") || "", + badgeColor: badge ? getComputedStyle(badge).backgroundColor : "", + }; + }); + assert(!/\b(description|article|notes)\b/i.test(markdownTabState.pageText), `页面 tab 不应显示 Material Symbols 文本: ${JSON.stringify(markdownTabState)}`); + assert(!/\b(description|article|notes)\b/i.test(markdownTabState.resourceText), `Markdown tab 不应显示 Material Symbols 文本: ${JSON.stringify(markdownTabState)}`); + assert.equal(markdownTabState.badgeKind, "code", `Markdown tab 应使用黑色 code 色块: ${JSON.stringify(markdownTabState)}`); + assert(markdownTabState.badgeColor === "rgb(17, 17, 17)", `Markdown tab 色块应为黑色: ${JSON.stringify(markdownTabState)}`); const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first(); await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await editor.click({ timeout: UI_TIMEOUT_MS }); @@ -186,6 +201,18 @@ async function main() { state: "visible", timeout: UI_TIMEOUT_MS, }); + const officeTabState = await page.evaluate(() => { + const tab = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="office"]'); + const badge = tab?.querySelector(".mnote-main-tab-badge"); + return { + resourceText: tab?.textContent || "", + badgeKind: tab?.getAttribute("data-mnote-tab-badge-kind") || "", + badgeColor: badge ? getComputedStyle(badge).backgroundColor : "", + }; + }); + assert(!/\b(description|article|notes)\b/i.test(officeTabState.resourceText), `Office tab 不应显示 Material Symbols 文本: ${JSON.stringify(officeTabState)}`); + assert.equal(officeTabState.badgeKind, "word", `docx tab 应使用 word 色块: ${JSON.stringify(officeTabState)}`); + assert(officeTabState.badgeColor === "rgb(79, 125, 243)", `docx tab 色块应与附件 word 蓝色一致: ${JSON.stringify(officeTabState)}`); const officeFrameSrc = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src", { timeout: UI_TIMEOUT_MS, });