diff --git a/src/components/config/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss index d727749..832148b 100644 --- a/src/components/config/VisualConfigEditor.module.scss +++ b/src/components/config/VisualConfigEditor.module.scss @@ -72,6 +72,61 @@ } } +.expandableInputWrapper { + position: relative; + display: flex; + align-items: flex-start; + min-width: 0; + flex: 1; +} + +.expandableInputWrapper > .expandableTextarea, +.expandableInputWrapper > :global(.input) { + flex: 1; + min-width: 0; + padding-right: 28px; +} + +.expandableTextarea { + resize: none; + min-height: 60px; + overflow: hidden; + line-height: 1.5; + padding-right: 32px; +} + +.expandableToggle { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 10px; + line-height: 1; + padding: 2px; + color: var(--text-secondary, #999); + opacity: 0.5; + transition: opacity 0.15s; + z-index: 1; + + &:hover { + opacity: 1; + } + + &:disabled { + cursor: default; + opacity: 0.35; + } +} + +.expandableInputExpanded .expandableToggle { + top: 8px; + transform: none; + right: 14px; +} + .overview { position: relative; overflow: hidden; diff --git a/src/components/config/VisualConfigEditorBlocks.tsx b/src/components/config/VisualConfigEditorBlocks.tsx index fdf1301..6f24a15 100644 --- a/src/components/config/VisualConfigEditorBlocks.tsx +++ b/src/components/config/VisualConfigEditorBlocks.tsx @@ -1,4 +1,4 @@ -import { memo, useId, useMemo, useState } from 'react'; +import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; @@ -23,6 +23,109 @@ import { import { maskApiKey } from '@/utils/format'; import { isValidApiKeyCharset } from '@/utils/validation'; +/** Minimum character count before the expand/collapse toggle appears. */ +const EXPAND_THRESHOLD = 30; + +/** Auto-expanding textarea that collapses back to a single-line input on demand. */ +function ExpandableInput({ + value, + placeholder, + ariaLabel, + disabled, + className, + onChange, +}: { + value: string; + placeholder?: string; + ariaLabel?: string; + disabled?: boolean; + className?: string; + onChange: (nextValue: string) => void; +}) { + const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState(true); + const textareaRef = useRef(null); + + const autoResize = useCallback((el: HTMLTextAreaElement) => { + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, []); + + const handleChange = (e: React.ChangeEvent) => { + // Strip newlines — these fields are single-line identifiers/paths that + // would break YAML serialization if they contained line breaks. + const sanitized = e.target.value.replace(/[\r\n]/g, ''); + onChange(sanitized); + // autoResize is handled by useLayoutEffect after React syncs the + // sanitized value back to the DOM — calling it here would measure + // stale content. + }; + + // Resize synchronously before paint to avoid visual flicker. + useLayoutEffect(() => { + if (!collapsed && textareaRef.current) { + autoResize(textareaRef.current); + } + }, [collapsed, value, autoResize]); + + if (collapsed) { + return ( +
+ onChange(e.target.value.replace(/[\r\n]/g, ''))} + disabled={disabled} + /> + {value.length > EXPAND_THRESHOLD && ( + + )} +
+ ); + } + + return ( +
+