fix: Fix menu dismissal blocking MathLive item clicks

This commit is contained in:
MizarZh 2026-07-13 23:59:28 +08:00
parent bd50e254ce
commit 4467c726e3

View file

@ -4,7 +4,10 @@ type MenuCleanup = () => void;
interface DocumentMenuState {
fields: Set<MathfieldElement>;
lastMathfield: MathfieldElement | null;
pendingClickMathfield: MathfieldElement | null;
onPointerDown: EventListener;
onClick: EventListener;
}
const documentMenuStates = new WeakMap<Document, DocumentMenuState>();
@ -33,6 +36,27 @@ function getActiveMathfield(
return mathfield;
}
function getPathMathfield(
path: EventTarget[],
fields: Set<MathfieldElement>
): MathfieldElement | null {
return (path.find((target) =>
fields.has(target as MathfieldElement)
) as MathfieldElement | undefined) ?? null;
}
function isInsideMenu(event: Event, menus: HTMLElement[]): boolean {
const path = event.composedPath();
if (menus.some((menu) => path.includes(menu))) return true;
if (!("clientX" in event) || !("clientY" in event)) return false;
const { clientX, clientY } = event as MouseEvent;
return menus.some((menu) => {
const bounds = menu.getBoundingClientRect();
return clientX >= bounds.left && clientX <= bounds.right &&
clientY >= bounds.top && clientY <= bounds.bottom;
});
}
export function dismissOpenMathLiveMenu(mfe: MathfieldElement): boolean {
const menu = getOpenMenus(mfe)[0];
if (!menu) return false;
@ -41,35 +65,42 @@ export function dismissOpenMathLiveMenu(mfe: MathfieldElement): boolean {
const KeyboardEventConstructor =
scrim.ownerDocument.defaultView?.KeyboardEvent ?? KeyboardEvent;
// A top-layer menu does not reliably bubble its synthetic Escape to the scrim.
// Keep it local so the math-field Escape handler does not undo current edits.
scrim.dispatchEvent(new KeyboardEventConstructor("keydown", {
key: "Escape",
bubbles: true,
bubbles: false,
cancelable: true,
composed: true,
composed: false,
}));
if (menu.parentElement && mfe.isConnected) {
mfe.executeCommand("toggleContextMenu");
}
// toggleContextMenu() can reopen a menu whose state is not exactly "open".
return menu.parentElement === null;
}
function handleDocumentPointerDown(
event: Event,
ownerDocument: Document,
fields: Set<MathfieldElement>
state: DocumentMenuState
): void {
const path = event.composedPath();
const pathMathfield = path.find((target) =>
fields.has(target as MathfieldElement)
) as MathfieldElement | undefined;
const mfe = pathMathfield ?? getActiveMathfield(ownerDocument, fields);
if (!mfe) return;
const pathMathfield = getPathMathfield(path, state.fields);
const activeMathfield = getActiveMathfield(ownerDocument, state.fields);
state.pendingClickMathfield = [
pathMathfield,
activeMathfield,
state.lastMathfield,
].find((mfe) => mfe && getOpenMenus(mfe).length > 0) ?? null;
if (pathMathfield) state.lastMathfield = pathMathfield;
}
function handleDocumentClick(event: Event, state: DocumentMenuState): void {
const mfe = state.pendingClickMathfield;
state.pendingClickMathfield = null;
if (!mfe || !state.fields.has(mfe)) return;
const menus = getOpenMenus(mfe);
if (menus.length === 0) return;
if (menus.some((menu) => path.includes(menu))) return;
// Let MathLive finish the pointer dispatch before changing its popover state.
if (menus.length === 0 || isInsideMenu(event, menus)) return;
// Menu item click handlers finish before this document-level fallback.
queueMicrotask(() => {
if (fields.has(mfe)) dismissOpenMathLiveMenu(mfe);
if (state.fields.has(mfe)) dismissOpenMathLiveMenu(mfe);
});
}
@ -78,11 +109,19 @@ function getDocumentMenuState(ownerDocument: Document): DocumentMenuState {
if (existing) return existing;
const fields = new Set<MathfieldElement>();
const onPointerDown: EventListener = (event) => {
handleDocumentPointerDown(event, ownerDocument, fields);
const state: DocumentMenuState = {
fields,
lastMathfield: null,
pendingClickMathfield: null,
onPointerDown: (event) => {
handleDocumentPointerDown(event, ownerDocument, state);
},
onClick: (event) => {
handleDocumentClick(event, state);
},
};
const state = { fields, onPointerDown };
ownerDocument.addEventListener("pointerdown", onPointerDown, true);
ownerDocument.addEventListener("pointerdown", state.onPointerDown, true);
ownerDocument.addEventListener("click", state.onClick);
documentMenuStates.set(ownerDocument, state);
return state;
}
@ -101,8 +140,11 @@ export function setupMathLiveMenuDismissal(mfe: MathfieldElement): void {
if (menuCleanups.get(mfe) !== cleanup) return;
menuCleanups.delete(mfe);
state.fields.delete(mfe);
if (state.lastMathfield === mfe) state.lastMathfield = null;
if (state.pendingClickMathfield === mfe) state.pendingClickMathfield = null;
if (state.fields.size > 0) return;
ownerDocument.removeEventListener("pointerdown", state.onPointerDown, true);
ownerDocument.removeEventListener("click", state.onClick);
documentMenuStates.delete(ownerDocument);
};
menuCleanups.set(mfe, cleanup);