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 0b37e4b3..f08e553e 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
@@ -168,7 +168,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::web_shell
- [x] C3. `sidebar-filetree-open-runtime.js`:local markdown / resource open target、active resource tab dispatch。
- [ ] C4. `sidebar-filetree-command-runtime.js`:create/rename/delete/copy/move/trash/restore/purge command payload。
- [ ] C5. `sidebar-filetree-upload-runtime.js`:local upload target plan、drop/paste preflight、readonly guard。
-- [ ] C6. `sidebar-attachment-open-runtime.js`:OnlyOffice/PDF/code/image open mode 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 行。
diff --git a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js
new file mode 100644
index 00000000..854166b7
--- /dev/null
+++ b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js
@@ -0,0 +1,652 @@
+export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
+ const {
+ attachmentClassForFileName,
+ buildLocalFileOpenUrl,
+ buildLocalOnlyOfficeOpenUrl,
+ buildOnlyOfficeOpenPath,
+ buildOnlyOfficeOpenUrl,
+ currentDocumentId,
+ currentRootUri,
+ currentWorkspaceSourcePayload,
+ fileTreeIconKindForFileName,
+ inferCodeAttachmentLanguage,
+ inferOnlyOfficeFileType,
+ isCodeAttachmentFileName,
+ isPdfAttachmentFileName,
+ localFilePathFromAssetId,
+ openLocalOfficeFileInActiveTab,
+ openLocalResourceInActiveTab,
+ openTreeContextMenu,
+ resolveWorkspaceId,
+ triggerBrowserDownload,
+ uploadedFileSize,
+ } = dependencies;
+
+ var activeEditorAttachmentLink = null;
+ var attachmentActionsHideTimer = 0;
+
+ function attachmentQueryParams(href) {
+ try {
+ return new URL(String(href || ''), window.location.origin).searchParams;
+ } catch (_) {
+ return new URLSearchParams();
+ }
+ }
+
+ 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 localFileOpenRootUriFromHref(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('rootUri') || '').trim();
+ } catch (_) {
+ return '';
+ }
+ }
+
+ function buildLocalFileStatusUrl(relativePath, rootUri) {
+ var effectiveRootUri = String(rootUri || currentRootUri() || '').trim();
+ if (!effectiveRootUri || !relativePath) return '';
+ var url = new URL('/api/local-folder/files/stat', window.location.origin);
+ url.searchParams.set('rootUri', effectiveRootUri);
+ url.searchParams.set('path', relativePath);
+ return url.toString();
+ }
+
+ function setEditorAttachmentMissingState(link, missing) {
+ if (!(link instanceof HTMLAnchorElement)) return;
+ var value = Boolean(missing);
+ if (value) {
+ link.setAttribute('data-mnote-attachment-missing', 'true');
+ link.classList.add('mnote-uploaded-attachment-missing');
+ link.setAttribute('aria-label', (link.textContent || '附件') + '(文件不存在)');
+ } else {
+ if (link.getAttribute('data-mnote-attachment-missing') !== 'true') return;
+ link.removeAttribute('data-mnote-attachment-missing');
+ link.classList.remove('mnote-uploaded-attachment-missing');
+ link.removeAttribute('aria-label');
+ }
+ }
+
+ async function refreshLocalAttachmentExistence(link) {
+ if (!(link instanceof HTMLAnchorElement)) return;
+ var href = link.getAttribute('href') || link.href || '';
+ var localFilePath = localFileOpenPathFromHref(href);
+ if (!localFilePath) return;
+ var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href));
+ if (!statusUrl) return;
+ try {
+ var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' });
+ var payload = await response.json().catch(function() { return null; });
+ var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true);
+ setEditorAttachmentMissingState(link, !exists);
+ } catch (_) {}
+ }
+
+ function refreshEditorLocalAttachmentExistence() {
+ document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
+ if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
+ });
+ }
+ window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence;
+
+ 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);
+ return url.pathname === '/onlyoffice' && (url.searchParams.has('assetId') || url.searchParams.has('fileName'));
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function normalizeOnlyOfficeAttachmentHref(href) {
+ try {
+ var url = new URL(String(href || ''), window.location.origin);
+ if (url.pathname !== '/onlyoffice') return String(href || '');
+ return buildOnlyOfficeOpenUrl({
+ fileUrl: url.searchParams.get('fileUrl') || '',
+ fileName: url.searchParams.get('fileName') || '未命名附件',
+ fileType: url.searchParams.get('fileType') || inferOnlyOfficeFileType(url.searchParams.get('fileName') || '', ''),
+ assetId: url.searchParams.get('assetId') || '',
+ documentId: url.searchParams.get('documentId') || currentDocumentId() || '',
+ userId: url.searchParams.get('userId') || '',
+ mode: url.searchParams.get('mode') || 'view'
+ });
+ } catch (_) {
+ return String(href || '');
+ }
+ }
+
+ function isOfficeFileName(fileName) {
+ return Boolean(inferOnlyOfficeFileType(fileName, ''));
+ }
+
+ function editorAttachmentPaneContext(link) {
+ var pane = link && typeof link.closest === 'function' ? link.closest('[data-document-pane="true"]') : null;
+ var roleHost = link && typeof link.closest === 'function' ? link.closest('[data-pane-role]') : null;
+ var paneRole = (
+ pane instanceof HTMLElement && pane.getAttribute('data-pane-role') === 'secondary'
+ ) || (
+ roleHost instanceof HTMLElement && roleHost.getAttribute('data-pane-role') === 'secondary'
+ ) ? 'secondary' : 'primary';
+ var paneDocumentId = '';
+ var paneWorkspaceId = '';
+ if (pane instanceof HTMLElement) {
+ paneDocumentId = (pane.getAttribute('data-pane-document-id') || '').trim();
+ paneWorkspaceId = (pane.getAttribute('data-pane-workspace-id') || '').trim();
+ var shell = pane.querySelector('.document-shell[data-document-id]');
+ if (!paneDocumentId && shell instanceof HTMLElement) paneDocumentId = (shell.getAttribute('data-document-id') || '').trim();
+ if (!paneWorkspaceId && shell instanceof HTMLElement) paneWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim();
+ }
+ if (roleHost instanceof HTMLElement) {
+ if (!paneDocumentId) paneDocumentId = (roleHost.getAttribute('data-document-id') || '').trim();
+ if (!paneWorkspaceId) paneWorkspaceId = (roleHost.getAttribute('data-workspace-id') || '').trim();
+ var roleHostShell = roleHost.matches('.document-shell') ? roleHost : roleHost.querySelector?.('.document-shell[data-document-id]');
+ if (!paneDocumentId && roleHostShell instanceof HTMLElement) paneDocumentId = (roleHostShell.getAttribute('data-document-id') || '').trim();
+ if (!paneWorkspaceId && roleHostShell instanceof HTMLElement) paneWorkspaceId = (roleHostShell.getAttribute('data-workspace-id') || '').trim();
+ }
+ return {
+ paneRole: paneRole,
+ documentId: paneDocumentId || currentDocumentId() || '',
+ workspaceId: paneWorkspaceId || resolveWorkspaceId(document.body) || ''
+ };
+ }
+
+ function detailFromEditorAttachmentLink(link) {
+ var rawHref = link instanceof HTMLAnchorElement ? link.href : '';
+ var params = attachmentQueryParams(rawHref);
+ var paneContext = editorAttachmentPaneContext(link);
+ 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') : '') || (localFilePath ? 'local-file:' + localFilePath : '');
+ var fileUrl = params.get('fileUrl') || '';
+ var documentId = params.get('documentId') || paneContext.documentId || '';
+ var href = rawHref;
+ if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) {
+ fileUrl = rawHref;
+ href = buildOnlyOfficeOpenUrl({
+ fileUrl: fileUrl,
+ fileName: fileName,
+ fileType: fileType,
+ assetId: assetId,
+ documentId: documentId,
+ userId: '',
+ mode: 'view'
+ });
+ } else if (isOnlyOfficeAttachmentHref(rawHref)) {
+ href = normalizeOnlyOfficeAttachmentHref(rawHref);
+ }
+ return {
+ href: href,
+ fileUrl: fileUrl,
+ fileName: fileName,
+ title: fileName,
+ fileType: fileType,
+ assetId: assetId,
+ documentId: documentId,
+ workspaceId: paneContext.workspaceId,
+ paneRole: paneContext.paneRole,
+ fileSize: (link ? link.getAttribute('data-file-size') : '') || ''
+ };
+ }
+
+ function enhanceEditorAttachmentLink(link) {
+ if (!(link instanceof HTMLAnchorElement)) return;
+ var href = link.getAttribute('href') || '';
+ var params = attachmentQueryParams(href);
+ var localFilePath = localFileOpenPathFromHref(href);
+ var paneContext = editorAttachmentPaneContext(link);
+ 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)
+ || Boolean(localFilePath);
+ if (!shouldEnhance) return;
+ if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
+ void refreshLocalAttachmentExistence(link);
+ return;
+ }
+ link.setAttribute('data-mnote-attachment-link', 'true');
+ 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({
+ fileUrl: params.get('fileUrl') || '',
+ fileName: fileName || '未命名附件',
+ fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''),
+ assetId: assetId,
+ documentId: params.get('documentId') || paneContext.documentId || '',
+ userId: params.get('userId') || '',
+ mode: params.get('mode') || 'view'
+ }));
+ }
+ attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
+ if (name) link.classList.add(name);
+ });
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener noreferrer nofollow');
+ void hydrateEditorAttachmentMeta(link);
+ }
+
+ function enhanceEditorAttachmentLinks() {
+ document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink);
+ void healLegacyOfficeAttachmentParagraphs();
+ }
+ window.__mnoteEnhanceEditorAttachmentLinks = function() {
+ observeEditorAttachmentRoots();
+ enhanceEditorAttachmentLinks();
+ };
+
+ function ensureAttachmentActions() {
+ var existing = document.querySelector('[data-testid="mnote-attachment-actions"]');
+ if (existing instanceof HTMLElement) return existing;
+ var actions = document.createElement('div');
+ actions.className = 'mnote-attachment-actions';
+ actions.setAttribute('data-testid', 'mnote-attachment-actions');
+ actions.hidden = true;
+ actions.innerHTML = '' +
+ '' +
+ '';
+ actions.addEventListener('mouseenter', function() {
+ if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
+ });
+ actions.addEventListener('mouseleave', scheduleHideAttachmentActions);
+ document.body.appendChild(actions);
+ return actions;
+ }
+
+ function positionAttachmentActions(link) {
+ if (!(link instanceof HTMLElement)) return;
+ var actions = ensureAttachmentActions();
+ var rect = link.getBoundingClientRect();
+ actions.hidden = false;
+ actions.style.left = Math.min(window.innerWidth - 76, Math.max(8, rect.right + 6)) + 'px';
+ actions.style.top = Math.max(8, rect.top + (rect.height - 28) / 2) + 'px';
+ activeEditorAttachmentLink = link;
+ }
+
+ function scheduleHideAttachmentActions() {
+ if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
+ attachmentActionsHideTimer = window.setTimeout(function() {
+ var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
+ if (actions instanceof HTMLElement) actions.hidden = true;
+ activeEditorAttachmentLink = null;
+ }, 220);
+ }
+
+ async function openEditorAttachmentDetail(detail) {
+ if (!detail || !detail.href) return;
+ var localFilePath = localFilePathFromAssetId(detail.assetId);
+ if (localFilePath) {
+ if (await openLocalOfficeFileInActiveTab(detail, 'view')) return;
+ // 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口
+ void openLocalResourceInActiveTab({
+ path: localFilePath,
+ title: detail.fileName || localFilePath.split('/').pop() || localFilePath,
+ kind: fileTreeIconKindForFileName(detail.fileName || localFilePath.split('/').pop() || localFilePath),
+ assetId: detail.assetId,
+ documentId: detail.documentId || currentDocumentId() || '',
+ workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
+ href: buildLocalFileOpenUrl(localFilePath, false),
+ paneRole: detail.paneRole || 'primary'
+ }).then(function(opened) {
+ if (!opened) window.open(buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer');
+ });
+ return;
+ }
+ if (isPdfAttachmentFileName(detail.fileName)) {
+ void openPdfEditorAttachment(detail);
+ return;
+ }
+ if (isCodeAttachmentFileName(detail.fileName)) {
+ void openCodeEditorAttachment(detail);
+ return;
+ }
+ var fileType = String(detail.fileType || '').trim();
+ if (fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
+ void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
+ objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
+ assetId: detail.assetId || '',
+ title: detail.fileName || '附件',
+ fileName: detail.fileName || '附件',
+ kind: 'office',
+ officeUrl: detail.href,
+ documentId: detail.documentId || '',
+ workspaceId: detail.workspaceId || '',
+ paneRole: detail.paneRole || 'primary'
+ });
+ return;
+ }
+ window.open(detail.href, '_blank', 'noopener,noreferrer');
+ }
+
+ function openEditorAttachmentNewWindow(detail, mode) {
+ if (!detail) return;
+ var requestedMode = mode === 'edit' ? 'edit' : 'view';
+ var localFilePath = localFilePathFromAssetId(detail.assetId);
+ if (localFilePath) {
+ var localFileName = localFilePath.split('/').pop() || localFilePath;
+ var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode);
+ var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
+ window.open(localOfficeUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ var href = detail.href || detail.fileUrl;
+ if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
+ try {
+ var url = new URL(href, window.location.origin);
+ url.searchParams.set('mode', requestedMode);
+ href = url.toString();
+ } catch (_) {}
+ } else if (detail.fileType) {
+ href = buildOnlyOfficeOpenUrl({
+ fileUrl: detail.fileUrl || href || '',
+ fileName: detail.fileName || '未命名附件',
+ fileType: detail.fileType,
+ assetId: detail.assetId || '',
+ documentId: detail.documentId || currentDocumentId() || '',
+ userId: '',
+ mode: requestedMode
+ });
+ }
+ window.open(href, '_blank', 'noopener,noreferrer');
+ }
+
+ async function openEditorAttachmentEditTab(detail) {
+ if (!detail) return false;
+ var localFilePath = localFilePathFromAssetId(detail.assetId);
+ if (localFilePath) {
+ if (await openLocalOfficeFileInActiveTab(detail, 'edit')) return true;
+ }
+ var href = detail.href || detail.fileUrl;
+ if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
+ try {
+ var url = new URL(href, window.location.origin);
+ url.searchParams.set('mode', 'edit');
+ href = url.toString();
+ } catch (_) {}
+ } else if (detail.fileType) {
+ href = buildOnlyOfficeOpenUrl({
+ fileUrl: detail.fileUrl || href || '',
+ fileName: detail.fileName || '未命名附件',
+ fileType: detail.fileType,
+ assetId: detail.assetId || '',
+ documentId: detail.documentId || currentDocumentId() || '',
+ userId: '',
+ mode: 'edit'
+ });
+ }
+ if (detail.fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
+ var didOpenEditTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
+ objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
+ assetId: detail.assetId || '',
+ title: detail.fileName || '附件',
+ fileName: detail.fileName || '附件',
+ kind: 'office',
+ officeUrl: href,
+ documentId: detail.documentId || '',
+ workspaceId: detail.workspaceId || '',
+ paneRole: detail.paneRole || 'primary'
+ });
+ if (!didOpenEditTab && href) window.open(href, '_blank', 'noopener,noreferrer');
+ return didOpenEditTab;
+ }
+ if (href) window.open(href, '_blank', 'noopener,noreferrer');
+ return false;
+ }
+
+ async function resolveEditorAttachmentUrl(detail) {
+ var assetId = String(detail && detail.assetId || '').trim();
+ var localFilePath = localFilePathFromAssetId(assetId);
+ if (localFilePath) {
+ var localUrl = buildLocalFileOpenUrl(localFilePath, false);
+ if (localUrl) {
+ return {
+ url: localUrl,
+ asset: {
+ file_name: String(detail && detail.fileName || '').trim(),
+ fileSize: String(detail && detail.fileSize || '').trim()
+ }
+ };
+ }
+ }
+ if (assetId) {
+ var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
+ method: 'GET',
+ credentials: 'include',
+ cache: 'no-store'
+ });
+ var payload = await response.json().catch(function() { return null; });
+ if (!response.ok || !payload) {
+ throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
+ }
+ var signedUrl = String(payload && payload.signedUrl || '').trim();
+ if (!signedUrl) throw new Error('附件链接不可用');
+ return {
+ url: signedUrl,
+ asset: payload.asset && typeof payload.asset === 'object' ? payload.asset : {}
+ };
+ }
+ var url = String(detail && (detail.fileUrl || detail.href) || '').trim();
+ if (!url) throw new Error('附件链接不可用');
+ return { url: url, asset: {} };
+ }
+
+ async function openPdfEditorAttachment(detail) {
+ try {
+ var resolved = await resolveEditorAttachmentUrl(detail);
+ window.open(resolved.url, '_blank', 'noopener,noreferrer');
+ } catch (error) {
+ window.alert(error && error.message ? error.message : '打开 PDF 失败');
+ }
+ }
+
+ async function openCodeEditorAttachment(detail) {
+ var resolved = null;
+ try {
+ resolved = await resolveEditorAttachmentUrl(detail);
+ var size = Number(resolved.asset && (resolved.asset.file_size || resolved.asset.fileSize) || 0);
+ if (Number.isFinite(size) && size > 1024 * 1024) {
+ window.open(resolved.url, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ var response = await fetch(resolved.url, {
+ method: 'GET',
+ credentials: 'include',
+ cache: 'no-store'
+ });
+ if (!response.ok) throw new Error('读取附件内容失败');
+ var text = await response.text();
+ if (text.length > 1024 * 1024) {
+ window.open(resolved.url, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ var editorRoot = document.querySelector('.editor-surface .ProseMirror');
+ var editor = editorRoot && editorRoot.editor;
+ if (!editor || !editor.chain) {
+ window.open(resolved.url, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ var title = String(detail.fileName || resolved.asset.file_name || '附件').trim() || '附件';
+ var language = inferCodeAttachmentLanguage(title);
+ editor.chain().focus().insertContent([
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: title }]
+ },
+ {
+ type: 'codeBlock',
+ attrs: { language: language },
+ content: text ? [{ type: 'text', text: text.replace(/\r\n?/g, '\n') }] : []
+ }
+ ]).run();
+ } catch (error) {
+ console.warn('[mnote attachment] open code attachment failed', error);
+ if (resolved && resolved.url) {
+ window.open(resolved.url, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ window.alert(error && error.message ? error.message : '打开代码附件失败');
+ }
+ }
+
+ async function openEditorAttachmentDownload(detail) {
+ if (!detail) return;
+ var localFilePath = localFilePathFromAssetId(detail.assetId);
+ if (localFilePath) {
+ var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true);
+ if (localDownloadUrl) {
+ triggerBrowserDownload(localDownloadUrl);
+ return;
+ }
+ }
+ if (detail.assetId) {
+ try {
+ var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(detail.assetId), {
+ method: 'GET',
+ credentials: 'include'
+ });
+ var payload = await response.json().catch(function() { return null; });
+ var signedUrl = String(payload && payload.signedUrl || '').trim();
+ if (response.ok && signedUrl) {
+ window.open(signedUrl, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ } catch (_) {}
+ }
+ var localRelativePath = String(detail.localRelativePath || '').trim();
+ if (localRelativePath) {
+ var localPathDownloadUrl = buildLocalFileOpenUrl(localRelativePath, true);
+ if (localPathDownloadUrl) {
+ triggerBrowserDownload(localPathDownloadUrl);
+ return;
+ }
+ }
+ var target = detail.fileUrl || detail.href;
+ if (!target) return;
+ window.open(target, '_blank', 'noopener,noreferrer');
+ }
+
+ function openEditorAttachmentMenu(link, trigger, point) {
+ var detail = detailFromEditorAttachmentLink(link);
+ var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : link.getBoundingClientRect();
+ var x = point && typeof point.x === 'number' ? point.x : rect.right;
+ var y = point && typeof point.y === 'number' ? point.y : rect.bottom + 4;
+ openTreeContextMenu('attachment', detail, x, y, trigger || link);
+ }
+
+ function openEditorAttachmentLink(link) {
+ enhanceEditorAttachmentLink(link);
+ openEditorAttachmentDetail(detailFromEditorAttachmentLink(link));
+ }
+
+ enhanceEditorAttachmentLinks();
+ var attachmentEnhanceFrame = 0;
+ var attachmentEditorObserver = null;
+ var attachmentObservedEditors = typeof WeakSet === 'function' ? new WeakSet() : null;
+ function scheduleEditorAttachmentEnhance() {
+ if (attachmentEnhanceFrame) return;
+ attachmentEnhanceFrame = window.requestAnimationFrame(function() {
+ attachmentEnhanceFrame = 0;
+ enhanceEditorAttachmentLinks();
+ observeEditorAttachmentRoots();
+ });
+ }
+ function addedNodeMayContainEditorAttachmentLink(node) {
+ return node instanceof HTMLElement && (
+ node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div')
+ || node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href]')
+ );
+ }
+ function observeEditorAttachmentRoots() {
+ if (!attachmentEditorObserver) return;
+ document.querySelectorAll('.editor-surface .ProseMirror').forEach(function(editor) {
+ if (!(editor instanceof HTMLElement)) return;
+ if (attachmentObservedEditors && attachmentObservedEditors.has(editor)) return;
+ if (attachmentObservedEditors) attachmentObservedEditors.add(editor);
+ attachmentEditorObserver.observe(editor, { childList: true, subtree: true });
+ });
+ }
+ attachmentEditorObserver = new MutationObserver(function(records) {
+ var shouldEnhance = Array.isArray(records) && records.some(function(record) {
+ if (!record || record.type !== 'childList') return false;
+ return Array.from(record.addedNodes || []).some(function(node) {
+ return addedNodeMayContainEditorAttachmentLink(node);
+ });
+ });
+ if (!shouldEnhance) return;
+ scheduleEditorAttachmentEnhance();
+ });
+ attachmentEditorObserver.observe(document.documentElement, { childList: true, subtree: true });
+ observeEditorAttachmentRoots();
+ window.addEventListener('mnote:editor-attachment-links-changed', function() {
+ window.__mnoteEnhanceEditorAttachmentLinks();
+ scheduleEditorAttachmentEnhance();
+ });
+
+ 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, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
+ if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
+ openEditorAttachmentLink(editorAttachmentLink);
+ }
+
+ function suppressEditorAttachmentLinkDefault(event) {
+ var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
+ if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
+ }
+
+ window.addEventListener('mousedown', suppressEditorAttachmentLinkDefault, true);
+ window.addEventListener('click', interceptEditorAttachmentLink, 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, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
+ if (!(link instanceof HTMLAnchorElement)) return;
+ if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
+ enhanceEditorAttachmentLink(link);
+ positionAttachmentActions(link);
+ });
+
+ document.addEventListener('mouseout', function(event) {
+ var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
+ if (!(link instanceof HTMLAnchorElement)) return;
+ var next = event.relatedTarget;
+ var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
+ if (next && (link.contains(next) || (actions && actions.contains(next)))) return;
+ scheduleHideAttachmentActions();
+ });
+
+
+ return {
+ detailFromEditorAttachmentLink,
+ enhanceEditorAttachmentLink,
+ enhanceEditorAttachmentLinks,
+ openCodeEditorAttachment,
+ openEditorAttachmentDetail,
+ openEditorAttachmentDownload,
+ openEditorAttachmentEditTab,
+ openEditorAttachmentNewWindow,
+ refreshEditorLocalAttachmentExistence,
+ };
+};
diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js
index 580af23e..dc1a3d02 100644
--- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js
+++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js
@@ -1,6 +1,7 @@
import { createSidebarWorkspaceRuntime } from './sidebar-workspace-runtime.js';
import { createSidebarTreeLiveApplyRuntime } from './sidebar-tree-live-apply-runtime.js';
import { createSidebarFileTreeOpenRuntime } from './sidebar-filetree-open-runtime.js';
+import { createSidebarAttachmentOpenRuntime } from './sidebar-attachment-open-runtime.js';
(function(){
if (window.__mnoteSidebarTreeRuntimeStarted) return;
@@ -25,8 +26,6 @@ import { createSidebarFileTreeOpenRuntime } from './sidebar-filetree-open-runtim
focusedRowId: null
};
var activeTreeContextMenu = null;
- var activeEditorAttachmentLink = null;
- var attachmentActionsHideTimer = 0;
var pageUiState = {
pageOptions: null,
historySnapshots: [],
@@ -6903,617 +6902,37 @@ import { createSidebarFileTreeOpenRuntime } from './sidebar-filetree-open-runtim
else openPageSettingsPopover();
}
- function attachmentQueryParams(href) {
- try {
- return new URL(String(href || ''), window.location.origin).searchParams;
- } catch (_) {
- return new URLSearchParams();
- }
- }
-
- 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 localFileOpenRootUriFromHref(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('rootUri') || '').trim();
- } catch (_) {
- return '';
- }
- }
-
- function buildLocalFileStatusUrl(relativePath, rootUri) {
- var effectiveRootUri = String(rootUri || currentRootUri() || '').trim();
- if (!effectiveRootUri || !relativePath) return '';
- var url = new URL('/api/local-folder/files/stat', window.location.origin);
- url.searchParams.set('rootUri', effectiveRootUri);
- url.searchParams.set('path', relativePath);
- return url.toString();
- }
-
- function setEditorAttachmentMissingState(link, missing) {
- if (!(link instanceof HTMLAnchorElement)) return;
- var value = Boolean(missing);
- if (value) {
- link.setAttribute('data-mnote-attachment-missing', 'true');
- link.classList.add('mnote-uploaded-attachment-missing');
- link.setAttribute('aria-label', (link.textContent || '附件') + '(文件不存在)');
- } else {
- if (link.getAttribute('data-mnote-attachment-missing') !== 'true') return;
- link.removeAttribute('data-mnote-attachment-missing');
- link.classList.remove('mnote-uploaded-attachment-missing');
- link.removeAttribute('aria-label');
- }
- }
-
- async function refreshLocalAttachmentExistence(link) {
- if (!(link instanceof HTMLAnchorElement)) return;
- var href = link.getAttribute('href') || link.href || '';
- var localFilePath = localFileOpenPathFromHref(href);
- if (!localFilePath) return;
- var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href));
- if (!statusUrl) return;
- try {
- var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' });
- var payload = await response.json().catch(function() { return null; });
- var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true);
- setEditorAttachmentMissingState(link, !exists);
- } catch (_) {}
- }
-
- function refreshEditorLocalAttachmentExistence() {
- document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
- if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
- });
- }
- window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence;
-
- 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);
- return url.pathname === '/onlyoffice' && (url.searchParams.has('assetId') || url.searchParams.has('fileName'));
- } catch (_) {
- return false;
- }
- }
-
- function normalizeOnlyOfficeAttachmentHref(href) {
- try {
- var url = new URL(String(href || ''), window.location.origin);
- if (url.pathname !== '/onlyoffice') return String(href || '');
- return buildOnlyOfficeOpenUrl({
- fileUrl: url.searchParams.get('fileUrl') || '',
- fileName: url.searchParams.get('fileName') || '未命名附件',
- fileType: url.searchParams.get('fileType') || inferOnlyOfficeFileType(url.searchParams.get('fileName') || '', ''),
- assetId: url.searchParams.get('assetId') || '',
- documentId: url.searchParams.get('documentId') || currentDocumentId() || '',
- userId: url.searchParams.get('userId') || '',
- mode: url.searchParams.get('mode') || 'view'
- });
- } catch (_) {
- return String(href || '');
- }
- }
-
- function isOfficeFileName(fileName) {
- return Boolean(inferOnlyOfficeFileType(fileName, ''));
- }
-
- function editorAttachmentPaneContext(link) {
- var pane = link && typeof link.closest === 'function' ? link.closest('[data-document-pane="true"]') : null;
- var roleHost = link && typeof link.closest === 'function' ? link.closest('[data-pane-role]') : null;
- var paneRole = (
- pane instanceof HTMLElement && pane.getAttribute('data-pane-role') === 'secondary'
- ) || (
- roleHost instanceof HTMLElement && roleHost.getAttribute('data-pane-role') === 'secondary'
- ) ? 'secondary' : 'primary';
- var paneDocumentId = '';
- var paneWorkspaceId = '';
- if (pane instanceof HTMLElement) {
- paneDocumentId = (pane.getAttribute('data-pane-document-id') || '').trim();
- paneWorkspaceId = (pane.getAttribute('data-pane-workspace-id') || '').trim();
- var shell = pane.querySelector('.document-shell[data-document-id]');
- if (!paneDocumentId && shell instanceof HTMLElement) paneDocumentId = (shell.getAttribute('data-document-id') || '').trim();
- if (!paneWorkspaceId && shell instanceof HTMLElement) paneWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim();
- }
- if (roleHost instanceof HTMLElement) {
- if (!paneDocumentId) paneDocumentId = (roleHost.getAttribute('data-document-id') || '').trim();
- if (!paneWorkspaceId) paneWorkspaceId = (roleHost.getAttribute('data-workspace-id') || '').trim();
- var roleHostShell = roleHost.matches('.document-shell') ? roleHost : roleHost.querySelector?.('.document-shell[data-document-id]');
- if (!paneDocumentId && roleHostShell instanceof HTMLElement) paneDocumentId = (roleHostShell.getAttribute('data-document-id') || '').trim();
- if (!paneWorkspaceId && roleHostShell instanceof HTMLElement) paneWorkspaceId = (roleHostShell.getAttribute('data-workspace-id') || '').trim();
- }
- return {
- paneRole: paneRole,
- documentId: paneDocumentId || currentDocumentId() || '',
- workspaceId: paneWorkspaceId || resolveWorkspaceId(document.body) || ''
- };
- }
-
- function detailFromEditorAttachmentLink(link) {
- var rawHref = link instanceof HTMLAnchorElement ? link.href : '';
- var params = attachmentQueryParams(rawHref);
- var paneContext = editorAttachmentPaneContext(link);
- 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') : '') || (localFilePath ? 'local-file:' + localFilePath : '');
- var fileUrl = params.get('fileUrl') || '';
- var documentId = params.get('documentId') || paneContext.documentId || '';
- var href = rawHref;
- if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) {
- fileUrl = rawHref;
- href = buildOnlyOfficeOpenUrl({
- fileUrl: fileUrl,
- fileName: fileName,
- fileType: fileType,
- assetId: assetId,
- documentId: documentId,
- userId: '',
- mode: 'view'
- });
- } else if (isOnlyOfficeAttachmentHref(rawHref)) {
- href = normalizeOnlyOfficeAttachmentHref(rawHref);
- }
- return {
- href: href,
- fileUrl: fileUrl,
- fileName: fileName,
- title: fileName,
- fileType: fileType,
- assetId: assetId,
- documentId: documentId,
- workspaceId: paneContext.workspaceId,
- paneRole: paneContext.paneRole,
- fileSize: (link ? link.getAttribute('data-file-size') : '') || ''
- };
- }
-
- function enhanceEditorAttachmentLink(link) {
- if (!(link instanceof HTMLAnchorElement)) return;
- var href = link.getAttribute('href') || '';
- var params = attachmentQueryParams(href);
- var localFilePath = localFileOpenPathFromHref(href);
- var paneContext = editorAttachmentPaneContext(link);
- 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)
- || Boolean(localFilePath);
- if (!shouldEnhance) return;
- if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
- void refreshLocalAttachmentExistence(link);
- return;
- }
- link.setAttribute('data-mnote-attachment-link', 'true');
- 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({
- fileUrl: params.get('fileUrl') || '',
- fileName: fileName || '未命名附件',
- fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''),
- assetId: assetId,
- documentId: params.get('documentId') || paneContext.documentId || '',
- userId: params.get('userId') || '',
- mode: params.get('mode') || 'view'
- }));
- }
- attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
- if (name) link.classList.add(name);
- });
- link.setAttribute('target', '_blank');
- link.setAttribute('rel', 'noopener noreferrer nofollow');
- void hydrateEditorAttachmentMeta(link);
- }
-
- function enhanceEditorAttachmentLinks() {
- document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink);
- void healLegacyOfficeAttachmentParagraphs();
- }
- window.__mnoteEnhanceEditorAttachmentLinks = function() {
- observeEditorAttachmentRoots();
- enhanceEditorAttachmentLinks();
- };
-
- function ensureAttachmentActions() {
- var existing = document.querySelector('[data-testid="mnote-attachment-actions"]');
- if (existing instanceof HTMLElement) return existing;
- var actions = document.createElement('div');
- actions.className = 'mnote-attachment-actions';
- actions.setAttribute('data-testid', 'mnote-attachment-actions');
- actions.hidden = true;
- actions.innerHTML = '' +
- '' +
- '';
- actions.addEventListener('mouseenter', function() {
- if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
- });
- actions.addEventListener('mouseleave', scheduleHideAttachmentActions);
- document.body.appendChild(actions);
- return actions;
- }
-
- function positionAttachmentActions(link) {
- if (!(link instanceof HTMLElement)) return;
- var actions = ensureAttachmentActions();
- var rect = link.getBoundingClientRect();
- actions.hidden = false;
- actions.style.left = Math.min(window.innerWidth - 76, Math.max(8, rect.right + 6)) + 'px';
- actions.style.top = Math.max(8, rect.top + (rect.height - 28) / 2) + 'px';
- activeEditorAttachmentLink = link;
- }
-
- function scheduleHideAttachmentActions() {
- if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
- attachmentActionsHideTimer = window.setTimeout(function() {
- var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
- if (actions instanceof HTMLElement) actions.hidden = true;
- activeEditorAttachmentLink = null;
- }, 220);
- }
-
- async function openEditorAttachmentDetail(detail) {
- if (!detail || !detail.href) return;
- var localFilePath = localFilePathFromAssetId(detail.assetId);
- if (localFilePath) {
- if (await openLocalOfficeFileInActiveTab(detail, 'view')) return;
- // 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口
- void openLocalResourceInActiveTab({
- path: localFilePath,
- title: detail.fileName || localFilePath.split('/').pop() || localFilePath,
- kind: fileTreeIconKindForFileName(detail.fileName || localFilePath.split('/').pop() || localFilePath),
- assetId: detail.assetId,
- documentId: detail.documentId || currentDocumentId() || '',
- workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
- href: buildLocalFileOpenUrl(localFilePath, false),
- paneRole: detail.paneRole || 'primary'
- }).then(function(opened) {
- if (!opened) window.open(buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer');
- });
- return;
- }
- if (isPdfAttachmentFileName(detail.fileName)) {
- void openPdfEditorAttachment(detail);
- return;
- }
- if (isCodeAttachmentFileName(detail.fileName)) {
- void openCodeEditorAttachment(detail);
- return;
- }
- var fileType = String(detail.fileType || '').trim();
- if (fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
- void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
- objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
- assetId: detail.assetId || '',
- title: detail.fileName || '附件',
- fileName: detail.fileName || '附件',
- kind: 'office',
- officeUrl: detail.href,
- documentId: detail.documentId || '',
- workspaceId: detail.workspaceId || '',
- paneRole: detail.paneRole || 'primary'
- });
- return;
- }
- window.open(detail.href, '_blank', 'noopener,noreferrer');
- }
-
- function openEditorAttachmentNewWindow(detail, mode) {
- if (!detail) return;
- var requestedMode = mode === 'edit' ? 'edit' : 'view';
- var localFilePath = localFilePathFromAssetId(detail.assetId);
- if (localFilePath) {
- var localFileName = localFilePath.split('/').pop() || localFilePath;
- var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode);
- var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
- window.open(localOfficeUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer');
- return;
- }
- var href = detail.href || detail.fileUrl;
- if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
- try {
- var url = new URL(href, window.location.origin);
- url.searchParams.set('mode', requestedMode);
- href = url.toString();
- } catch (_) {}
- } else if (detail.fileType) {
- href = buildOnlyOfficeOpenUrl({
- fileUrl: detail.fileUrl || href || '',
- fileName: detail.fileName || '未命名附件',
- fileType: detail.fileType,
- assetId: detail.assetId || '',
- documentId: detail.documentId || currentDocumentId() || '',
- userId: '',
- mode: requestedMode
- });
- }
- window.open(href, '_blank', 'noopener,noreferrer');
- }
-
- async function openEditorAttachmentEditTab(detail) {
- if (!detail) return false;
- var localFilePath = localFilePathFromAssetId(detail.assetId);
- if (localFilePath) {
- if (await openLocalOfficeFileInActiveTab(detail, 'edit')) return true;
- }
- var href = detail.href || detail.fileUrl;
- if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
- try {
- var url = new URL(href, window.location.origin);
- url.searchParams.set('mode', 'edit');
- href = url.toString();
- } catch (_) {}
- } else if (detail.fileType) {
- href = buildOnlyOfficeOpenUrl({
- fileUrl: detail.fileUrl || href || '',
- fileName: detail.fileName || '未命名附件',
- fileType: detail.fileType,
- assetId: detail.assetId || '',
- documentId: detail.documentId || currentDocumentId() || '',
- userId: '',
- mode: 'edit'
- });
- }
- if (detail.fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
- var didOpenEditTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
- objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
- assetId: detail.assetId || '',
- title: detail.fileName || '附件',
- fileName: detail.fileName || '附件',
- kind: 'office',
- officeUrl: href,
- documentId: detail.documentId || '',
- workspaceId: detail.workspaceId || '',
- paneRole: detail.paneRole || 'primary'
- });
- if (!didOpenEditTab && href) window.open(href, '_blank', 'noopener,noreferrer');
- return didOpenEditTab;
- }
- if (href) window.open(href, '_blank', 'noopener,noreferrer');
- return false;
- }
-
- async function resolveEditorAttachmentUrl(detail) {
- var assetId = String(detail && detail.assetId || '').trim();
- var localFilePath = localFilePathFromAssetId(assetId);
- if (localFilePath) {
- var localUrl = buildLocalFileOpenUrl(localFilePath, false);
- if (localUrl) {
- return {
- url: localUrl,
- asset: {
- file_name: String(detail && detail.fileName || '').trim(),
- fileSize: String(detail && detail.fileSize || '').trim()
- }
- };
- }
- }
- if (assetId) {
- var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
- method: 'GET',
- credentials: 'include',
- cache: 'no-store'
- });
- var payload = await response.json().catch(function() { return null; });
- if (!response.ok || !payload) {
- throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
- }
- var signedUrl = String(payload && payload.signedUrl || '').trim();
- if (!signedUrl) throw new Error('附件链接不可用');
- return {
- url: signedUrl,
- asset: payload.asset && typeof payload.asset === 'object' ? payload.asset : {}
- };
- }
- var url = String(detail && (detail.fileUrl || detail.href) || '').trim();
- if (!url) throw new Error('附件链接不可用');
- return { url: url, asset: {} };
- }
-
- async function openPdfEditorAttachment(detail) {
- try {
- var resolved = await resolveEditorAttachmentUrl(detail);
- window.open(resolved.url, '_blank', 'noopener,noreferrer');
- } catch (error) {
- window.alert(error && error.message ? error.message : '打开 PDF 失败');
- }
- }
-
- async function openCodeEditorAttachment(detail) {
- var resolved = null;
- try {
- resolved = await resolveEditorAttachmentUrl(detail);
- var size = Number(resolved.asset && (resolved.asset.file_size || resolved.asset.fileSize) || 0);
- if (Number.isFinite(size) && size > 1024 * 1024) {
- window.open(resolved.url, '_blank', 'noopener,noreferrer');
- return;
- }
- var response = await fetch(resolved.url, {
- method: 'GET',
- credentials: 'include',
- cache: 'no-store'
- });
- if (!response.ok) throw new Error('读取附件内容失败');
- var text = await response.text();
- if (text.length > 1024 * 1024) {
- window.open(resolved.url, '_blank', 'noopener,noreferrer');
- return;
- }
- var editorRoot = document.querySelector('.editor-surface .ProseMirror');
- var editor = editorRoot && editorRoot.editor;
- if (!editor || !editor.chain) {
- window.open(resolved.url, '_blank', 'noopener,noreferrer');
- return;
- }
- var title = String(detail.fileName || resolved.asset.file_name || '附件').trim() || '附件';
- var language = inferCodeAttachmentLanguage(title);
- editor.chain().focus().insertContent([
- {
- type: 'paragraph',
- content: [{ type: 'text', text: title }]
- },
- {
- type: 'codeBlock',
- attrs: { language: language },
- content: text ? [{ type: 'text', text: text.replace(/\r\n?/g, '\n') }] : []
- }
- ]).run();
- } catch (error) {
- console.warn('[mnote attachment] open code attachment failed', error);
- if (resolved && resolved.url) {
- window.open(resolved.url, '_blank', 'noopener,noreferrer');
- return;
- }
- window.alert(error && error.message ? error.message : '打开代码附件失败');
- }
- }
-
- async function openEditorAttachmentDownload(detail) {
- if (!detail) return;
- var localFilePath = localFilePathFromAssetId(detail.assetId);
- if (localFilePath) {
- var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true);
- if (localDownloadUrl) {
- triggerBrowserDownload(localDownloadUrl);
- return;
- }
- }
- if (detail.assetId) {
- try {
- var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(detail.assetId), {
- method: 'GET',
- credentials: 'include'
- });
- var payload = await response.json().catch(function() { return null; });
- var signedUrl = String(payload && payload.signedUrl || '').trim();
- if (response.ok && signedUrl) {
- window.open(signedUrl, '_blank', 'noopener,noreferrer');
- return;
- }
- } catch (_) {}
- }
- var localRelativePath = String(detail.localRelativePath || '').trim();
- if (localRelativePath) {
- var localPathDownloadUrl = buildLocalFileOpenUrl(localRelativePath, true);
- if (localPathDownloadUrl) {
- triggerBrowserDownload(localPathDownloadUrl);
- return;
- }
- }
- var target = detail.fileUrl || detail.href;
- if (!target) return;
- window.open(target, '_blank', 'noopener,noreferrer');
- }
-
- function openEditorAttachmentMenu(link, trigger, point) {
- var detail = detailFromEditorAttachmentLink(link);
- var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : link.getBoundingClientRect();
- var x = point && typeof point.x === 'number' ? point.x : rect.right;
- var y = point && typeof point.y === 'number' ? point.y : rect.bottom + 4;
- openTreeContextMenu('attachment', detail, x, y, trigger || link);
- }
-
- function openEditorAttachmentLink(link) {
- enhanceEditorAttachmentLink(link);
- openEditorAttachmentDetail(detailFromEditorAttachmentLink(link));
- }
-
- enhanceEditorAttachmentLinks();
- var attachmentEnhanceFrame = 0;
- var attachmentEditorObserver = null;
- var attachmentObservedEditors = typeof WeakSet === 'function' ? new WeakSet() : null;
- function scheduleEditorAttachmentEnhance() {
- if (attachmentEnhanceFrame) return;
- attachmentEnhanceFrame = window.requestAnimationFrame(function() {
- attachmentEnhanceFrame = 0;
- enhanceEditorAttachmentLinks();
- observeEditorAttachmentRoots();
- });
- }
- function addedNodeMayContainEditorAttachmentLink(node) {
- return node instanceof HTMLElement && (
- node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div')
- || node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href]')
- );
- }
- function observeEditorAttachmentRoots() {
- if (!attachmentEditorObserver) return;
- document.querySelectorAll('.editor-surface .ProseMirror').forEach(function(editor) {
- if (!(editor instanceof HTMLElement)) return;
- if (attachmentObservedEditors && attachmentObservedEditors.has(editor)) return;
- if (attachmentObservedEditors) attachmentObservedEditors.add(editor);
- attachmentEditorObserver.observe(editor, { childList: true, subtree: true });
- });
- }
- attachmentEditorObserver = new MutationObserver(function(records) {
- var shouldEnhance = Array.isArray(records) && records.some(function(record) {
- if (!record || record.type !== 'childList') return false;
- return Array.from(record.addedNodes || []).some(function(node) {
- return addedNodeMayContainEditorAttachmentLink(node);
- });
- });
- if (!shouldEnhance) return;
- scheduleEditorAttachmentEnhance();
- });
- attachmentEditorObserver.observe(document.documentElement, { childList: true, subtree: true });
- observeEditorAttachmentRoots();
- window.addEventListener('mnote:editor-attachment-links-changed', function() {
- window.__mnoteEnhanceEditorAttachmentLinks();
- scheduleEditorAttachmentEnhance();
- });
-
- 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, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
- if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
- event.preventDefault();
- event.stopPropagation();
- if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
- openEditorAttachmentLink(editorAttachmentLink);
- }
-
- function suppressEditorAttachmentLinkDefault(event) {
- var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
- if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
- event.preventDefault();
- event.stopPropagation();
- if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
- }
-
- window.addEventListener('mousedown', suppressEditorAttachmentLinkDefault, true);
- window.addEventListener('click', interceptEditorAttachmentLink, 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, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
- if (!(link instanceof HTMLAnchorElement)) return;
- if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
- enhanceEditorAttachmentLink(link);
- positionAttachmentActions(link);
- });
-
- document.addEventListener('mouseout', function(event) {
- var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
- if (!(link instanceof HTMLAnchorElement)) return;
- var next = event.relatedTarget;
- var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
- if (next && (link.contains(next) || (actions && actions.contains(next)))) return;
- scheduleHideAttachmentActions();
+ const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
+ attachmentClassForFileName: (...args) => attachmentClassForFileName(...args),
+ buildLocalFileOpenUrl,
+ buildLocalOnlyOfficeOpenUrl,
+ buildOnlyOfficeOpenPath,
+ buildOnlyOfficeOpenUrl,
+ currentDocumentId,
+ currentRootUri,
+ currentWorkspaceSourcePayload,
+ fileTreeIconKindForFileName,
+ inferCodeAttachmentLanguage: (...args) => inferCodeAttachmentLanguage(...args),
+ inferOnlyOfficeFileType,
+ isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args),
+ isPdfAttachmentFileName: (...args) => isPdfAttachmentFileName(...args),
+ localFilePathFromAssetId,
+ openLocalOfficeFileInActiveTab,
+ openLocalResourceInActiveTab,
+ openTreeContextMenu: (...args) => openTreeContextMenu(...args),
+ resolveWorkspaceId,
+ triggerBrowserDownload: (...args) => triggerBrowserDownload(...args),
+ uploadedFileSize,
});
+ const refreshEditorLocalAttachmentExistence = (...args) => sidebarAttachmentOpen.refreshEditorLocalAttachmentExistence(...args);
+ const detailFromEditorAttachmentLink = (...args) => sidebarAttachmentOpen.detailFromEditorAttachmentLink(...args);
+ const enhanceEditorAttachmentLink = (...args) => sidebarAttachmentOpen.enhanceEditorAttachmentLink(...args);
+ const enhanceEditorAttachmentLinks = (...args) => sidebarAttachmentOpen.enhanceEditorAttachmentLinks(...args);
+ const openEditorAttachmentDetail = (...args) => sidebarAttachmentOpen.openEditorAttachmentDetail(...args);
+ const openEditorAttachmentNewWindow = (...args) => sidebarAttachmentOpen.openEditorAttachmentNewWindow(...args);
+ const openEditorAttachmentEditTab = (...args) => sidebarAttachmentOpen.openEditorAttachmentEditTab(...args);
+ const openCodeEditorAttachment = (...args) => sidebarAttachmentOpen.openCodeEditorAttachment(...args);
+ const openEditorAttachmentDownload = (...args) => sidebarAttachmentOpen.openEditorAttachmentDownload(...args);
document.addEventListener('click', function(e) {
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs
index a9044691..d7f07c09 100644
--- a/rust/crates/mnote-web/src/routes/mod.rs
+++ b/rust/crates/mnote-web/src/routes/mod.rs
@@ -138,6 +138,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-filetree-open-runtime.js",
get(web_shell::sidebar_filetree_open_runtime_asset),
)
+ .route(
+ "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
+ get(web_shell::sidebar_attachment_open_runtime_asset),
+ )
.route(
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
get(web_shell::sidebar_tree_runtime_asset),
@@ -594,6 +598,7 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-workspace-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-live-apply-runtime.js",
"/api/mnote-browser-runtime/sidebar-filetree-open-runtime.js",
+ "/api/mnote-browser-runtime/sidebar-attachment-open-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 99796c38..9e569c8e 100644
--- a/rust/crates/mnote-web/src/routes/web_shell.rs
+++ b/rust/crates/mnote-web/src/routes/web_shell.rs
@@ -784,6 +784,20 @@ pub async fn sidebar_filetree_open_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
+pub async fn sidebar_attachment_open_runtime_asset() -> Response {
+ const JS: &str = include_str!("../../browser/sidebar-attachment-open-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 filetree_keyboard_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-keyboard-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 30886ce3..521fa635 100644
--- a/rust/crates/mnote-web/src/ssr/pages/layout.rs
+++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs
@@ -203,6 +203,8 @@ mod tests {
include_str!("../../../browser/sidebar-tree-live-apply-runtime.js");
const SIDEBAR_FILETREE_OPEN_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-filetree-open-runtime.js");
+ const SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS: &str =
+ include_str!("../../../browser/sidebar-attachment-open-runtime.js");
const SIDEBAR_SHELL_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-shell-runtime.js");
const SIDEBAR_WORKSPACE_RUNTIME_JS: &str =
@@ -531,7 +533,7 @@ mod tests {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("localForm.append('documentId', documentId)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isLocalUploadedAsset(asset)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildLocalOnlyOfficeOpenUrl"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("localOfficeUrl"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("localOfficeUrl"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeIconKindForFileName(title)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("wolai:local-assets-changed"));
@@ -1145,29 +1147,34 @@ mod tests {
.contains("{ action: 'new-window-edit', icon: 'open_in_new', label: '新窗口编辑' }"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function withOfficeEditModeGuard(callback)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-last-office-edit-mode-requested"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openEditorAttachmentEditTab(detail)"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
+ .contains("function openEditorAttachmentEditTab(detail)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("void openEditorAttachmentEditTab(detail);"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openLocalOfficeFileInActiveTab(detail, 'edit')"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
+ .contains("openLocalOfficeFileInActiveTab(detail, 'edit')"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openEditorAttachmentNewWindow(detail, 'edit')"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("forceEditMode ? 'edit' : 'view'"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("url.searchParams.set('mode', requestedMode);"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mode: requestedMode"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
+ .contains("url.searchParams.set('mode', requestedMode);"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("mode: requestedMode"));
}
#[test]
fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() {
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openPdfEditorAttachment"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openCodeEditorAttachment"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function resolveEditorAttachmentUrl"));
- assert!(SIDEBAR_TREE_RUNTIME_JS
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function openPdfEditorAttachment"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function openCodeEditorAttachment"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function resolveEditorAttachmentUrl"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("var localFilePath = localFilePathFromAssetId(assetId)"));
- assert!(SIDEBAR_TREE_RUNTIME_JS
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true)"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("type: 'codeBlock'"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("attrs: { language: language }"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("type: 'codeBlock'"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("attrs: { language: language }"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("inferCodeAttachmentLanguage"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (isPdfAttachmentFileName(detail.fileName))"));
- assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (isCodeAttachmentFileName(detail.fileName))"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
+ .contains("if (isPdfAttachmentFileName(detail.fileName))"));
+ assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
+ .contains("if (isCodeAttachmentFileName(detail.fileName))"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
.contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("await openCodeEditorAttachment({"));