Bug fix: mention textarea & reference deck (#1818)

- **Prompt input no longer freezes at a character tag** - the caret
could get stuck inside a chip (e.g. after deleting the space behind it),
blocking all typing.
- **Reference add-menu no longer hides behind the sidebar** - it now
renders above everything instead of getting clipped by the promptbox
layers.
- **Character autocomplete is scrollable in focus mode** - the
fullscreen prompt's scroll lock was blocking it; same fix for the chip
menu's Replace list.
This commit is contained in:
BFlat
2026-07-30 07:43:40 +07:00
committed by GitHub
parent 13b5e7b5c2
commit bc4b3a6c1f
4 changed files with 174 additions and 52 deletions
@@ -127,6 +127,14 @@ export function MentionChipMenu({
return createPortal(
<div
ref={panelRef}
// Marks clicks in this body-portaled panel as not-outside for the
// focus-mode modal (see OUTSIDE_SAFE_SELECTOR in @storyteller/ui-modal).
data-modal-outside-safe=""
// Body-portaled, so the focus-mode modal's scroll lock
// (react-remove-scroll) would preventDefault wheel/touch events over
// this panel — stop propagation so the Replace list stays scrollable.
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
className={twMerge(
"fixed z-[9999] w-56 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl",
"transform-gpu transition duration-75 ease-out",
@@ -136,15 +144,17 @@ export function MentionChipMenu({
? "-translate-y-1 opacity-0"
: "translate-y-1 opacity-0",
)}
style={
placement
style={{
// Re-enable interaction under the modal's body-wide pointer-events lock.
pointerEvents: "auto",
...(placement
? { left: placement.left, top: placement.top }
: {
left: anchorRect.left,
top: anchorRect.bottom + ANCHOR_GAP,
visibility: "hidden",
}
}
}),
}}
>
{view === "menu" ? (
<>
@@ -125,6 +125,16 @@ function isChip(node: Node): node is HTMLElement {
);
}
/** Nearest chip element enclosing `node` (up to `root`), or null. */
function chipContaining(root: HTMLElement, node: Node | null): HTMLElement | null {
let current: Node | null = node;
while (current && current !== root) {
if (isChip(current)) return current;
current = current.parentNode;
}
return null;
}
/**
* DOM -> plain text. Mention chips contribute their full label ("@Name"),
* <br> contributes "\n". This replaces `el.innerText` everywhere: with chips
@@ -420,6 +430,12 @@ function MentionDropdown({
// 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=""
// The focus-mode modal's scroll lock (react-remove-scroll) blocks any
// wheel/touch event that bubbles to document from outside the dialog —
// and this panel is body-portaled, so it counts as outside. Stopping
// propagation here keeps the list's native scrolling working.
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
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={{
pointerEvents: "auto",
@@ -653,6 +669,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
let html = "";
let lastIndex = 0;
let endsWithChip = false;
const regex = new RegExp(mentionRegex);
let match: RegExpExecArray | null;
@@ -668,10 +685,13 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
if (item?.type === "character") {
html += buildChipHTML(item, fullMatch);
endsWithChip = true;
} else if (color) {
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
endsWithChip = false;
} else {
html += escapeHTML(fullMatch);
endsWithChip = false;
}
lastIndex = match.index + fullMatch.length;
@@ -679,11 +699,20 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
if (lastIndex < text.length) {
html += escapeHTML(text.slice(lastIndex));
endsWithChip = false;
}
html = html.replace(/\n/g, "<br>");
if (html.endsWith("<br>")) {
html += "<br>";
} else if (endsWithChip) {
// A chip as the very last node leaves no caret slot after it —
// Chrome then renders the caret INSIDE the contenteditable=false
// chip and silently swallows every keystroke (easy to hit: delete
// the space after a mention, then type). A single trailing <br>
// is invisible after inline content but gives the caret a valid
// landing spot; serialization strips the "\n" it contributes.
html += "<br>";
}
return normalizeHTML(html);
@@ -736,6 +765,35 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
}
}, [value, buildHTML]);
// Last line of defense for the frozen-input bug: browsers can still drop
// the caret INSIDE a contenteditable=false chip (Firefox arrow keys,
// drag-selection collapse, IME) — typing is then silently ignored. Eject
// the caret to just after the chip. Guarded by activeElement so a stale
// selection can't steal focus back from an open popover (Chrome focuses
// a contentEditable on addRange into it).
useEffect(() => {
const handleSelectionChange = () => {
const el = editorRef.current;
if (!el || document.activeElement !== el) return;
const sel = window.getSelection();
if (!sel?.rangeCount || !sel.isCollapsed) return;
const chip = chipContaining(el, sel.anchorNode);
if (!chip) return;
try {
const range = document.createRange();
range.setStartAfter(chip);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} catch (e) {
console.debug("chip caret eject: DOM changed during move", e);
}
};
document.addEventListener("selectionchange", handleSelectionChange);
return () =>
document.removeEventListener("selectionchange", handleSelectionChange);
}, []);
// Get pixel coordinates of a text offset relative to the wrapper
const getOffsetRect = useCallback((charOffset: number) => {
try {
@@ -174,6 +174,11 @@ export const ReferenceDeck = ({
const handleRootEnter = () => {
clearCloseTimer();
// The add menu is body-portaled, so hovering it counts as leaving the
// root and decays `hovered` (the panel stays up via addMenuOpen). Coming
// back onto the open panel must re-arm `hovered`, or it would collapse
// under the cursor the moment the menu closes.
if (expanded) setHovered(true);
};
const handleRootLeave = () => {
@@ -215,6 +220,11 @@ export const ReferenceDeck = ({
interactive={true}
position="top"
delay={100}
// Body-portaled: rendered inline, the menu is trapped in the
// promptbox's .glass stacking context and paints under the fixed
// z-30 sidebar no matter its own z-index.
portal
zIndex={9999}
className="-mb-0.5 border border-ui-controls-border bg-ui-controls p-1.5 text-base-fg"
closeOnClick={true}
onOpenChange={holdPanel ? setAddMenuOpen : undefined}
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, useReducer } from "react";
import { createPortal } from "react-dom";
import { Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
@@ -25,6 +26,16 @@ interface TooltipProps {
* when the tooltip is inside an overflow-hidden / overflow-auto container.
*/
strategy?: "absolute" | "fixed";
/**
* Render the tooltip in a document.body portal (implies fixed positioning).
* Use when the trigger sits inside its own stacking context (backdrop-blur
* "glass" surfaces, transformed ancestors) that would otherwise paint the
* tooltip below overlays like the sidebar — no zIndex can escape that from
* inside. Pair with a high `zIndex`; the portal root is marked
* `data-modal-outside-safe` so clicks in it don't count as outside clicks
* for the shared Modal.
*/
portal?: boolean;
}
export const Tooltip = ({
@@ -41,6 +52,7 @@ export const Tooltip = ({
zIndex = 10,
disabled = false,
strategy = "absolute",
portal = false,
}: TooltipProps) => {
const [isShowing, setIsShowing] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
@@ -184,16 +196,29 @@ export const Tooltip = ({
estWidth: number,
padding: number,
): React.CSSProperties => {
const offset = getContainingBlockOffset();
// A body portal has no transformed/filtered ancestor to compensate for.
const offset = portal ? { x: 0, y: 0 } : getContainingBlockOffset();
const GAP = 10;
switch (position) {
case "top":
case "top": {
const centerX = rect.left + rect.width / 2;
const halfWidth = estWidth / 2;
let translateX = "-50%";
if (centerX - halfWidth < padding) {
const diff = padding - (centerX - halfWidth);
translateX = `calc(-50% + ${diff}px)`;
} else if (centerX + halfWidth > vw - padding) {
const diff = centerX + halfWidth - (vw - padding);
translateX = `calc(-50% - ${diff}px)`;
}
return {
top: rect.top - offset.y - GAP,
left: rect.left + rect.width / 2 - offset.x,
transform: "translate(-50%, -100%)",
left: centerX - offset.x,
transform: `translate(${translateX}, -100%)`,
};
}
case "bottom": {
const centerX = rect.left + rect.width / 2;
const halfWidth = estWidth / 2;
@@ -240,7 +265,7 @@ export const Tooltip = ({
if (currentWidth > 0) estWidth = currentWidth;
}
return strategy === "fixed"
return strategy === "fixed" || portal
? getFixedStyle(rect, vw, estWidth, padding)
: getAbsoluteStyle(rect, vw, estWidth, padding);
};
@@ -288,51 +313,38 @@ export const Tooltip = ({
};
}, []);
const isFixed = strategy === "fixed";
const isFixed = strategy === "fixed" || portal;
return (
<div
ref={triggerRef}
onPointerEnter={() => {
setIsHoveringTrigger(true);
if (!checkForOpenPopovers() && !disabled) {
setIsShowing(true);
}
}}
onPointerLeave={() => {
setIsHoveringTrigger(false);
if (!interactive) {
setIsShowing(false);
}
}}
onPointerCancel={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onBlur={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onClick={handleClick}
className="relative"
// A portaled tooltip positions off a snapshot of the trigger's viewport
// rect — re-render on scroll/resize while showing so it tracks the trigger
// instead of floating where it used to be.
const [, bumpReposition] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
if (!portal || !isShowing) return;
window.addEventListener("scroll", bumpReposition, true);
window.addEventListener("resize", bumpReposition);
return () => {
window.removeEventListener("scroll", bumpReposition, true);
window.removeEventListener("resize", bumpReposition);
};
}, [portal, isShowing]);
const tooltipPanel = (
<Transition
show={isShowing}
enter={twMerge(
"transition ease-out duration-200",
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
)}
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{children}
<Transition
show={isShowing}
enter={twMerge(
"transition ease-out duration-200",
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
)}
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
ref={setTooltipRef}
{...(portal ? { "data-modal-outside-safe": "" } : {})}
onPointerEnter={() => interactive && setIsHoveringTooltip(true)}
onPointerLeave={() => interactive && setIsHoveringTooltip(false)}
onClick={() => {
@@ -378,7 +390,39 @@ export const Tooltip = ({
</div>
)}
</div>
</Transition>
</Transition>
);
return (
<div
ref={triggerRef}
onPointerEnter={() => {
setIsHoveringTrigger(true);
if (!checkForOpenPopovers() && !disabled) {
setIsShowing(true);
}
}}
onPointerLeave={() => {
setIsHoveringTrigger(false);
if (!interactive) {
setIsShowing(false);
}
}}
onPointerCancel={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onBlur={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onClick={handleClick}
className="relative"
>
{children}
{portal ? createPortal(tooltipPanel, document.body) : tooltipPanel}
</div>
);
};