d4c29a1bf4
* fix(ci): fix release workflow artifact upload issue - Split artifact upload into 3 separate steps (Chrome, Firefox, Source) - Update download steps to match new artifact names - Add if-no-files-found: error for better error handling - Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern * docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 * docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 * fix: remove unnecessary animations from StorageCleaner page - Remove entrance animations (animate-in, fade-in, zoom-in) from all components - Remove scale effect (active:scale-[0.99]) from clean button - Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle - Remove bouncing animation from error icon - Remove pulsing animation from warning banner - Keep animate-spin on button loading spinner as functional indicator * fix: remove unnecessary animations from RightClickRestorer page * fix: remove all unnecessary animations from all pages - Remove dead code animations (animate-in, fade-in, slide-in, zoom-in, shake) from tailwindcss-animate plugin (not installed) - Remove decorative transition effects (transition-all, transition-colors) from all page components - Remove scale effects (active:scale-95) from buttons - Remove bounce animations (animate-bounce) from icons - Keep functional animate-spin on loading spinners as they provide essential loading feedback Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics * docs: 添加项目文档(copilot-instructions、编码规范、各目录 README) - 新增 .github/copilot-instructions.md:Copilot 指令文件,涵盖构建命令、CI 流水线、项目架构、关键规范 - 新增 .github/CODING_STANDARDS.md:完整的代码编写规范文档(TypeScript、React、样式、错误处理、命名、测试、Hook、i18n、存储等 12 个章节) - 新增 11 个目录的 README.md:components、components/ui、config、entrypoints、i18n、lib、pages、providers、public、src、types、utils - 更新 AGENTS.md:补充页面组件模式说明和 components/ui 目录 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): use shadcn buttonVariants instead of duplicated variant classes Remove hand-written variantClasses/sizeClasses that duplicated shadcn's buttonVariants. Now extends ButtonProps and imports buttonVariants from components/ui/button for consistent styling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): use shadcn destructive tokens instead of hardcoded red colors Replace hardcoded red-200/red-50/red-600 with border-destructive, bg-destructive/5, text-destructive. Use variant='destructive' on Button. Align error log styling with PageErrorBoundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TextInputArea): replace hardcoded Chinese strings with i18n t() calls Use existing translation keys for clear success, copy success, and copy error messages instead of hardcoded Chinese text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(QrCodePreview): use shadcn Button instead of manual button styles Replace hand-written button class strings with shadcn Button component using outline and default variants. Removes ~15 lines of duplicated Tailwind classes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(GlobalSnackbar): clarify relationship with sonner toast Add note explaining when to use GlobalSnackbar (provider context, severity levels, custom positioning) vs sonner toast (simple one-off messages). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ImageUploader): wrap handleClearFile with useCallback Prevents unnecessary re-renders by memoizing the callback, consistent with handleFileChange which already uses useCallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(SwitchButtonGroup): remove redundant containerPadding conditional Both branches of the ternary returned 'p-1'. Inline the constant directly into the className string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add missing openExtensionPage import The function was called in handleOpenInTab but never imported from utils/chromeTabs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add selectedIndex boundary protection in keyboard nav Guard against out-of-bounds access when search results change during keyboard navigation. Check selectedIndex < totalItems before accessing arrays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): remove unnecessary empty-dep useMemo getEntryPointType() is a simple config getter. Replace useMemo(() => ..., []) with a direct call — the empty dep array made the memo pointless. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(i18n): add missing translation keys for error boundaries and copy messages Add keys for errorBoundary, pageErrorBoundary, router, and messages.copyEmpty in both zh and en locales. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): replace hardcoded Chinese strings with i18n t() calls Use useTranslation('common') for tooltip, copy empty/success/error toast messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and refresh button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(PageErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and retry button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): replace hardcoded Chinese with i18n t() calls Use useTranslation('common') for 404 page title and description with entryPointType interpolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码 - 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm) - 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232) - 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools) - MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式 - 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码 (FileMode.tsx, ImageMode.tsx 及其测试文件) - 更新 README.md 移除对已删除文件的引用 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 * refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 * fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常 --------- Co-authored-by: Ubuntu <ubuntu@localhost.localdomain> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
313 lines
9.8 KiB
TypeScript
313 lines
9.8 KiB
TypeScript
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { cn } from '@/lib/utils';
|
|
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
|
|
import { CopyButton } from '@/components/CopyButton';
|
|
|
|
export type ValidateRule = {
|
|
validator: (value: string) => boolean;
|
|
message: string;
|
|
};
|
|
|
|
export type ToolbarAction = {
|
|
key: string;
|
|
label: string;
|
|
icon?: React.ReactNode;
|
|
position?: 'top' | 'bottom';
|
|
type?: 'primary' | 'default' | 'danger';
|
|
disabled?: boolean | ((value: string) => boolean);
|
|
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
|
};
|
|
|
|
export interface TextInputAreaProps extends Omit<
|
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
'onChange'
|
|
> {
|
|
value?: string;
|
|
defaultValue?: string;
|
|
|
|
/** 值变化回调,返回最新的字符串内容 */
|
|
onChange?: (value: string) => void;
|
|
|
|
minRows?: number;
|
|
maxRows?: number;
|
|
showCount?: boolean;
|
|
showClear?: boolean;
|
|
allowCopy?: boolean;
|
|
rules?: ValidateRule[];
|
|
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
|
actions?: ToolbarAction[];
|
|
topExtra?: React.ReactNode;
|
|
title?: string;
|
|
externalError?: string;
|
|
onClear?: () => void;
|
|
}
|
|
|
|
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
|
|
function ActionButton({
|
|
action,
|
|
value,
|
|
globalDisabled,
|
|
onAction,
|
|
}: {
|
|
action: ToolbarAction;
|
|
value: string;
|
|
globalDisabled: boolean;
|
|
onAction: (action: ToolbarAction) => void;
|
|
}) {
|
|
const isBtnDisabled =
|
|
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
|
|
|
|
const variantClasses = {
|
|
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
|
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
|
default:
|
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
|
};
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onAction(action)}
|
|
disabled={isBtnDisabled || globalDisabled}
|
|
className={cn(
|
|
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
|
'disabled:pointer-events-none disabled:opacity-50',
|
|
variantClasses[action.type || 'default'],
|
|
)}
|
|
>
|
|
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
|
{action.label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
|
const {
|
|
value: controlledValue,
|
|
defaultValue = '',
|
|
onChange,
|
|
placeholder: placeholderProp,
|
|
disabled = false,
|
|
readOnly = false,
|
|
autoFocus = false,
|
|
minRows = 4,
|
|
maxRows = 12,
|
|
maxLength,
|
|
className,
|
|
showCount = false,
|
|
showClear = true,
|
|
allowCopy = false,
|
|
rules = [],
|
|
validateTrigger = 'onAction',
|
|
actions = [],
|
|
topExtra,
|
|
title,
|
|
externalError,
|
|
onClear,
|
|
...restProps
|
|
} = props;
|
|
|
|
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
const [error, setError] = useState<string>('');
|
|
|
|
const { t } = useTranslation('common');
|
|
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
|
|
|
const isControlled = controlledValue !== undefined;
|
|
const value = isControlled ? controlledValue : internalValue;
|
|
const displayError = externalError ?? error;
|
|
|
|
// 双向合并 ref 指针
|
|
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
|
|
|
|
// 1. 高性能的动态高度自适应计算
|
|
const adjustHeight = useCallback(() => {
|
|
const textArea = internalRef.current;
|
|
if (!textArea) return;
|
|
|
|
// 重置高度计算
|
|
textArea.style.height = 'auto';
|
|
|
|
const computedMin = minRows * 24; // 每行粗略按 24px 计算
|
|
const computedMax = maxRows * 24;
|
|
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
|
|
|
|
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
|
|
}, [minRows, maxRows]);
|
|
|
|
// 当数值改变时自适应扩展
|
|
React.useEffect(() => {
|
|
adjustHeight();
|
|
}, [value, adjustHeight]);
|
|
|
|
const validate = useCallback(
|
|
(val: string, trigger?: string): boolean => {
|
|
if (validateTrigger !== trigger && trigger) return true;
|
|
for (const rule of rules) {
|
|
if (!rule.validator(val)) {
|
|
setError(rule.message);
|
|
return false;
|
|
}
|
|
}
|
|
setError('');
|
|
return true;
|
|
},
|
|
[rules, validateTrigger],
|
|
);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newVal = e.target.value;
|
|
if (maxLength && newVal.length > maxLength) {
|
|
const msg = t('charCount', { count: maxLength });
|
|
setError(msg);
|
|
toast.warning(msg);
|
|
return;
|
|
}
|
|
|
|
if (!isControlled) setInternalValue(newVal);
|
|
onChange?.(newVal);
|
|
|
|
if (error) setError('');
|
|
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
|
};
|
|
|
|
const handleClear = useCallback(() => {
|
|
if (!isControlled) setInternalValue('');
|
|
onChange?.('');
|
|
setError('');
|
|
internalRef.current?.focus();
|
|
toast.success(t('textInputArea.cleared'));
|
|
onClear?.();
|
|
}, [isControlled, onChange, onClear, t]);
|
|
|
|
const handleAction = useCallback(
|
|
(action: ToolbarAction) => {
|
|
const isDisabled =
|
|
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
|
if (isDisabled || disabled) return;
|
|
|
|
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
|
|
|
|
action.onClick(value, {
|
|
clear: handleClear,
|
|
setError,
|
|
});
|
|
},
|
|
[value, disabled, validate, validateTrigger, handleClear],
|
|
);
|
|
|
|
const topActions = actions.filter((a) => a.position !== 'bottom');
|
|
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
|
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
|
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
|
|
|
|
return (
|
|
<div className={cn('w-full flex flex-col gap-1.5', className)}>
|
|
{hasTopBar && (
|
|
<div className="flex items-center justify-between px-0.5">
|
|
<div className="flex items-center gap-2">
|
|
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
|
|
{topExtra}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{topActions.map((action) => (
|
|
<ActionButton
|
|
key={action.key}
|
|
action={action}
|
|
value={value}
|
|
globalDisabled={disabled}
|
|
onAction={handleAction}
|
|
/>
|
|
))}
|
|
{showCount && (
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{value.length}
|
|
{maxLength ? ` / ${maxLength}` : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
|
|
displayError &&
|
|
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
|
|
)}
|
|
>
|
|
<textarea
|
|
ref={internalRef}
|
|
value={value}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
disabled={disabled}
|
|
autoFocus={autoFocus}
|
|
readOnly={readOnly}
|
|
placeholder={placeholder}
|
|
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
|
|
{...restProps}
|
|
/>
|
|
|
|
{hasBottomBar && (
|
|
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
|
|
{/* 左侧自定义动作 */}
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{bottomActions.map((action) => (
|
|
<ActionButton
|
|
key={action.key}
|
|
action={action}
|
|
value={value}
|
|
globalDisabled={disabled}
|
|
onAction={handleAction}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* 右侧系统按钮组 */}
|
|
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
|
{allowCopy && value && (
|
|
<CopyButton
|
|
text={value}
|
|
tooltip={t('textInputArea.copyContent')}
|
|
size="sm"
|
|
className="h-7 w-7 p-1"
|
|
/>
|
|
)}
|
|
{showClear && value && !disabled && !readOnly && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
aria-label={t('textInputArea.clear')}
|
|
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 错误提示 */}
|
|
{displayError && (
|
|
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
|
{displayError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
TextInputArea.displayName = 'TextInputArea';
|
|
|
|
export default TextInputArea;
|