diff --git a/studio/src/components/HistoryInput/index.tsx b/studio/src/components/HistoryInput/index.tsx new file mode 100644 index 00000000..597a470f --- /dev/null +++ b/studio/src/components/HistoryInput/index.tsx @@ -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, 'onChange' | 'value'> & { + storageKey: string; + value: string; + onChange: (value: string) => void; +}; + +export function HistoryInput({ storageKey, value, onChange, ...rest }: HistoryInputProps): ReactElement { + const inputRef = useRef(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): 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 ( + + hist.handleFocus(localValue)} + onBlur={() => hist.handleBlur(localValue)} + onKeyDown={(e) => hist.handleKeyDown(e, localValue, (v) => { setLocalValue(v); onChange(v); })} + /> + {localValue && ( + + )} + {hist.isOpen && hist.suggestions.length > 0 && ( + + {hist.suggestions.map((entry, i) => ( + { e.preventDefault(); handleSelect(entry); }} + > + {entry} + + ))} + + )} + + ); +} + +// ─── 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, 'onChange' | 'value'> & { + storageKey: string; + value: string; + onChange: (value: string) => void; +}; + +export function HistoryTextarea({ storageKey, value, onChange, ...rest }: HistoryTextareaProps): ReactElement { + const textareaRef = useRef(null); + const [localValue, setLocalValue] = useState(value); + const hist = useInputHistory(storageKey); + + if (localValue !== value && document.activeElement !== textareaRef.current) { + setLocalValue(value); + } + + function handleChange(e: React.ChangeEvent): 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 ( + + hist.handleFocus(localValue)} + onBlur={() => hist.handleBlur(localValue)} + onKeyDown={(e) => hist.handleKeyDown(e, localValue, (v) => { setLocalValue(v); onChange(v); })} + /> + {localValue && ( + + )} + {hist.isOpen && hist.suggestions.length > 0 && ( + + {hist.suggestions.map((entry, i) => ( + { e.preventDefault(); handleSelect(entry); }} + > + {entry} + + ))} + + )} + + ); +} diff --git a/studio/src/components/SceneBuilder/index.tsx b/studio/src/components/SceneBuilder/index.tsx index 2fd6949c..d9aff165 100644 --- a/studio/src/components/SceneBuilder/index.tsx +++ b/studio/src/components/SceneBuilder/index.tsx @@ -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 -