90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
import { clsx, type ClassValue } from "clsx";
|
||
import { twMerge } from "tailwind-merge";
|
||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||
|
||
export function cn(...inputs: ClassValue[]) {
|
||
return twMerge(clsx(inputs));
|
||
}
|
||
|
||
/** Shared class for external links (underline by default). */
|
||
export const externalLinkClass =
|
||
"text-primary underline underline-offset-2 hover:no-underline";
|
||
/** Link style without underline by default (e.g. for streaming/loading). */
|
||
export const externalLinkClassNoUnderline = "text-primary hover:underline";
|
||
|
||
/**
|
||
* Copy text to clipboard, using postMessage when in iframe.
|
||
* In iframe context, sends message to parent window to handle clipboard operation.
|
||
*/
|
||
export async function copyToClipboard(text: string): Promise<void> {
|
||
const isInIframe = window.self !== window.top;
|
||
const message = {
|
||
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
|
||
text,
|
||
} as const;
|
||
|
||
if (isInIframe) {
|
||
try {
|
||
// Request parent window to copy
|
||
sendToParent(message);
|
||
console.log(
|
||
"[copyToClipboard] iframe mode → postMessage to parent",
|
||
message,
|
||
);
|
||
return;
|
||
} catch (error) {
|
||
console.warn("[copyToClipboard] iframe postMessage failed", error);
|
||
}
|
||
}
|
||
|
||
// Direct clipboard access when not in iframe
|
||
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 = 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}`;
|
||
}
|