feat(ui): 新增文件名截断工具及下拉菜单文本溢出优化

- 新增 truncateMiddle 工具函数,支持中英文混合字符串按视觉宽度截断
- artifact-file-list 和 dropdown-selector 应用截断处理,避免长文本溢出
- dropdown-menu 和 ArtifactTitle 添加文本溢出省略样式
This commit is contained in:
肖应宇 2026-03-24 10:28:50 +08:00
parent fd43a7ae68
commit ff0c25db54
6 changed files with 69 additions and 16 deletions

View File

@ -63,7 +63,7 @@ export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<div
className={cn("text-foreground text-sm font-medium", className)}
className={cn("text-foreground flex justify-center w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium", className)}
{...props}
/>
);

View File

@ -130,7 +130,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 whitespace-nowrap rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none overflow-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}

View File

@ -7,6 +7,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn, truncateMiddle } from "@/lib/utils";
export interface DropdownSelectorOption<T extends string> {
value: T;
@ -76,26 +77,30 @@ export function DropdownSelector<T extends string>({
<DropdownMenuTrigger
className={
triggerClassName ??
"border-none bg-transparent shadow-none select-none focus:outline-none"
"border-none bg-transparent flex justify-center w-full overflow-hidden text-ellipsis whitespace-nowrap shadow-none select-none focus:outline-none"
}
>
<span className="flex items-center gap-1">
{selectedOption?.label ?? value}
<span className="flex w-full justify-center items-center gap-1">
{truncateMiddle(selectedOption?.label ?? value, 50)}
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent className={contentClassName}>
<DropdownMenuContent className={cn(contentClassName, "max-w-80")}>
<DropdownMenuRadioGroup
value={value}
onValueChange={(v) => onChange(v as T)}
>
{options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
<DropdownMenuRadioItem
key={option.value}
value={option.value}
title={option.label}
>
{truncateMiddle(option.label)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@ -34,7 +34,7 @@ import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { useMarkdownDownload } from "@/core/utils/markdown-download";
import { cn, copyToClipboard } from "@/lib/utils";
import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
@ -236,7 +236,7 @@ export function ArtifactFileDetail({
<div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
<div className=" w-full overflow-hidden text-ellipsis whitespace-nowrap px-2">{truncateMiddle(getFileName(filepath), 50)}</div>
) : (
<DropdownSelector
value={filepath}

View File

@ -18,7 +18,7 @@ import {
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { cn, truncateMiddle } from "@/lib/utils";
import { useArtifacts } from "./context";
@ -80,12 +80,14 @@ export function ArtifactFileList({
onClick={() => handleClick(file)}
>
<CardHeader className="pr-2 pl-1">
<CardTitle className="relative pl-8">
<div className="text-sm font-normal">{getFileName(file)}</div>
<div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
<CardTitle className=" relative pl-8 overflow-hidden">
<div className=" text-ellipsis whitespace-nowrap text-sm font-normal" title={getFileName(file)}>
{truncateMiddle(getFileName(file), 50)}
</div>
</CardTitle>
<div className="absolute top-5 left-3">
{getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
</div>
<CardDescription className="pl-8 text-xs">
{getFileExtensionDisplayName(file)} file
</CardDescription>

View File

@ -33,3 +33,49 @@ export async function copyToClipboard(text: string): Promise<void> {
console.log("[copyToClipboard] direct mode", message);
await navigator.clipboard.writeText(text);
}
/**
* 21
*/
export function getVisualWidth(text: string): number {
let width = 0;
for (const char of text) {
// 中文字符范围:\u4e00-\u9fff基本汉字
width += /[\u4e00-\u9fff]/.test(char) ? 2 : 1;
}
return width;
}
/**
*
* : "very-long-file-name.txt" -> "very-lon...me.txt"
* 21
*/
export function truncateMiddle(text: string, maxVisualWidth: number = 30): string {
const visualWidth = getVisualWidth(text);
if (visualWidth <= maxVisualWidth) return text;
const startWidth = Math.ceil(maxVisualWidth * 0.6);
const endWidth = Math.floor(maxVisualWidth * 0.4) - 3; // -3 for "..."
let startPart = "";
let currentWidth = 0;
for (const char of text) {
const charWidth = /[\u4e00-\u9fff]/.test(char) ? 2 : 1;
if (currentWidth + charWidth > startWidth) break;
startPart += char;
currentWidth += charWidth;
}
let endPart = "";
currentWidth = 0;
for (let i = text.length - 1; i >= 0; i--) {
const char = text[i]!;
const charWidth = /[\u4e00-\u9fff]/.test(char) ? 2 : 1;
if (currentWidth + charWidth > endWidth) break;
endPart = char + endPart;
currentWidth += charWidth;
}
return `${startPart}...${endPart}`;
}