diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/CharactersModal.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/CharactersModal.tsx index 905e4a2994..8d0043421b 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/CharactersModal.tsx +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/CharactersModal.tsx @@ -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; diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/MentionTextarea.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/MentionTextarea.tsx deleted file mode 100644 index fc1176fb75..0000000000 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/MentionTextarea.tsx +++ /dev/null @@ -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) => void; - onFocus?: () => void; - onBlur?: () => void; - disabled?: boolean; - colorMap: Record; -} - -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, """); -} - -// --------------------------------------------------------------------------- -// 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(null); - - useEffect(() => { - const list = listRef.current; - if (!list) return; - const activeItem = list.children[1]?.children[activeIndex] as HTMLElement; - activeItem?.scrollIntoView({ block: "nearest" }); - }, [activeIndex]); - - return ( -
-
- Mentions -
-
- {items.map((item, i) => ( - - ))} -
-
- ); -} - -// --------------------------------------------------------------------------- -// MentionTextarea Component -// --------------------------------------------------------------------------- - -export const MentionTextarea = forwardRef( - function MentionTextarea( - { - value, - onChange, - mentionItems, - placeholder, - className, - style, - onKeyDown: externalOnKeyDown, - onFocus, - onBlur, - disabled, - colorMap, - }, - ref, - ) { - const editorRef = useRef(null); - useImperativeHandle(ref, () => editorRef.current!, []); - const isInternalUpdate = useRef(false); - const isComposing = useRef(false); - const pendingCaret = useRef(null); - - const [mentionState, setMentionState] = useState({ - 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 = {}; - 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, "
"); - if (html.endsWith("
")) html += "
"; - 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 += `${escapeHTML(fullMatch)}`; - } else { - html += escapeHTML(fullMatch); - } - - lastIndex = match.index + fullMatch.length; - } - - if (lastIndex < text.length) { - html += escapeHTML(text.slice(lastIndex)); - } - - html = html.replace(/\n/g, "
"); - if (html.endsWith("
")) { - html += "
"; - } - - 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) => { - 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) => { - e.preventDefault(); - const text = e.clipboardData.getData("text/plain"); - document.execCommand("insertText", false, text); - }, - [], - ); - - return ( -
- {!value && placeholder && ( -
- {placeholder} -
- )} - -
- - {mentionState.isOpen && filteredItems.length > 0 && ( - - setMentionState((prev) => ({ ...prev, activeIndex: i })) - } - position={dropdownPos} - /> - )} -
- ); - }, -); diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx index 26721cf541..7be2e18ec5 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx @@ -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; + // 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( modelSelector, secondaryPromptRow, mentionItems, + onMentionSelect, + mentionSelections, maxPromptLength, }, ref, @@ -789,10 +800,13 @@ export const PromptBox = forwardRef( 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( 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" && diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/characters-store.ts b/frontend/apps/artcraft-webapp/src/components/prompt-box/characters-store.ts deleted file mode 100644 index 69c854c116..0000000000 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/characters-store.ts +++ /dev/null @@ -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) => void; - removeCharacter: (token: string) => void; - setLoaded: (loaded: boolean) => void; -} - -export const useCharactersStore = create()((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 }), -})); diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/index.ts b/frontend/apps/artcraft-webapp/src/components/prompt-box/index.ts index 6e0caf1419..c21ab32842 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/index.ts +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/index.ts @@ -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, diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/mobile/MobilePromptForm.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/mobile/MobilePromptForm.tsx index e194404d84..29d38cd5b6 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/mobile/MobilePromptForm.tsx +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/mobile/MobilePromptForm.tsx @@ -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; // 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} />
diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/types.ts b/frontend/apps/artcraft-webapp/src/components/prompt-box/types.ts index 21f0f1dac5..1fc3ee168e 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/types.ts +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/types.ts @@ -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"; diff --git a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx index 6ee537040b..d30b139970 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx @@ -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 + >({}); + + 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(); - 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(); + 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); }} /> diff --git a/frontend/libs/components/generation-list/src/lib/GalleryCard.tsx b/frontend/libs/components/generation-list/src/lib/GalleryCard.tsx index 492763bd13..2937c8f212 100644 --- a/frontend/libs/components/generation-list/src/lib/GalleryCard.tsx +++ b/frontend/libs/components/generation-list/src/lib/GalleryCard.tsx @@ -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. */}
{isAudio ? (
@@ -222,7 +231,7 @@ export const GalleryCard = memo(function GalleryCard({ )} {/* Hover overlay with media type + model badges and quick actions */} -
+
diff --git a/frontend/libs/components/promptbox/src/index.ts b/frontend/libs/components/promptbox/src/index.ts index 3d7b2e7189..15601f115d 100644 --- a/frontend/libs/components/promptbox/src/index.ts +++ b/frontend/libs/components/promptbox/src/index.ts @@ -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"; diff --git a/frontend/libs/components/promptbox/src/lib/MentionChipMenu.tsx b/frontend/libs/components/promptbox/src/lib/MentionChipMenu.tsx new file mode 100644 index 0000000000..d12a86f4ce --- /dev/null +++ b/frontend/libs/components/promptbox/src/lib/MentionChipMenu.tsx @@ -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(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( +
+ {view === "menu" ? ( + <> +
+ +
+
+ {currentName} +
+
Character
+
+
+
+ + +
+ + + ) : ( + <> +
+ +
+
Replace
+
+ Currently {currentName} +
+
+
+
+
+ {replaceItems.length === 0 && ( +
+ No other characters +
+ )} + {replaceItems.map((item, i) => ( + + ))} +
+ + )} +
, + document.body, + ); +} + +function ChipAvatar({ preview, name }: { preview?: string; name: string }) { + return ( +
+ {preview ? ( + {name} + ) : ( + + )} +
+ ); +} diff --git a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx index 1c1005e53a..79d61489c4 100644 --- a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx +++ b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx @@ -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; + /** + * 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; } 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 , 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"), + *
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(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(
References @@ -250,13 +433,16 @@ function MentionDropdown({
{items.map((item, i) => ( ))}
-
+
, + document.body, ); } @@ -320,6 +507,9 @@ export const MentionTextarea = forwardRef( onBlur, disabled, colorMap, + enterToGenerate: enterToGenerateProp, + onMentionSelect, + selectedTokens, }, ref, ) { @@ -328,7 +518,8 @@ export const MentionTextarea = forwardRef( const isInternalUpdate = useRef(false); const isComposing = useRef(false); const pendingCaret = useRef(null); - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); + const enterToGenerateStore = useEnterToGenerateStore((s) => s.enabled); + const enterToGenerate = enterToGenerateProp ?? enterToGenerateStore; const [mentionState, setMentionState] = useState({ isOpen: false, @@ -337,11 +528,16 @@ export const MentionTextarea = forwardRef( 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(null); + const [previewItem, setPreviewItem] = useState(null); const filteredItems = useMemo(() => { if (!mentionState.isOpen) return []; @@ -373,7 +569,69 @@ export const MentionTextarea = forwardRef( 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(); + 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(); + 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 ( + `` + + (item.preview + ? `` + : `@`) + + `${escapeHTML(matchText.slice(1))}` + + `` + ); + }, + [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( 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 += `${escapeHTML(fullMatch)}`; } else { html += escapeHTML(fullMatch); @@ -416,9 +677,9 @@ export const MentionTextarea = forwardRef( html += "
"; } - 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( 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( 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( } 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( [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( 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( }); } 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( // 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( }); 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) => { if (mentionState.isOpen && filteredItems.length > 0) { @@ -627,6 +1070,55 @@ export const MentionTextarea = forwardRef( } } + // 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("[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
. // 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( 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) => { + // 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) => { + 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) => { @@ -673,6 +1201,31 @@ export const MentionTextarea = forwardRef( [], ); + // Copy/cut must yield plain text with mention labels intact ("@Name"), + // never chip HTML or thumbnail URLs. + const handleCopy = useCallback( + (e: React.ClipboardEvent) => { + 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) => { + const sel = window.getSelection(); + if (!sel?.rangeCount || sel.isCollapsed) return; + handleCopy(e); + document.execCommand("delete"); + handleInput(); + }, + [handleCopy, handleInput], + ); + return (
{!value && placeholder && ( @@ -693,8 +1246,11 @@ export const MentionTextarea = forwardRef( 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( position={dropdownPos} /> )} + + {chipMenu && ( + + )} + + setPreviewItem(null)} + />
); }, diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx index 5e841205f5..d08f9578d2 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx @@ -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 + >({}); + // 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 = {}; @@ -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(); - 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(); + 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 = ({
)} -
+
{isReferenceMode ? renderReferenceDeck() : renderKeyframeCards()}
{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..." diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/mention-colors.ts b/frontend/libs/components/promptbox/src/lib/mentionColors.ts similarity index 97% rename from frontend/apps/artcraft-webapp/src/components/prompt-box/mention-colors.ts rename to frontend/libs/components/promptbox/src/lib/mentionColors.ts index 9ef6f48277..697f229764 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/mention-colors.ts +++ b/frontend/libs/components/promptbox/src/lib/mentionColors.ts @@ -1,4 +1,4 @@ -import type { MentionItem } from "./types"; +import type { MentionItem } from "./MentionTextarea"; // ── @-mention color palette ───────────────────────────────────────────── diff --git a/frontend/libs/components/promptbox/src/lib/promptStore.ts b/frontend/libs/components/promptbox/src/lib/promptStore.ts index 6f2dbadbf1..44ad7d36ac 100644 --- a/frontend/libs/components/promptbox/src/lib/promptStore.ts +++ b/frontend/libs/components/promptbox/src/lib/promptStore.ts @@ -340,6 +340,7 @@ export interface StoredCharacter { character_token: string; name: string; avatar_image_url?: string; + full_image_url?: string; } interface CharactersStore {