Hotfix: mobile mention popover positioning, chip replace closing early & duplicating text

This commit is contained in:
bflatastic
2026-08-04 23:08:46 +07:00
parent 099e3cb27b
commit cbfed0017b
2 changed files with 121 additions and 26 deletions
@@ -13,6 +13,11 @@ import type { MentionItem } from "./MentionTextarea";
const VIEWPORT_MARGIN = 8;
const ANCHOR_GAP = 4;
// Window after opening during which scroll/resize re-anchors the menu
// instead of closing it. Opening the menu on touch blurs the editor, and the
// on-screen keyboard's dismissal fires exactly those events — closing on
// them would dismiss the menu before the user's first tap.
const OPEN_GRACE_MS = 700;
const MENU_ROW =
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-base-fg transition-colors hover:bg-ui-controls/60 cursor-pointer";
@@ -59,6 +64,10 @@ export function MentionChipMenu({
onClose,
}: MentionChipMenuProps) {
const panelRef = useRef<HTMLDivElement>(null);
const openedAt = useRef(performance.now());
// Anchor rect the placement actually uses — re-measured from anchorNode
// when the viewport shifts during the opening grace window.
const [liveRect, setLiveRect] = useState(anchorRect);
const [view, setView] = useState<"menu" | "replace">("menu");
const [placement, setPlacement] = useState<{
left: number;
@@ -70,6 +79,9 @@ export function MentionChipMenu({
const currentName = currentLabel.replace(/^@/, "");
// Keep the live anchor in sync when the caller hands us a new chip rect.
useEffect(() => setLiveRect(anchorRect), [anchorRect]);
// Position below the chip, flipped above when there is no room, clamped
// horizontally into the viewport. Re-measured when the view swaps (the
// replace panel is taller than the menu).
@@ -79,21 +91,21 @@ export function MentionChipMenu({
const width = panel.offsetWidth;
const height = panel.offsetHeight;
let top = anchorRect.bottom + ANCHOR_GAP;
let top = liveRect.bottom + ANCHOR_GAP;
let flippedAbove = false;
if (top + height > window.innerHeight - VIEWPORT_MARGIN) {
top = anchorRect.top - height - ANCHOR_GAP;
top = liveRect.top - height - ANCHOR_GAP;
flippedAbove = true;
}
top = Math.max(VIEWPORT_MARGIN, top);
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(anchorRect.left, window.innerWidth - width - VIEWPORT_MARGIN),
Math.min(liveRect.left, window.innerWidth - width - VIEWPORT_MARGIN),
);
setPlacement({ left, top, flippedAbove });
}, [anchorRect, view, replaceItems.length]);
}, [liveRect, view, replaceItems.length]);
useEffect(() => {
if (!placement || entered) return;
@@ -108,22 +120,35 @@ export function MentionChipMenu({
if (anchorNode?.contains(target)) return;
if (panel && !panel.contains(target)) onClose();
};
// During the opening grace window, viewport churn re-anchors the menu to
// its chip instead of closing (see OPEN_GRACE_MS); afterwards scrolling
// or resizing dismisses as usual.
const repositionOrClose = () => {
if (
performance.now() - openedAt.current < OPEN_GRACE_MS &&
anchorNode?.isConnected
) {
setLiveRect(anchorNode.getBoundingClientRect());
return;
}
onClose();
};
const handleScroll = (e: Event) => {
const panel = panelRef.current;
if (panel && e.target instanceof Node && panel.contains(e.target)) return;
onClose();
repositionOrClose();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", onClose);
window.addEventListener("resize", repositionOrClose);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", onClose);
window.removeEventListener("resize", repositionOrClose);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, anchorNode]);
@@ -159,8 +184,8 @@ export function MentionChipMenu({
...(placement
? { left: placement.left, top: placement.top }
: {
left: anchorRect.left,
top: anchorRect.bottom + ANCHOR_GAP,
left: liveRect.left,
top: liveRect.bottom + ANCHOR_GAP,
visibility: "hidden",
}),
}}
@@ -86,6 +86,14 @@ const CHIP_IMG_CLASS =
"pointer-events-none h-4 w-4 shrink-0 rounded object-cover select-none";
const CHIP_NAME_CLASS = "pointer-events-none truncate";
/** Coarse-pointer (touch) device — no hover, on-screen keyboard. */
function isTouchDevice(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia?.("(hover: none)").matches === true
);
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -364,6 +372,10 @@ function scrollCaretIntoView(el: HTMLElement) {
// ---------------------------------------------------------------------------
const DROPDOWN_MARGIN = 8;
// Natural height cap — must match the panel's `max-h-72` class.
const DROPDOWN_MAX_HEIGHT = 288;
// Minimum useful panel height before placement stops shrinking it further.
const DROPDOWN_MIN_HEIGHT = 96;
function MentionDropdown({
items,
@@ -394,32 +406,59 @@ function MentionDropdown({
const [placement, setPlacement] = useState<{
left: number;
top: number;
maxHeight?: number;
} | null>(null);
useLayoutEffect(() => {
const panel = listRef.current;
if (!panel) return;
const panelWidth = panel.offsetWidth;
const panelHeight = panel.offsetHeight;
// Bound placement to the VISUAL viewport. With the on-screen keyboard
// up, window.innerHeight still spans the keyboard-covered / scrolled-away
// area, so clamping against it parked the panel above the visible screen
// (users had to scroll up to find it).
const vv = window.visualViewport;
const vpTop = vv?.offsetTop ?? 0;
const vpLeft = vv?.offsetLeft ?? 0;
const vpHeight = vv?.height ?? window.innerHeight;
const vpWidth = vv?.width ?? window.innerWidth;
const left = Math.max(
DROPDOWN_MARGIN,
Math.min(position.left, window.innerWidth - panelWidth - DROPDOWN_MARGIN),
const panelWidth = panel.offsetWidth;
// scrollHeight, not offsetHeight: offsetHeight reflects a maxHeight this
// effect previously applied, which would feed back into the next measure.
const naturalHeight = Math.min(
panel.scrollHeight + 2,
DROPDOWN_MAX_HEIGHT,
);
const spaceAbove = position.caretTop - DROPDOWN_MARGIN;
const spaceBelow =
window.innerHeight - position.caretBottom - DROPDOWN_MARGIN;
const left = Math.max(
vpLeft + DROPDOWN_MARGIN,
Math.min(position.left, vpLeft + vpWidth - panelWidth - DROPDOWN_MARGIN),
);
const spaceAbove = position.caretTop - vpTop - DROPDOWN_MARGIN;
const spaceBelow =
vpTop + vpHeight - position.caretBottom - DROPDOWN_MARGIN;
// Shrink the panel to the space on its side of the caret rather than
// letting it overflow the visible area (it scrolls internally).
let top: number;
if (panelHeight > spaceAbove && spaceBelow > spaceAbove) {
let maxHeight: number | undefined;
if (naturalHeight > spaceAbove && spaceBelow > spaceAbove) {
top = position.caretBottom + 4;
if (naturalHeight > spaceBelow) {
maxHeight = Math.max(DROPDOWN_MIN_HEIGHT, spaceBelow);
}
} else {
top = Math.max(DROPDOWN_MARGIN, position.caretTop - panelHeight - 4);
const height = Math.min(
naturalHeight,
Math.max(DROPDOWN_MIN_HEIGHT, spaceAbove),
);
if (height < naturalHeight) maxHeight = height;
top = Math.max(vpTop + DROPDOWN_MARGIN, position.caretTop - height - 4);
}
setPlacement({ left, top });
setPlacement({ left, top, maxHeight });
}, [position, items]);
useEffect(() => {
@@ -450,7 +489,13 @@ function MentionDropdown({
style={{
pointerEvents: "auto",
...(placement
? { left: placement.left, top: placement.top }
? {
left: placement.left,
top: placement.top,
...(placement.maxHeight != null
? { maxHeight: placement.maxHeight }
: {}),
}
: {
left: position.left,
top: position.caretBottom + 4,
@@ -1002,10 +1047,17 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
};
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
// Mobile keyboard show/hide moves the visual viewport without firing
// window resize — re-anchor on those too.
const vv = window.visualViewport;
vv?.addEventListener("resize", reposition);
vv?.addEventListener("scroll", reposition);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
vv?.removeEventListener("resize", reposition);
vv?.removeEventListener("scroll", reposition);
};
}, [mentionState.isOpen, mentionState.triggerIndex, getOffsetRect]);
@@ -1162,7 +1214,12 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
(next: MentionItem) => {
if (!chipMenu) return;
setChipMenu(null);
if (selectChipRange(chipMenu, false)) {
// The execCommand path exists to put the edit on the desktop undo
// stack. On touch it must not run: selectChipRange refocuses the
// editor (re-summoning the keyboard we dismissed), and mobile IMEs
// interact badly with execCommand — the replaced label came back
// duplicated as plain text after the chip.
if (!isTouchDevice() && selectChipRange(chipMenu, false)) {
suppressMentionDetect.current = true;
document.execCommand(
"insertHTML",
@@ -1176,14 +1233,19 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
suppressMentionDetect.current = false;
});
} else {
// Chip element detached (DOM rebuilt) — splice the value instead.
// Touch, or chip element detached — splice the value instead.
const start = chipStart(chipMenu);
if (start === -1) return;
const newValue =
value.slice(0, start) +
next.label +
value.slice(start + chipMenu.label.length);
pendingCaret.current = start + next.label.length;
// Restoring the caret moves the selection into the editor, which
// Chrome treats as focus — only do it when it's already focused,
// so a blurred mobile editor doesn't pop the keyboard back up.
if (document.activeElement === editorRef.current) {
pendingCaret.current = start + next.label.length;
}
onChange(newValue);
}
onMentionSelect?.(next);
@@ -1203,7 +1265,8 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
const handleChipRemove = useCallback(() => {
if (!chipMenu) return;
setChipMenu(null);
if (selectChipRange(chipMenu, true)) {
// Same touch guard as handleChipReplace.
if (!isTouchDevice() && selectChipRange(chipMenu, true)) {
document.execCommand("delete");
handleInput();
} else {
@@ -1211,7 +1274,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
if (start === -1) return;
let end = start + chipMenu.label.length;
if (value[end] === " ") end += 1;
pendingCaret.current = start;
if (document.activeElement === editorRef.current) {
pendingCaret.current = start;
}
onChange(value.slice(0, start) + value.slice(end));
}
}, [chipMenu, selectChipRange, handleInput, chipStart, value, onChange]);
@@ -1396,6 +1461,11 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
setChipMenu(null);
return;
}
// Dismiss the on-screen keyboard: keeping the editor focused
// squeezes the viewport, and the keyboard-dismissal scroll/resize
// fired by the user's FIRST tap inside the menu (e.g. Replace)
// would close it before that tap registered.
if (isTouchDevice()) el.blur();
const label = chip.dataset.mention ?? "";
setMentionState((prev) =>
prev.isOpen ? { ...prev, isOpen: false } : prev,