fix: repair resource tab and slash menu regressions
This commit is contained in:
@@ -223,6 +223,13 @@ fn onlyoffice_internal_candidates() -> Vec<String> {
|
|||||||
candidates
|
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 {
|
async fn resolve_onlyoffice_internal_url() -> String {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_millis(2_500))
|
.timeout(Duration::from_millis(2_500))
|
||||||
@@ -349,6 +356,7 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
|||||||
Some("view") => "view",
|
Some("view") => "view",
|
||||||
_ => "edit",
|
_ => "edit",
|
||||||
};
|
};
|
||||||
|
let document_url_base = onlyoffice_document_url_base_js();
|
||||||
|
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
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 MNOTE_AGENT_PLUGIN_GUID = "asc.{{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}}";
|
||||||
const pageLocal = location.hostname === "127.0.0.1" || location.hostname === "localhost";
|
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;
|
const callbackOrigin = proxyOrigin;
|
||||||
|
|
||||||
function showError(message) {{
|
function showError(message) {{
|
||||||
@@ -592,6 +601,7 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
|||||||
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
|
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
|
||||||
pageOrigin: location.origin,
|
pageOrigin: location.origin,
|
||||||
baseUrl: "/onlyoffice-server",
|
baseUrl: "/onlyoffice-server",
|
||||||
|
documentUrlBase,
|
||||||
proxyOrigin,
|
proxyOrigin,
|
||||||
callbackOrigin,
|
callbackOrigin,
|
||||||
fileUrlInput: fileState.effectiveFileUrl,
|
fileUrlInput: fileState.effectiveFileUrl,
|
||||||
@@ -640,6 +650,7 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
|||||||
document_id = json_string(&document_id),
|
document_id = json_string(&document_id),
|
||||||
user_id = json_string(&user_id),
|
user_id = json_string(&user_id),
|
||||||
mode = json_string(mode),
|
mode = json_string(mode),
|
||||||
|
document_url_base = json_string(&document_url_base),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Html(html).into_response())
|
Ok(Html(html).into_response())
|
||||||
@@ -1441,6 +1452,32 @@ mod tests {
|
|||||||
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
|
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 {
|
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
|
||||||
AppState::new(AppConfig {
|
AppState::new(AppConfig {
|
||||||
service_name: "mnote-web".into(),
|
service_name: "mnote-web".into(),
|
||||||
|
|||||||
@@ -613,6 +613,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
|
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
|
||||||
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
|
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
|
||||||
const READY_EVENT = `${EVENT_PREFIX}:ready`;
|
const READY_EVENT = `${EVENT_PREFIX}:ready`;
|
||||||
|
const STATE_EVENT = `${EVENT_PREFIX}:state`;
|
||||||
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
|
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
|
||||||
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
|
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
|
||||||
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
|
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.onError) root.removeEventListener(ERROR_EVENT, view.onError);
|
||||||
if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange);
|
if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange);
|
||||||
if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave);
|
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.onInput) root.removeEventListener('input', view.onInput);
|
||||||
|
if (view.disconnectSlashObserver) {
|
||||||
|
view.disconnectSlashObserver();
|
||||||
|
view.disconnectSlashObserver = null;
|
||||||
|
}
|
||||||
if (view.mountId != null && typeof view.runtime?.unmount === 'function') {
|
if (view.mountId != null && typeof view.runtime?.unmount === 'function') {
|
||||||
try {
|
try {
|
||||||
view.runtime.unmount(view.mountId);
|
view.runtime.unmount(view.mountId);
|
||||||
@@ -2929,6 +2936,135 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
view.disconnectObserver = () => observer.disconnect();
|
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) => {
|
const createEditorViewBinding = (session, runtime, runtimeDescriptor) => {
|
||||||
cancelDocumentSessionRelease(session);
|
cancelDocumentSessionRelease(session);
|
||||||
const view = {
|
const view = {
|
||||||
@@ -2946,7 +3082,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
onError: null,
|
onError: null,
|
||||||
onChange: null,
|
onChange: null,
|
||||||
onSave: null,
|
onSave: null,
|
||||||
|
onState: null,
|
||||||
|
onKeydown: null,
|
||||||
onInput: null,
|
onInput: null,
|
||||||
|
disconnectSlashObserver: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
view.onReady = () => {
|
view.onReady = () => {
|
||||||
@@ -2965,23 +3104,34 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error');
|
setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error');
|
||||||
};
|
};
|
||||||
view.onChange = (event) => {
|
view.onChange = (event) => {
|
||||||
|
scheduleSlashMenuPosition(runtimeDescriptor.root);
|
||||||
handleSessionChange(session, view, event);
|
handleSessionChange(session, view, event);
|
||||||
};
|
};
|
||||||
view.onSave = (event) => {
|
view.onSave = (event) => {
|
||||||
handleSessionChange(session, view, event);
|
handleSessionChange(session, view, event);
|
||||||
};
|
};
|
||||||
|
view.onState = () => {
|
||||||
|
scheduleSlashMenuPosition(runtimeDescriptor.root);
|
||||||
|
};
|
||||||
|
view.onKeydown = (event) => {
|
||||||
|
if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root);
|
||||||
|
};
|
||||||
view.onInput = (event) => {
|
view.onInput = (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof Element) || !target.closest('.ProseMirror')) return;
|
if (!(target instanceof Element) || !target.closest('.ProseMirror')) return;
|
||||||
session.lastUserInputAt = Date.now();
|
session.lastUserInputAt = Date.now();
|
||||||
|
scheduleSlashMenuPosition(runtimeDescriptor.root);
|
||||||
};
|
};
|
||||||
|
|
||||||
runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady);
|
runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady);
|
||||||
runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError);
|
runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError);
|
||||||
runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange);
|
runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange);
|
||||||
runtimeDescriptor.root.addEventListener(SAVE_EVENT, view.onSave);
|
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);
|
runtimeDescriptor.root.addEventListener('input', view.onInput);
|
||||||
session.views.set(view.id, view);
|
session.views.set(view.id, view);
|
||||||
|
observeSlashMenuPosition(view);
|
||||||
observeEditorViewBinding(view);
|
observeEditorViewBinding(view);
|
||||||
return 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]'),
|
panelRoot: document.querySelector('[data-mnote-resource-tab-panel-root]'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resourceIconForKind = (kind) => {
|
const resourceTabBadgeKind = (input, kind) => {
|
||||||
if (kind === 'office') return 'article';
|
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||||||
if (kind === 'pdf') return 'picture_as_pdf';
|
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 === 'image') return 'image';
|
||||||
if (kind === 'markdown') return 'notes';
|
return 'file';
|
||||||
if (kind === 'code') return 'code';
|
|
||||||
return 'draft';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentWebShellWorkspaceId = () => {
|
const currentWebShellWorkspaceId = () => {
|
||||||
@@ -3078,7 +3232,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
tab.setAttribute('role', 'tab');
|
tab.setAttribute('role', 'tab');
|
||||||
tab.setAttribute('data-mnote-main-tab', objectIdentity);
|
tab.setAttribute('data-mnote-main-tab', objectIdentity);
|
||||||
tab.setAttribute('data-mnote-tab-kind', kind);
|
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');
|
const titleNode = tab.querySelector('.mnote-main-tab-title');
|
||||||
if (titleNode) titleNode.textContent = title;
|
if (titleNode) titleNode.textContent = title;
|
||||||
tab.addEventListener('click', (event) => {
|
tab.addEventListener('click', (event) => {
|
||||||
@@ -3985,6 +4140,17 @@ mod tests {
|
|||||||
assert!(html.contains("data-pane-role=\"primary\""));
|
assert!(html.contains("data-pane-role=\"primary\""));
|
||||||
assert!(html.contains("data-document-pane-resizer=\"true\""));
|
assert!(html.contains("data-document-pane-resizer=\"true\""));
|
||||||
assert!(html.contains("data-mnote-main-tab-strip"));
|
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("data-mnote-resource-tab-host"));
|
||||||
assert!(html.contains("openPrimaryMindmap"));
|
assert!(html.contains("openPrimaryMindmap"));
|
||||||
assert!(html.contains("openResourceInActiveTab"));
|
assert!(html.contains("openResourceInActiveTab"));
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ pub fn DocumentPage(
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected="true"
|
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>
|
<span class="mnote-main-tab-title">{title.clone()}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2417,9 +2417,34 @@ body {
|
|||||||
box-shadow: inset 0 1px 0 #E9E9E8, inset 1px 0 0 #E9E9E8, inset -1px 0 0 #E9E9E8;
|
box-shadow: inset 0 1px 0 #E9E9E8, inset 1px 0 0 #E9E9E8, inset -1px 0 0 #E9E9E8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-main-tab .material-symbols-outlined {
|
.mnote-main-tab-badge {
|
||||||
font-size: 16px;
|
width: 14px;
|
||||||
flex: 0 0 auto;
|
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 {
|
.mnote-main-tab-title {
|
||||||
@@ -4195,6 +4220,11 @@ mod tests {
|
|||||||
assert!(MNOTE_CSS.contains("background: #2f9e44"));
|
assert!(MNOTE_CSS.contains("background: #2f9e44"));
|
||||||
assert!(MNOTE_CSS.contains("a.mnote-uploaded-attachment-code::before"));
|
assert!(MNOTE_CSS.contains("a.mnote-uploaded-attachment-code::before"));
|
||||||
assert!(MNOTE_CSS.contains("background: #111111"));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1899,12 +1899,12 @@ const SPIKE_STYLE: &str = r#"
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slash-menu {
|
.slash-menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 24px;
|
top: 24px;
|
||||||
left: 24px;
|
left: 24px;
|
||||||
z-index: 11;
|
z-index: 130;
|
||||||
width: min(316px, calc(100% - 48px));
|
width: min(316px, calc(100vw - 16px));
|
||||||
max-height: min(430px, calc(100vh - 180px));
|
max-height: min(430px, calc(100vh - 16px));
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -3381,6 +3381,14 @@ mod tests {
|
|||||||
Some(5)
|
Some(5)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_menu_css_uses_viewport_overlay_layer() {
|
||||||
|
assert!(STYLE.contains(".slash-menu"));
|
||||||
|
assert!(STYLE.contains("position: fixed;"));
|
||||||
|
assert!(STYLE.contains("z-index: 130;"));
|
||||||
|
assert!(!STYLE.contains("z-index: 11;"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -5480,6 +5488,56 @@ fn floating_toolbar_anchor_from_selection() -> Option<FloatingToolbarAnchor> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn slash_menu_anchor_style() -> String {
|
||||||
|
const MENU_WIDTH: f64 = 316.0;
|
||||||
|
const MENU_HEIGHT: f64 = 430.0;
|
||||||
|
const GAP: f64 = 8.0;
|
||||||
|
|
||||||
|
let viewport_width = window()
|
||||||
|
.and_then(|win| win.inner_width().ok())
|
||||||
|
.and_then(|value| value.as_f64())
|
||||||
|
.unwrap_or(1024.0)
|
||||||
|
.max(320.0);
|
||||||
|
let viewport_height = window()
|
||||||
|
.and_then(|win| win.inner_height().ok())
|
||||||
|
.and_then(|value| value.as_f64())
|
||||||
|
.unwrap_or(768.0)
|
||||||
|
.max(240.0);
|
||||||
|
|
||||||
|
let selection_rect = window()
|
||||||
|
.and_then(|win| win.get_selection().ok().flatten())
|
||||||
|
.and_then(|selection| selection.get_range_at(0).ok())
|
||||||
|
.map(|range| range.get_bounding_client_rect())
|
||||||
|
.filter(|rect| rect.top() > 0.0 || rect.left() > 0.0 || rect.height() > 0.0);
|
||||||
|
|
||||||
|
let (raw_top, raw_left, raw_anchor_top) = if let Some(rect) = selection_rect {
|
||||||
|
(rect.bottom() + GAP, rect.left(), rect.top())
|
||||||
|
} else if let Some(block) = hovered_block_from_selection() {
|
||||||
|
if let Some(stage) = editor_stage_element() {
|
||||||
|
let stage_rect = stage.get_bounding_client_rect();
|
||||||
|
(
|
||||||
|
stage_rect.top() + block.top + block.height + GAP,
|
||||||
|
stage_rect.left() + content_text_left(stage_rect.width()),
|
||||||
|
stage_rect.top() + block.top,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(24.0, 24.0, 24.0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(24.0, 24.0, 24.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let clamped_left = raw_left.clamp(GAP, (viewport_width - MENU_WIDTH - GAP).max(GAP));
|
||||||
|
let below_limit = viewport_height - GAP;
|
||||||
|
let clamped_top = if raw_top + MENU_HEIGHT > below_limit {
|
||||||
|
(raw_anchor_top - MENU_HEIGHT - GAP).clamp(GAP, below_limit - 120.0)
|
||||||
|
} else {
|
||||||
|
raw_top.clamp(GAP, below_limit - 120.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("top:{clamped_top:.1}px;left:{clamped_left:.1}px;")
|
||||||
|
}
|
||||||
|
|
||||||
fn image_toolbar_anchor_from_image(image: &Element) -> Option<ImageToolbarAnchor> {
|
fn image_toolbar_anchor_from_image(image: &Element) -> Option<ImageToolbarAnchor> {
|
||||||
let stage = editor_stage_element()?;
|
let stage = editor_stage_element()?;
|
||||||
let rect = image.get_bounding_client_rect();
|
let rect = image.get_bounding_client_rect();
|
||||||
@@ -10762,7 +10820,11 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
|||||||
{move || {
|
{move || {
|
||||||
if slash_open.get() {
|
if slash_open.get() {
|
||||||
view! {
|
view! {
|
||||||
<div class="slash-menu" data-testid="mnote-leptos-tiptap-slash-menu">
|
<div
|
||||||
|
class="slash-menu"
|
||||||
|
data-testid="mnote-leptos-tiptap-slash-menu"
|
||||||
|
style=slash_menu_anchor_style()
|
||||||
|
>
|
||||||
<div class="slash-list">
|
<div class="slash-list">
|
||||||
{move || {
|
{move || {
|
||||||
let query = slash_query.get().trim().to_ascii_lowercase();
|
let query = slash_query.get().trim().to_ascii_lowercase();
|
||||||
|
|||||||
@@ -183,6 +183,24 @@ async function readOnlyOfficeDebug(page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertOnlyOfficeDocumentUrlReachableFromContainer(debug) {
|
||||||
|
const resolvedFileUrl = String(debug && debug.resolvedFileUrl || "");
|
||||||
|
assert(resolvedFileUrl.includes("/api/onlyoffice/proxy"), `OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(debug)}`);
|
||||||
|
assert(
|
||||||
|
/^(http:\/\/host\.docker\.internal:3000|http:\/\/172\.17\.0\.1:3000)/.test(resolvedFileUrl),
|
||||||
|
`OnlyOffice resolvedFileUrl 应使用 DocumentServer 可回源的 mnote-web 地址:${resolvedFileUrl}`,
|
||||||
|
);
|
||||||
|
const { execFile } = require("node:child_process");
|
||||||
|
const { promisify } = require("node:util");
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const response = await execFileAsync(
|
||||||
|
"docker",
|
||||||
|
["exec", "mnote-onlyoffice-documentserver", "curl", "-fsSI", "--max-time", "10", resolvedFileUrl],
|
||||||
|
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
assert(/HTTP\/[0-9.]+ 200/.test(response.stdout), `DocumentServer 容器无法回源读取文档:${response.stdout}\n${response.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
@@ -217,6 +235,8 @@ async function main() {
|
|||||||
const firstDebug = await readOnlyOfficeDebug(popup);
|
const firstDebug = await readOnlyOfficeDebug(popup);
|
||||||
assert(firstDebug.ready, "OnlyOffice ready flag 应为 true");
|
assert(firstDebug.ready, "OnlyOffice ready flag 应为 true");
|
||||||
assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas");
|
assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas");
|
||||||
|
assert(firstDebug.debug && firstDebug.debug.documentUrlBase, `OnlyOffice debug 应暴露 documentUrlBase:${JSON.stringify(firstDebug)}`);
|
||||||
|
await assertOnlyOfficeDocumentUrlReachableFromContainer(firstDebug.debug);
|
||||||
assert(
|
assert(
|
||||||
/^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey),
|
/^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey),
|
||||||
`OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`,
|
`OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`,
|
||||||
|
|||||||
@@ -78,6 +78,50 @@ async function insertMindmapThroughSlash(page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertSlashMenuAnchorsAfterMindmap(page) {
|
||||||
|
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||||
|
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End");
|
||||||
|
await page.keyboard.type("/");
|
||||||
|
await page.getByTestId("mnote-leptos-tiptap-slash-menu").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')?.getAttribute("data-mnote-slash-positioned") === "host",
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const menu = document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
|
||||||
|
const mindmap = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
|
||||||
|
const menuRect = menu?.getBoundingClientRect();
|
||||||
|
const mindmapRect = mindmap?.getBoundingClientRect();
|
||||||
|
const pointNode = menuRect
|
||||||
|
? document.elementFromPoint(menuRect.left + 24, menuRect.top + 24)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
menuRect: menuRect ? { top: menuRect.top, bottom: menuRect.bottom, left: menuRect.left, right: menuRect.right } : null,
|
||||||
|
mindmapRect: mindmapRect ? { top: mindmapRect.top, bottom: mindmapRect.bottom, left: mindmapRect.left, right: mindmapRect.right } : null,
|
||||||
|
menuPosition: menu instanceof HTMLElement ? getComputedStyle(menu).position : "",
|
||||||
|
menuZIndex: menu instanceof HTMLElement ? getComputedStyle(menu).zIndex : "",
|
||||||
|
hostPositioned: menu instanceof HTMLElement ? menu.getAttribute("data-mnote-slash-positioned") || "" : "",
|
||||||
|
hitInsideMenu: Boolean(pointNode && menu && menu.contains(pointNode)),
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(state.menuRect, `slash 菜单应可见: ${JSON.stringify(state)}`);
|
||||||
|
assert.equal(state.menuPosition, "fixed", `slash 菜单应由宿主定位到 viewport 层: ${JSON.stringify(state)}`);
|
||||||
|
assert(Number(state.menuZIndex) >= 120, `slash 菜单层级应高于 mindmap/块工具: ${JSON.stringify(state)}`);
|
||||||
|
assert(state.hitInsideMenu, `slash 菜单不应被思维导图或其它层遮挡: ${JSON.stringify(state)}`);
|
||||||
|
assert(state.menuRect.top >= 0 && state.menuRect.bottom <= state.viewportHeight, `slash 菜单不应超出视口: ${JSON.stringify(state)}`);
|
||||||
|
if (state.mindmapRect) {
|
||||||
|
assert(
|
||||||
|
state.menuRect.top > state.mindmapRect.top - 80,
|
||||||
|
`mindmap 后输入 / 时菜单不应回退到编辑器左上固定旧位置: ${JSON.stringify(state)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
async function readState(page) {
|
async function readState(page) {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||||
@@ -285,6 +329,7 @@ async function main() {
|
|||||||
{ timeout: UI_TIMEOUT_MS },
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
result.saveAfterInsert = await waitForStableEditorSave(page, networkRecords, "after-insert");
|
result.saveAfterInsert = await waitForStableEditorSave(page, networkRecords, "after-insert");
|
||||||
|
result.slashMenuAfterMindmap = await assertSlashMenuAnchorsAfterMindmap(page);
|
||||||
|
|
||||||
result.diskAfterInsert = fs.readdirSync(pageDir).sort();
|
result.diskAfterInsert = fs.readdirSync(pageDir).sort();
|
||||||
assert(result.diskAfterInsert.includes("CleanPage.md"), `页面 Markdown 应存在: ${result.diskAfterInsert.join(",")}`);
|
assert(result.diskAfterInsert.includes("CleanPage.md"), `页面 Markdown 应存在: ${result.diskAfterInsert.join(",")}`);
|
||||||
|
|||||||
@@ -154,6 +154,21 @@ async function main() {
|
|||||||
state: "visible",
|
state: "visible",
|
||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
const markdownTabState = await page.evaluate(() => {
|
||||||
|
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
|
||||||
|
const tab = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]');
|
||||||
|
const badge = tab?.querySelector(".mnote-main-tab-badge");
|
||||||
|
return {
|
||||||
|
pageText: pageTab?.textContent || "",
|
||||||
|
resourceText: tab?.textContent || "",
|
||||||
|
badgeKind: tab?.getAttribute("data-mnote-tab-badge-kind") || "",
|
||||||
|
badgeColor: badge ? getComputedStyle(badge).backgroundColor : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(!/\b(description|article|notes)\b/i.test(markdownTabState.pageText), `页面 tab 不应显示 Material Symbols 文本: ${JSON.stringify(markdownTabState)}`);
|
||||||
|
assert(!/\b(description|article|notes)\b/i.test(markdownTabState.resourceText), `Markdown tab 不应显示 Material Symbols 文本: ${JSON.stringify(markdownTabState)}`);
|
||||||
|
assert.equal(markdownTabState.badgeKind, "code", `Markdown tab 应使用黑色 code 色块: ${JSON.stringify(markdownTabState)}`);
|
||||||
|
assert(markdownTabState.badgeColor === "rgb(17, 17, 17)", `Markdown tab 色块应为黑色: ${JSON.stringify(markdownTabState)}`);
|
||||||
const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first();
|
const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||||
@@ -186,6 +201,18 @@ async function main() {
|
|||||||
state: "visible",
|
state: "visible",
|
||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
const officeTabState = await page.evaluate(() => {
|
||||||
|
const tab = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="office"]');
|
||||||
|
const badge = tab?.querySelector(".mnote-main-tab-badge");
|
||||||
|
return {
|
||||||
|
resourceText: tab?.textContent || "",
|
||||||
|
badgeKind: tab?.getAttribute("data-mnote-tab-badge-kind") || "",
|
||||||
|
badgeColor: badge ? getComputedStyle(badge).backgroundColor : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(!/\b(description|article|notes)\b/i.test(officeTabState.resourceText), `Office tab 不应显示 Material Symbols 文本: ${JSON.stringify(officeTabState)}`);
|
||||||
|
assert.equal(officeTabState.badgeKind, "word", `docx tab 应使用 word 色块: ${JSON.stringify(officeTabState)}`);
|
||||||
|
assert(officeTabState.badgeColor === "rgb(79, 125, 243)", `docx tab 色块应与附件 word 蓝色一致: ${JSON.stringify(officeTabState)}`);
|
||||||
const officeFrameSrc = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src", {
|
const officeFrameSrc = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src", {
|
||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user