diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 786b2dee..0df20323 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MarkdownEditor } from "./MarkdownEditor"; +import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor"; const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, @@ -162,4 +162,28 @@ describe("MarkdownEditor", () => { root.unmount(); }); }); + + it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 180, viewportLeft: 120 }, + { offsetLeft: 24, offsetTop: 320, width: 320, height: 260 }, + ), + ).toEqual({ + top: 372, + left: 144, + }); + }); + + it("clamps the mention menu back into view near the viewport edges", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 260, viewportLeft: 240 }, + { offsetLeft: 0, offsetTop: 0, width: 280, height: 220 }, + ), + ).toEqual({ + top: 12, + left: 92, + }); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 24fddcc1..a8fd8e98 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -95,6 +95,17 @@ interface MentionState { endPos: number; } +interface MentionMenuViewport { + offsetLeft: number; + offsetTop: number; + width: number; + height: number; +} + +const MENTION_MENU_WIDTH = 188; +const MENTION_MENU_HEIGHT = 208; +const MENTION_MENU_PADDING = 8; + const CODE_BLOCK_LANGUAGES: Record = { txt: "Text", md: "Markdown", @@ -171,6 +182,40 @@ function detectMention(container: HTMLElement): MentionState | null { }; } +function getMentionMenuViewport(): MentionMenuViewport { + const viewport = window.visualViewport; + if (viewport) { + return { + offsetLeft: viewport.offsetLeft, + offsetTop: viewport.offsetTop, + width: viewport.width, + height: viewport.height, + }; + } + + return { + offsetLeft: 0, + offsetTop: 0, + width: window.innerWidth, + height: window.innerHeight, + }; +} + +export function computeMentionMenuPosition( + anchor: Pick, + viewport: MentionMenuViewport, +) { + const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING; + const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH; + const minTop = viewport.offsetTop + MENTION_MENU_PADDING; + const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT; + + return { + top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)), + left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)), + }; +} + function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { if (!node || !container.contains(node)) return false; const el = node.nodeType === Node.ELEMENT_NODE @@ -416,6 +461,25 @@ export const MarkdownEditor = forwardRef }; }, [checkMention, mentions]); + useEffect(() => { + if (!mentionActive) return; + + const updatePosition = () => requestAnimationFrame(checkMention); + const viewport = window.visualViewport; + + viewport?.addEventListener("resize", updatePosition); + viewport?.addEventListener("scroll", updatePosition); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + viewport?.removeEventListener("resize", updatePosition); + viewport?.removeEventListener("scroll", updatePosition); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [checkMention, mentionActive]); + useEffect(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!editable) return; @@ -526,6 +590,10 @@ export const MarkdownEditor = forwardRef ref.current.insertMarkdown(normalizeMarkdown(rawText)); }, []); + const mentionMenuPosition = mentionState + ? computeMentionMenuPosition(mentionState, getMentionMenuViewport()) + : null; + return (
createPortal(
{filteredMentions.map((option, i) => (