mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user