diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 6b2f8436..04acbef0 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -78,7 +78,8 @@ const SIDEBAR_TREE_JS: &str = r##" }; function closestAction(target, selector) { - return target && typeof target.closest === 'function' ? target.closest(selector) : null; + var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target; + return node && typeof node.closest === 'function' ? node.closest(selector) : null; } function toggleWorkspaceSidebar(trigger) { @@ -7135,6 +7136,21 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function localFileOpenPathFromHref(href) { + try { + var url = new URL(String(href || ''), window.location.origin); + if (url.pathname !== '/api/local-folder/files/open') return ''; + return String(url.searchParams.get('path') || '').trim(); + } catch (_) { + return ''; + } + } + + function fileNameFromPath(path) { + var value = String(path || '').trim(); + return value.indexOf('/') >= 0 ? value.split('/').pop() : value; + } + function isOnlyOfficeAttachmentHref(href) { try { var url = new URL(String(href || ''), window.location.origin); @@ -7169,9 +7185,10 @@ const SIDEBAR_TREE_JS: &str = r##" function detailFromEditorAttachmentLink(link) { var rawHref = link instanceof HTMLAnchorElement ? link.href : ''; var params = attachmentQueryParams(rawHref); - var fileName = params.get('fileName') || (link ? link.textContent : '') || '未命名附件'; + var localFilePath = localFileOpenPathFromHref(rawHref); + var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || (link ? link.textContent : '') || '未命名附件'; var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, ''); - var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || ''; + var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || (localFilePath ? 'local-file:' + localFilePath : ''); var fileUrl = params.get('fileUrl') || ''; var href = rawHref; if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) { @@ -7205,14 +7222,16 @@ const SIDEBAR_TREE_JS: &str = r##" if (!(link instanceof HTMLAnchorElement)) return; var href = link.getAttribute('href') || ''; var params = attachmentQueryParams(href); - var fileName = params.get('fileName') || link.textContent || ''; + var localFilePath = localFileOpenPathFromHref(href); + var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || link.textContent || ''; var className = link.getAttribute('class') || ''; var shouldEnhance = isOnlyOfficeAttachmentHref(href) || className.indexOf('mnote-uploaded-attachment-row') >= 0 - || isOfficeFileName(fileName); + || isOfficeFileName(fileName) + || Boolean(localFilePath && (isPdfAttachmentFileName(fileName) || isCodeAttachmentFileName(fileName))); if (!shouldEnhance) return; link.setAttribute('data-mnote-attachment-link', 'true'); - var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || ''; + var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : ''); if (assetId) link.setAttribute('data-asset-id', assetId); if (isOnlyOfficeAttachmentHref(href)) { link.setAttribute('href', buildOnlyOfficeOpenPath({ @@ -7458,6 +7477,27 @@ const SIDEBAR_TREE_JS: &str = r##" var attachmentObserver = new MutationObserver(function() { enhanceEditorAttachmentLinks(); }); attachmentObserver.observe(document.documentElement, { childList: true, subtree: true }); + function interceptEditorAttachmentLink(event) { + var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return; + event.preventDefault(); + event.stopPropagation(); + if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation(); + openEditorAttachmentLink(editorAttachmentLink); + } + + window.addEventListener('mousedown', interceptEditorAttachmentLink, true); + window.addEventListener('click', interceptEditorAttachmentLink, true); + + document.addEventListener('click', function(e) { + var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return; + e.preventDefault(); + e.stopPropagation(); + if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); + openEditorAttachmentLink(editorAttachmentLink); + }, true); + document.addEventListener('mouseover', function(event) { var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); if (!(link instanceof HTMLAnchorElement)) return; diff --git a/scripts/task459-local-markdown-attachment-tab-smoke.js b/scripts/task459-local-markdown-attachment-tab-smoke.js new file mode 100644 index 00000000..45066445 --- /dev/null +++ b/scripts/task459-local-markdown-attachment-tab-smoke.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + return url.toString(); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ownerId}:task459`, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + } +} + +async function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task459-md-attachment-")); + const relativePath = "README.md"; + const documentId = localMdDocumentId(relativePath); + writeWorkspaceManifest(root, "user_real"); + fs.mkdirSync(path.join(root, "README"), { recursive: true }); + fs.writeFileSync( + path.join(root, relativePath), + [ + "---", + "title: MD Attachment", + "---", + "", + "# MD Attachment", + "", + "[resource-note.md](README/resource-note.md)", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync(path.join(root, "README", "resource-note.md"), "# Resource Note\n\n资源正文\n", "utf8"); + + const browser = await chromium.launch({ + headless: process.env.HEADFUL !== "1", + executablePath: CHROMIUM_EXECUTABLE_PATH, + }); + const context = await browser.newContext({ + viewport: { width: 1360, height: 900 }, + extraHTTPHeaders: { + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + }); + const page = await context.newPage(); + const popups = []; + page.on("popup", (popup) => popups.push(popup)); + try { + await quickLogin(page); + await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const link = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"][href*="README%2Fresource-note.md"]').first(); + await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction(() => { + const node = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"][href*="README%2Fresource-note.md"]'); + return node && node.classList.contains("mnote-uploaded-attachment-row") && node.getAttribute("data-mnote-attachment-link") === "true"; + }, null, { timeout: UI_TIMEOUT_MS }); + + await link.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const resourceEditor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first(); + await resourceEditor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const resourceText = await resourceEditor.innerText({ timeout: UI_TIMEOUT_MS }); + assert(resourceText.includes("资源正文"), `MD 附件应在资源 tab 中用 tiptap 打开:${resourceText}`); + assert.equal(popups.length, 0, `点击 MD 附件不应打开浏览器新窗口,实际 popup=${popups.length}`); + const pageText = await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().innerText({ + timeout: UI_TIMEOUT_MS, + }); + assert(pageText.includes("resource-note.md"), "主页面正文应保持原附件链接"); + console.log(JSON.stringify({ ok: true, root, documentId, popups: popups.length }, null, 2)); + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +});