feat(ui): 新增文件名截断工具及下拉菜单文本溢出优化
- 新增 truncateMiddle 工具函数,支持中英文混合字符串按视觉宽度截断 - artifact-file-list 和 dropdown-selector 应用截断处理,避免长文本溢出 - dropdown-menu 和 ArtifactTitle 添加文本溢出省略样式
This commit is contained in:
parent
fd43a7ae68
commit
ff0c25db54
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -33,3 +33,49 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|||
console.log("[copyToClipboard] direct mode", message);
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串的视觉宽度(中文算2,英文算1)
|
||||
*/
|
||||
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"
|
||||
* 中文按视觉宽度计算(中文算2,英文算1)
|
||||
*/
|
||||
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}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue