Mention textarea focus & dropdown fixes (#1817)
- Fix popovers (model selector etc.) closing instantly when the mention textarea was focused - Fix mention autocomplete not clickable in prompt focus mode - Fix chip "Replace" reopening the autocomplete - now confirms immediately - Fix autocomplete appearing at top-left of screen when caret is next to a leading chip - Don't open the autocomplete when clicking the caret onto an already-confirmed chip
This commit is contained in:
1 parent
4ff5bdf475
commit
13b5e7b5c2
2 files changed
+115
-12
No files matched your search
@@ -66,6 +66,8 @@ if (typeof window !== "undefined" && !window.__modalEscListenerInstalled) {
|
||||
window.__modalEscListenerInstalled = true;
|
||||
window.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" && e.key !== "Esc") return;
|
||||
// An open outside-safe panel (mention autocomplete etc.) claims Escape.
|
||||
if (document.querySelector(OUTSIDE_SAFE_SELECTOR)) return;
|
||||
const top = getTopEscapableModal();
|
||||
if (top) {
|
||||
e.preventDefault();
|
||||
@@ -77,6 +79,12 @@ if (typeof window !== "undefined" && !window.__modalEscListenerInstalled) {
|
||||
// Simple global z-index tracker for stacked modals
|
||||
let modalZCounter = 70;
|
||||
|
||||
// Body-portaled companions of modal content (e.g. the prompt box's mention
|
||||
// autocomplete, which portals to <body> for fixed positioning) mark their
|
||||
// root with this attribute. Pointer-downs inside them must not count as
|
||||
// outside clicks, and while one is open it claims Escape before the modal.
|
||||
const OUTSIDE_SAFE_SELECTOR = "[data-modal-outside-safe]";
|
||||
|
||||
const AnimatedBackdrop = ({
|
||||
styles,
|
||||
backdropClassName,
|
||||
@@ -1004,12 +1012,23 @@ export const Modal = ({
|
||||
? {}
|
||||
: ({ "aria-describedby": undefined } as any))}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (!closeOnOutsideClick || allowBackgroundInteraction) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
!closeOnOutsideClick ||
|
||||
allowBackgroundInteraction ||
|
||||
target?.closest?.(OUTSIDE_SAFE_SELECTOR)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!closeOnEsc) {
|
||||
// Radix listens on document in the capture phase, so an
|
||||
// open outside-safe panel must be checked here too — its
|
||||
// own Escape handling runs later in the bubble phase.
|
||||
if (
|
||||
!closeOnEsc ||
|
||||
document.querySelector(OUTSIDE_SAFE_SELECTOR)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -416,16 +416,21 @@ function MentionDropdown({
|
||||
return createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
// `data-modal-outside-safe` tells the shared Modal (focus mode) that
|
||||
// clicks here are not outside clicks; `pointerEvents: auto` re-enables
|
||||
// interaction under the Radix modal's body-wide pointer-events lock.
|
||||
data-modal-outside-safe=""
|
||||
className="fixed z-[9999] w-64 max-w-[calc(100vw-2rem)] max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl"
|
||||
style={
|
||||
placement
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
...(placement
|
||||
? { left: placement.left, top: placement.top }
|
||||
: {
|
||||
left: position.left,
|
||||
top: position.caretBottom + 4,
|
||||
visibility: "hidden",
|
||||
}
|
||||
}
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
||||
References
|
||||
@@ -518,6 +523,10 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const isInternalUpdate = useRef(false);
|
||||
const isComposing = useRef(false);
|
||||
const pendingCaret = useRef<number | null>(null);
|
||||
// One-shot guard: a programmatic edit (chip replace) leaves the caret
|
||||
// right after a mention label, which detectMention would read as a fresh
|
||||
// "@..." trigger and reopen the dropdown over the already-confirmed chip.
|
||||
const suppressMentionDetect = useRef(false);
|
||||
const enterToGenerateStore = useEnterToGenerateStore((s) => s.enabled);
|
||||
const enterToGenerate = enterToGenerateProp ?? enterToGenerateStore;
|
||||
|
||||
@@ -733,6 +742,13 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const el = editorRef.current;
|
||||
if (!el) return null;
|
||||
|
||||
// The measurement below moves the document selection, and in Chrome
|
||||
// setting the selection inside a contentEditable FOCUSES it. If the
|
||||
// editor isn't focused (e.g. the user just opened a toolbar popover),
|
||||
// that focus steal would instantly dismiss whatever they opened —
|
||||
// skip the measurement instead of hijacking focus.
|
||||
if (document.activeElement !== el) return null;
|
||||
|
||||
// Temporarily place caret at charOffset to measure position
|
||||
const saved = getCaretOffset(el);
|
||||
setCaretOffset(el, charOffset);
|
||||
@@ -745,6 +761,18 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const rect = range.getBoundingClientRect();
|
||||
setCaretOffset(el, saved);
|
||||
|
||||
// A collapsed range adjacent to a non-editable chip span can measure
|
||||
// as an all-zero rect — returning it would pin the dropdown to the
|
||||
// viewport's top-left corner. Anchor to the editor's box instead.
|
||||
if (rect.left === 0 && rect.top === 0 && rect.bottom === 0) {
|
||||
const elRect = el.getBoundingClientRect();
|
||||
return {
|
||||
left: elRect.left,
|
||||
caretTop: elRect.top,
|
||||
caretBottom: elRect.top + 20,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: rect.left,
|
||||
caretTop: rect.top,
|
||||
@@ -756,6 +784,25 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// True when [start, start + query.length) is exactly a chip element in
|
||||
// the editor — i.e. the "trigger" the caret sits after is a mention that
|
||||
// is already confirmed, not something the user is still typing.
|
||||
const rangeIsRenderedChip = useCallback((start: number, query: string) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return false;
|
||||
for (const chip of Array.from(
|
||||
el.querySelectorAll<HTMLElement>("[data-mention]"),
|
||||
)) {
|
||||
if (
|
||||
chip.dataset.mention === query &&
|
||||
getNodeStartOffset(el, chip) === start
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Detect @mention trigger from cursor position
|
||||
// Supports multi-word names by scanning back to the nearest @.
|
||||
// A valid trigger requires the char before @ to not be an ASCII
|
||||
@@ -763,7 +810,14 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
// quotes, and punctuation all count as word boundaries, so users can
|
||||
// type "@Image1" directly after Chinese text like 从@Image1.
|
||||
const detectMention = useCallback(
|
||||
(text: string, cursorPos: number) => {
|
||||
(
|
||||
text: string,
|
||||
cursorPos: number,
|
||||
// Caret-placement calls (clicks) pass true: landing next to an
|
||||
// already-confirmed chip must not reopen the list over it. Typing
|
||||
// keeps the dropdown so Enter can still confirm an exact-typed name.
|
||||
skipConfirmedChips = false,
|
||||
) => {
|
||||
// Find the last @ before cursor
|
||||
const textBefore = text.slice(0, cursorPos);
|
||||
const lastAt = textBefore.lastIndexOf("@");
|
||||
@@ -773,7 +827,10 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
) {
|
||||
const query = text.slice(lastAt, cursorPos); // includes @
|
||||
// Only open if there's no newline in the query
|
||||
if (!query.includes("\n")) {
|
||||
if (
|
||||
!query.includes("\n") &&
|
||||
!(skipConfirmedChips && rangeIsRenderedChip(lastAt, query))
|
||||
) {
|
||||
const pos = getOffsetRect(lastAt);
|
||||
if (pos) setDropdownPos(pos);
|
||||
setMentionState({
|
||||
@@ -789,7 +846,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
prev.isOpen ? { ...prev, isOpen: false } : prev,
|
||||
);
|
||||
},
|
||||
[getOffsetRect],
|
||||
[getOffsetRect, rangeIsRenderedChip],
|
||||
);
|
||||
|
||||
// The dropdown is body-portaled with fixed positioning, so a scroll or
|
||||
@@ -835,7 +892,17 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
detectMention(text, caret);
|
||||
// The flag is NOT cleared here: execCommand fires a native input event
|
||||
// synchronously, so a chip replace runs handleInput twice (React's
|
||||
// onInput + the explicit call). The setter clears it via microtask
|
||||
// after the whole operation.
|
||||
if (suppressMentionDetect.current) {
|
||||
setMentionState((prev) =>
|
||||
prev.isOpen ? { ...prev, isOpen: false } : prev,
|
||||
);
|
||||
} else {
|
||||
detectMention(text, caret);
|
||||
}
|
||||
|
||||
// Keep caret visible when content overflows (contentEditable doesn't auto-scroll)
|
||||
requestAnimationFrame(() => {
|
||||
@@ -955,12 +1022,18 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
if (!chipMenu) return;
|
||||
setChipMenu(null);
|
||||
if (selectChipRange(chipMenu, false)) {
|
||||
suppressMentionDetect.current = true;
|
||||
document.execCommand(
|
||||
"insertHTML",
|
||||
false,
|
||||
buildChipHTML(next, next.label),
|
||||
);
|
||||
handleInput();
|
||||
// Cleared on a microtask (not inside handleInput) so it covers both
|
||||
// handleInput invocations of this replace — see note in handleInput.
|
||||
queueMicrotask(() => {
|
||||
suppressMentionDetect.current = false;
|
||||
});
|
||||
} else {
|
||||
// Chip element detached (DOM rebuilt) — splice the value instead.
|
||||
const start = chipStart(chipMenu);
|
||||
@@ -1065,6 +1138,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Escape closes only the dropdown — don't let it bubble to the
|
||||
// focus-mode modal's global Escape listener and close that too.
|
||||
e.stopPropagation();
|
||||
setMentionState((prev) => ({ ...prev, isOpen: false }));
|
||||
return;
|
||||
}
|
||||
@@ -1187,7 +1263,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return; // preserve text selection
|
||||
detectMention(value, getCaretOffset(el));
|
||||
detectMention(value, getCaretOffset(el), true);
|
||||
},
|
||||
[value, detectMention],
|
||||
);
|
||||
@@ -1252,7 +1328,15 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
onCopy={handleCopy}
|
||||
onCut={handleCut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onBlur={() => {
|
||||
// Focus left the editor (dropdown item clicks preventDefault
|
||||
// pointerdown, so they never land here) — the trigger context is
|
||||
// gone; close the dropdown instead of leaving it floating.
|
||||
setMentionState((prev) =>
|
||||
prev.isOpen ? { ...prev, isOpen: false } : prev,
|
||||
);
|
||||
onBlur?.();
|
||||
}}
|
||||
style={style}
|
||||
className={twMerge(
|
||||
className,
|
||||
|
||||
Reference in new issue
Block a user