Fix mobile mention menu placement

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 13:12:06 -05:00
parent 65818c3447
commit 5a9a2a9112
2 changed files with 96 additions and 6 deletions

View file

@ -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,
});
});
});

View file

@ -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<string, string> = {
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<MentionState, "viewportTop" | "viewportLeft">,
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<MarkdownEditorRef, MarkdownEditorProps>
};
}, [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<MarkdownEditorRef, MarkdownEditorProps>
ref.current.insertMarkdown(normalizeMarkdown(rawText));
}, []);
const mentionMenuPosition = mentionState
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
: null;
return (
<div
ref={containerRef}
@ -645,19 +713,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
createPortal(
<div
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
}}
style={mentionMenuPosition ?? undefined}
>
{filteredMentions.map((option, i) => (
<button
key={option.id}
type="button"
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onMouseDown={(e) => {
onPointerDown={(e) => {
e.preventDefault(); // prevent blur
selectMention(option);
}}