fix: stabilize local attachment icons and office proxy urls

This commit is contained in:
lix-2026
2026-05-26 09:44:35 +08:00
parent 0a15595066
commit 8cd8b44db3
11 changed files with 564 additions and 14 deletions
+22
View File
@@ -68,4 +68,26 @@
- 系统性后续: - 系统性后续:
- 当前修复覆盖“选中文本删除本地附件链接”的高频路径。块菜单删除、拖拽重排、折叠标题转换等仍有 `set_content`/HTML 整文替换路径,后续应拆一个 editor command history 设计,把块级删除/重排统一迁移到 Tiptap transaction/command,而不是继续扩散整文替换。 - 当前修复覆盖“选中文本删除本地附件链接”的高频路径。块菜单删除、拖拽重排、折叠标题转换等仍有 `set_content`/HTML 整文替换路径,后续应拆一个 editor command history 设计,把块级删除/重排统一迁移到 Tiptap transaction/command,而不是继续扩散整文替换。
5,md文件上传后刷新前面的黑色方块会变成灰色:/mnt/Data1T/mnote/tmp/image copy 80.png/mnt/Data1T/mnote/tmp/image copy 81.png 5,md文件上传后刷新前面的黑色方块会变成灰色:/mnt/Data1T/mnote/tmp/image copy 80.png/mnt/Data1T/mnote/tmp/image copy 81.png
处理记录(2026-05-26):
- 归类:`05-editor-mainline`,本质是本地 Markdown 上传附件插入后,Tiptap DOM、document session 保存、刷新后附件增强三者之间的时序合同不完整。
- CodeGraph 定位:拆分后可直接从 `local-upload-runtime.js::insertUploadedAssetIntoEditor``sidebar-attachment-open-runtime.js::enhanceEditorAttachmentLink``document-editor-adapter-runtime.js::handleSessionChange``document-session-runtime.js::persistSession` 定位,不再需要在 `layout.rs` 大型 raw string 中翻找。
- 根因:
- 上传插入成功后,链接只进入当前 Tiptap DOM,没有稳定触发 local-folder document save,刷新后会从旧 Markdown 重建。
- 刷新后 `/api/local-folder/files/open` 本地链接增强存在首屏时序竞态,普通 local open 链接可能暂时只有灰色通用文件 badge。
- 修复:
- `rust/crates/mnote-web/browser/local-upload-runtime.js`:上传插入后派发 Tiptap bridge change,并对 local-folder 上传直接按现有 conversion runtime 转为 `editorBlocks``/api/documents/save`;保存前登记 self-change suppression,避免 watcher 把自写入误报为外部冲突。
- `rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js`:本地 open 链接也先补 `data-mnote-attachment-link``data-asset-id` 和类型 class,再做存在性刷新;并增加初始附件增强重扫。
- `rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js`:刷新时从本地 open URL 的 `path` 重新推导附件类型,在 Markdown → Tiptap link mark 边界直接补 `mnote-uploaded-attachment-code` 等类型 classCSS 只消费 class,不再用 `.md` href 颜色选择器兜底。
- `scripts/task491-local-md-attachment-icon-refresh-smoke.js`:新增窄浏览器回归,覆盖上传 md 附件、写入 Markdown、刷新后仍保持黑色 md 附件图标。
- `scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`:第 1 项补充刷新前后 md 附件图标断言,必须恢复 `mnote-uploaded-attachment-code` class,不再接受单纯 `::before` 黑色背景。
- 验证:
- `node scripts/task491-local-md-attachment-icon-refresh-smoke.js` 通过。
- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js``editor-upload-to-page-resource-dir` 通过;该宽脚本仍有既有 `broken-link-after-real-file-delete``editor-delete-one-attachment-preserves-adjacent` 失败,非本条阻塞。
6. 当前office文件本地可以打开,我们当前使用sakura frp映射3000端口至:https://www.aichem.dpdns.org 后,发现pdf可以正常打开,但onlyoffice打不开了,报错为:ONLYOFFICE 加载失败{"target":{"frameOrigin":"https://www.aichem.dpdns.org"},"data":{"errorCode":-4,"errorDescription":"下载失败"}}。请检查原因,同时要兼容未来更换域名。 6. 当前office文件本地可以打开,我们当前使用sakura frp映射3000端口至:https://www.aichem.dpdns.org 后,发现pdf可以正常打开,但onlyoffice打不开了,报错为:ONLYOFFICE 加载失败{"target":{"frameOrigin":"https://www.aichem.dpdns.org"},"data":{"errorCode":-4,"errorDescription":"下载失败"}}。请检查原因,同时要兼容未来更换域名。
处理记录(2026-05-26):
- 归类:`05-editor-mainline` / OnlyOffice transport,问题是 DocumentServer 下载地址随浏览器公网域名变化。
- 根因:OnlyOffice 页面在非 localhost 访问时把 `proxyOrigin` 设为 `location.origin`,导致 DocumentServer 去拉 `https://www.aichem.dpdns.org/api/onlyoffice/proxy?...`;公网映射域名对 DocumentServer 不一定可达,触发 `errorCode=-4 下载失败`
- 修复:`rust/crates/mnote-web/src/routes/onlyoffice.rs``proxyOrigin``callbackOrigin` 固定使用 `documentUrlBase`,即 `ONLYOFFICE_DOCUMENT_URL_BASE` / 内部可达 base,不再依赖浏览器当前域名;未来换 FRP 域名不影响 DocumentServer 回源。
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_page_exposes_documentserver_fetch_base_for_local_files -- --test-threads=1` 通过。
@@ -122,6 +122,16 @@ import {
} }
}; };
const enhanceEditorAttachmentLinksSoon = () => {
const run = () => {
if (typeof window.__mnoteEnhanceEditorAttachmentLinks === 'function') {
window.__mnoteEnhanceEditorAttachmentLinks();
}
};
run();
[120, 500, 1200].forEach((delayMs) => window.setTimeout(run, delayMs));
};
const pageAggregateUrl = (bootstrap) => { const pageAggregateUrl = (bootstrap) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin); const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin);
url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder'); url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder');
@@ -357,6 +367,7 @@ import {
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
setStatus(runtimeDescriptor, 'mounting-editor'); setStatus(runtimeDescriptor, 'mounting-editor');
enhanceEditorAttachmentLinksSoon();
paneViewRegistry.set(paneRole, view); paneViewRegistry.set(paneRole, view);
return view; return view;
} catch (error) { } catch (error) {
@@ -572,6 +583,7 @@ import {
} else { } else {
setStatus(runtimeDescriptor, 'ready'); setStatus(runtimeDescriptor, 'ready');
} }
enhanceEditorAttachmentLinksSoon();
}; };
view.onError = (event) => { view.onError = (event) => {
const payload = normalizeEnvelopePayload(event); const payload = normalizeEnvelopePayload(event);
@@ -580,12 +592,15 @@ import {
view.onChange = (event) => { view.onChange = (event) => {
scheduleSlashMenuPosition(runtimeDescriptor.root); scheduleSlashMenuPosition(runtimeDescriptor.root);
handleSessionChange(session, view, event); handleSessionChange(session, view, event);
enhanceEditorAttachmentLinksSoon();
}; };
view.onSave = (event) => { view.onSave = (event) => {
handleSessionChange(session, view, event); handleSessionChange(session, view, event);
enhanceEditorAttachmentLinksSoon();
}; };
view.onState = () => { view.onState = () => {
scheduleSlashMenuPosition(runtimeDescriptor.root); scheduleSlashMenuPosition(runtimeDescriptor.root);
enhanceEditorAttachmentLinksSoon();
}; };
view.onKeydown = (event) => { view.onKeydown = (event) => {
if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root); if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root);
@@ -664,6 +679,7 @@ import {
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
setStatus(runtimeDescriptor, 'mounting-editor'); setStatus(runtimeDescriptor, 'mounting-editor');
enhanceEditorAttachmentLinksSoon();
paneViewRegistry.set(runtimeDescriptor.paneRole, view); paneViewRegistry.set(runtimeDescriptor.paneRole, view);
} catch (error) { } catch (error) {
unmountEditorViewBinding(view); unmountEditorViewBinding(view);
@@ -321,6 +321,51 @@ export const localFileOpenUrlForTiptap = (value, context) => {
return url.toString(); return url.toString();
}; };
export const localFileOpenPathFromTiptapHref = (href) => {
try {
const 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 '';
}
};
export const fileNameFromPath = (path) => {
const value = String(path || '').trim();
return value.includes('/') ? value.split('/').pop() : value;
};
export const attachmentClassForFileName = (fileName) => {
const name = String(fileName || '').trim().toLowerCase();
const ext = name.includes('.') ? name.split('.').pop() : '';
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-word';
if (['ppt', 'pptx', 'odp'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt';
if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet';
if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf';
if (['md', 'markdown', 'txt', 'json', 'js', 'ts', 'tsx', 'jsx', 'rs', 'py', 'go', 'java', 'kt', 'swift', 'c', 'cpp', 'h', 'hpp', 'css', 'scss', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'sh', 'bash', 'zsh', 'sql', 'vue', 'svelte'].includes(ext)) {
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-code';
}
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file';
};
export const mergeClassNames = (...values) => {
const seen = new Set();
return values
.flatMap((value) => String(value || '').split(/\s+/))
.filter((name) => {
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
})
.join(' ');
};
export const localAttachmentClassForTiptapHref = (href) => {
const localFilePath = localFileOpenPathFromTiptapHref(href);
return localFilePath ? attachmentClassForFileName(fileNameFromPath(localFilePath)) : '';
};
export const localizeTiptapAssetUrls = (node, context) => { export const localizeTiptapAssetUrls = (node, context) => {
if (!node || typeof node !== 'object') return node; if (!node || typeof node !== 'object') return node;
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
@@ -329,7 +374,18 @@ export const localizeTiptapAssetUrls = (node, context) => {
if (Array.isArray(node.marks)) { if (Array.isArray(node.marks)) {
node.marks = node.marks.map((mark) => { node.marks = node.marks.map((mark) => {
if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark; if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark;
return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } }; const href = localFileOpenUrlForTiptap(mark.attrs.href, context);
const attachmentClass = localAttachmentClassForTiptapHref(href);
const attrs = attachmentClass
? {
...mark.attrs,
href,
class: mergeClassNames(mark.attrs.class, attachmentClass),
target: mark.attrs.target || '_blank',
rel: mark.attrs.rel || 'noopener noreferrer nofollow',
}
: { ...mark.attrs, href };
return { ...mark, attrs };
}); });
} }
if (Array.isArray(node.content)) { if (Array.isArray(node.content)) {
@@ -2,6 +2,10 @@
// 当前先承接 SIDEBAR_TREE_JS 中的纯辅助函数。 // 当前先承接 SIDEBAR_TREE_JS 中的纯辅助函数。
// SIDEBAR_TREE_JS 会优先委托到 window.__mnoteLocalUploadRuntime // SIDEBAR_TREE_JS 会优先委托到 window.__mnoteLocalUploadRuntime
// 模块未加载时,inline fallback 保持旧行为。 // 模块未加载时,inline fallback 保持旧行为。
import {
editorDocumentFromTiptapDocument,
legacyBlocksFromEditorDocument,
} from './document-tiptap-conversion-runtime.js';
function uploadedAssetTitle(asset) { function uploadedAssetTitle(asset) {
return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件'; return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件';
@@ -11,6 +15,117 @@ function uploadedAssetUrl(asset) {
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
} }
function dispatchUploadedEditorChange(editorRoot, editor, deps) {
deps = deps || {};
if (!(editorRoot instanceof HTMLElement) || !editor || typeof editor.getJSON !== 'function') return;
var runtimeRoot = editorRoot.closest('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
if (!(runtimeRoot instanceof HTMLElement) || typeof CustomEvent !== 'function') return;
try {
editorRoot.dispatchEvent(new Event('input', { bubbles: true }));
} catch (_) {
// ignore
}
var currentDocumentId = typeof deps.currentDocumentId === 'function' ? deps.currentDocumentId() : '';
var payload = {
protocol: 'mnote.leptos_tiptap.bridge.v1',
runtime: '8123-leptos-tiptap-runtime',
version: '1.1.0',
source: 'mnote:leptos-tiptap-spike',
event: 'mnote:leptos-tiptap-spike:change',
payload: {
documentId: String(currentDocumentId || '').trim() || null,
workspaceId: null,
title: '',
content: editor.getJSON(),
meta: {
dirtyCount: Date.now(),
editorFocused: document.activeElement === editorRoot || editorRoot.contains(document.activeElement),
slashOpen: false,
toolbarOpen: false,
selectedBlockIndex: null,
revision: null,
conflictDetectionKey: null,
readOnly: false
}
}
};
runtimeRoot.dispatchEvent(new CustomEvent('mnote:leptos-tiptap-spike:change', {
detail: payload,
bubbles: true
}));
}
function currentConflictDetectionKey() {
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__');
if (!script) return '';
try {
var body = JSON.parse(script.textContent || 'null');
return String(body && (body.conflictDetectionKey || body.conflict_detection_key || body.fileVersion || body.file_version) || '').trim();
} catch (_) {
return '';
}
}
function persistLocalFolderSelfChangeSuppression(documentId, expiresAt) {
var doc = String(documentId || '').trim();
if (!doc) return;
window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map();
window.__mnoteLocalFolderSelfChangeSuppressions.set(doc, expiresAt);
try {
var key = 'mnote.localFolder.selfChangeSuppressions.v1';
var existing = JSON.parse(window.sessionStorage.getItem(key) || '{}');
existing[doc] = expiresAt;
window.sessionStorage.setItem(key, JSON.stringify(existing));
} catch (_) {}
}
async function persistUploadedEditorChange(editor, asset, deps) {
deps = deps || {};
var isLocalAsset = typeof deps.isLocalUploadedAsset === 'function' ? deps.isLocalUploadedAsset(asset) : isLocalUploadedAsset(asset);
if (!isLocalAsset || !editor || typeof editor.getJSON !== 'function') return;
var documentId = String(
asset && (asset.document_id || asset.documentId)
|| (typeof deps.currentDocumentId === 'function' ? deps.currentDocumentId() : '')
|| ''
).trim();
var rootUri = String(
asset && (asset.root_uri || asset.rootUri)
|| (typeof deps.currentRootUri === 'function' ? deps.currentRootUri() : currentRootUri())
|| ''
).trim();
if (!documentId || !rootUri) return;
var tiptapDocument = editor.getJSON();
var editorDocument = editorDocumentFromTiptapDocument({ documentId: documentId }, tiptapDocument);
var content = legacyBlocksFromEditorDocument(editorDocument);
var savePayload = {
documentId: documentId,
sourceKind: 'local_folder',
rootUri: rootUri,
expectedFileVersion: currentConflictDetectionKey(),
contentFormat: 'editorBlocks',
editorSource: 'local-upload-runtime',
editorDocument: editorDocument,
content: content,
tiptapDocument: tiptapDocument,
blockCount: Array.isArray(editorDocument.blocks) ? editorDocument.blocks.length : 0
};
persistLocalFolderSelfChangeSuppression(documentId, Date.now() + 5000);
var response = await fetch('/api/documents/save', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(savePayload)
});
var result = await response.json().catch(function() { return null; });
if (!response.ok || !result || result.ok !== true) {
var message = result && (result.message || (result.error && result.error.message)) || ('save_failed_' + response.status);
document.documentElement.setAttribute('data-mnote-last-upload-save-error', message);
throw new Error(message);
}
document.documentElement.setAttribute('data-mnote-last-upload-saved', 'true');
document.documentElement.removeAttribute('data-mnote-last-upload-save-error');
}
function currentRootUri() { function currentRootUri() {
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var fromUrl = (params.get('rootUri') || '').trim(); var fromUrl = (params.get('rootUri') || '').trim();
@@ -270,6 +385,8 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'true'); document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'true');
document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId || ''); document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId || '');
document.documentElement.removeAttribute('data-mnote-last-upload-insert-error'); document.documentElement.removeAttribute('data-mnote-last-upload-insert-error');
dispatchUploadedEditorChange(editorRoot, editor, deps);
await persistUploadedEditorChange(editor, asset, deps);
} else { } else {
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false'); document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false');
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed'); document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed');
@@ -219,13 +219,18 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|| isOfficeFileName(fileName) || isOfficeFileName(fileName)
|| Boolean(localFilePath); || Boolean(localFilePath);
if (!shouldEnhance) return; if (!shouldEnhance) 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);
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
if (name) link.classList.add(name);
});
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer nofollow');
if (localFilePath && !isOnlyOfficeAttachmentHref(href)) { if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
void refreshLocalAttachmentExistence(link); void refreshLocalAttachmentExistence(link);
return; 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)) { if (isOnlyOfficeAttachmentHref(href)) {
link.setAttribute('href', buildOnlyOfficeOpenPath({ link.setAttribute('href', buildOnlyOfficeOpenPath({
fileUrl: params.get('fileUrl') || '', fileUrl: params.get('fileUrl') || '',
@@ -237,11 +242,6 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
mode: params.get('mode') || 'view' 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); void hydrateEditorAttachmentMeta(link);
} }
@@ -253,6 +253,12 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
observeEditorAttachmentRoots(); observeEditorAttachmentRoots();
enhanceEditorAttachmentLinks(); enhanceEditorAttachmentLinks();
}; };
var attachmentInitialEnhanceAttempts = 0;
var attachmentInitialEnhanceTimer = window.setInterval(function() {
attachmentInitialEnhanceAttempts += 1;
enhanceEditorAttachmentLinks();
if (attachmentInitialEnhanceAttempts >= 120) window.clearInterval(attachmentInitialEnhanceTimer);
}, 500);
function ensureAttachmentActions() { function ensureAttachmentActions() {
var existing = document.querySelector('[data-testid="mnote-attachment-actions"]'); var existing = document.querySelector('[data-testid="mnote-attachment-actions"]');
@@ -600,6 +606,26 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
window.__mnoteEnhanceEditorAttachmentLinks(); window.__mnoteEnhanceEditorAttachmentLinks();
scheduleEditorAttachmentEnhance(); scheduleEditorAttachmentEnhance();
}); });
[
'mnote:leptos-tiptap-spike:ready',
'mnote:leptos-tiptap-spike:change',
'mnote:leptos-tiptap-spike:state'
].forEach(function(eventName) {
window.addEventListener(eventName, function() {
enhanceEditorAttachmentLinks();
observeEditorAttachmentRoots();
window.setTimeout(function() {
enhanceEditorAttachmentLinks();
observeEditorAttachmentRoots();
}, 120);
}, true);
});
[120, 500, 1200, 2500].forEach(function(delayMs) {
window.setTimeout(function() {
enhanceEditorAttachmentLinks();
observeEditorAttachmentRoots();
}, delayMs);
});
function interceptEditorAttachmentLink(event) { 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"]'); 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"]');
@@ -1234,6 +1234,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
buildOnlyOfficeOpenPath: buildOnlyOfficeOpenPath, buildOnlyOfficeOpenPath: buildOnlyOfficeOpenPath,
inferOnlyOfficeFileType: inferOnlyOfficeFileType, inferOnlyOfficeFileType: inferOnlyOfficeFileType,
currentDocumentId: currentDocumentId, currentDocumentId: currentDocumentId,
currentRootUri: currentRootUri,
uploadedAttachmentClass: uploadedAttachmentClass, uploadedAttachmentClass: uploadedAttachmentClass,
uploadedFileSize: uploadedFileSize, uploadedFileSize: uploadedFileSize,
cssEscape: cssEscape, cssEscape: cssEscape,
@@ -405,10 +405,9 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
mode: {mode} mode: {mode}
}}; }};
const MNOTE_AGENT_PLUGIN_GUID = "asc.{{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}}"; const MNOTE_AGENT_PLUGIN_GUID = "asc.{{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}}";
const pageLocal = location.hostname === "127.0.0.1" || location.hostname === "localhost";
const documentUrlBase = {document_url_base}; const documentUrlBase = {document_url_base};
const proxyOrigin = pageLocal ? documentUrlBase : location.origin; const proxyOrigin = documentUrlBase;
const callbackOrigin = proxyOrigin; const callbackOrigin = documentUrlBase;
function showError(message) {{ function showError(message) {{
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "error", message: String(message || "") }}); window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "error", message: String(message || "") }});
@@ -1821,7 +1820,11 @@ mod tests {
.expect("body"); .expect("body");
let html = String::from_utf8(body.to_vec()).expect("html"); let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("const documentUrlBase = \"http://host.docker.internal:3000\";")); assert!(html.contains("const documentUrlBase = \"http://host.docker.internal:3000\";"));
assert!(html.contains("const proxyOrigin = pageLocal ? documentUrlBase : location.origin;")); assert!(html.contains("const proxyOrigin = documentUrlBase;"));
assert!(html.contains("const callbackOrigin = documentUrlBase;"));
assert!(
!html.contains("const proxyOrigin = pageLocal ? documentUrlBase : location.origin;")
);
assert!(html.contains("documentUrlBase,")); assert!(html.contains("documentUrlBase,"));
assert!(html.contains("resolvedFileUrl: fileState.resolvedFileUrl")); assert!(html.contains("resolvedFileUrl: fileState.resolvedFileUrl"));
} }
@@ -1927,6 +1927,10 @@ mod tests {
assert!( assert!(
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content") conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")
); );
assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref"));
assert!(conversion_runtime.contains("mnote-uploaded-attachment-code"));
assert!(conversion_runtime
.contains("class: mergeClassNames(mark.attrs.class, attachmentClass)"));
assert!(session_runtime assert!(session_runtime
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);")); .contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
assert!(session_runtime.contains( assert!(session_runtime.contains(
@@ -2400,6 +2404,9 @@ mod tests {
assert!( assert!(
DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js") DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js")
); );
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("enhanceEditorAttachmentLinksSoon"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("window.__mnoteEnhanceEditorAttachmentLinks"));
assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap")); assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap"));
@@ -575,6 +575,13 @@ mod tests {
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadLocalFolderAsset")); assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadLocalFolderAsset"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadMediaAsset")); assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadMediaAsset"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function insertUploadedAssetIntoEditor")); assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function insertUploadedAssetIntoEditor"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function dispatchUploadedEditorChange"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function persistUploadedEditorChange"));
assert!(
LOCAL_UPLOAD_RUNTIME_JS.contains("function persistLocalFolderSelfChangeSuppression")
);
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("editorSource: 'local-upload-runtime'"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("mnote:leptos-tiptap-spike:change"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadFileToMediaAsset")); assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadFileToMediaAsset"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadFilesWithResolvedTarget")); assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadFilesWithResolvedTarget"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("data-mnote-last-upload-inserted")); assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("data-mnote-last-upload-inserted"));
@@ -1211,6 +1218,19 @@ mod tests {
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
.contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id")); .contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("await openCodeEditorAttachment({")); assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("await openCodeEditorAttachment({"));
let attachment_class_index = SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.find("attachmentClassForFileName(fileName).split")
.expect("editor attachment links apply type-specific classes");
let local_refresh_index = SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.find("if (localFilePath && !isOnlyOfficeAttachmentHref(href))")
.expect("local file links refresh existence after enhancement");
assert!(attachment_class_index < local_refresh_index);
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("'mnote:leptos-tiptap-spike:ready'"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("window.setTimeout(function()"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("[120, 500, 1200, 2500]"));
assert!(
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("attachmentInitialEnhanceAttempts >= 120")
);
} }
#[test] #[test]
@@ -177,6 +177,24 @@ async function waitForPrimaryAttachment(page, fileName) {
return link; return link;
} }
async function waitForPrimaryAttachmentClass(page, fileName, className) {
await page.waitForFunction(
({ name, className }) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || [])
.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name));
return link instanceof HTMLAnchorElement && link.classList.contains(className);
},
{ name: fileName, className },
{ timeout: UI_TIMEOUT_MS },
);
return await waitForPrimaryAttachment(page, fileName)
.then((link) => link.evaluate((node) => {
const badgeColor = getComputedStyle(node, "::before").backgroundColor;
return `${node.getAttribute("class") || ""} ${badgeColor}`.trim();
}));
}
async function selectPrimaryAttachmentLink(page, fileName) { async function selectPrimaryAttachmentLink(page, fileName) {
await page.evaluate((name) => { await page.evaluate((name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
@@ -348,13 +366,34 @@ async function main() {
assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath"); assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath");
assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId"); assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId");
await assertNoPrimaryConflictPanel(page, "主编辑区上传附件后"); await assertNoPrimaryConflictPanel(page, "主编辑区上传附件后");
const uploadedClassBeforeReload = await waitForPrimaryAttachmentClass(
page,
"uploaded-one.md",
"mnote-uploaded-attachment-code",
);
assert(
uploadedClassBeforeReload.includes("mnote-uploaded-attachment-code"),
`上传后的 md 附件应使用 code/markdown 黑色图标 class,实际 class=${uploadedClassBeforeReload}`,
);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const uploadedClassAfterReload = await waitForPrimaryAttachmentClass(
page,
"uploaded-one.md",
"mnote-uploaded-attachment-code",
);
assert(
uploadedClassAfterReload.includes("mnote-uploaded-attachment-code"),
`刷新后的 md 附件应保持 code/markdown 黑色图标 class,实际 class=${uploadedClassAfterReload}`,
);
checks[checkId] = { checks[checkId] = {
ok: true, ok: true,
message: `上传文件落在资源目录 ${correctPath}`, message: `上传文件落在资源目录 ${correctPath},刷新后仍保持 md 附件图标 class`,
landedInResourceDir, landedInResourceDir,
landedInRoot, landedInRoot,
uploadAsset, uploadAsset,
uploadedClassBeforeReload,
uploadedClassAfterReload,
}; };
} catch (err) { } catch (err) {
overallOk = false; overallOk = false;
@@ -0,0 +1,243 @@
#!/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 OUTPUT_DIR = path.join(process.cwd(), "tmp", "task491-local-md-attachment-icon-refresh-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
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}:task491`,
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 openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function installChangeEventProbe(page) {
await page.evaluate(() => {
window.__task491ChangeEvents = [];
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
if (!(root instanceof HTMLElement)) throw new Error("runtime_root_missing");
root.addEventListener("mnote:leptos-tiptap-spike:change", (event) => {
const payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
window.__task491ChangeEvents.push({
at: Date.now(),
hasPayload: Boolean(event.detail && event.detail.payload),
documentId: payload && payload.documentId,
contentText: JSON.stringify(payload && payload.content || {}).slice(0, 500),
text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.innerText || "",
});
});
});
}
async function uploadAttachmentViaSlash(page, fileName, content) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.type("/");
const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
await fileChooser.setFiles({
name: fileName,
mimeType: "text/markdown",
buffer: Buffer.from(content, "utf8"),
});
}
async function waitForMdAttachmentClass(page, fileName) {
await page.waitForFunction(
(name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const links = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || []);
const link = links.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name));
return link instanceof HTMLAnchorElement && link.classList.contains("mnote-uploaded-attachment-code");
},
fileName,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate((name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const links = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || []);
const link = links.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name));
if (!(link instanceof HTMLAnchorElement)) return null;
return {
href: link.getAttribute("href") || "",
className: link.getAttribute("class") || "",
assetId: link.getAttribute("data-asset-id") || "",
text: link.textContent || "",
};
}, fileName);
}
async function waitForFileContent(filePath, predicate, timeoutMs) {
const startedAt = Date.now();
let lastContent = "";
while (Date.now() - startedAt < timeoutMs) {
if (fs.existsSync(filePath)) {
lastContent = fs.readFileSync(filePath, "utf8");
if (predicate(lastContent)) return lastContent;
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error(`文件内容未达到预期: ${filePath}; lastContent=${JSON.stringify(lastContent)}`);
}
async function changeEventProbe(page) {
return await page.evaluate(() => window.__task491ChangeEvents || []).catch(() => []);
}
(async () => {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task491-"));
const relativePath = "README.md";
const resourceDir = path.join(root, "README");
const fileName = "uploaded-one.md";
writeWorkspaceManifest(root, "mnote-e2e");
fs.mkdirSync(resourceDir, { recursive: true });
fs.writeFileSync(path.join(root, relativePath), "# Attachment Icon\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: 1366, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const result = { root, fileName, states: [], console: [], pageErrors: [] };
const saveResponses = [];
page.on("console", (message) => {
result.console.push({ type: message.type(), text: message.text() });
});
page.on("pageerror", (error) => {
result.pageErrors.push(String(error && error.stack || error));
});
page.on("response", async (response) => {
if (!response.url().includes("/api/documents/save")) return;
saveResponses.push({
status: response.status(),
payload: await response.json().catch(() => null),
});
});
try {
await quickLogin(page);
await openDocument(page, root, relativePath);
await installChangeEventProbe(page);
await uploadAttachmentViaSlash(page, fileName, "# Uploaded One\n\n第一个上传附件\n");
const beforeReload = await waitForMdAttachmentClass(page, fileName);
result.states.push({ step: "before-reload", link: beforeReload, changeEvents: await changeEventProbe(page), saveResponses });
const expectedPath = path.join(resourceDir, fileName);
assert(fs.existsSync(expectedPath), `上传后的 md 附件应落盘到页面资源目录: ${expectedPath}`);
const savedMarkdown = await waitForFileContent(
path.join(root, relativePath),
(content) => content.includes(fileName) && content.includes("README/uploaded-one.md"),
UI_TIMEOUT_MS,
);
result.states.push({ step: "saved-markdown", savedMarkdown });
await page.reload({ 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 afterReload = await waitForMdAttachmentClass(page, fileName);
result.states.push({ step: "after-reload", link: afterReload });
assert(beforeReload.className.includes("mnote-uploaded-attachment-code"), "刷新前 md 附件应使用 code 图标 class");
assert(afterReload.className.includes("mnote-uploaded-attachment-code"), "刷新后 md 附件应保留 code 图标 class");
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, beforeReload, afterReload }, null, 2));
} catch (error) {
const diagnostics = await page.evaluate(() => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const status = document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "";
const statusError = document.querySelector('[data-runtime-editor-error]')?.getAttribute('data-runtime-editor-error') || "";
return {
url: location.href,
status,
statusError,
editorText: editor instanceof HTMLElement ? editor.innerText : "",
links: Array.from(editor?.querySelectorAll("a") || []).map((node) => ({
href: node.getAttribute("href") || "",
className: node.getAttribute("class") || "",
text: node.textContent || "",
})),
};
}).catch((err) => ({ diagnosticsError: String(err) }));
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({ ok: false, root, fileName, states: result.states, saveResponses, diagnostics, console: result.console, pageErrors: result.pageErrors, error: String(error && error.stack || error) }, null, 2)}\n`,
"utf8",
);
console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
process.exitCode = 1;
} finally {
await browser.close().catch(() => undefined);
}
})();