mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Fix mobile mention menu placement
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
65818c3447
commit
5a9a2a9112
2 changed files with 96 additions and 6 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
|
||||||
const mdxEditorMockState = vi.hoisted(() => ({
|
const mdxEditorMockState = vi.hoisted(() => ({
|
||||||
emitMountEmptyReset: false,
|
emitMountEmptyReset: false,
|
||||||
|
|
@ -162,4 +162,28 @@ describe("MarkdownEditor", () => {
|
||||||
root.unmount();
|
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;
|
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> = {
|
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||||
txt: "Text",
|
txt: "Text",
|
||||||
md: "Markdown",
|
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 {
|
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
||||||
if (!node || !container.contains(node)) return false;
|
if (!node || !container.contains(node)) return false;
|
||||||
const el = node.nodeType === Node.ELEMENT_NODE
|
const el = node.nodeType === Node.ELEMENT_NODE
|
||||||
|
|
@ -416,6 +461,25 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
};
|
};
|
||||||
}, [checkMention, mentions]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||||
if (!editable) return;
|
if (!editable) return;
|
||||||
|
|
@ -526,6 +590,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
ref.current.insertMarkdown(normalizeMarkdown(rawText));
|
ref.current.insertMarkdown(normalizeMarkdown(rawText));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const mentionMenuPosition = mentionState
|
||||||
|
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -645,19 +713,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<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"
|
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={{
|
style={mentionMenuPosition ?? undefined}
|
||||||
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
|
|
||||||
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{filteredMentions.map((option, i) => (
|
{filteredMentions.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
"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",
|
i === mentionIndex && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.preventDefault(); // prevent blur
|
e.preventDefault(); // prevent blur
|
||||||
selectMention(option);
|
selectMention(option);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue