fix: repair resource tab and slash menu regressions

This commit is contained in:
lix-2026
2026-05-20 17:47:09 +08:00
parent bbd9dab701
commit 4c62ea7e35
8 changed files with 404 additions and 17 deletions
+38 -1
View File
@@ -223,6 +223,13 @@ fn onlyoffice_internal_candidates() -> Vec<String> {
candidates
}
fn onlyoffice_document_url_base_js() -> String {
env_or_dotenv("ONLYOFFICE_DOCUMENT_URL_BASE")
.or_else(|| env_or_dotenv("MNOTE_WEB_ONLYOFFICE_INTERNAL_BASE_URL"))
.and_then(|value| normalize_http_origin(&value))
.unwrap_or_else(|| "http://host.docker.internal:3000".into())
}
async fn resolve_onlyoffice_internal_url() -> String {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(2_500))
@@ -349,6 +356,7 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
Some("view") => "view",
_ => "edit",
};
let document_url_base = onlyoffice_document_url_base_js();
let html = format!(
r#"<!doctype html>
@@ -384,7 +392,8 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
}};
const MNOTE_AGENT_PLUGIN_GUID = "asc.{{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}}";
const pageLocal = location.hostname === "127.0.0.1" || location.hostname === "localhost";
const proxyOrigin = pageLocal ? "http://host.docker.internal:3000" : location.origin;
const documentUrlBase = {document_url_base};
const proxyOrigin = pageLocal ? documentUrlBase : location.origin;
const callbackOrigin = proxyOrigin;
function showError(message) {{
@@ -592,6 +601,7 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
pageOrigin: location.origin,
baseUrl: "/onlyoffice-server",
documentUrlBase,
proxyOrigin,
callbackOrigin,
fileUrlInput: fileState.effectiveFileUrl,
@@ -640,6 +650,7 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
document_id = json_string(&document_id),
user_id = json_string(&user_id),
mode = json_string(mode),
document_url_base = json_string(&document_url_base),
);
Ok(Html(html).into_response())
@@ -1441,6 +1452,32 @@ mod tests {
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
}
#[tokio::test]
async fn onlyoffice_page_exposes_documentserver_fetch_base_for_local_files() {
let response = page(Query(OnlyOfficePageQuery {
file_url: Some(
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
.into(),
),
file_name: Some("report.docx".into()),
file_type: Some("docx".into()),
asset_id: Some("local-file:Page/report.docx".into()),
document_id: Some("local-md:Page".into()),
user_id: None,
mode: Some("view".into()),
}))
.await
.expect("onlyoffice page");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("const documentUrlBase = \"http://host.docker.internal:3000\";"));
assert!(html.contains("const proxyOrigin = pageLocal ? documentUrlBase : location.origin;"));
assert!(html.contains("documentUrlBase,"));
assert!(html.contains("resolvedFileUrl: fileState.resolvedFileUrl"));
}
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
AppState::new(AppConfig {
service_name: "mnote-web".into(),
+173 -7
View File
@@ -613,6 +613,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const STATE_EVENT = `${EVENT_PREFIX}:state`;
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
@@ -2898,7 +2899,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (view.onError) root.removeEventListener(ERROR_EVENT, view.onError);
if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange);
if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave);
if (view.onState) root.removeEventListener(STATE_EVENT, view.onState);
if (view.onKeydown) root.removeEventListener('keydown', view.onKeydown, true);
if (view.onInput) root.removeEventListener('input', view.onInput);
if (view.disconnectSlashObserver) {
view.disconnectSlashObserver();
view.disconnectSlashObserver = null;
}
if (view.mountId != null && typeof view.runtime?.unmount === 'function') {
try {
view.runtime.unmount(view.mountId);
@@ -2929,6 +2936,135 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
view.disconnectObserver = () => observer.disconnect();
};
const activeEditorRootForSlashMenu = () => {
const selection = window.getSelection();
const anchorNode = selection?.anchorNode || null;
const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE
? anchorNode
: anchorNode?.parentElement || null;
const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => node instanceof HTMLElement);
if (anchorElement instanceof Element) {
const activeRoot = roots.find((root) => root.contains(anchorElement));
if (activeRoot) return activeRoot;
}
return roots.find((root) => root.offsetParent !== null) || roots[0] || null;
};
const slashMenuAnchorFromSelection = (root) => {
const currentBlockFromSelection = () => {
try {
const selection = window.getSelection();
const anchorNode = selection?.anchorNode || null;
const editor = root.querySelector('.ProseMirror');
const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE
? anchorNode
: anchorNode?.parentElement || null;
if (!(editor instanceof HTMLElement) || !(anchorElement instanceof Element) || !editor.contains(anchorElement)) return null;
let current = anchorElement;
while (current && current.parentElement && current.parentElement !== editor) {
current = current.parentElement;
}
return current instanceof HTMLElement && current.parentElement === editor ? current : null;
} catch (_) {
return null;
}
};
const fallback = () => {
const editor = root.querySelector('.ProseMirror');
if (editor instanceof HTMLElement) {
const block = currentBlockFromSelection()
|| Array.from(editor.children).find((node) => node instanceof HTMLElement && node.matches(':focus-within, .ProseMirror-selectednode'))
|| Array.from(editor.children).find((node) => node instanceof HTMLElement && node.getBoundingClientRect().height > 0);
if (block instanceof HTMLElement) {
const rect = block.getBoundingClientRect();
return { top: rect.bottom + 8, left: rect.left, anchorTop: rect.top };
}
}
const rect = root.getBoundingClientRect();
return { top: rect.top + 24, left: rect.left + 24, anchorTop: rect.top + 24 };
};
try {
const selection = window.getSelection();
if (!selection || selection.rangeCount <= 0) return fallback();
const anchorNode = selection.anchorNode;
if (anchorNode && !root.contains(anchorNode.nodeType === Node.ELEMENT_NODE ? anchorNode : anchorNode.parentElement)) {
return fallback();
}
const rect = selection.getRangeAt(0).getBoundingClientRect();
if (rect && (rect.top > 0 || rect.left > 0 || rect.height > 0)) {
return { top: rect.bottom + 8, left: rect.left, anchorTop: rect.top };
}
} catch (_) {}
return fallback();
};
const positionSlashMenuForRoot = (root) => {
if (!(root instanceof HTMLElement)) root = activeEditorRootForSlashMenu();
if (!(root instanceof HTMLElement)) return;
const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')
|| document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
if (!(menu instanceof HTMLElement)) return;
const anchor = slashMenuAnchorFromSelection(root);
const gap = 8;
const menuWidth = Math.min(316, Math.max(160, window.innerWidth - 16));
const menuHeight = Math.min(430, Math.max(120, window.innerHeight - 16));
const left = Math.min(Math.max(gap, anchor.left), Math.max(gap, window.innerWidth - menuWidth - gap));
const belowTop = Math.min(Math.max(gap, anchor.top), Math.max(gap, window.innerHeight - 120));
let top = belowTop + menuHeight > window.innerHeight - gap
? Math.min(Math.max(gap, anchor.anchorTop - menuHeight - gap), Math.max(gap, window.innerHeight - 120))
: belowTop;
const mindmap = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
if (mindmap instanceof HTMLElement) {
const mindmapRect = mindmap.getBoundingClientRect();
const overlapsMindmap = top < mindmapRect.bottom && top + menuHeight > mindmapRect.top;
if (overlapsMindmap && mindmapRect.bottom > window.innerHeight - 48) {
top = Math.max(top, Math.max(gap, window.innerHeight - menuHeight - gap));
} else if (overlapsMindmap && mindmapRect.bottom + menuHeight + gap <= window.innerHeight) {
top = mindmapRect.bottom + gap;
}
}
menu.style.position = 'fixed';
menu.style.zIndex = '130';
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.width = `min(316px, calc(100vw - 16px))`;
menu.style.maxHeight = `min(430px, calc(100vh - 16px))`;
menu.setAttribute('data-mnote-slash-positioned', 'host');
};
const scheduleSlashMenuPosition = (root) => {
window.requestAnimationFrame(() => positionSlashMenuForRoot(root));
};
const scheduleGlobalSlashMenuPosition = () => {
scheduleSlashMenuPosition(activeEditorRootForSlashMenu());
};
const installGlobalSlashMenuPositioning = () => {
if (window.__MNOTE_SLASH_MENU_POSITIONING_INSTALLED__) return;
window.__MNOTE_SLASH_MENU_POSITIONING_INSTALLED__ = true;
if (typeof MutationObserver === 'function' && document.body instanceof HTMLElement) {
const observer = new MutationObserver(() => scheduleGlobalSlashMenuPosition());
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden'] });
}
document.addEventListener('keydown', (event) => {
if (event.key === '/') scheduleGlobalSlashMenuPosition();
}, true);
document.addEventListener('selectionchange', () => scheduleGlobalSlashMenuPosition(), true);
window.addEventListener('resize', scheduleGlobalSlashMenuPosition);
window.addEventListener('scroll', scheduleGlobalSlashMenuPosition, true);
};
installGlobalSlashMenuPositioning();
const observeSlashMenuPosition = (view) => {
const root = view.runtimeDescriptor.root;
if (!(root instanceof HTMLElement) || typeof MutationObserver !== 'function') return;
const observer = new MutationObserver(() => scheduleSlashMenuPosition(root));
observer.observe(document.body, { childList: true, subtree: true });
view.disconnectSlashObserver = () => observer.disconnect();
};
const createEditorViewBinding = (session, runtime, runtimeDescriptor) => {
cancelDocumentSessionRelease(session);
const view = {
@@ -2946,7 +3082,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
onError: null,
onChange: null,
onSave: null,
onState: null,
onKeydown: null,
onInput: null,
disconnectSlashObserver: null,
};
view.onReady = () => {
@@ -2965,23 +3104,34 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error');
};
view.onChange = (event) => {
scheduleSlashMenuPosition(runtimeDescriptor.root);
handleSessionChange(session, view, event);
};
view.onSave = (event) => {
handleSessionChange(session, view, event);
};
view.onState = () => {
scheduleSlashMenuPosition(runtimeDescriptor.root);
};
view.onKeydown = (event) => {
if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root);
};
view.onInput = (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest('.ProseMirror')) return;
session.lastUserInputAt = Date.now();
scheduleSlashMenuPosition(runtimeDescriptor.root);
};
runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady);
runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError);
runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange);
runtimeDescriptor.root.addEventListener(SAVE_EVENT, view.onSave);
runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);
runtimeDescriptor.root.addEventListener('keydown', view.onKeydown, true);
runtimeDescriptor.root.addEventListener('input', view.onInput);
session.views.set(view.id, view);
observeSlashMenuPosition(view);
observeEditorViewBinding(view);
return view;
};
@@ -2994,13 +3144,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
panelRoot: document.querySelector('[data-mnote-resource-tab-panel-root]'),
});
const resourceIconForKind = (kind) => {
if (kind === 'office') return 'article';
if (kind === 'pdf') return 'picture_as_pdf';
const resourceTabBadgeKind = (input, kind) => {
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
if (kind === 'office') {
if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
return 'word';
}
if (kind === 'pdf') return 'ppt';
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code';
if (kind === 'image') return 'image';
if (kind === 'markdown') return 'notes';
if (kind === 'code') return 'code';
return 'draft';
return 'file';
};
const currentWebShellWorkspaceId = () => {
@@ -3078,7 +3232,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
tab.setAttribute('role', 'tab');
tab.setAttribute('data-mnote-main-tab', objectIdentity);
tab.setAttribute('data-mnote-tab-kind', kind);
tab.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">' + resourceIconForKind(kind) + '</span><span class="mnote-main-tab-title"></span><span class="mnote-main-tab-close" role="button" aria-label="关闭标签页">×</span>';
tab.setAttribute('data-mnote-tab-badge-kind', resourceTabBadgeKind(input, kind));
tab.innerHTML = '<span class="mnote-main-tab-badge" aria-hidden="true"></span><span class="mnote-main-tab-title"></span><span class="mnote-main-tab-close" role="button" aria-label="关闭标签页">×</span>';
const titleNode = tab.querySelector('.mnote-main-tab-title');
if (titleNode) titleNode.textContent = title;
tab.addEventListener('click', (event) => {
@@ -3985,6 +4140,17 @@ mod tests {
assert!(html.contains("data-pane-role=\"primary\""));
assert!(html.contains("data-document-pane-resizer=\"true\""));
assert!(html.contains("data-mnote-main-tab-strip"));
assert!(html.contains("class=\"mnote-main-tab-badge\""));
assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
assert!(html.contains("data-mnote-tab-badge-kind"));
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
assert!(html.contains("positionSlashMenuForRoot"));
assert!(html.contains("menu.style.position = 'fixed';"));
assert!(html.contains("installGlobalSlashMenuPositioning();"));
assert!(html.contains("data-mnote-slash-positioned', 'host'"));
assert!(
html.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
);
assert!(html.contains("data-mnote-resource-tab-host"));
assert!(html.contains("openPrimaryMindmap"));
assert!(html.contains("openResourceInActiveTab"));
@@ -307,7 +307,7 @@ pub fn DocumentPage(
role="tab"
aria-selected="true"
>
<span class="material-symbols-outlined" aria-hidden="true">"description"</span>
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
<span class="mnote-main-tab-title">{title.clone()}</span>
</button>
</div>
+33 -3
View File
@@ -2417,9 +2417,34 @@ body {
box-shadow: inset 0 1px 0 #E9E9E8, inset 1px 0 0 #E9E9E8, inset -1px 0 0 #E9E9E8;
}
.mnote-main-tab .material-symbols-outlined {
font-size: 16px;
flex: 0 0 auto;
.mnote-main-tab-badge {
width: 14px;
height: 14px;
flex: 0 0 14px;
border-radius: 2px;
background: #6b7280;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12);
}
.mnote-main-tab[data-mnote-tab-kind="page"] .mnote-main-tab-badge,
.mnote-main-tab[data-mnote-tab-badge-kind="code"] .mnote-main-tab-badge {
background: #111111;
}
.mnote-main-tab[data-mnote-tab-badge-kind="word"] .mnote-main-tab-badge {
background: #4f7df3;
}
.mnote-main-tab[data-mnote-tab-badge-kind="ppt"] .mnote-main-tab-badge {
background: #d94841;
}
.mnote-main-tab[data-mnote-tab-badge-kind="sheet"] .mnote-main-tab-badge {
background: #2f9e44;
}
.mnote-main-tab[data-mnote-tab-badge-kind="image"] .mnote-main-tab-badge {
background: #5b6cf0;
}
.mnote-main-tab-title {
@@ -4195,6 +4220,11 @@ mod tests {
assert!(MNOTE_CSS.contains("background: #2f9e44"));
assert!(MNOTE_CSS.contains("a.mnote-uploaded-attachment-code::before"));
assert!(MNOTE_CSS.contains("background: #111111"));
assert!(MNOTE_CSS.contains(".mnote-main-tab-badge"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"word\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"ppt\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"sheet\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"code\"]"));
}
#[test]