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:
BFlat
2026-07-29 21:43:02 +07:00
committed by GitHub
parent 2d7bf65a23
commit e1b16cf2de
15 changed files with 1032 additions and 835 deletions
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// 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,
@@ -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, "&quot;");
}
/**
* 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,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 {