refactor: split document slash positioning runtime
This commit is contained in:
+1
-1
@@ -123,7 +123,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tests::mno
|
|||||||
- [x] B2. `document-tiptap-conversion-runtime.js`:legacy block / inline content / marks 转 Tiptap document。
|
- [x] B2. `document-tiptap-conversion-runtime.js`:legacy block / inline content / marks 转 Tiptap document。
|
||||||
- [ ] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。
|
- [ ] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。
|
||||||
- [ ] B4. `document-mindmap-host-runtime.js`:primary mindmap object shell、mindmap resource tab mount/unmount。
|
- [ ] B4. `document-mindmap-host-runtime.js`:primary mindmap object shell、mindmap resource tab mount/unmount。
|
||||||
- [ ] B5. `document-slash-position-runtime.js`:slash menu active root、positioning、mutation observer。
|
- [x] B5. `document-slash-position-runtime.js`:slash menu active root、positioning、mutation observer。
|
||||||
- [ ] B6. entrypoint 只负责读取 bootstrap、加载 runtime、装配各子模块。
|
- [ ] B6. entrypoint 只负责读取 bootstrap、加载 runtime、装配各子模块。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
installGlobalSlashMenuPositioning,
|
||||||
|
markIntendedSlashRoot,
|
||||||
|
observeSlashMenuPosition,
|
||||||
|
scheduleSlashMenuPosition,
|
||||||
|
} from './document-slash-position-runtime.js';
|
||||||
import {
|
import {
|
||||||
buildPaneRuntime,
|
buildPaneRuntime,
|
||||||
clearSecondaryParams,
|
clearSecondaryParams,
|
||||||
@@ -1950,225 +1956,8 @@ import {
|
|||||||
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
|
|
||||||
&& 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const markIntendedSlashRoot = (entry) => {
|
|
||||||
const root = entry?.view?.runtimeDescriptor?.root;
|
|
||||||
if (!(root instanceof HTMLElement) || !root.isConnected) return;
|
|
||||||
window.__mnoteIntendedSlashRoot = root;
|
|
||||||
hideSlashMenusOutsideRoot(root);
|
|
||||||
scheduleSlashMenuPosition(root);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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 = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSlashMenuStyle = (menu, property, value) => {
|
|
||||||
if (!(menu instanceof HTMLElement)) return;
|
|
||||||
if (menu.style[property] !== value) menu.style[property] = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSlashMenuAttribute = (menu, name, value) => {
|
|
||||||
if (!(menu instanceof HTMLElement)) return;
|
|
||||||
if (menu.getAttribute(name) !== value) menu.setAttribute(name, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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');
|
|
||||||
};
|
|
||||||
|
|
||||||
let pendingSlashMenuPositionFrame = 0;
|
|
||||||
let pendingSlashMenuPositionRoot = null;
|
|
||||||
const scheduleSlashMenuPosition = (root) => {
|
|
||||||
if (root instanceof HTMLElement) pendingSlashMenuPositionRoot = root;
|
|
||||||
if (pendingSlashMenuPositionFrame) return;
|
|
||||||
pendingSlashMenuPositionFrame = window.requestAnimationFrame(() => {
|
|
||||||
const targetRoot = pendingSlashMenuPositionRoot;
|
|
||||||
pendingSlashMenuPositionFrame = 0;
|
|
||||||
pendingSlashMenuPositionRoot = null;
|
|
||||||
positionSlashMenuForRoot(targetRoot);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
installGlobalSlashMenuPositioning();
|
||||||
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createEditorViewBinding = (session, runtime, runtimeDescriptor) => {
|
const createEditorViewBinding = (session, runtime, runtimeDescriptor) => {
|
||||||
cancelDocumentSessionRelease(session);
|
cancelDocumentSessionRelease(session);
|
||||||
const view = {
|
const view = {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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();
|
||||||
|
};
|
||||||
@@ -146,6 +146,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/mnote-browser-runtime/document-pane-runtime.js",
|
"/api/mnote-browser-runtime/document-pane-runtime.js",
|
||||||
get(web_shell::document_pane_runtime_asset),
|
get(web_shell::document_pane_runtime_asset),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/mnote-browser-runtime/document-slash-position-runtime.js",
|
||||||
|
get(web_shell::document_slash_position_runtime_asset),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
|
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
|
||||||
get(web_shell::document_tiptap_conversion_runtime_asset),
|
get(web_shell::document_tiptap_conversion_runtime_asset),
|
||||||
@@ -568,6 +572,7 @@ mod tests {
|
|||||||
"/api/mnote-browser-runtime/tree-shell-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-runtime.js",
|
||||||
"/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
|
"/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
|
||||||
"/api/mnote-browser-runtime/document-pane-runtime.js",
|
"/api/mnote-browser-runtime/document-pane-runtime.js",
|
||||||
|
"/api/mnote-browser-runtime/document-slash-position-runtime.js",
|
||||||
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
|
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
|
||||||
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
|
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -869,6 +869,20 @@ pub async fn document_pane_runtime_asset() -> Response {
|
|||||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn document_slash_position_runtime_asset() -> Response {
|
||||||
|
const JS: &str = include_str!("../../browser/document-slash-position-runtime.js");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
)
|
||||||
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||||
|
.body(Body::from(JS))
|
||||||
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn document_tiptap_conversion_runtime_asset() -> Response {
|
pub async fn document_tiptap_conversion_runtime_asset() -> Response {
|
||||||
const JS: &str = include_str!("../../browser/document-tiptap-conversion-runtime.js");
|
const JS: &str = include_str!("../../browser/document-tiptap-conversion-runtime.js");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
@@ -1307,6 +1321,8 @@ mod tests {
|
|||||||
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
|
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
|
||||||
include_str!("../../browser/document-editor-adapter-runtime.js");
|
include_str!("../../browser/document-editor-adapter-runtime.js");
|
||||||
const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js");
|
const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js");
|
||||||
|
const DOCUMENT_SLASH_POSITION_RUNTIME_JS: &str =
|
||||||
|
include_str!("../../browser/document-slash-position-runtime.js");
|
||||||
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
|
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
|
||||||
include_str!("../../browser/document-tiptap-conversion-runtime.js");
|
include_str!("../../browser/document-tiptap-conversion-runtime.js");
|
||||||
|
|
||||||
@@ -1565,21 +1581,24 @@ mod tests {
|
|||||||
assert!(!html
|
assert!(!html
|
||||||
.contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
|
.contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
|
||||||
assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })"));
|
assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })"));
|
||||||
assert!(runtime.contains("positionSlashMenuForRoot"));
|
let slash_runtime = DOCUMENT_SLASH_POSITION_RUNTIME_JS;
|
||||||
assert!(runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');"));
|
assert!(slash_runtime.contains("positionSlashMenuForRoot"));
|
||||||
|
assert!(slash_runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');"));
|
||||||
assert!(runtime.contains("installGlobalSlashMenuPositioning();"));
|
assert!(runtime.contains("installGlobalSlashMenuPositioning();"));
|
||||||
assert!(runtime.contains("data-mnote-slash-positioned', 'host'"));
|
assert!(slash_runtime.contains("data-mnote-slash-positioned', 'host'"));
|
||||||
assert!(runtime.contains(
|
assert!(slash_runtime.contains(
|
||||||
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
|
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
|
||||||
));
|
));
|
||||||
assert!(runtime.contains("const setSlashMenuInactive = (menu, inactive) =>"));
|
assert!(slash_runtime.contains("const setSlashMenuInactive = (menu, inactive) =>"));
|
||||||
assert!(runtime.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
|
assert!(slash_runtime.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
|
||||||
assert!(runtime.contains("data-mnote-slash-inactive"));
|
assert!(slash_runtime.contains("data-mnote-slash-inactive"));
|
||||||
assert!(runtime.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)"));
|
assert!(
|
||||||
|
slash_runtime.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)")
|
||||||
|
);
|
||||||
assert!(!runtime.contains(
|
assert!(!runtime.contains(
|
||||||
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
|
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
|
||||||
));
|
));
|
||||||
assert!(runtime.contains("data-mnote-side-target-unsupported') !== 'true'"));
|
assert!(slash_runtime.contains("data-mnote-side-target-unsupported') !== 'true'"));
|
||||||
assert!(
|
assert!(
|
||||||
runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
|
runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
|
||||||
);
|
);
|
||||||
@@ -2059,6 +2078,9 @@ mod tests {
|
|||||||
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js"));
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js"));
|
||||||
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane"));
|
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane"));
|
||||||
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime"));
|
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime"));
|
||||||
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js"));
|
||||||
|
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition"));
|
||||||
|
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition"));
|
||||||
assert!(
|
assert!(
|
||||||
DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js")
|
DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js")
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user