mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Character mention chips + mention fixes (#1815)
### New stuff - Character `@mentions` in the video promptbox now show as **chips with a thumbnail**, so you can see exactly which character is attached to the prompt - Clicking a chip opens a menu to **Replace** (pick another character), **Preview** (view their image), or **Remove** the mention - Chips behave like a single unit - one backspace deletes the whole mention, and removing/replacing can be undone with Ctrl+Z - Copying prompt text still gives clean plain text (`@name ...`), and pasting it back restores the chips - When two characters share a name, the one you actually picked is now the one that gets sent ### Fixes - Mobile: the `@` autocomplete no longer goes off-screen - it stays inside the viewport, flips below the caret when needed, and taps select reliably - Focus mode: the autocomplete no longer pushes the prompt sideways / causes overflow - Enter-to-generate now works while the mention editor is active on the webapp
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
||||
import { toast } from "../toast/toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useCharactersStore } from "./characters-store";
|
||||
import { useCharactersStore } from "@storyteller/ui-promptbox";
|
||||
|
||||
interface CharactersModalProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -1,693 +0,0 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUserGroup } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faVideo, faMusic } from "@fortawesome/pro-regular-svg-icons";
|
||||
import type { MentionItem } from "./types";
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mentionItems: MentionItem[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
/** Inline style applied to the editable element (e.g. a maxHeight cap or
|
||||
* resize override for the fullscreen modal). */
|
||||
style?: React.CSSProperties;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
interface MentionState {
|
||||
isOpen: boolean;
|
||||
triggerIndex: number;
|
||||
query: string;
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeHTML(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor helpers for contentEditable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCaretOffset(el: HTMLElement): number {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anchorNode = sel.anchorNode;
|
||||
const anchorOffset = sel.anchorOffset;
|
||||
let offset = 0;
|
||||
|
||||
if (anchorNode === el) {
|
||||
for (let i = 0; i < anchorOffset; i++) {
|
||||
const child = el.childNodes[i];
|
||||
if (!child) break;
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function countBefore(parent: Node): boolean {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child === anchorNode) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += anchorOffset;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (child.contains(anchorNode)) {
|
||||
return countBefore(child);
|
||||
}
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
countBefore(el);
|
||||
return offset;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
try {
|
||||
let remaining = offset;
|
||||
|
||||
function findPosition(parent: Node): boolean {
|
||||
for (let i = 0; i < parent.childNodes.length; i++) {
|
||||
const child = parent.childNodes[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const len = child.textContent?.length ?? 0;
|
||||
if (remaining <= len) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(child, remaining);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= len;
|
||||
} else if (child.nodeName === "BR") {
|
||||
if (remaining === 0) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(parent, i);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= 1;
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (findPosition(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!findPosition(el)) {
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.selectAllChildren(el);
|
||||
sel.collapseToEnd();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DOM changed during caret restore
|
||||
}
|
||||
}
|
||||
|
||||
function scrollCaretIntoView(el: HTMLElement) {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
|
||||
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(false);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200B";
|
||||
range.insertNode(span);
|
||||
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
|
||||
if (spanRect.bottom > elRect.bottom) {
|
||||
el.scrollTop += spanRect.bottom - elRect.bottom;
|
||||
} else if (spanRect.top < elRect.top) {
|
||||
el.scrollTop -= elRect.top - spanRect.top;
|
||||
}
|
||||
|
||||
const parent = span.parentNode;
|
||||
if (parent) {
|
||||
const next = span.nextSibling;
|
||||
parent.removeChild(span);
|
||||
|
||||
const restored = document.createRange();
|
||||
if (next) {
|
||||
restored.setStartBefore(next);
|
||||
} else if (parent.lastChild) {
|
||||
restored.setStartAfter(parent.lastChild);
|
||||
} else {
|
||||
restored.selectNodeContents(parent);
|
||||
}
|
||||
restored.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(restored);
|
||||
}
|
||||
} catch {
|
||||
// DOM changed during measurement
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mention Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MentionDropdown({
|
||||
items,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
onHover,
|
||||
position,
|
||||
}: {
|
||||
items: MentionItem[];
|
||||
activeIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
onHover: (index: number) => void;
|
||||
position: { left: number; bottom: number };
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const activeItem = list.children[1]?.children[activeIndex] as HTMLElement;
|
||||
activeItem?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl"
|
||||
style={{ left: position.left, bottom: position.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
|
||||
Mentions
|
||||
</div>
|
||||
<div>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className={twMerge(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors cursor-pointer",
|
||||
i === activeIndex ? "bg-white/10" : "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
}}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
|
||||
{(item.type === "character" || item.type === "image") && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faUserGroup}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
) : item.type === "video" && item.preview ? (
|
||||
<video
|
||||
src={item.preview}
|
||||
muted
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={item.type === "video" ? faVideo : faMusic}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionTextarea Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
function MentionTextarea(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
mentionItems,
|
||||
placeholder,
|
||||
className,
|
||||
style,
|
||||
onKeyDown: externalOnKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
disabled,
|
||||
colorMap,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
useImperativeHandle(ref, () => editorRef.current!, []);
|
||||
const isInternalUpdate = useRef(false);
|
||||
const isComposing = useRef(false);
|
||||
const pendingCaret = useRef<number | null>(null);
|
||||
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isOpen: false,
|
||||
triggerIndex: -1,
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
});
|
||||
|
||||
const [dropdownPos, setDropdownPos] = useState<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
}>({ left: 0, bottom: 0 });
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!mentionState.isOpen) return [];
|
||||
return mentionItems.filter((item) =>
|
||||
mentionState.query
|
||||
? item.label.toLowerCase().includes(mentionState.query.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
}, [mentionItems, mentionState.isOpen, mentionState.query]);
|
||||
|
||||
// Build a regex that matches any known mention label. Case-insensitive so
|
||||
// a typed `@image1` still highlights when the canonical label is `@Image1`.
|
||||
// Sort longest-first so `@Pumpkin Head` matches before `@Pumpkin`.
|
||||
const mentionRegex = useMemo(() => {
|
||||
const labels = mentionItems.map((item) => item.label);
|
||||
if (labels.length === 0) return null;
|
||||
const sorted = [...labels].sort((a, b) => b.length - a.length);
|
||||
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
|
||||
return new RegExp(`(${pattern})`, "gi");
|
||||
}, [mentionItems]);
|
||||
|
||||
// Case-insensitive colorMap lookup: "@image1" finds "@Image1".
|
||||
const lowerColorMap = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(colorMap)) {
|
||||
m[k.toLowerCase()] = v;
|
||||
}
|
||||
return m;
|
||||
}, [colorMap]);
|
||||
|
||||
// Build innerHTML with colored @mentions inline
|
||||
const buildHTML = useCallback(
|
||||
(text: string): string => {
|
||||
if (!text) return "";
|
||||
if (!mentionRegex) {
|
||||
let html = escapeHTML(text);
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
if (html.endsWith("<br>")) html += "<br>";
|
||||
return html;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let lastIndex = 0;
|
||||
const regex = new RegExp(mentionRegex);
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const color = lowerColorMap[fullMatch.toLowerCase()];
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
html += escapeHTML(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (color) {
|
||||
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
|
||||
} else {
|
||||
html += escapeHTML(fullMatch);
|
||||
}
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
html += escapeHTML(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
if (html.endsWith("<br>")) {
|
||||
html += "<br>";
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
[lowerColorMap, mentionRegex],
|
||||
);
|
||||
|
||||
// Sync DOM when value changes from parent
|
||||
useEffect(() => {
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// This effect re-runs whenever `buildHTML`'s identity changes (the parent
|
||||
// recomputing `colorMap`/`mentionItems`), not only when `value` genuinely
|
||||
// changes from outside. If the DOM already shows this exact text while the
|
||||
// user is focused, skip the innerHTML rewrite entirely: reassigning
|
||||
// innerHTML under a live caret detaches it on iOS Safari, which makes the
|
||||
// cursor disappear mid-typing. A pending caret (mention insert) must still
|
||||
// resync, and an unfocused editor is safe to recolor.
|
||||
if (pendingCaret.current === null && document.activeElement === el) {
|
||||
let domText = el.innerText;
|
||||
if (domText.endsWith("\n")) domText = domText.slice(0, -1);
|
||||
if (domText === value) return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (document.activeElement === el && sel && !sel.isCollapsed) return;
|
||||
|
||||
try {
|
||||
const caret = pendingCaret.current ?? getCaretOffset(el);
|
||||
el.innerHTML = buildHTML(value);
|
||||
if (pendingCaret.current !== null) {
|
||||
setCaretOffset(el, pendingCaret.current);
|
||||
pendingCaret.current = null;
|
||||
} else if (document.activeElement === el) {
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
} catch {
|
||||
el.innerHTML = buildHTML(value);
|
||||
}
|
||||
}, [value, buildHTML]);
|
||||
|
||||
// Get pixel coordinates of a text offset relative to the wrapper
|
||||
const getOffsetRect = useCallback((charOffset: number) => {
|
||||
try {
|
||||
const el = editorRef.current;
|
||||
if (!el) return null;
|
||||
|
||||
const saved = getCaretOffset(el);
|
||||
setCaretOffset(el, charOffset);
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount) {
|
||||
setCaretOffset(el, saved);
|
||||
return null;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const wrapperRect = el.parentElement!.getBoundingClientRect();
|
||||
setCaretOffset(el, saved);
|
||||
|
||||
return {
|
||||
left: rect.left - wrapperRect.left,
|
||||
bottom: wrapperRect.bottom - rect.top,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Detect @mention trigger from cursor position. The `@` is a valid mention
|
||||
// trigger when it follows anything that isn't a word character — that covers
|
||||
// whitespace, punctuation, CJK/Chinese text, emoji, line starts, etc. Only
|
||||
// reject it when it's sitting mid-word after an alphanumeric/underscore.
|
||||
const detectMention = useCallback(
|
||||
(text: string, cursorPos: number) => {
|
||||
const textBefore = text.slice(0, cursorPos);
|
||||
const lastAt = textBefore.lastIndexOf("@");
|
||||
if (
|
||||
lastAt !== -1 &&
|
||||
(lastAt === 0 || !/[A-Za-z0-9_]/.test(text[lastAt - 1]))
|
||||
) {
|
||||
const query = text.slice(lastAt, cursorPos);
|
||||
if (!query.includes("\n")) {
|
||||
const pos = getOffsetRect(lastAt);
|
||||
if (pos) setDropdownPos(pos);
|
||||
setMentionState({
|
||||
isOpen: true,
|
||||
triggerIndex: lastAt,
|
||||
query,
|
||||
activeIndex: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMentionState((prev) =>
|
||||
prev.isOpen ? { ...prev, isOpen: false } : prev,
|
||||
);
|
||||
},
|
||||
[getOffsetRect],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposing.current) return;
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
let text = el.innerText;
|
||||
if (text.endsWith("\n")) {
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
|
||||
const caret = getCaretOffset(el);
|
||||
const html = buildHTML(text);
|
||||
if (el.innerHTML !== html) {
|
||||
el.innerHTML = html;
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
detectMention(text, caret);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
scrollCaretIntoView(el);
|
||||
});
|
||||
} catch {
|
||||
const text = el.innerText?.replace(/\n$/, "") ?? "";
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
}
|
||||
}, [onChange, buildHTML, detectMention]);
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
isComposing.current = true;
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd = useCallback(() => {
|
||||
isComposing.current = false;
|
||||
handleInput();
|
||||
}, [handleInput]);
|
||||
|
||||
// Select a mention from the dropdown
|
||||
const handleSelect = useCallback(
|
||||
(item: MentionItem) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Use the known mention bounds — `getCaretOffset` can return 0 when the
|
||||
// dropdown click collapses the selection out of the editor, which would
|
||||
// leave the typed query in the prompt alongside the inserted label.
|
||||
const queryEnd =
|
||||
mentionState.triggerIndex + mentionState.query.length;
|
||||
const before = value.slice(0, mentionState.triggerIndex);
|
||||
const after = value.slice(queryEnd);
|
||||
const mention = `${item.label} `;
|
||||
const newValue = before + mention + after;
|
||||
|
||||
pendingCaret.current = before.length + mention.length;
|
||||
|
||||
setMentionState({
|
||||
isOpen: false,
|
||||
triggerIndex: -1,
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
});
|
||||
|
||||
onChange(newValue);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
});
|
||||
},
|
||||
[value, mentionState.triggerIndex, mentionState.query, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (mentionState.isOpen && filteredItems.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({
|
||||
...prev,
|
||||
activeIndex: Math.min(
|
||||
prev.activeIndex + 1,
|
||||
filteredItems.length - 1,
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({
|
||||
...prev,
|
||||
activeIndex: Math.max(prev.activeIndex - 1, 0),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredItems[mentionState.activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({ ...prev, isOpen: false }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
document.execCommand("insertLineBreak");
|
||||
handleInput();
|
||||
scrollCaretIntoView(editorRef.current!);
|
||||
return;
|
||||
}
|
||||
|
||||
externalOnKeyDown?.(e);
|
||||
},
|
||||
[
|
||||
mentionState.isOpen,
|
||||
mentionState.activeIndex,
|
||||
filteredItems,
|
||||
handleSelect,
|
||||
externalOnKeyDown,
|
||||
handleInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return;
|
||||
detectMention(value, getCaretOffset(el));
|
||||
}, [value, detectMention]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
document.execCommand("insertText", false, text);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0 pb-[7px]">
|
||||
{!value && placeholder && (
|
||||
<div
|
||||
className={twMerge(
|
||||
className,
|
||||
"absolute inset-0 pointer-events-none text-white/50 z-[1]",
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable={!disabled}
|
||||
onInput={handleInput}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
onPaste={handlePaste}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={style}
|
||||
className={twMerge(
|
||||
className,
|
||||
"outline-none whitespace-pre-wrap break-words overflow-y-auto resize-y",
|
||||
)}
|
||||
/>
|
||||
|
||||
{mentionState.isOpen && filteredItems.length > 0 && (
|
||||
<MentionDropdown
|
||||
items={filteredItems}
|
||||
activeIndex={mentionState.activeIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={(i) =>
|
||||
setMentionState((prev) => ({ ...prev, activeIndex: i }))
|
||||
}
|
||||
position={dropdownPos}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -20,21 +20,22 @@ import { GenerateIconButton } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
KeyframeCards,
|
||||
MentionTextarea,
|
||||
PromptClearAllButton,
|
||||
ReferenceDeck,
|
||||
buildMentionColorMap,
|
||||
getMentionColor,
|
||||
useDeckMedia,
|
||||
type DeckAddAction,
|
||||
type DeckItem,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import {
|
||||
PromptBoxDropOverlay,
|
||||
usePromptBoxDrop,
|
||||
type DroppedFiles,
|
||||
} from "./PromptBoxDropZone";
|
||||
import { toast } from "../toast/toast";
|
||||
import { getMentionColor, buildMentionColorMap } from "./mention-colors";
|
||||
import { uploadImage } from "./upload-image";
|
||||
import { uploadVideo, uploadAudio } from "./upload-media";
|
||||
import type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
|
||||
@@ -114,6 +115,14 @@ interface PromptBoxProps {
|
||||
// @-mention support (enables colored prompt overlay + autocomplete)
|
||||
mentionItems?: MentionItem[];
|
||||
|
||||
// Records which character token a mention name refers to (dropdown pick or
|
||||
// chip-menu replace) — needed because several characters can share a name.
|
||||
onMentionSelect?: (item: MentionItem) => void;
|
||||
|
||||
// name (without "@") -> character token; picks which character's thumbnail
|
||||
// renders in a mention chip when labels collide.
|
||||
mentionSelections?: Record<string, string>;
|
||||
|
||||
// Soft prompt-length limit from the model API (`text_prompt_max_length`).
|
||||
// Undefined = unlimited (no counter). The limit is not enforced here; the
|
||||
// page's submit handler blocks generation when over.
|
||||
@@ -162,6 +171,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
modelSelector,
|
||||
secondaryPromptRow,
|
||||
mentionItems,
|
||||
onMentionSelect,
|
||||
mentionSelections,
|
||||
maxPromptLength,
|
||||
},
|
||||
ref,
|
||||
@@ -789,10 +800,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
mentionItems={mentionItems}
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full pr-8 text-base-fg placeholder-base-fg/60",
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full resize-y pr-8 text-base-fg placeholder-base-fg/60",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
)}
|
||||
colorMap={mentionColorMap}
|
||||
enterToGenerate={enterToGenerate}
|
||||
onMentionSelect={onMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
@@ -1009,6 +1023,9 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
className="promptbox-scrollbar h-full min-h-0 w-full overflow-y-auto text-base-fg placeholder-base-fg/60"
|
||||
style={{ resize: "none" }}
|
||||
colorMap={mentionColorMap}
|
||||
enterToGenerate={enterToGenerate}
|
||||
onMentionSelect={onMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface StoredCharacter {
|
||||
character_token: string;
|
||||
name: string;
|
||||
avatar_image_url?: string;
|
||||
}
|
||||
|
||||
interface CharactersStore {
|
||||
characters: StoredCharacter[];
|
||||
loaded: boolean;
|
||||
setCharacters: (characters: StoredCharacter[]) => void;
|
||||
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
|
||||
removeCharacter: (token: string) => void;
|
||||
setLoaded: (loaded: boolean) => void;
|
||||
}
|
||||
|
||||
export const useCharactersStore = create<CharactersStore>()((set) => ({
|
||||
characters: [],
|
||||
loaded: false,
|
||||
setCharacters: (characters) => set({ characters }),
|
||||
updateCharacter: (token, updates) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.map((c) =>
|
||||
c.character_token === token ? { ...c, ...updates } : c,
|
||||
),
|
||||
})),
|
||||
removeCharacter: (token) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.filter((c) => c.character_token !== token),
|
||||
})),
|
||||
setLoaded: (loaded) => set({ loaded }),
|
||||
}));
|
||||
@@ -2,8 +2,8 @@ export { PromptBox } from "./PromptBox";
|
||||
export { ImagePromptRow, AddButton } from "./ImagePromptRow";
|
||||
export { MediaReferenceRow } from "./MediaReferenceRow";
|
||||
export { CharactersModal } from "./CharactersModal";
|
||||
export { useCharactersStore } from "./characters-store";
|
||||
export type { StoredCharacter } from "./characters-store";
|
||||
export { useCharactersStore } from "@storyteller/ui-promptbox";
|
||||
export type { StoredCharacter } from "@storyteller/ui-promptbox";
|
||||
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
|
||||
export {
|
||||
getAudioDurationFromUrl,
|
||||
|
||||
+13
-3
@@ -1,9 +1,11 @@
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { GenerateButton } from "@storyteller/ui-button";
|
||||
import { MentionTextarea } from "../MentionTextarea";
|
||||
import { buildMentionColorMap } from "../mention-colors";
|
||||
import {
|
||||
MentionTextarea,
|
||||
buildMentionColorMap,
|
||||
type MentionItem,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import { useMobileCreateTabs } from "../../generation-gallery/mobile-create-tabs";
|
||||
import type { MentionItem } from "../types";
|
||||
|
||||
interface MobilePromptFormProps {
|
||||
prompt: string;
|
||||
@@ -14,6 +16,8 @@ interface MobilePromptFormProps {
|
||||
submitLabel?: string;
|
||||
credits?: number | null;
|
||||
mentionItems?: MentionItem[];
|
||||
onMentionSelect?: (item: MentionItem) => void;
|
||||
mentionSelections?: Record<string, string>;
|
||||
// Whether tapping Create will actually start a generation (logged in, prompt
|
||||
// present, no missing required input). Gates the auto-switch to History.
|
||||
autoAdvance?: boolean;
|
||||
@@ -38,6 +42,8 @@ export function MobilePromptForm({
|
||||
submitLabel = "Create",
|
||||
credits,
|
||||
mentionItems,
|
||||
onMentionSelect,
|
||||
mentionSelections,
|
||||
autoAdvance = true,
|
||||
banner,
|
||||
inputModeSelector,
|
||||
@@ -79,6 +85,10 @@ export function MobilePromptForm({
|
||||
colorMap={colorMap}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[120px] max-h-[40vh] w-full text-md text-base-fg"
|
||||
// Mobile has no Enter-submit; Enter always inserts a newline.
|
||||
enterToGenerate={false}
|
||||
onMentionSelect={onMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,8 +23,4 @@ export interface RefAudio {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface MentionItem {
|
||||
label: string;
|
||||
type: "image" | "video" | "audio" | "character";
|
||||
preview?: string;
|
||||
}
|
||||
export type { MentionItem } from "@storyteller/ui-promptbox";
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RefVideo,
|
||||
type RefAudio,
|
||||
type MentionItem,
|
||||
type StoredCharacter,
|
||||
} from "../../components/prompt-box";
|
||||
import {
|
||||
GenerationGallery,
|
||||
@@ -443,6 +444,19 @@ export default function CreateVideo() {
|
||||
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
|
||||
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
|
||||
|
||||
// Mentions are plain text: with several characters sharing a name,
|
||||
// "@Robot" alone can't identify one. Records which token the user actually
|
||||
// picked (dropdown, modal, or chip-menu replace), keyed by name.
|
||||
const [mentionSelections, setMentionSelections] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const handleMentionSelect = useCallback((item: MentionItem) => {
|
||||
if (item.type !== "character" || !item.token) return;
|
||||
const name = item.label.replace(/^@/, "");
|
||||
setMentionSelections((prev) => ({ ...prev, [name]: item.token! }));
|
||||
}, []);
|
||||
|
||||
// Load characters on mount if not already loaded
|
||||
useEffect(() => {
|
||||
if (charactersLoaded) return;
|
||||
@@ -456,6 +470,7 @@ export default function CreateVideo() {
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
full_image_url: c.maybe_full_image?.cdn_url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -601,6 +616,8 @@ export default function CreateVideo() {
|
||||
label: `@${char.name}`,
|
||||
type: "character" as const,
|
||||
preview: char.avatar_image_url,
|
||||
token: char.character_token,
|
||||
fullPreview: char.full_image_url ?? char.avatar_image_url,
|
||||
}));
|
||||
return [...refItems, ...charItems];
|
||||
}, [
|
||||
@@ -1207,27 +1224,35 @@ export default function CreateVideo() {
|
||||
.filter((t): t is string => typeof t === "string" && t.length > 0)
|
||||
: undefined;
|
||||
|
||||
// Extract character tokens from @-mentions in the prompt. Match longest
|
||||
// names first and require a non-word boundary after so `@Bob` doesn't
|
||||
// false-match inside `@Bobby`, and only pick up characters that still
|
||||
// exist in the current store (stale names are ignored).
|
||||
const mentionedCharacters = (() => {
|
||||
// Extract character tokens from @-mentions in the prompt, resolving to
|
||||
// exactly one token per mentioned name. Match longest names first and
|
||||
// require a non-word boundary after so `@Bob` doesn't false-match inside
|
||||
// `@Bobby`, and only pick up characters that still exist in the current
|
||||
// store (stale names are ignored). Several characters can share a name;
|
||||
// prefer the user's explicit pick (mentionSelections), else the newest
|
||||
// (store is newest-first).
|
||||
const mentionedTokens = (() => {
|
||||
if (activeCharacters.length === 0) return [];
|
||||
const sorted = [...activeCharacters].sort(
|
||||
(a, b) => b.name.length - a.name.length,
|
||||
);
|
||||
const matched = new Set<string>();
|
||||
for (const c of sorted) {
|
||||
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`@${escaped}(?![\\w])`);
|
||||
if (regex.test(prompt)) matched.add(c.character_token);
|
||||
const byName = new Map<string, StoredCharacter[]>();
|
||||
for (const c of activeCharacters) {
|
||||
byName.set(c.name, [...(byName.get(c.name) ?? []), c]);
|
||||
}
|
||||
return activeCharacters.filter((c) => matched.has(c.character_token));
|
||||
const names = [...byName.keys()].sort((a, b) => b.length - a.length);
|
||||
const tokens: string[] = [];
|
||||
for (const name of names) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (!new RegExp(`@${escaped}(?![\\w])`).test(prompt)) continue;
|
||||
const candidates = byName.get(name)!;
|
||||
const chosen =
|
||||
candidates.find(
|
||||
(c) => c.character_token === mentionSelections[name],
|
||||
) ?? candidates[0];
|
||||
tokens.push(chosen.character_token);
|
||||
}
|
||||
return tokens;
|
||||
})();
|
||||
const referenceCharacterTokens =
|
||||
mentionedCharacters.length > 0
|
||||
? mentionedCharacters.map((c) => c.character_token)
|
||||
: undefined;
|
||||
mentionedTokens.length > 0 ? mentionedTokens : undefined;
|
||||
|
||||
const baseParams = {
|
||||
prompt: prompt.trim(),
|
||||
@@ -1402,6 +1427,8 @@ export default function CreateVideo() {
|
||||
credits={estimatedCredits}
|
||||
placeholder={promptPlaceholder}
|
||||
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
mentionSelections={mentionSelections}
|
||||
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
|
||||
banner={
|
||||
requiresImageInput ? (
|
||||
@@ -1727,6 +1754,8 @@ export default function CreateVideo() {
|
||||
})
|
||||
}
|
||||
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
mentionSelections={mentionSelections}
|
||||
videoRefsSupported={supportsVideoRefs}
|
||||
referenceVideos={referenceVideos}
|
||||
onReferenceVideosChange={setReferenceVideos}
|
||||
@@ -1956,6 +1985,10 @@ export default function CreateVideo() {
|
||||
const spaceBefore =
|
||||
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
|
||||
setPrompt(prompt + spaceBefore + mention + " ");
|
||||
setMentionSelections((prev) => ({
|
||||
...prev,
|
||||
[character.name]: character.token,
|
||||
}));
|
||||
setIsCharactersModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -105,7 +105,13 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
: isAudio
|
||||
? faMusic
|
||||
: faImage;
|
||||
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : isAudio ? "Audio" : "Image";
|
||||
const mediaLabel = isVideo
|
||||
? "Video"
|
||||
: is3D
|
||||
? "3D"
|
||||
: isAudio
|
||||
? "Audio"
|
||||
: "Image";
|
||||
const modelDisplayName = item.modelId
|
||||
? getModelDisplayName(item.modelId)
|
||||
: null;
|
||||
@@ -172,7 +178,10 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
outside the card's rounded corners without being clipped. */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-[inherit]"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
|
||||
style={{
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "auto 200px",
|
||||
}}
|
||||
>
|
||||
{isAudio ? (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] p-3">
|
||||
@@ -222,7 +231,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
)}
|
||||
|
||||
{/* Hover overlay with media type + model badges and quick actions */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t rounded-b-lg from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||
<FontAwesomeIcon icon={mediaIcon} className="text-[10px]" />
|
||||
|
||||
@@ -10,6 +10,9 @@ export * from "./lib/PromptFullscreenModal";
|
||||
export * from "./lib/PromptFullscreenButton";
|
||||
export * from "./lib/PromptClearAllButton";
|
||||
export * from "./lib/promptStore";
|
||||
export * from "./lib/MentionTextarea";
|
||||
export * from "./lib/MentionChipMenu";
|
||||
export * from "./lib/mentionColors";
|
||||
export * from "./lib/PromptBoxErrorBoundary";
|
||||
export * from "./lib/common/AudioTuningPopover";
|
||||
export * from "./lib/common/SoundsSettingsPopover";
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowsRotate,
|
||||
faChevronLeft,
|
||||
faEye,
|
||||
faTrashCan,
|
||||
faUser,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { MentionItem } from "./MentionTextarea";
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const ANCHOR_GAP = 4;
|
||||
|
||||
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";
|
||||
|
||||
export interface MentionChipMenuProps {
|
||||
/** Viewport-space rect of the clicked chip. */
|
||||
anchorRect: DOMRect;
|
||||
/** Canonical mention label including the "@", e.g. "@robot cartoon". */
|
||||
currentLabel: string;
|
||||
/** Avatar of the currently attached character, shown in the menu header. */
|
||||
currentPreview?: string;
|
||||
/** Characters offered as replacements (current one excluded by the caller). */
|
||||
replaceItems: MentionItem[];
|
||||
onReplace: (item: MentionItem) => void;
|
||||
onPreview: () => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating menu for an inline character-mention chip: Replace / Preview /
|
||||
* Remove, with the Replace action swapping to a second "Back" panel listing
|
||||
* the other available characters.
|
||||
*
|
||||
* Portaled to document.body with fixed positioning — the promptbox `.glass`
|
||||
* container (backdrop-blur) is a containing block that would trap
|
||||
* `position: fixed` descendants, so the menu must not render inside it.
|
||||
*/
|
||||
export function MentionChipMenu({
|
||||
anchorRect,
|
||||
currentLabel,
|
||||
currentPreview,
|
||||
replaceItems,
|
||||
onReplace,
|
||||
onPreview,
|
||||
onRemove,
|
||||
onClose,
|
||||
}: MentionChipMenuProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [view, setView] = useState<"menu" | "replace">("menu");
|
||||
const [placement, setPlacement] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
flippedAbove: boolean;
|
||||
} | null>(null);
|
||||
// Drives the same 75ms fade/slide-in our PopoverMenu panels use.
|
||||
const [entered, setEntered] = useState(false);
|
||||
|
||||
const currentName = currentLabel.replace(/^@/, "");
|
||||
|
||||
// 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).
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const width = panel.offsetWidth;
|
||||
const height = panel.offsetHeight;
|
||||
|
||||
let top = anchorRect.bottom + ANCHOR_GAP;
|
||||
let flippedAbove = false;
|
||||
if (top + height > window.innerHeight - VIEWPORT_MARGIN) {
|
||||
top = anchorRect.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),
|
||||
);
|
||||
|
||||
setPlacement({ left, top, flippedAbove });
|
||||
}, [anchorRect, view, replaceItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!placement || entered) return;
|
||||
const raf = requestAnimationFrame(() => setEntered(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [placement, entered]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
const panel = panelRef.current;
|
||||
if (panel && !panel.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const handleScroll = (e: Event) => {
|
||||
const panel = panelRef.current;
|
||||
if (panel && e.target instanceof Node && panel.contains(e.target)) return;
|
||||
onClose();
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", onClose);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", onClose);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleRowKeyActivate = useCallback(
|
||||
(e: React.PointerEvent) => e.preventDefault(),
|
||||
[],
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
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",
|
||||
entered
|
||||
? "translate-y-0 opacity-100"
|
||||
: placement?.flippedAbove
|
||||
? "-translate-y-1 opacity-0"
|
||||
: "translate-y-1 opacity-0",
|
||||
)}
|
||||
style={
|
||||
placement
|
||||
? { left: placement.left, top: placement.top }
|
||||
: {
|
||||
left: anchorRect.left,
|
||||
top: anchorRect.bottom + ANCHOR_GAP,
|
||||
visibility: "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
{view === "menu" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<ChipAvatar preview={currentPreview} name={currentName} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-base-fg">
|
||||
{currentName}
|
||||
</div>
|
||||
<div className="text-[11px] text-base-fg/50">Character</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 border-t border-ui-panel-border" />
|
||||
<button type="button" className={MENU_ROW} onClick={() => setView("replace")}>
|
||||
<FontAwesomeIcon icon={faArrowsRotate} className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="flex-1 text-left">Replace</span>
|
||||
<span className="text-xs text-base-fg/40">{replaceItems.length}</span>
|
||||
</button>
|
||||
<button type="button" className={MENU_ROW} onClick={onPreview}>
|
||||
<FontAwesomeIcon icon={faEye} className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="flex-1 text-left">Preview</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-ui-panel-border" />
|
||||
<button
|
||||
type="button"
|
||||
className={twMerge(MENU_ROW, "text-red-500 hover:bg-red-500/10")}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="flex-1 text-left">Remove</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/60 transition-colors hover:bg-ui-controls/60 hover:text-base-fg"
|
||||
onClick={() => setView("menu")}
|
||||
aria-label="Back"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="h-3 w-3" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-base-fg">Replace</div>
|
||||
<div className="truncate text-[11px] text-base-fg/50">
|
||||
Currently {currentName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 border-t border-ui-panel-border" />
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{replaceItems.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-base-fg/50">
|
||||
No other characters
|
||||
</div>
|
||||
)}
|
||||
{replaceItems.map((item, i) => (
|
||||
<button
|
||||
key={item.token ?? `${item.label}-${i}`}
|
||||
type="button"
|
||||
className={MENU_ROW}
|
||||
onPointerDown={handleRowKeyActivate}
|
||||
onClick={() => onReplace(item)}
|
||||
>
|
||||
<ChipAvatar preview={item.preview} name={item.label} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{item.label.replace(/^@/, "")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ChipAvatar({ preview, name }: { preview?: string; name: string }) {
|
||||
return (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/10 bg-black/20">
|
||||
{preview ? (
|
||||
<img src={preview} alt={name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUser} className="h-3.5 w-3.5 text-base-fg/60" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,20 +3,29 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faVideo, faMusic } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { useEnterToGenerateStore } from "./promptStore";
|
||||
import { MentionChipMenu } from "./MentionChipMenu";
|
||||
import { DeckPreviewModal } from "./deck/DeckCard";
|
||||
|
||||
export interface MentionItem {
|
||||
label: string;
|
||||
type: "image" | "video" | "audio" | "character";
|
||||
/** Small thumbnail (chip + dropdown row). */
|
||||
preview?: string;
|
||||
/** Identity for character items (character_token). */
|
||||
token?: string;
|
||||
/** Larger image for the chip Preview modal; falls back to `preview`. */
|
||||
fullPreview?: string;
|
||||
}
|
||||
|
||||
interface MentionTextareaProps {
|
||||
@@ -32,6 +41,21 @@ interface MentionTextareaProps {
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
colorMap: Record<string, string>;
|
||||
/**
|
||||
* Overrides the shared enter-to-generate store (used by hosts with their
|
||||
* own persisted setting, or to force newline-only behavior on mobile).
|
||||
*/
|
||||
enterToGenerate?: boolean;
|
||||
/**
|
||||
* Fired when the user picks a mention from the dropdown or swaps one via
|
||||
* the chip menu — lets the host record which token a name refers to.
|
||||
*/
|
||||
onMentionSelect?: (item: MentionItem) => void;
|
||||
/**
|
||||
* name (without "@") -> character token. When several characters share a
|
||||
* label, picks which one's thumbnail renders in the chip.
|
||||
*/
|
||||
selectedTokens?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface MentionState {
|
||||
@@ -41,6 +65,27 @@ interface MentionState {
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
interface ChipMenuState {
|
||||
label: string;
|
||||
token?: string;
|
||||
start: number;
|
||||
rect: DOMRect;
|
||||
/** The clicked chip element — edits go through DOM ranges so they land on
|
||||
* the browser undo stack; `start` is the fallback when it's detached. */
|
||||
node: HTMLElement;
|
||||
}
|
||||
|
||||
// align-middle, not align-baseline: an inline-flex box takes its baseline
|
||||
// from the first flex item — here the <img>, whose "baseline" is its bottom
|
||||
// edge — which makes the chip ride high against the surrounding text.
|
||||
// No select-none: chips must paint the native selection highlight so users
|
||||
// can see they're included when sweep-selecting text to copy.
|
||||
const CHIP_CLASS =
|
||||
"mention-chip mx-0.5 inline-flex max-w-[10rem] cursor-pointer items-center gap-1 rounded-md border border-white/10 bg-ui-controls/80 px-1.5 py-0.5 align-middle leading-tight transition-colors hover:border-white/25 hover:bg-white/15";
|
||||
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";
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -53,6 +98,89 @@ function escapeHTML(text: string): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip generated HTML through the browser parser so it byte-matches
|
||||
* `el.innerHTML` reads — `handleInput` compares the two to skip needless
|
||||
* rewrites, and chip markup (attributes, img tags) would otherwise never
|
||||
* compare equal to the browser's serialization.
|
||||
*/
|
||||
let normalizerDiv: HTMLDivElement | null = null;
|
||||
function normalizeHTML(html: string): string {
|
||||
if (typeof document === "undefined") return html;
|
||||
if (!normalizerDiv) normalizerDiv = document.createElement("div");
|
||||
normalizerDiv.innerHTML = html;
|
||||
const out = normalizerDiv.innerHTML;
|
||||
normalizerDiv.innerHTML = "";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plain-text serialization (chips count as their mention label)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isChip(node: Node): node is HTMLElement {
|
||||
return (
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
!!(node as HTMLElement).dataset?.mention
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM -> plain text. Mention chips contribute their full label ("@Name"),
|
||||
* <br> contributes "\n". This replaces `el.innerText` everywhere: with chips
|
||||
* in the tree, innerText would return the chip's visible name without the
|
||||
* "@" and drift from the plain-text value.
|
||||
*/
|
||||
function serializeEditor(root: Node): string {
|
||||
let out = "";
|
||||
const walk = (node: Node) => {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
out += child.textContent ?? "";
|
||||
} else if (child.nodeName === "BR") {
|
||||
out += "\n";
|
||||
} else if (isChip(child)) {
|
||||
out += child.dataset.mention ?? "";
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Plain-text length a node contributes (chips = label length, BR = 1). */
|
||||
function nodeLength(node: Node): number {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length ?? 0;
|
||||
if (node.nodeName === "BR") return 1;
|
||||
if (isChip(node)) return node.dataset.mention?.length ?? 0;
|
||||
let len = 0;
|
||||
for (const child of Array.from(node.childNodes)) len += nodeLength(child);
|
||||
return len;
|
||||
}
|
||||
|
||||
/** Plain-text offset at which `target` (e.g. a chip element) starts. */
|
||||
function getNodeStartOffset(root: Node, target: Node): number {
|
||||
let offset = 0;
|
||||
const walk = (node: Node): boolean => {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child === target) return true;
|
||||
if (
|
||||
child.nodeType === Node.ELEMENT_NODE &&
|
||||
!isChip(child) &&
|
||||
child.contains(target)
|
||||
) {
|
||||
return walk(child);
|
||||
}
|
||||
offset += nodeLength(child);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
walk(root);
|
||||
return offset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor helpers for contentEditable
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,13 +200,7 @@ function getCaretOffset(el: HTMLElement): number {
|
||||
for (let i = 0; i < anchorOffset; i++) {
|
||||
const child = el.childNodes[i];
|
||||
if (!child) break;
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
offset += nodeLength(child);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
@@ -91,16 +213,19 @@ function getCaretOffset(el: HTMLElement): number {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (isChip(child)) {
|
||||
// A selection boundary inside an atomic chip counts as after it.
|
||||
if (child.contains(anchorNode)) {
|
||||
offset += nodeLength(child);
|
||||
return true;
|
||||
}
|
||||
offset += nodeLength(child);
|
||||
continue;
|
||||
}
|
||||
if (child.contains(anchorNode)) {
|
||||
return countBefore(child);
|
||||
}
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
offset += nodeLength(child);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -117,32 +242,44 @@ function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
try {
|
||||
let remaining = offset;
|
||||
|
||||
function placeAt(node: Node, nodeOffset: number): void {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(node, nodeOffset);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
|
||||
function findPosition(parent: Node): boolean {
|
||||
for (let i = 0; i < parent.childNodes.length; i++) {
|
||||
const child = parent.childNodes[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const len = child.textContent?.length ?? 0;
|
||||
if (remaining <= len) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(child, remaining);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
placeAt(child, remaining);
|
||||
return true;
|
||||
}
|
||||
remaining -= len;
|
||||
} else if (child.nodeName === "BR") {
|
||||
if (remaining === 0) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(parent, i);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
placeAt(parent, i);
|
||||
return true;
|
||||
}
|
||||
remaining -= 1;
|
||||
} else if (isChip(child)) {
|
||||
// Atomic: land before the chip at 0, snap after it otherwise —
|
||||
// the caret must never end up inside the chip.
|
||||
const len = nodeLength(child);
|
||||
if (remaining <= 0) {
|
||||
placeAt(parent, i);
|
||||
return true;
|
||||
}
|
||||
if (remaining < len) {
|
||||
placeAt(parent, i + 1);
|
||||
return true;
|
||||
}
|
||||
remaining -= len;
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (findPosition(child)) {
|
||||
return true;
|
||||
@@ -216,6 +353,8 @@ function scrollCaretIntoView(el: HTMLElement) {
|
||||
// Mention Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DROPDOWN_MARGIN = 8;
|
||||
|
||||
function MentionDropdown({
|
||||
items,
|
||||
activeIndex,
|
||||
@@ -227,9 +366,45 @@ function MentionDropdown({
|
||||
activeIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
onHover: (index: number) => void;
|
||||
position: { left: number; bottom: number };
|
||||
/** Viewport-space caret coordinates (the "@" trigger). */
|
||||
position: { left: number; caretTop: number; caretBottom: number };
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
// Body-portaled with `position: fixed` so the panel never contributes
|
||||
// scrollable overflow to an ancestor — absolutely positioned inside the
|
||||
// editor wrapper it widened the fullscreen modal's scroll area, and the
|
||||
// browser's caret auto-scroll then dragged the whole prompt sideways.
|
||||
// Opens above the caret by default, flipped below when space is short.
|
||||
const [placement, setPlacement] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = listRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
const panelWidth = panel.offsetWidth;
|
||||
const panelHeight = panel.offsetHeight;
|
||||
|
||||
const left = Math.max(
|
||||
DROPDOWN_MARGIN,
|
||||
Math.min(position.left, window.innerWidth - panelWidth - DROPDOWN_MARGIN),
|
||||
);
|
||||
|
||||
const spaceAbove = position.caretTop - DROPDOWN_MARGIN;
|
||||
const spaceBelow =
|
||||
window.innerHeight - position.caretBottom - DROPDOWN_MARGIN;
|
||||
|
||||
let top: number;
|
||||
if (panelHeight > spaceAbove && spaceBelow > spaceAbove) {
|
||||
top = position.caretBottom + 4;
|
||||
} else {
|
||||
top = Math.max(DROPDOWN_MARGIN, position.caretTop - panelHeight - 4);
|
||||
}
|
||||
|
||||
setPlacement({ left, top });
|
||||
}, [position, items]);
|
||||
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
@@ -238,11 +413,19 @@ function MentionDropdown({
|
||||
activeItem?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl"
|
||||
style={{ left: position.left, bottom: position.bottom }}
|
||||
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
|
||||
? { 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
|
||||
@@ -250,13 +433,16 @@ function MentionDropdown({
|
||||
<div>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
key={item.token ?? `${item.label}-${i}`}
|
||||
type="button"
|
||||
className={twMerge(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-base-fg transition-colors cursor-pointer",
|
||||
i === activeIndex ? "bg-white/10" : "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
onPointerDown={(e) => {
|
||||
// pointerdown (not click) keeps the editor focused, and works
|
||||
// for both mouse and touch — the old mousedown-only handler
|
||||
// made taps unreliable on mobile.
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
}}
|
||||
@@ -298,7 +484,8 @@ function MentionDropdown({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,6 +507,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
onBlur,
|
||||
disabled,
|
||||
colorMap,
|
||||
enterToGenerate: enterToGenerateProp,
|
||||
onMentionSelect,
|
||||
selectedTokens,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -328,7 +518,8 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const isInternalUpdate = useRef(false);
|
||||
const isComposing = useRef(false);
|
||||
const pendingCaret = useRef<number | null>(null);
|
||||
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
|
||||
const enterToGenerateStore = useEnterToGenerateStore((s) => s.enabled);
|
||||
const enterToGenerate = enterToGenerateProp ?? enterToGenerateStore;
|
||||
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isOpen: false,
|
||||
@@ -337,11 +528,16 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
activeIndex: 0,
|
||||
});
|
||||
|
||||
// Pixel position of the @ trigger relative to the wrapper div
|
||||
// Viewport-space pixel position of the @ trigger (the dropdown is
|
||||
// body-portaled with fixed positioning).
|
||||
const [dropdownPos, setDropdownPos] = useState<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
}>({ left: 0, bottom: 0 });
|
||||
caretTop: number;
|
||||
caretBottom: number;
|
||||
}>({ left: 0, caretTop: 0, caretBottom: 0 });
|
||||
|
||||
const [chipMenu, setChipMenu] = useState<ChipMenuState | null>(null);
|
||||
const [previewItem, setPreviewItem] = useState<MentionItem | null>(null);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!mentionState.isOpen) return [];
|
||||
@@ -373,7 +569,69 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
return m;
|
||||
}, [colorMap]);
|
||||
|
||||
// Build innerHTML with colored @mentions inline
|
||||
// Lowercased label -> items sharing that label (duplicate names possible).
|
||||
const itemsByLabel = useMemo(() => {
|
||||
const m = new Map<string, MentionItem[]>();
|
||||
for (const item of mentionItems) {
|
||||
const key = item.label.toLowerCase();
|
||||
const arr = m.get(key);
|
||||
if (arr) arr.push(item);
|
||||
else m.set(key, [item]);
|
||||
}
|
||||
return m;
|
||||
}, [mentionItems]);
|
||||
|
||||
// Lowercased labels that render as atomic chips (characters only).
|
||||
const chipLabels = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const item of mentionItems) {
|
||||
if (item.type === "character") s.add(item.label.toLowerCase());
|
||||
}
|
||||
return s;
|
||||
}, [mentionItems]);
|
||||
|
||||
/** The item a matched label resolves to (honoring the host's token picks). */
|
||||
const resolveItem = useCallback(
|
||||
(label: string): MentionItem | undefined => {
|
||||
const candidates = itemsByLabel.get(label.toLowerCase());
|
||||
if (!candidates?.length) return undefined;
|
||||
const name = candidates[0].label.replace(/^@/, "");
|
||||
const wantedToken = selectedTokens?.[name];
|
||||
if (wantedToken) {
|
||||
const picked = candidates.find((c) => c.token === wantedToken);
|
||||
if (picked) return picked;
|
||||
}
|
||||
return candidates[0];
|
||||
},
|
||||
[itemsByLabel, selectedTokens],
|
||||
);
|
||||
|
||||
/**
|
||||
* Markup for one atomic character chip. data-mention keeps the exact
|
||||
* typed text so serialization is lossless; the visible chip shows the
|
||||
* name without the "@".
|
||||
*/
|
||||
const buildChipHTML = useCallback(
|
||||
(item: MentionItem, matchText: string): string => {
|
||||
const color = lowerColorMap[matchText.toLowerCase()];
|
||||
const colorStyle = color ? ` style="color:${color}"` : "";
|
||||
return (
|
||||
`<span contenteditable="false" draggable="false"` +
|
||||
` data-mention="${escapeHTML(matchText)}"` +
|
||||
(item.token ? ` data-token="${escapeHTML(item.token)}"` : "") +
|
||||
` class="${CHIP_CLASS}">` +
|
||||
(item.preview
|
||||
? `<img src="${escapeHTML(item.preview)}" alt="" draggable="false" class="${CHIP_IMG_CLASS}">`
|
||||
: `<span class="${CHIP_NAME_CLASS}"${colorStyle}>@</span>`) +
|
||||
`<span class="${CHIP_NAME_CLASS}"${colorStyle}>${escapeHTML(matchText.slice(1))}</span>` +
|
||||
`</span>`
|
||||
);
|
||||
},
|
||||
[lowerColorMap],
|
||||
);
|
||||
|
||||
// Build innerHTML with mentions inline: character mentions render as
|
||||
// atomic thumbnail chips, other reference types as colored text.
|
||||
const buildHTML = useCallback(
|
||||
(text: string): string => {
|
||||
if (!text) return "";
|
||||
@@ -393,12 +651,15 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const color = lowerColorMap[fullMatch.toLowerCase()];
|
||||
const item = resolveItem(fullMatch);
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
html += escapeHTML(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (color) {
|
||||
if (item?.type === "character") {
|
||||
html += buildChipHTML(item, fullMatch);
|
||||
} else if (color) {
|
||||
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
|
||||
} else {
|
||||
html += escapeHTML(fullMatch);
|
||||
@@ -416,9 +677,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
html += "<br>";
|
||||
}
|
||||
|
||||
return html;
|
||||
return normalizeHTML(html);
|
||||
},
|
||||
[lowerColorMap, mentionRegex],
|
||||
[lowerColorMap, mentionRegex, resolveItem, buildChipHTML],
|
||||
);
|
||||
|
||||
// Sync DOM when value changes from parent (not from user input)
|
||||
@@ -431,6 +692,19 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// This effect re-runs whenever `buildHTML`'s identity changes (the parent
|
||||
// recomputing `colorMap`/`mentionItems`), not only when `value` genuinely
|
||||
// changes from outside. If the DOM already shows this exact text while the
|
||||
// user is focused, skip the innerHTML rewrite entirely: reassigning
|
||||
// innerHTML under a live caret detaches it on iOS Safari, which makes the
|
||||
// cursor disappear mid-typing. A pending caret (mention insert) must still
|
||||
// resync, and an unfocused editor is safe to recolor.
|
||||
if (pendingCaret.current === null && document.activeElement === el) {
|
||||
let domText = serializeEditor(el);
|
||||
if (domText.endsWith("\n")) domText = domText.slice(0, -1);
|
||||
if (domText === value) return;
|
||||
}
|
||||
|
||||
// Don't rewrite innerHTML while the user has text selected
|
||||
const sel = window.getSelection();
|
||||
if (document.activeElement === el && sel && !sel.isCollapsed) return;
|
||||
@@ -445,7 +719,10 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("MentionTextarea sync: DOM changed during caret restore", e);
|
||||
console.debug(
|
||||
"MentionTextarea sync: DOM changed during caret restore",
|
||||
e,
|
||||
);
|
||||
el.innerHTML = buildHTML(value);
|
||||
}
|
||||
}, [value, buildHTML]);
|
||||
@@ -466,12 +743,12 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const wrapperRect = el.parentElement!.getBoundingClientRect();
|
||||
setCaretOffset(el, saved);
|
||||
|
||||
return {
|
||||
left: rect.left - wrapperRect.left,
|
||||
bottom: wrapperRect.bottom - rect.top,
|
||||
left: rect.left,
|
||||
caretTop: rect.top,
|
||||
caretBottom: rect.bottom,
|
||||
};
|
||||
} catch (e) {
|
||||
console.debug("getOffsetRect: DOM changed during measurement", e);
|
||||
@@ -515,6 +792,28 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
[getOffsetRect],
|
||||
);
|
||||
|
||||
// The dropdown is body-portaled with fixed positioning, so a scroll or
|
||||
// resize while it's open would leave it floating where the caret used to
|
||||
// be — re-measure the trigger's viewport position on those events.
|
||||
useEffect(() => {
|
||||
if (!mentionState.isOpen) return;
|
||||
let raf = 0;
|
||||
const reposition = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
const pos = getOffsetRect(mentionState.triggerIndex);
|
||||
if (pos) setDropdownPos(pos);
|
||||
});
|
||||
};
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
window.addEventListener("resize", reposition);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
};
|
||||
}, [mentionState.isOpen, mentionState.triggerIndex, getOffsetRect]);
|
||||
|
||||
// Extract text and re-render with mention styling
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposing.current) return;
|
||||
@@ -522,7 +821,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
let text = el.innerText;
|
||||
let text = serializeEditor(el);
|
||||
if (text.endsWith("\n")) {
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
@@ -544,7 +843,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug("handleInput: DOM changed during input processing", e);
|
||||
const text = el.innerText?.replace(/\n$/, "") ?? "";
|
||||
const text = serializeEditor(el).replace(/\n$/, "");
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
}
|
||||
@@ -568,8 +867,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
// Use the known mention bounds — `getCaretOffset` can return 0 when the
|
||||
// dropdown click collapses the selection out of the editor, which would
|
||||
// leave the typed query in the prompt alongside the inserted label.
|
||||
const queryEnd =
|
||||
mentionState.triggerIndex + mentionState.query.length;
|
||||
const queryEnd = mentionState.triggerIndex + mentionState.query.length;
|
||||
const before = value.slice(0, mentionState.triggerIndex);
|
||||
const after = value.slice(queryEnd);
|
||||
const mention = `${item.label} `;
|
||||
@@ -585,14 +883,159 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
});
|
||||
|
||||
onChange(newValue);
|
||||
onMentionSelect?.(item);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
});
|
||||
},
|
||||
[value, mentionState.triggerIndex, mentionState.query, onChange],
|
||||
[
|
||||
value,
|
||||
mentionState.triggerIndex,
|
||||
mentionState.query,
|
||||
onChange,
|
||||
onMentionSelect,
|
||||
],
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Chip menu (Replace / Preview / Remove)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const closeChipMenu = useCallback(() => setChipMenu(null), []);
|
||||
|
||||
/** Re-locate the chip's label in the current value; -1 when it's gone. */
|
||||
const chipStart = useCallback(
|
||||
(menu: ChipMenuState): number => {
|
||||
if (
|
||||
value.slice(menu.start, menu.start + menu.label.length) === menu.label
|
||||
) {
|
||||
return menu.start;
|
||||
}
|
||||
return value.indexOf(menu.label);
|
||||
},
|
||||
[value],
|
||||
);
|
||||
|
||||
/**
|
||||
* Select the chip element (plus its trailing space for removals) so the
|
||||
* follow-up execCommand edit lands on the browser's undo stack — Ctrl+Z
|
||||
* then restores the chip exactly like undoing deleted text. Returns false
|
||||
* when the chip element is no longer in the editor (DOM was rebuilt).
|
||||
*/
|
||||
const selectChipRange = useCallback(
|
||||
(menu: ChipMenuState, includeTrailingSpace: boolean): boolean => {
|
||||
const el = editorRef.current;
|
||||
const chip = menu.node;
|
||||
if (!el || !chip || !el.contains(chip)) return false;
|
||||
el.focus();
|
||||
const range = document.createRange();
|
||||
range.setStartBefore(chip);
|
||||
const next = chip.nextSibling;
|
||||
if (
|
||||
includeTrailingSpace &&
|
||||
next?.nodeType === Node.TEXT_NODE &&
|
||||
next.textContent?.startsWith(" ")
|
||||
) {
|
||||
range.setEnd(next, 1);
|
||||
} else {
|
||||
range.setEndAfter(chip);
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return false;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChipReplace = useCallback(
|
||||
(next: MentionItem) => {
|
||||
if (!chipMenu) return;
|
||||
setChipMenu(null);
|
||||
if (selectChipRange(chipMenu, false)) {
|
||||
document.execCommand(
|
||||
"insertHTML",
|
||||
false,
|
||||
buildChipHTML(next, next.label),
|
||||
);
|
||||
handleInput();
|
||||
} else {
|
||||
// Chip element detached (DOM rebuilt) — 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;
|
||||
onChange(newValue);
|
||||
}
|
||||
onMentionSelect?.(next);
|
||||
},
|
||||
[
|
||||
chipMenu,
|
||||
selectChipRange,
|
||||
buildChipHTML,
|
||||
handleInput,
|
||||
chipStart,
|
||||
value,
|
||||
onChange,
|
||||
onMentionSelect,
|
||||
],
|
||||
);
|
||||
|
||||
const handleChipRemove = useCallback(() => {
|
||||
if (!chipMenu) return;
|
||||
setChipMenu(null);
|
||||
if (selectChipRange(chipMenu, true)) {
|
||||
document.execCommand("delete");
|
||||
handleInput();
|
||||
} else {
|
||||
const start = chipStart(chipMenu);
|
||||
if (start === -1) return;
|
||||
let end = start + chipMenu.label.length;
|
||||
if (value[end] === " ") end += 1;
|
||||
pendingCaret.current = start;
|
||||
onChange(value.slice(0, start) + value.slice(end));
|
||||
}
|
||||
}, [chipMenu, selectChipRange, handleInput, chipStart, value, onChange]);
|
||||
|
||||
const handleChipPreview = useCallback(() => {
|
||||
if (!chipMenu) return;
|
||||
const item =
|
||||
(chipMenu.token &&
|
||||
mentionItems.find(
|
||||
(i) => i.type === "character" && i.token === chipMenu.token,
|
||||
)) ||
|
||||
resolveItem(chipMenu.label);
|
||||
setChipMenu(null);
|
||||
if (item) setPreviewItem(item);
|
||||
}, [chipMenu, mentionItems, resolveItem]);
|
||||
|
||||
const chipMenuItem = useMemo(() => {
|
||||
if (!chipMenu) return undefined;
|
||||
return (
|
||||
(chipMenu.token &&
|
||||
mentionItems.find(
|
||||
(i) => i.type === "character" && i.token === chipMenu.token,
|
||||
)) ||
|
||||
resolveItem(chipMenu.label)
|
||||
);
|
||||
}, [chipMenu, mentionItems, resolveItem]);
|
||||
|
||||
const replaceItems = useMemo(() => {
|
||||
if (!chipMenu) return [];
|
||||
return mentionItems.filter(
|
||||
(i) =>
|
||||
i.type === "character" &&
|
||||
(chipMenu.token
|
||||
? i.token !== chipMenu.token
|
||||
: i.label.toLowerCase() !== chipMenu.label.toLowerCase()),
|
||||
);
|
||||
}, [chipMenu, mentionItems]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (mentionState.isOpen && filteredItems.length > 0) {
|
||||
@@ -627,6 +1070,55 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic chip deletion: native contenteditable=false removal is
|
||||
// inconsistent across browsers (Safari especially), so when the caret
|
||||
// sits at a character-mention boundary, delete the whole label from
|
||||
// the plain-text value in one keypress.
|
||||
if (
|
||||
(e.key === "Backspace" || e.key === "Delete") &&
|
||||
chipLabels.size > 0 &&
|
||||
mentionRegex
|
||||
) {
|
||||
const el = editorRef.current;
|
||||
const sel = window.getSelection();
|
||||
if (el && sel?.isCollapsed) {
|
||||
const caret = getCaretOffset(el);
|
||||
const regex = new RegExp(mentionRegex);
|
||||
let match: RegExpExecArray | null;
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: --
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
if (!chipLabels.has(match[0].toLowerCase())) continue;
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (
|
||||
(e.key === "Backspace" && end === caret) ||
|
||||
(e.key === "Delete" && start === caret)
|
||||
) {
|
||||
e.preventDefault();
|
||||
// Delete the chip element through the browser's edit command
|
||||
// so the removal lands on the undo stack (Ctrl+Z restores it).
|
||||
const chip = Array.from(
|
||||
el.querySelectorAll<HTMLElement>("[data-mention]"),
|
||||
).find((c) => getNodeStartOffset(el, c) === start);
|
||||
if (chip) {
|
||||
const range = document.createRange();
|
||||
range.setStartBefore(chip);
|
||||
range.setEndAfter(chip);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.execCommand("delete");
|
||||
handleInput();
|
||||
} else {
|
||||
pendingCaret.current = start;
|
||||
onChange(value.slice(0, start) + value.slice(end));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (start > caret) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert a newline instead of letting the contentEditable create a <div>.
|
||||
// When `enterToGenerate` is off (default), all Enter combos insert a newline
|
||||
// (only the button submits). When the user opts into Enter-to-generate,
|
||||
@@ -653,16 +1145,52 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
externalOnKeyDown,
|
||||
handleInput,
|
||||
enterToGenerate,
|
||||
chipLabels,
|
||||
mentionRegex,
|
||||
value,
|
||||
onChange,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return; // preserve text selection
|
||||
detectMention(value, getCaretOffset(el));
|
||||
}, [value, detectMention]);
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Clicking a chip must not move the caret into/around it.
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.("[data-mention]")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const chip = target.closest?.("[data-mention]") as HTMLElement | null;
|
||||
if (chip && el.contains(chip)) {
|
||||
const label = chip.dataset.mention ?? "";
|
||||
setMentionState((prev) =>
|
||||
prev.isOpen ? { ...prev, isOpen: false } : prev,
|
||||
);
|
||||
setChipMenu({
|
||||
label,
|
||||
token: chip.dataset.token,
|
||||
start: getNodeStartOffset(el, chip),
|
||||
rect: chip.getBoundingClientRect(),
|
||||
node: chip,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return; // preserve text selection
|
||||
detectMention(value, getCaretOffset(el));
|
||||
},
|
||||
[value, detectMention],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
@@ -673,6 +1201,31 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Copy/cut must yield plain text with mention labels intact ("@Name"),
|
||||
// never chip HTML or thumbnail URLs.
|
||||
const handleCopy = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || sel.isCollapsed) return;
|
||||
const holder = document.createElement("div");
|
||||
holder.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
e.clipboardData.setData("text/plain", serializeEditor(holder));
|
||||
e.preventDefault();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCut = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || sel.isCollapsed) return;
|
||||
handleCopy(e);
|
||||
document.execCommand("delete");
|
||||
handleInput();
|
||||
},
|
||||
[handleCopy, handleInput],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0 pb-[7px]">
|
||||
{!value && placeholder && (
|
||||
@@ -693,8 +1246,11 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
onPaste={handlePaste}
|
||||
onCopy={handleCopy}
|
||||
onCut={handleCut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={style}
|
||||
@@ -715,6 +1271,33 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
position={dropdownPos}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chipMenu && (
|
||||
<MentionChipMenu
|
||||
anchorRect={chipMenu.rect}
|
||||
currentLabel={chipMenu.label}
|
||||
currentPreview={chipMenuItem ? chipMenuItem.preview : undefined}
|
||||
replaceItems={replaceItems}
|
||||
onReplace={handleChipReplace}
|
||||
onPreview={handleChipPreview}
|
||||
onRemove={handleChipRemove}
|
||||
onClose={closeChipMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeckPreviewModal
|
||||
item={
|
||||
previewItem
|
||||
? {
|
||||
id: previewItem.token ?? previewItem.label,
|
||||
kind: "image",
|
||||
url: previewItem.fullPreview ?? previewItem.preview,
|
||||
name: previewItem.label.replace(/^@/, ""),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
@@ -174,6 +174,13 @@ export const PromptBoxVideo = ({
|
||||
useFullscreenPrompt();
|
||||
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
|
||||
|
||||
// Mentions are plain text: with several characters sharing a name,
|
||||
// "@Robot" alone can't identify one. Records which token the user actually
|
||||
// picked (dropdown, modal, or chip-menu replace), keyed by name.
|
||||
const [mentionSelections, setMentionSelections] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
// Characters store for @-mentions
|
||||
const storedCharacters = useCharactersStore((s) => s.characters);
|
||||
const charactersLoaded = useCharactersStore((s) => s.loaded);
|
||||
@@ -193,6 +200,7 @@ export const PromptBoxVideo = ({
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
full_image_url: c.maybe_full_image?.cdn_url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -837,6 +845,8 @@ export const PromptBoxVideo = ({
|
||||
label: `@${char.name}`,
|
||||
type: "character" as const,
|
||||
preview: char.avatar_image_url,
|
||||
token: char.character_token,
|
||||
fullPreview: char.full_image_url ?? char.avatar_image_url,
|
||||
})),
|
||||
],
|
||||
[
|
||||
@@ -848,6 +858,14 @@ export const PromptBoxVideo = ({
|
||||
],
|
||||
);
|
||||
|
||||
// Record which token a mention name refers to when the user picks a
|
||||
// character explicitly (dropdown pick or chip-menu replace).
|
||||
const handleMentionSelect = useCallback((item: MentionItem) => {
|
||||
if (item.type !== "character" || !item.token) return;
|
||||
const name = item.label.replace(/^@/, "");
|
||||
setMentionSelections((prev) => ({ ...prev, [name]: item.token! }));
|
||||
}, []);
|
||||
|
||||
// Build label → color map for inline mention highlighting
|
||||
const mentionColorMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -1051,25 +1069,33 @@ export const PromptBoxVideo = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Extract character tokens from @-mentions in prompt.
|
||||
// Extract character tokens from @-mentions in prompt, resolving to
|
||||
// exactly one token per mentioned name. Several characters can share a
|
||||
// name; prefer the user's explicit pick (mentionSelections), else the
|
||||
// newest (store is newest-first).
|
||||
// Use a word-boundary regex so `@Bob` doesn't match inside `@Bob2`.
|
||||
const mentionedCharacters = (() => {
|
||||
const mentionedTokens = (() => {
|
||||
if (activeCharacters.length === 0) return [];
|
||||
const sorted = [...activeCharacters].sort(
|
||||
(a, b) => b.name.length - a.name.length,
|
||||
);
|
||||
const matched = new Set<string>();
|
||||
for (const c of sorted) {
|
||||
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`@${escaped}(?!\\w)`);
|
||||
if (regex.test(prompt)) matched.add(c.character_token);
|
||||
const byName = new Map<string, StoredCharacter[]>();
|
||||
for (const c of activeCharacters) {
|
||||
byName.set(c.name, [...(byName.get(c.name) ?? []), c]);
|
||||
}
|
||||
return activeCharacters.filter((c) => matched.has(c.character_token));
|
||||
const names = [...byName.keys()].sort((a, b) => b.length - a.length);
|
||||
const tokens: string[] = [];
|
||||
for (const name of names) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (!new RegExp(`@${escaped}(?!\\w)`).test(prompt)) continue;
|
||||
const candidates = byName.get(name)!;
|
||||
const chosen =
|
||||
candidates.find(
|
||||
(c) => c.character_token === mentionSelections[name],
|
||||
) ?? candidates[0];
|
||||
tokens.push(chosen.character_token);
|
||||
}
|
||||
return tokens;
|
||||
})();
|
||||
if (mentionedCharacters.length > 0) {
|
||||
request.reference_character_tokens = mentionedCharacters.map(
|
||||
(c) => c.character_token,
|
||||
);
|
||||
if (mentionedTokens.length > 0) {
|
||||
request.reference_character_tokens = mentionedTokens;
|
||||
}
|
||||
|
||||
// Pass duration if model supports it
|
||||
@@ -1278,7 +1304,7 @@ export const PromptBoxVideo = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex justify-center gap-2">
|
||||
<div className="relative flex justify-center gap-3">
|
||||
{isReferenceMode ? renderReferenceDeck() : renderKeyframeCards()}
|
||||
<div className="promptbox-resize-wrap relative flex-1 min-w-0">
|
||||
{hasAnyMentionables ? (
|
||||
@@ -1288,6 +1314,8 @@ export const PromptBoxVideo = ({
|
||||
onChange={setPrompt}
|
||||
mentionItems={allMentionItems}
|
||||
colorMap={mentionColorMap}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
@@ -1506,6 +1534,10 @@ export const PromptBoxVideo = ({
|
||||
const spaceBefore =
|
||||
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
|
||||
setPrompt(prompt + spaceBefore + mention + " ");
|
||||
setMentionSelections((prev) => ({
|
||||
...prev,
|
||||
[character.name]: character.token,
|
||||
}));
|
||||
setIsCharactersModalOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const el = mentionEditorRef.current;
|
||||
@@ -1549,6 +1581,8 @@ export const PromptBoxVideo = ({
|
||||
onChange={setPrompt}
|
||||
mentionItems={allMentionItems}
|
||||
colorMap={mentionColorMap}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { MentionItem } from "./types";
|
||||
import type { MentionItem } from "./MentionTextarea";
|
||||
|
||||
// ── @-mention color palette ─────────────────────────────────────────────
|
||||
|
||||
@@ -340,6 +340,7 @@ export interface StoredCharacter {
|
||||
character_token: string;
|
||||
name: string;
|
||||
avatar_image_url?: string;
|
||||
full_image_url?: string;
|
||||
}
|
||||
|
||||
interface CharactersStore {
|
||||
|
||||
Reference in New Issue
Block a user