forked from farhoodlabs/paperclip
Fix mobile mention menu placement
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user