style: polish UI and improve UX
This commit is contained in:
106
src/components/home/TypewriterHero.tsx
Normal file
106
src/components/home/TypewriterHero.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* TypewriterHero — 标题 + 终端打字机副标题
|
||||
* 对焦动画保留,取景器四角线移至页面级包裹
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
const TYPE_SPEED = 55
|
||||
const DELETE_SPEED = 20
|
||||
const PAUSE_AFTER_TYPE = 3200
|
||||
const PAUSE_AFTER_DELETE = 500
|
||||
|
||||
interface TypewriterHeroProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
export default function TypewriterHero({ title, subtitle }: TypewriterHeroProps) {
|
||||
const [text, setText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const prevLenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
prevLenRef.current = text.length
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout
|
||||
if (!isDeleting && text.length === subtitle.length) {
|
||||
timeout = setTimeout(() => setIsDeleting(true), PAUSE_AFTER_TYPE)
|
||||
} else if (isDeleting && text.length === 0) {
|
||||
timeout = setTimeout(() => setIsDeleting(false), PAUSE_AFTER_DELETE)
|
||||
} else {
|
||||
timeout = setTimeout(
|
||||
() => setText(subtitle.slice(0, text.length + (isDeleting ? -1 : 1))),
|
||||
isDeleting ? DELETE_SPEED : TYPE_SPEED
|
||||
)
|
||||
}
|
||||
return () => clearTimeout(timeout)
|
||||
}, [text, isDeleting, subtitle])
|
||||
|
||||
const isNewChar = (i: number) =>
|
||||
!isDeleting && i === text.length - 1 && text.length > prevLenRef.current
|
||||
|
||||
return (
|
||||
<div className="text-center mb-4">
|
||||
<style>{`
|
||||
@keyframes twh-focus-pull {
|
||||
0%, 70%, 100% { filter: blur(0px); opacity: 1; }
|
||||
75% { filter: blur(3px); opacity: 0.85; }
|
||||
80% { filter: blur(1.5px); opacity: 0.9; }
|
||||
85% { filter: blur(0.5px); opacity: 0.95; }
|
||||
88% { filter: blur(1px); opacity: 0.92; }
|
||||
92% { filter: blur(0px); opacity: 1; }
|
||||
}
|
||||
@keyframes twh-charIn {
|
||||
0% { opacity: 0; transform: translateY(6px) scale(0.8); }
|
||||
60% { opacity: 1; transform: translateY(-1px) scale(1.05); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes twh-hover {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-1.5px); }
|
||||
}
|
||||
@keyframes twh-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 标题 — 带对焦动画 */}
|
||||
<h1
|
||||
className="text-3xl font-bold text-[var(--glass-text-primary)] tracking-[0.08em] mb-2"
|
||||
style={{ animation: 'twh-focus-pull 8s ease-in-out infinite' }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* 终端打字机副标题 */}
|
||||
<p className="font-mono text-sm h-6 flex items-center justify-center" style={{ color: 'var(--glass-text-tertiary)' }}>
|
||||
<span className="mr-1.5 opacity-50">>_</span>
|
||||
{text.split('').map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
animationName: isNewChar(i) ? 'twh-charIn' : 'twh-hover',
|
||||
animationDuration: isNewChar(i) ? '0.25s' : '3s',
|
||||
animationTimingFunction: isNewChar(i) ? 'ease-out' : 'ease-in-out',
|
||||
animationIterationCount: isNewChar(i) ? 1 : 'infinite',
|
||||
animationFillMode: isNewChar(i) ? 'forwards' : 'none',
|
||||
animationDelay: isNewChar(i) ? '0s' : `${i * 0.08}s`,
|
||||
}}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className="inline-block w-2 h-4 ml-0.5 bg-[var(--glass-text-tertiary)] align-middle rounded-[1px]"
|
||||
style={{ animation: 'twh-blink 1s step-end infinite' }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,9 +6,80 @@
|
||||
*
|
||||
* 使用场景:首页、项目故事输入页
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback, type CSSProperties } from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
const TRIGGER_CLASSNAME = 'glass-input-base flex h-10 w-full items-center justify-between gap-2 px-2.5 transition-colors'
|
||||
const TRIGGER_TEXT_CLASSNAME = 'text-[13px] font-medium text-[var(--glass-text-primary)]'
|
||||
|
||||
const VIEWPORT_EDGE_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 280
|
||||
|
||||
function useFloatingDropdown(isOpen: boolean, minWidth: number) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<CSSProperties>({})
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current || typeof window === 'undefined') return
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
const openUpward = spaceBelow < 220 && spaceAbove > spaceBelow
|
||||
const availableSpace = openUpward ? spaceAbove : spaceBelow
|
||||
const width = Math.min(
|
||||
Math.max(rect.width, minWidth),
|
||||
viewportWidth - VIEWPORT_EDGE_GAP * 2,
|
||||
)
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_EDGE_GAP, rect.left),
|
||||
viewportWidth - width - VIEWPORT_EDGE_GAP,
|
||||
)
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width,
|
||||
maxHeight: Math.max(120, Math.min(DEFAULT_MAX_HEIGHT, availableSpace)),
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 4 }
|
||||
: { top: rect.bottom + 4 }),
|
||||
})
|
||||
}, [minWidth])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (!isOpen) return
|
||||
setPanelStyle({})
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return { triggerRef, panelRef, panelStyle }
|
||||
}
|
||||
|
||||
/** 线框比例预览块 */
|
||||
function RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
|
||||
const [w, h] = ratio.split(':').map(Number)
|
||||
@@ -38,38 +109,43 @@ export function RatioSelector({
|
||||
getUsage?: (ratio: string) => string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 300)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
}, [isOpen, panelRef, triggerRef])
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RatioShape ratio={value} size={18} selected />
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption?.label || value}</span>
|
||||
<span className={`${TRIGGER_TEXT_CLASSNAME} truncate`}>{selectedOption?.label || value}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
|
||||
style={{ minWidth: '300px' }}
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] p-3 overflow-y-auto custom-scrollbar"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{options.map((option) => {
|
||||
@@ -98,9 +174,10 @@ export function RatioSelector({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,33 +191,44 @@ export function StyleSelector({
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 320)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
}, [isOpen, panelRef, triggerRef])
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||
>
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AppIcon name="sparklesAlt" className="h-4 w-4 text-[var(--glass-accent-from)]" />
|
||||
<span className={`${TRIGGER_TEXT_CLASSNAME} truncate`}>{selectedOption.label}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] p-3"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
@@ -165,8 +253,125 @@ export function StyleSelector({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StylePresetSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: readonly { value: string; label: string; description: string }[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 260)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen, panelRef, triggerRef])
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||
title={selectedOption.label}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AppIcon name="clapperboard" className="h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||
<span className={`${TRIGGER_TEXT_CLASSNAME} min-w-0 flex-1 truncate`}>
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`h-4 w-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] p-2.5"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
|
||||
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'font-medium text-[var(--glass-text-primary)]'}`}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<AppIcon name="check" className="h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StylePresetBadge({
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-input-base relative flex h-10 w-full items-center gap-2 overflow-hidden px-2.5">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(99,102,241,0.1))',
|
||||
}}
|
||||
/>
|
||||
<AppIcon name="clapperboard" className="relative h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||
<span className="relative min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--glass-text-primary)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="relative shrink-0 rounded-full bg-[var(--glass-tone-info-bg)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--glass-tone-info-fg)]">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
170
src/components/story-input/StoryInputComposer.tsx
Normal file
170
src/components/story-input/StoryInputComposer.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, type CompositionEvent, type ReactNode } from 'react'
|
||||
import { RatioSelector, StylePresetSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
import { resolveTextareaTargetHeight } from '@/lib/ui/textarea-height'
|
||||
|
||||
interface StoryInputComposerOption {
|
||||
value: string
|
||||
label: string
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
interface StoryInputComposerStylePresetOption {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface StoryInputComposerProps {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
placeholder: string
|
||||
minRows: number
|
||||
disabled?: boolean
|
||||
maxHeightViewportRatio?: number
|
||||
topRight?: ReactNode
|
||||
footer?: ReactNode
|
||||
secondaryActions?: ReactNode
|
||||
primaryAction: ReactNode
|
||||
videoRatio: string
|
||||
onVideoRatioChange: (value: string) => void
|
||||
ratioOptions: StoryInputComposerOption[]
|
||||
getRatioUsage?: (ratio: string) => string
|
||||
artStyle: string
|
||||
onArtStyleChange: (value: string) => void
|
||||
styleOptions: StoryInputComposerOption[]
|
||||
stylePresetValue: string
|
||||
onStylePresetChange: (value: string) => void
|
||||
stylePresetOptions: readonly StoryInputComposerStylePresetOption[]
|
||||
onCompositionStart?: () => void
|
||||
onCompositionEnd?: (event: CompositionEvent<HTMLTextAreaElement>) => void
|
||||
textareaClassName?: string
|
||||
}
|
||||
|
||||
export default function StoryInputComposer({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
minRows,
|
||||
disabled = false,
|
||||
maxHeightViewportRatio = 0.5,
|
||||
topRight,
|
||||
footer,
|
||||
secondaryActions,
|
||||
primaryAction,
|
||||
videoRatio,
|
||||
onVideoRatioChange,
|
||||
ratioOptions,
|
||||
getRatioUsage,
|
||||
artStyle,
|
||||
onArtStyleChange,
|
||||
styleOptions,
|
||||
stylePresetValue,
|
||||
onStylePresetChange,
|
||||
stylePresetOptions,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
textareaClassName,
|
||||
}: StoryInputComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaMinHeightRef = useRef<number | null>(null)
|
||||
|
||||
const autoResizeTextarea = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el || typeof window === 'undefined') return
|
||||
|
||||
const maxHeight = window.innerHeight * maxHeightViewportRatio
|
||||
const oldHeight = el.offsetHeight
|
||||
const oldScrollTop = el.scrollTop
|
||||
|
||||
if (textareaMinHeightRef.current === null && oldHeight > 0) {
|
||||
textareaMinHeightRef.current = oldHeight
|
||||
}
|
||||
|
||||
const minHeight = textareaMinHeightRef.current ?? oldHeight
|
||||
|
||||
el.style.transition = 'none'
|
||||
el.style.height = 'auto'
|
||||
const scrollHeight = el.scrollHeight
|
||||
const targetHeight = resolveTextareaTargetHeight({
|
||||
minHeight,
|
||||
maxHeight,
|
||||
scrollHeight,
|
||||
})
|
||||
el.style.height = `${oldHeight}px`
|
||||
el.scrollTop = oldScrollTop
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = oldScrollTop
|
||||
el.style.transition = 'height 200ms ease-out'
|
||||
el.style.height = `${targetHeight}px`
|
||||
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||||
})
|
||||
}, [maxHeightViewportRatio])
|
||||
|
||||
useEffect(() => {
|
||||
autoResizeTextarea()
|
||||
}, [value, autoResizeTextarea])
|
||||
|
||||
return (
|
||||
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
||||
<div className="p-6 pb-0">
|
||||
{topRight && (
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
{topRight}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
placeholder={placeholder}
|
||||
rows={minRows}
|
||||
disabled={disabled}
|
||||
className={`w-full resize-none border-none bg-transparent text-base text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] custom-scrollbar ${textareaClassName ?? 'p-5 pb-3'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto px-5 pb-4">
|
||||
<div className="flex min-w-max flex-1 items-center gap-2">
|
||||
<div className="w-[118px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={onVideoRatioChange}
|
||||
options={ratioOptions}
|
||||
getUsage={getRatioUsage}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[132px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={onArtStyleChange}
|
||||
options={styleOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[152px] flex-shrink-0">
|
||||
<StylePresetSelector
|
||||
value={stylePresetValue}
|
||||
onChange={onStylePresetChange}
|
||||
options={stylePresetOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-max items-center gap-2">
|
||||
{secondaryActions}
|
||||
{primaryAction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-6 pb-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user