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) => (
|
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||||
<div
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ function DropdownMenuRadioItem({
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn, truncateMiddle } from "@/lib/utils";
|
||||||
|
|
||||||
export interface DropdownSelectorOption<T extends string> {
|
export interface DropdownSelectorOption<T extends string> {
|
||||||
value: T;
|
value: T;
|
||||||
|
|
@ -76,22 +77,26 @@ export function DropdownSelector<T extends string>({
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
className={
|
className={
|
||||||
triggerClassName ??
|
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">
|
<span className="flex w-full justify-center items-center gap-1">
|
||||||
{selectedOption?.label ?? value}
|
{truncateMiddle(selectedOption?.label ?? value, 50)}
|
||||||
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className={contentClassName}>
|
<DropdownMenuContent className={cn(contentClassName, "max-w-80")}>
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={(v) => onChange(v as T)}
|
onValueChange={(v) => onChange(v as T)}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
<DropdownMenuRadioItem
|
||||||
{option.label}
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
title={option.label}
|
||||||
|
>
|
||||||
|
{truncateMiddle(option.label)}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||||
import { useMarkdownDownload } from "@/core/utils/markdown-download";
|
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";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ export function ArtifactFileDetail({
|
||||||
<div className="flex min-w-0 grow items-center justify-center">
|
<div className="flex min-w-0 grow items-center justify-center">
|
||||||
<ArtifactTitle>
|
<ArtifactTitle>
|
||||||
{isWriteFile ? (
|
{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
|
<DropdownSelector
|
||||||
value={filepath}
|
value={filepath}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
getFileName,
|
getFileName,
|
||||||
} from "@/core/utils/files";
|
} from "@/core/utils/files";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, truncateMiddle } from "@/lib/utils";
|
||||||
|
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
|
|
||||||
|
|
@ -80,12 +80,14 @@ export function ArtifactFileList({
|
||||||
onClick={() => handleClick(file)}
|
onClick={() => handleClick(file)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pr-2 pl-1">
|
<CardHeader className="pr-2 pl-1">
|
||||||
<CardTitle className="relative pl-8">
|
<CardTitle className=" relative pl-8 overflow-hidden">
|
||||||
<div className="text-sm font-normal">{getFileName(file)}</div>
|
<div className=" text-ellipsis whitespace-nowrap text-sm font-normal" title={getFileName(file)}>
|
||||||
<div className="absolute top-2 -left-0.5">
|
{truncateMiddle(getFileName(file), 50)}
|
||||||
{getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
|
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="absolute top-5 left-3">
|
||||||
|
{getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
|
||||||
|
</div>
|
||||||
<CardDescription className="pl-8 text-xs">
|
<CardDescription className="pl-8 text-xs">
|
||||||
{getFileExtensionDisplayName(file)} file
|
{getFileExtensionDisplayName(file)} file
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,49 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||||
console.log("[copyToClipboard] direct mode", message);
|
console.log("[copyToClipboard] direct mode", message);
|
||||||
await navigator.clipboard.writeText(text);
|
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