mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:22:29 +00:00
Hotfix: mobile mention popover positioning, chip replace closing early & duplicating text
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user