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:
parent
94a681db93
commit
94f8eec054
3 changed files with 422 additions and 8 deletions
247
studio/src/components/HistoryInput/index.tsx
Normal file
247
studio/src/components/HistoryInput/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
162
studio/src/hooks/useInputHistory.ts
Normal file
162
studio/src/hooks/useInputHistory.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue