mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
Enhance FloatingGenerateBox with auto-resizing textarea and default voice selection
- Added auto-resizing functionality to the textarea in FloatingGenerateBox, improving user experience when inputting text. - Implemented logic to set the first voice profile as default if none is selected, ensuring a smoother workflow. - Updated StoryContent to remove hardcoded height for the generate box, simplifying layout calculations. - Refactored StoryTrackEditor to improve background styling for better visual consistency.
This commit is contained in:
@@ -34,6 +34,7 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isInstructMode, setIsInstructMode] = useState(false);
|
const [isInstructMode, setIsInstructMode] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
const isStoriesRoute = matchRoute({ to: '/stories' });
|
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
@@ -100,6 +101,66 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
|
|||||||
};
|
};
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
// Set first voice as default if none selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProfileId && profiles && profiles.length > 0) {
|
||||||
|
setSelectedProfileId(profiles[0].id);
|
||||||
|
}
|
||||||
|
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||||
|
|
||||||
|
// Get current form value to trigger resize when it changes
|
||||||
|
const formValue = form.watch(isInstructMode ? 'instruct' : 'text');
|
||||||
|
|
||||||
|
// Auto-resize textarea based on content (only when expanded)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
// Reset textarea height after collapse animation completes
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = '32px';
|
||||||
|
textarea.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
}, 200); // Wait for animation to complete
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const adjustHeight = () => {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const scrollHeight = textarea.scrollHeight;
|
||||||
|
const minHeight = 100; // Expanded minimum
|
||||||
|
const maxHeight = 300; // Max height in pixels
|
||||||
|
const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
|
||||||
|
textarea.style.height = `${targetHeight}px`;
|
||||||
|
|
||||||
|
// Show scrollbar if content exceeds max height
|
||||||
|
if (scrollHeight > maxHeight) {
|
||||||
|
textarea.style.overflowY = 'auto';
|
||||||
|
} else {
|
||||||
|
textarea.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small delay to let framer animation complete
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Adjust on mount and when value changes
|
||||||
|
adjustHeight();
|
||||||
|
|
||||||
|
// Watch for input changes
|
||||||
|
textarea.addEventListener('input', adjustHeight);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
textarea.removeEventListener('input', adjustHeight);
|
||||||
|
};
|
||||||
|
}, [formValue, isInstructMode, isExpanded]);
|
||||||
|
|
||||||
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||||
await handleSubmit(data, selectedProfileId);
|
await handleSubmit(data, selectedProfileId);
|
||||||
}
|
}
|
||||||
@@ -146,7 +207,23 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
height: isExpanded ? 'auto' : '32px',
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
|
// Store ref for auto-resize
|
||||||
|
textareaRef.current = node;
|
||||||
|
// Forward ref to react-hook-form
|
||||||
|
if (typeof field.ref === 'function') {
|
||||||
|
field.ref(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
isInstructMode
|
isInstructMode
|
||||||
? 'Add delivery instructions...'
|
? 'Add delivery instructions...'
|
||||||
@@ -156,16 +233,16 @@ export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector =
|
|||||||
? `Generate speech using ${selectedProfile.name}...`
|
? `Generate speech using ${selectedProfile.name}...`
|
||||||
: 'Select a voice profile above...'
|
: 'Select a voice profile above...'
|
||||||
}
|
}
|
||||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 overflow-hidden transition-all"
|
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||||
style={{
|
style={{
|
||||||
minHeight: isExpanded ? '100px' : '32px',
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
height: isExpanded ? '100px' : '32px',
|
maxHeight: '300px',
|
||||||
}}
|
}}
|
||||||
disabled={!selectedProfileId}
|
disabled={!selectedProfileId}
|
||||||
onClick={() => setIsExpanded(true)}
|
onClick={() => setIsExpanded(true)}
|
||||||
onFocus={() => setIsExpanded(true)}
|
onFocus={() => setIsExpanded(true)}
|
||||||
{...field}
|
|
||||||
/>
|
/>
|
||||||
|
</motion.div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage className="text-xs" />
|
<FormMessage className="text-xs" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
|||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
// Height of the floating generate box plus some padding
|
|
||||||
const GENERATE_BOX_HEIGHT = 160;
|
|
||||||
|
|
||||||
export function StoryContent() {
|
export function StoryContent() {
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||||
@@ -68,8 +65,8 @@ export function StoryContent() {
|
|||||||
// Track editor is shown when story has items
|
// Track editor is shown when story has items
|
||||||
const hasBottomBar = story && story.items.length > 0;
|
const hasBottomBar = story && story.items.length > 0;
|
||||||
|
|
||||||
// Calculate dynamic bottom padding: track editor + generate box + gap
|
// Calculate dynamic bottom padding: track editor + gap
|
||||||
const bottomPadding = hasBottomBar ? trackEditorHeight + GENERATE_BOX_HEIGHT + 24 : 0;
|
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
|
||||||
|
|
||||||
// Drag and drop sensors
|
// Drag and drop sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
||||||
<div className="border-t bg-card overflow-hidden relative" ref={containerRef}>
|
<div className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative" ref={containerRef}>
|
||||||
{/* Resize handle at top */}
|
{/* Resize handle at top */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user