mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 09:22:41 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32248cb540 | ||
|
|
3f93fa835e | ||
|
|
04a5637dc0 |
@@ -0,0 +1,628 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
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";
|
||||
|
||||
export interface MentionItem {
|
||||
label: string;
|
||||
type: "image" | "video" | "audio" | "character";
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mentionItems: MentionItem[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
interface MentionState {
|
||||
isOpen: boolean;
|
||||
triggerIndex: number;
|
||||
query: string;
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeHTML(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor helpers for contentEditable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCaretOffset(el: HTMLElement): number {
|
||||
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.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;
|
||||
}
|
||||
|
||||
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the contentEditable so the caret is visible.
|
||||
* Uses a zero-width space span to get a measurable rect on empty lines.
|
||||
*/
|
||||
function scrollCaretIntoView(el: HTMLElement) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Remove temp node and restore caret after it
|
||||
const parent = span.parentNode!;
|
||||
const next = span.nextSibling;
|
||||
parent.removeChild(span);
|
||||
|
||||
const restored = document.createRange();
|
||||
if (next) {
|
||||
restored.setStartBefore(next);
|
||||
} else {
|
||||
restored.setStartAfter(parent.lastChild ?? parent);
|
||||
}
|
||||
restored.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(restored);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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-ui-controls 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-base-fg/50">
|
||||
References
|
||||
</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-base-fg 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.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faUser}
|
||||
className="h-3.5 w-3.5 text-base-fg/60"
|
||||
/>
|
||||
) : item.type === "image" && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : 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-base-fg/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,
|
||||
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,
|
||||
});
|
||||
|
||||
// Pixel position of the @ trigger relative to the wrapper div
|
||||
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 (supports spaces in names)
|
||||
// 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})`, "g");
|
||||
}, [mentionItems]);
|
||||
|
||||
// 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;
|
||||
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: --
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const color = colorMap[fullMatch];
|
||||
|
||||
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;
|
||||
},
|
||||
[colorMap, mentionRegex],
|
||||
);
|
||||
|
||||
// Sync DOM when value changes from parent (not from user input)
|
||||
useEffect(() => {
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
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);
|
||||
}
|
||||
}, [value, buildHTML]);
|
||||
|
||||
// Get pixel coordinates of a text offset relative to the wrapper
|
||||
const getOffsetRect = useCallback((charOffset: number) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return null;
|
||||
|
||||
// Temporarily place caret at charOffset to measure position
|
||||
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,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Detect @mention trigger from cursor position
|
||||
// Supports multi-word names by scanning back to the nearest @
|
||||
const detectMention = useCallback((text: string, cursorPos: number) => {
|
||||
// Find the last @ before cursor
|
||||
const textBefore = text.slice(0, cursorPos);
|
||||
const lastAt = textBefore.lastIndexOf("@");
|
||||
if (lastAt !== -1 && (lastAt === 0 || /\s/.test(text[lastAt - 1]))) {
|
||||
const query = text.slice(lastAt, cursorPos); // includes @
|
||||
// Only open if there's no newline in the query
|
||||
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]);
|
||||
|
||||
// Extract text and re-render with mention styling
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposing.current) return;
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
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);
|
||||
|
||||
// Keep caret visible when content overflows (contentEditable doesn't auto-scroll)
|
||||
requestAnimationFrame(() => {
|
||||
scrollCaretIntoView(el);
|
||||
});
|
||||
}, [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;
|
||||
|
||||
const caretPos = getCaretOffset(el);
|
||||
const before = value.slice(0, mentionState.triggerIndex);
|
||||
const after = value.slice(caretPos);
|
||||
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, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Shift+Enter (or Cmd+Enter on Mac): insert a newline instead of
|
||||
// letting the contentEditable create a <div>
|
||||
if (e.key === "Enter" && (e.shiftKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
document.execCommand("insertLineBreak");
|
||||
handleInput();
|
||||
// Scroll caret into view (textarea does this automatically, contentEditable does not)
|
||||
scrollCaretIntoView(editorRef.current!);
|
||||
return;
|
||||
}
|
||||
|
||||
externalOnKeyDown?.(e);
|
||||
},
|
||||
[
|
||||
mentionState.isOpen,
|
||||
mentionState.activeIndex,
|
||||
filteredItems,
|
||||
handleSelect,
|
||||
externalOnKeyDown,
|
||||
handleInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (el) {
|
||||
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">
|
||||
{!value && placeholder && (
|
||||
<div
|
||||
className={twMerge(
|
||||
className,
|
||||
"absolute inset-0 pointer-events-none text-base-fg/60 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}
|
||||
className={twMerge(
|
||||
className,
|
||||
"outline-none whitespace-pre-wrap break-words overflow-y-auto",
|
||||
)}
|
||||
/>
|
||||
|
||||
{mentionState.isOpen && filteredItems.length > 0 && (
|
||||
<MentionDropdown
|
||||
items={filteredItems}
|
||||
activeIndex={mentionState.activeIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={(i) =>
|
||||
setMentionState((prev) => ({ ...prev, activeIndex: i }))
|
||||
}
|
||||
position={dropdownPos}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
import { downloadFileFromUrl } from "@storyteller/api";
|
||||
@@ -18,11 +18,7 @@ import {
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faCircleInfo,
|
||||
faVideo,
|
||||
faMusic,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { faCircleInfo } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
@@ -46,7 +42,8 @@ import { toast } from "@storyteller/ui-toaster";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { CharactersModal } from "./CharactersModal";
|
||||
import { CharactersApi } from "@storyteller/api";
|
||||
import { faUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import type { MentionItem } from "./MentionTextarea";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -175,8 +172,9 @@ export const PromptBoxVideo = ({
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
const el = (mentionEditorRef.current ?? textareaRef.current) as HTMLElement | null;
|
||||
if (el) {
|
||||
el.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -229,17 +227,18 @@ export const PromptBoxVideo = ({
|
||||
useState<PopoverItem[]>(aspectRatioOptions);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mentionEditorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Apply height constraints to whichever editor element is active
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
// Hard pixel limit so the resize handle can never exceed viewport
|
||||
const el = (mentionEditorRef.current ?? textareaRef.current) as HTMLElement | null;
|
||||
if (el) {
|
||||
const maxH = isExpanded ? 500 : Math.min(window.innerHeight - 700, 500);
|
||||
textareaRef.current.style.maxHeight = `${Math.max(maxH, 88)}px`;
|
||||
textareaRef.current.style.minHeight = "0";
|
||||
el.style.maxHeight = `${Math.max(maxH, 88)}px`;
|
||||
el.style.minHeight = "0";
|
||||
if (!isExpanded) {
|
||||
// Cap auto-grow at ~5.5em so it doesn't fight with manual resize
|
||||
const capped = Math.min(textareaRef.current.scrollHeight, 88);
|
||||
textareaRef.current.style.minHeight = `${capped}px`;
|
||||
const capped = Math.min(el.scrollHeight, 88);
|
||||
el.style.minHeight = `${capped}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -391,15 +390,6 @@ export const PromptBoxVideo = ({
|
||||
? (selectedModel?.maxReferenceImages ?? 3)
|
||||
: 1;
|
||||
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync scroll between textarea and highlight overlay
|
||||
const handleScroll = () => {
|
||||
if (highlightRef.current && textareaRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
// Color palettes for @-mention highlights
|
||||
const IMAGE_COLORS = [
|
||||
"rgb(96, 165, 250)", // blue
|
||||
@@ -436,78 +426,7 @@ export const PromptBoxVideo = ({
|
||||
|
||||
const hasAnyMentionables = hasAnyRefs || activeCharacters.length > 0;
|
||||
|
||||
const renderHighlightedPrompt = () => {
|
||||
if (!hasAnyMentionables) return null;
|
||||
// Build regex that matches @Image1, @Video1, @Audio1 and @CharacterName
|
||||
const charPattern = characterNames
|
||||
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("|");
|
||||
const refPattern = "@(?:Image|Video|Audio)\\d+";
|
||||
const fullPattern = charPattern
|
||||
? `(${refPattern}|@(?:${charPattern}))`
|
||||
: `(${refPattern})`;
|
||||
const regex = new RegExp(fullPattern, "g");
|
||||
const parts = prompt.split(regex);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
const imgMatch = part.match(/^@Image(\d+)$/);
|
||||
if (imgMatch) {
|
||||
const idx = parseInt(imgMatch[1]) - 1;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ color: IMAGE_COLORS[idx % IMAGE_COLORS.length] }}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const vidMatch = part.match(/^@Video(\d+)$/);
|
||||
if (vidMatch) {
|
||||
const idx = parseInt(vidMatch[1]) - 1;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ color: VIDEO_COLORS[idx % VIDEO_COLORS.length] }}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const audMatch = part.match(/^@Audio(\d+)$/);
|
||||
if (audMatch) {
|
||||
const idx = parseInt(audMatch[1]) - 1;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ color: AUDIO_COLORS[idx % AUDIO_COLORS.length] }}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Check character match
|
||||
if (part.startsWith("@")) {
|
||||
const charName = part.slice(1);
|
||||
const charIdx = characterNames.indexOf(charName);
|
||||
if (charIdx !== -1) {
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: CHARACTER_COLORS[charIdx % CHARACTER_COLORS.length],
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
// @-mention autocomplete state
|
||||
// @-mention autocomplete state (for fallback textarea path)
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
@@ -544,6 +463,67 @@ export const PromptBoxVideo = ({
|
||||
: true,
|
||||
);
|
||||
|
||||
// All mention items (unfiltered) for the contentEditable MentionTextarea
|
||||
const allMentionItems: MentionItem[] = useMemo(() => [
|
||||
...(isReferenceMode
|
||||
? [
|
||||
...referenceImages.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
type: "image" as const,
|
||||
preview: img.url,
|
||||
})),
|
||||
...referenceVideos.map((vid, i) => ({
|
||||
label: `@Video${i + 1}`,
|
||||
type: "video" as const,
|
||||
preview: vid.url,
|
||||
})),
|
||||
...referenceAudios.map((_aud, i) => ({
|
||||
label: `@Audio${i + 1}`,
|
||||
type: "audio" as const,
|
||||
preview: undefined as string | undefined,
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
...activeCharacters.map((char) => ({
|
||||
label: `@${char.name}`,
|
||||
type: "character" as const,
|
||||
preview: char.avatar_image_url,
|
||||
})),
|
||||
], [isReferenceMode, referenceImages, referenceVideos, referenceAudios, activeCharacters]);
|
||||
|
||||
// Build label → color map for inline mention highlighting
|
||||
const mentionColorMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const item of allMentionItems) {
|
||||
const imgMatch = item.label.match(/^@Image(\d+)$/);
|
||||
if (imgMatch) {
|
||||
const idx = parseInt(imgMatch[1]) - 1;
|
||||
map[item.label] = IMAGE_COLORS[idx % IMAGE_COLORS.length];
|
||||
continue;
|
||||
}
|
||||
const vidMatch = item.label.match(/^@Video(\d+)$/);
|
||||
if (vidMatch) {
|
||||
const idx = parseInt(vidMatch[1]) - 1;
|
||||
map[item.label] = VIDEO_COLORS[idx % VIDEO_COLORS.length];
|
||||
continue;
|
||||
}
|
||||
const audMatch = item.label.match(/^@Audio(\d+)$/);
|
||||
if (audMatch) {
|
||||
const idx = parseInt(audMatch[1]) - 1;
|
||||
map[item.label] = AUDIO_COLORS[idx % AUDIO_COLORS.length];
|
||||
continue;
|
||||
}
|
||||
if (item.type === "character") {
|
||||
const charName = item.label.slice(1);
|
||||
const charIdx = characterNames.indexOf(charName);
|
||||
if (charIdx !== -1) {
|
||||
map[item.label] = CHARACTER_COLORS[charIdx % CHARACTER_COLORS.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allMentionItems, characterNames]);
|
||||
|
||||
const insertMention = (label: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || mentionAnchorRef.current === null) return;
|
||||
@@ -913,125 +893,45 @@ export const PromptBoxVideo = ({
|
||||
)}
|
||||
>
|
||||
<div className="relative flex justify-center gap-2">
|
||||
{/* @-mention autocomplete dropdown */}
|
||||
{mentionOpen && mentionItems.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 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">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
||||
References
|
||||
</div>
|
||||
{mentionItems.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
className={twMerge(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-base-fg transition-colors cursor-pointer",
|
||||
i === mentionIndex ? "bg-white/10" : "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(item.label);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(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.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faUser}
|
||||
className="h-3.5 w-3.5 text-base-fg/60"
|
||||
/>
|
||||
) : item.type === "image" && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : 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-base-fg/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Hide the Add image button for video for now */}
|
||||
{/* <Tooltip
|
||||
content="Add Image"
|
||||
position="top"
|
||||
closeOnClick={true}
|
||||
className={isImageRowVisible ? "hidden opacity-0" : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="action"
|
||||
className={`h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all ${
|
||||
isImageRowVisible ? "text-primary" : ""
|
||||
}`}
|
||||
onClick={() => setShowImagePrompts((prev) => !prev)}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 24 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="group-hover:opacity-100 opacity-80 transition-all"
|
||||
>
|
||||
<path
|
||||
opacity="1"
|
||||
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</Tooltip> */}
|
||||
|
||||
<div className="promptbox-resize-wrap relative flex-1">
|
||||
{hasAnyMentionables && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden
|
||||
className="text-md pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words rounded pb-2 pr-2 pt-1 text-base-fg"
|
||||
>
|
||||
{renderHighlightedPrompt()}
|
||||
</div>
|
||||
{hasAnyMentionables ? (
|
||||
<MentionTextarea
|
||||
ref={mentionEditorRef}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
mentionItems={allMentionItems}
|
||||
colorMap={mentionColorMap}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
: "Describe what you want to happen in the video..."
|
||||
}
|
||||
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (selectedModel?.requiresImage && referenceImages.length === 0) return;
|
||||
if (!prompt.trim()) return;
|
||||
handleEnqueue();
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe what you want to happen in the video..."
|
||||
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
: "Describe what you want to happen in the video..."
|
||||
}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 placeholder-base-fg/60 focus:outline-none",
|
||||
hasAnyMentionables
|
||||
? "text-transparent caret-base-fg"
|
||||
: "text-base-fg",
|
||||
)}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleScroll}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
@@ -1229,22 +1129,8 @@ export const PromptBoxVideo = ({
|
||||
onClose={() => setIsCharactersModalOpen(false)}
|
||||
onSelectCharacter={(character) => {
|
||||
const mention = `@${character.name}`;
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart ?? prompt.length;
|
||||
const before = prompt.slice(0, pos);
|
||||
const after = prompt.slice(pos);
|
||||
const spaceBefore =
|
||||
before.length > 0 && !before.endsWith(" ") ? " " : "";
|
||||
setPrompt(before + spaceBefore + mention + " " + after);
|
||||
requestAnimationFrame(() => {
|
||||
const newPos = pos + spaceBefore.length + mention.length + 1;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
textarea.focus();
|
||||
});
|
||||
} else {
|
||||
setPrompt(prompt ? prompt + " " + mention + " " : mention + " ");
|
||||
}
|
||||
const spaceBefore = prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
|
||||
setPrompt(prompt + spaceBefore + mention + " ");
|
||||
setIsCharactersModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user