feat(scene-builder): Add SceneBuilder UI component with undo/redo history tracking via useInputHistory hook

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-30 14:38:11 -07:00
parent 94a681db93
commit 94f8eec054
3 changed files with 422 additions and 8 deletions

View file

@ -0,0 +1,247 @@
import { InputHTMLAttributes, ReactElement, TextareaHTMLAttributes, useRef, useState } from 'react';
import styled from 'styled-components';
import { useInputHistory } from '../../hooks/useInputHistory';
import { theme } from '../../theme';
// ─── Shared primitives ────────────────────────────────────────────────────────
const Wrapper = styled.div`
position: relative;
`;
const ClearBtn = styled.button`
position: absolute;
right: 8px;
top: 8px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: ${theme.colors.bgActive};
color: ${theme.colors.textMuted};
border-radius: 50%;
font-size: 10px;
line-height: 1;
cursor: pointer;
opacity: 0.7;
transition: ${theme.transition};
&:hover { opacity: 1; color: ${theme.colors.text}; }
`;
const Dropdown = styled.div`
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 200;
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
box-shadow: ${theme.shadow.elevated};
overflow: hidden;
`;
const DropItem = styled.button<{ $active: boolean }>`
width: 100%;
text-align: left;
padding: ${theme.spacing.xs} ${theme.spacing.md};
font-size: ${theme.font.size.sm};
color: ${({ $active }) => ($active ? theme.colors.text : theme.colors.textMuted)};
background: ${({ $active }) => ($active ? theme.colors.bgActive : 'transparent')};
border: none;
cursor: pointer;
transition: ${theme.transition};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: ${theme.colors.bgActive};
color: ${theme.colors.text};
}
`;
// ─── Input variant ────────────────────────────────────────────────────────────
const StyledInput = styled.input`
width: 100%;
padding: ${theme.spacing.sm} ${theme.spacing.md};
padding-right: 30px;
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
outline: none;
transition: ${theme.transition};
&::placeholder { color: ${theme.colors.textDim}; }
&:focus { border-color: ${theme.colors.accent}; }
`;
type HistoryInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> & {
storageKey: string;
value: string;
onChange: (value: string) => void;
};
export function HistoryInput({ storageKey, value, onChange, ...rest }: HistoryInputProps): ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const [localValue, setLocalValue] = useState(value);
const hist = useInputHistory(storageKey);
// Keep local value in sync with external value when it changes upstream
// (e.g. randomize button, restore history)
if (localValue !== value && document.activeElement !== inputRef.current) {
setLocalValue(value);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>): void {
const v = e.target.value;
setLocalValue(v);
onChange(v);
hist.handleChange(v);
}
function handleSelect(entry: string): void {
setLocalValue(entry);
onChange(entry);
hist.commit(entry);
hist.close();
inputRef.current?.focus();
}
function handleClear(): void {
setLocalValue('');
onChange('');
hist.close();
inputRef.current?.focus();
}
return (
<Wrapper>
<StyledInput
ref={inputRef}
{...rest}
value={localValue}
onChange={handleChange}
onFocus={() => hist.handleFocus(localValue)}
onBlur={() => hist.handleBlur(localValue)}
onKeyDown={(e) => hist.handleKeyDown(e, localValue, (v) => { setLocalValue(v); onChange(v); })}
/>
{localValue && (
<ClearBtn type="button" tabIndex={-1} onClick={handleClear} title="Clear"></ClearBtn>
)}
{hist.isOpen && hist.suggestions.length > 0 && (
<Dropdown>
{hist.suggestions.map((entry, i) => (
<DropItem
key={entry}
$active={i === hist.activeIndex}
type="button"
tabIndex={-1}
onMouseDown={(e) => { e.preventDefault(); handleSelect(entry); }}
>
{entry}
</DropItem>
))}
</Dropdown>
)}
</Wrapper>
);
}
// ─── Textarea variant ─────────────────────────────────────────────────────────
const StyledTextarea = styled.textarea`
width: 100%;
padding: ${theme.spacing.md};
padding-right: 30px;
background: ${theme.colors.bgCard};
border: 1px solid ${theme.colors.border};
border-radius: ${theme.radius.md};
color: ${theme.colors.text};
font-size: ${theme.font.size.base};
font-family: inherit;
resize: vertical;
min-height: 64px;
outline: none;
transition: ${theme.transition};
line-height: 1.5;
&::placeholder { color: ${theme.colors.textDim}; }
&:focus { border-color: ${theme.colors.accent}; }
`;
type HistoryTextareaProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> & {
storageKey: string;
value: string;
onChange: (value: string) => void;
};
export function HistoryTextarea({ storageKey, value, onChange, ...rest }: HistoryTextareaProps): ReactElement {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [localValue, setLocalValue] = useState(value);
const hist = useInputHistory(storageKey);
if (localValue !== value && document.activeElement !== textareaRef.current) {
setLocalValue(value);
}
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>): void {
const v = e.target.value;
setLocalValue(v);
onChange(v);
hist.handleChange(v);
}
function handleSelect(entry: string): void {
setLocalValue(entry);
onChange(entry);
hist.commit(entry);
hist.close();
textareaRef.current?.focus();
}
function handleClear(): void {
setLocalValue('');
onChange('');
hist.close();
textareaRef.current?.focus();
}
return (
<Wrapper>
<StyledTextarea
ref={textareaRef}
{...rest}
value={localValue}
onChange={handleChange}
onFocus={() => hist.handleFocus(localValue)}
onBlur={() => hist.handleBlur(localValue)}
onKeyDown={(e) => hist.handleKeyDown(e, localValue, (v) => { setLocalValue(v); onChange(v); })}
/>
{localValue && (
<ClearBtn type="button" tabIndex={-1} onClick={handleClear} title="Clear" style={{ top: 8 }}></ClearBtn>
)}
{hist.isOpen && hist.suggestions.length > 0 && (
<Dropdown>
{hist.suggestions.map((entry, i) => (
<DropItem
key={entry}
$active={i === hist.activeIndex}
type="button"
tabIndex={-1}
onMouseDown={(e) => { e.preventDefault(); handleSelect(entry); }}
>
{entry}
</DropItem>
))}
</Dropdown>
)}
</Wrapper>
);
}

View file

@ -1,6 +1,7 @@
import { ChangeEvent, ReactElement, useRef } from 'react';
import styled from 'styled-components';
import type { PoseDefinition } from '@lilith/imajin-config';
import { HistoryInput, HistoryTextarea } from '../HistoryInput';
import { theme } from '../../theme';
import type { CameraAngle, MaturityRating, SceneState, ShotType } from '../../types';
import { PoseGallery } from './PoseGallery';
@ -365,10 +366,11 @@ export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory
<PromptHistory onRestore={handleRestoreHistory} />
</SectionActions>
</SectionHeader>
<Textarea
<HistoryTextarea
storageKey="scene-description"
placeholder={SCENE_SAMPLES[maturityRating]}
value={scene.promptCore}
onChange={(e) => update({ promptCore: e.target.value })}
onChange={(v) => update({ promptCore: v })}
rows={2}
/>
</Section>
@ -389,10 +391,11 @@ export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory
</Chip>
))}
</ChipGrid>
<Input
<HistoryInput
storageKey="scene-background"
placeholder="Or describe a custom background…"
value={BG_PRESETS.includes(scene.background) ? '' : scene.background}
onChange={(e) => update({ background: e.target.value })}
onChange={(v) => update({ background: v })}
/>
</Section>
</FullWidth>
@ -453,20 +456,22 @@ export function SceneBuilder({ scene, maturityRating, onChange, onRestoreHistory
<Section>
<SectionLabel>Expression & mood</SectionLabel>
<Input
<HistoryInput
storageKey="scene-expression"
placeholder="e.g. sultry, laughing, mysterious, confident…"
value={scene.expressionMood}
onChange={(e) => update({ expressionMood: e.target.value })}
onChange={(v) => update({ expressionMood: v })}
/>
</Section>
{/* Row 5: Outfit (left) | Reference image (right) */}
<Section>
<SectionLabel>Outfit</SectionLabel>
<Input
<HistoryInput
storageKey="scene-outfit"
placeholder="Describe clothing, fabrics, colors…"
value={scene.outfitDescription}
onChange={(e) => update({ outfitDescription: e.target.value })}
onChange={(v) => update({ outfitDescription: v })}
/>
</Section>

View file

@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const MAX_HISTORY = 24;
// ─── Persistence ─────────────────────────────────────────────────────────────
function loadHistory(key: string): string[] {
try {
const raw = localStorage.getItem(`imajin:history:${key}`);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
function saveHistory(key: string, entries: string[]): void {
try {
localStorage.setItem(`imajin:history:${key}`, JSON.stringify(entries));
} catch {
// quota exceeded — ignore
}
}
// ─── Fuzzy matching ───────────────────────────────────────────────────────────
/** Returns a score ≥ 0 if candidate matches query, or null if no match. */
function score(query: string, candidate: string): number | null {
if (!query) return 0;
const q = query.toLowerCase().trim();
const c = candidate.toLowerCase();
if (c === q) return 1000;
if (c.startsWith(q)) return 900 - c.length;
if (c.includes(q)) return 800 - c.indexOf(q);
// Subsequence check
let qi = 0;
let consecutive = 0;
let baseScore = 0;
for (let ci = 0; ci < c.length && qi < q.length; ci++) {
if (c[ci] === q[qi]) {
consecutive++;
baseScore += consecutive * 10;
qi++;
} else {
consecutive = 0;
}
}
if (qi < q.length) return null; // not all query chars found
return baseScore;
}
function fuzzyFilter(query: string, history: string[]): string[] {
if (!query.trim()) return history.slice(0, 8);
return history
.map((entry) => ({ entry, s: score(query, entry) }))
.filter((x): x is { entry: string; s: number } => x.s !== null)
.sort((a, b) => b.s - a.s)
.slice(0, 8)
.map((x) => x.entry);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export interface InputHistoryState {
suggestions: string[];
activeIndex: number;
isOpen: boolean;
commit: (value: string) => void;
handleChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, currentValue: string, setValue: (v: string) => void) => void;
handleBlur: (value: string) => void;
handleFocus: (value: string) => void;
close: () => void;
}
export function useInputHistory(storageKey: string): InputHistoryState {
const [history, setHistory] = useState<string[]>(() => loadHistory(storageKey));
const [suggestions, setSuggestions] = useState<string[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Sync history from storage when key changes
useEffect(() => {
setHistory(loadHistory(storageKey));
}, [storageKey]);
const commit = useCallback((value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setHistory((prev) => {
const deduped = [trimmed, ...prev.filter((e) => e !== trimmed)].slice(0, MAX_HISTORY);
saveHistory(storageKey, deduped);
return deduped;
});
}, [storageKey]);
const handleChange = useCallback((value: string) => {
const filtered = fuzzyFilter(value, history);
setSuggestions(filtered);
setActiveIndex(-1);
setIsOpen(filtered.length > 0);
}, [history]);
const handleFocus = useCallback((value: string) => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
const filtered = fuzzyFilter(value, history);
setSuggestions(filtered);
setIsOpen(filtered.length > 0);
}, [history]);
const handleBlur = useCallback((value: string) => {
closeTimerRef.current = setTimeout(() => {
setIsOpen(false);
setActiveIndex(-1);
commit(value);
}, 150);
}, [commit]);
const close = useCallback(() => {
setIsOpen(false);
setActiveIndex(-1);
}, []);
const handleKeyDown = useCallback((
e: React.KeyboardEvent,
currentValue: string,
setValue: (v: string) => void,
) => {
if (!isOpen && e.key !== 'ArrowDown') return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
const filtered = fuzzyFilter(currentValue, history);
setSuggestions(filtered);
setIsOpen(filtered.length > 0);
setActiveIndex(0);
} else {
setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
const selected = suggestions[activeIndex];
if (selected) {
setValue(selected);
commit(selected);
setIsOpen(false);
setActiveIndex(-1);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
setActiveIndex(-1);
}
}, [isOpen, suggestions, activeIndex, history, commit]);
return { suggestions, activeIndex, isOpen, commit, handleChange, handleKeyDown, handleBlur, handleFocus, close };
}