From bcaf6372534761fffe129882a76a96d66f09ced2 Mon Sep 17 00:00:00 2001 From: Xvvln <3369759202@qq.com> Date: Sun, 22 Mar 2026 18:52:32 +0800 Subject: [PATCH] feat(config): add expandable input for long payload override values - Add ExpandableInput component that toggles between single-line and auto-resizing for long content (>30 chars) - Apply to all payload rule fields: model name (both layout branches), json_path, param value, filter model name, and string list items - Strip newline characters in both input and textarea onChange handlers to prevent multi-line values from breaking YAML serialization - Use useLayoutEffect for resize to avoid visual flicker - Remove redundant autoResize call from click handler (handled by effect) - Disable expand/collapse buttons when field is disabled, with proper cursor and opacity styles - Localize button labels via i18n (en, zh-CN, ru) - Use explicit .expandableInputExpanded class instead of CSS :has() for cross-browser compatibility --- .../config/VisualConfigEditor.module.scss | 55 +++++++ .../config/VisualConfigEditorBlocks.tsx | 148 +++++++++++++++--- src/i18n/locales/en.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + 5 files changed, 183 insertions(+), 26 deletions(-) diff --git a/src/components/config/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss index e6c3287..de2fd88 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 && ( + { + setCollapsed(false); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + }} + title={t('common.expand')} + aria-label={t('common.expand')} + > + ▼ + + )} + + ); + } + + return ( + + + setCollapsed(true)} + title={t('common.collapse')} + aria-label={t('common.collapse')} + > + ▲ + + + ); +} + function getValidationMessage( t: ReturnType['t'], errorCode?: PayloadParamValidationErrorCode @@ -325,14 +428,12 @@ const StringListEditor = memo(function StringListEditor({ {items.map((item, index) => ( - updateItem(index, e.target.value)} + onChange={(nextValue) => updateItem(index, nextValue)} disabled={disabled} - style={{ flex: 1 }} /> removeItem(index)} disabled={disabled}> {t('config_management.visual.common.delete')} @@ -508,12 +609,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ } return ( - updateParam(ruleIndex, paramIndex, { value: e.target.value })} + onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })} disabled={disabled} /> ); @@ -564,23 +664,21 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ }) } /> - updateModel(ruleIndex, modelIndex, { name: e.target.value })} + onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })} disabled={disabled} /> > ) : ( <> - updateModel(ruleIndex, modelIndex, { name: e.target.value })} + onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })} disabled={disabled} /> - updateParam(ruleIndex, paramIndex, { path: e.target.value })} + onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })} disabled={disabled} /> {rawJsonValues ? null : ( @@ -767,12 +864,11 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({ {rule.models.map((model, modelIndex) => ( - updateModel(ruleIndex, modelIndex, { name: e.target.value })} + onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })} disabled={disabled} />