const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]'; export 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 && node.offsetParent !== null && node.getAttribute('data-mnote-side-target-unsupported') !== 'true' )); const intendedRoot = window.__mnoteIntendedSlashRoot instanceof HTMLElement && roots.includes(window.__mnoteIntendedSlashRoot) ? window.__mnoteIntendedSlashRoot : null; if (intendedRoot) return intendedRoot; const focused = document.activeElement instanceof Element ? roots.find((root) => root.contains(document.activeElement)) : null; if (focused) return focused; if (anchorElement instanceof Element) { const activeRoot = roots.find((root) => root.contains(anchorElement)); if (activeRoot) return activeRoot; } return roots.find((root) => root.querySelector('.ProseMirror:focus-within')) || roots[0] || null; }; export const markIntendedSlashRoot = (entry) => { const root = entry?.view?.runtimeDescriptor?.root; if (!(root instanceof HTMLElement) || !root.isConnected) return; window.__mnoteIntendedSlashRoot = root; hideSlashMenusOutsideRoot(root); scheduleSlashMenuPosition(root); }; export 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(); }; export const setSlashMenuInactive = (menu, inactive) => { if (!(menu instanceof HTMLElement)) return; if (inactive) { if (menu.getAttribute('data-mnote-slash-inactive') !== 'true') { menu.setAttribute('data-mnote-slash-inactive', 'true'); } if (menu.style.display !== 'none') menu.style.display = 'none'; return; } if (menu.getAttribute('data-mnote-slash-inactive') === 'true') { menu.removeAttribute('data-mnote-slash-inactive'); } if (menu.style.display === 'none') menu.style.display = ''; }; export const setSlashMenuStyle = (menu, property, value) => { if (!(menu instanceof HTMLElement)) return; if (menu.style[property] !== value) menu.style[property] = value; }; export const setSlashMenuAttribute = (menu, name, value) => { if (!(menu instanceof HTMLElement)) return; if (menu.getAttribute(name) !== value) menu.setAttribute(name, value); }; export const hideSlashMenusOutsideRoot = (activeRoot) => { document.querySelectorAll(`${ROOT_SELECTOR} [data-testid="mnote-leptos-tiptap-slash-menu"]`).forEach((menu) => { const root = menu.closest(ROOT_SELECTOR); if (root !== activeRoot) setSlashMenuInactive(menu, true); }); }; export const positionSlashMenuForRoot = (root) => { const activeRoot = activeEditorRootForSlashMenu(); if (!(root instanceof HTMLElement)) root = activeRoot; if (!(root instanceof HTMLElement)) return; if (activeRoot instanceof HTMLElement && root !== activeRoot) { const inactiveMenu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); setSlashMenuInactive(inactiveMenu, true); hideSlashMenusOutsideRoot(activeRoot); return; } hideSlashMenusOutsideRoot(root); const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); if (!(menu instanceof HTMLElement)) return; setSlashMenuInactive(menu, false); 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; } } setSlashMenuStyle(menu, 'position', 'fixed'); setSlashMenuStyle(menu, 'zIndex', '130'); setSlashMenuStyle(menu, 'left', `${Math.round(left)}px`); setSlashMenuStyle(menu, 'top', `${Math.round(top)}px`); setSlashMenuStyle(menu, 'width', `min(316px, calc(100vw - 16px))`); setSlashMenuStyle(menu, 'maxHeight', `min(430px, calc(100vh - 16px))`); setSlashMenuAttribute(menu, 'data-mnote-slash-positioned', 'host'); }; export let pendingSlashMenuPositionFrame = 0; export let pendingSlashMenuPositionRoot = null; export const scheduleSlashMenuPosition = (root) => { if (root instanceof HTMLElement) pendingSlashMenuPositionRoot = root; if (pendingSlashMenuPositionFrame) return; pendingSlashMenuPositionFrame = window.requestAnimationFrame(() => { const targetRoot = pendingSlashMenuPositionRoot; pendingSlashMenuPositionFrame = 0; pendingSlashMenuPositionRoot = null; positionSlashMenuForRoot(targetRoot); }); }; export const shouldReactToSlashMenuMutation = (records, root) => { if (!Array.isArray(records) || !(root instanceof HTMLElement)) return false; return records.some((record) => { const target = record && record.target instanceof HTMLElement ? record.target : null; if (!target || !root.contains(target) && target !== root) return false; if (record.type === 'attributes') { return record.attributeName !== 'style' && record.attributeName !== 'data-mnote-slash-positioned' && record.attributeName !== 'data-mnote-slash-inactive'; } if (record.type === 'childList') { return Array.from(record.addedNodes || []).some((node) => node instanceof HTMLElement && node.closest('[data-testid="mnote-leptos-tiptap-slash-menu"]')) || Array.from(record.removedNodes || []).some((node) => node instanceof HTMLElement && node.closest('[data-testid="mnote-leptos-tiptap-slash-menu"]')); } return true; }); }; export const scheduleGlobalSlashMenuPosition = () => { scheduleSlashMenuPosition(activeEditorRootForSlashMenu()); }; export 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); }; export const observeSlashMenuPosition = (view) => { const root = view.runtimeDescriptor.root; if (!(root instanceof HTMLElement) || typeof MutationObserver !== 'function') return; const observer = new MutationObserver((records) => { if (!view.disposed && shouldReactToSlashMenuMutation(records, root)) { scheduleSlashMenuPosition(root); } }); observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'hidden', 'class', 'data-mnote-slash-positioned', 'data-mnote-slash-inactive'] }); view.disconnectSlashObserver = () => observer.disconnect(); };