feat(04): stabilize iframe messaging and markdown export flows
This commit is contained in:
parent
931c418c87
commit
643b61d15a
|
|
@ -30,14 +30,14 @@ export function useThreadChat() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const stored = window.sessionStorage.getItem("workspace.thread_id");
|
const stored = window.sessionStorage.getItem("workspace.thread_id");
|
||||||
return stored && stored !== "new" ? stored : undefined;
|
return isValidThreadId(stored) ? stored : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
// 读取 query 的 thread_id(先用 hook,必要时用 window 兜底)。
|
// 读取 query 的 thread_id(先用 hook,必要时用 window 兜底)。
|
||||||
const readQueryThreadId = () => {
|
const readQueryThreadId = () => {
|
||||||
const fromHook = searchParams.get("thread_id")?.trim();
|
const fromHook = searchParams.get("thread_id")?.trim();
|
||||||
if (fromHook && fromHook !== "new") {
|
if (isValidThreadId(fromHook)) {
|
||||||
return fromHook;
|
return fromHook;
|
||||||
}
|
}
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
|
|
@ -46,7 +46,7 @@ export function useThreadChat() {
|
||||||
const fromLocation = new URLSearchParams(window.location.search).get(
|
const fromLocation = new URLSearchParams(window.location.search).get(
|
||||||
"thread_id",
|
"thread_id",
|
||||||
);
|
);
|
||||||
if (fromLocation && fromLocation !== "new") {
|
if (isValidThreadId(fromLocation)) {
|
||||||
return fromLocation.trim();
|
return fromLocation.trim();
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -113,3 +113,14 @@ export function useThreadChat() {
|
||||||
invalidNewRoute,
|
invalidNewRoute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidThreadId(value?: string | null): value is string {
|
||||||
|
if (!value) return false;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.length > 0 &&
|
||||||
|
normalized !== "new" &&
|
||||||
|
normalized !== "undefined" &&
|
||||||
|
normalized !== "null"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import type { AgentThread } from "@/core/threads/types";
|
||||||
import { useThread } from "./messages/context";
|
import { useThread } from "./messages/context";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
export function ExportTrigger({ threadId }: { threadId: string }) {
|
export function ExportTrigger({ threadId }: { threadId?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
|
|
||||||
|
|
@ -39,17 +39,23 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||||
values: thread.values,
|
values: thread.values,
|
||||||
} as AgentThread;
|
} as AgentThread;
|
||||||
|
|
||||||
|
try {
|
||||||
if (format === "markdown") {
|
if (format === "markdown") {
|
||||||
exportThreadAsMarkdown(agentThread, messages);
|
exportThreadAsMarkdown(agentThread, messages);
|
||||||
} else {
|
} else {
|
||||||
exportThreadAsJSON(agentThread, messages);
|
exportThreadAsJSON(agentThread, messages);
|
||||||
}
|
}
|
||||||
toast.success(t.common.exportSuccess);
|
toast.success(t.common.exportSuccess);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to export";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[messages, thread.values, threadId, t],
|
[messages, thread.values, threadId, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (!threadId || messages.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* iframe 与宿主页通信消息类型常量
|
||||||
|
*
|
||||||
|
* 消息格式:{ type: MESSAGE_TYPE, ...其他字段 }
|
||||||
|
* 发送方式:window.parent.postMessage(message, "*")
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 发送给宿主页的消息类型
|
||||||
|
export const POST_MESSAGE_TYPES = {
|
||||||
|
// 全屏切换
|
||||||
|
FULLSCREEN: "fullscreen",
|
||||||
|
// XClaw 使用状态
|
||||||
|
XCLAW_USED: "XClawUsed",
|
||||||
|
// 选择预定义 skill
|
||||||
|
SELECT_SKILL: "selectSkill",
|
||||||
|
// 打开 skill 选择对话框
|
||||||
|
OPEN_SKILL_DIALOG: "openSkillDialog",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 接收来自宿主页的消息类型
|
||||||
|
export const RECEIVE_MESSAGE_TYPES = {
|
||||||
|
// 选中的 skill 数据
|
||||||
|
SELECTED_SKILL: "selectedSkill",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 消息类型
|
||||||
|
export type PostMessageType =
|
||||||
|
(typeof POST_MESSAGE_TYPES)[keyof typeof POST_MESSAGE_TYPES];
|
||||||
|
export type ReceiveMessageType =
|
||||||
|
(typeof RECEIVE_MESSAGE_TYPES)[keyof typeof RECEIVE_MESSAGE_TYPES];
|
||||||
|
|
||||||
|
// 消息数据类型
|
||||||
|
export interface FullscreenMessage {
|
||||||
|
type: typeof POST_MESSAGE_TYPES.FULLSCREEN;
|
||||||
|
fullscreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XClawUsedMessage {
|
||||||
|
type: typeof POST_MESSAGE_TYPES.XCLAW_USED;
|
||||||
|
XClawUsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectSkillMessage {
|
||||||
|
type: typeof POST_MESSAGE_TYPES.SELECT_SKILL;
|
||||||
|
skill_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenSkillDialogMessage {
|
||||||
|
type: typeof POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG;
|
||||||
|
openSkillDialog: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedSkillMessage {
|
||||||
|
type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL;
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function asRecord(value: unknown): UnknownRecord | null {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as UnknownRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMessage {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { id, title } = record;
|
||||||
|
const isValidId = typeof id === "string" || typeof id === "number";
|
||||||
|
return isValidId && typeof title === "string" && title.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息的辅助函数
|
||||||
|
export function sendToParent(
|
||||||
|
message:
|
||||||
|
| FullscreenMessage
|
||||||
|
| XClawUsedMessage
|
||||||
|
| SelectSkillMessage
|
||||||
|
| OpenSkillDialogMessage,
|
||||||
|
): void {
|
||||||
|
console.log("[iframe] sendToParent:", message);
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage(message, "*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -113,15 +113,21 @@ export function downloadAsFile(
|
||||||
filename: string,
|
filename: string,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
) {
|
) {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
throw new Error("Download is only supported in browser environment.");
|
||||||
|
}
|
||||||
const blob = new Blob([content], { type: mimeType });
|
const blob = new Blob([content], { type: mimeType });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
} finally {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportThreadAsMarkdown(
|
export function exportThreadAsMarkdown(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,507 @@
|
||||||
|
import {
|
||||||
|
Document as DocxDocument,
|
||||||
|
Packer,
|
||||||
|
Paragraph,
|
||||||
|
TextRun,
|
||||||
|
HeadingLevel,
|
||||||
|
} from "docx";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown Token 类型(简化版)
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type MarkdownToken = any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF 转换选项
|
||||||
|
*/
|
||||||
|
export interface PdfOptions {
|
||||||
|
/**
|
||||||
|
* 页边距 [上, 右, 下, 左],单位 mm
|
||||||
|
* @default [15, 15, 15, 15]
|
||||||
|
*/
|
||||||
|
margin?: [number, number, number, number];
|
||||||
|
/**
|
||||||
|
* 页面格式
|
||||||
|
* @default "a4"
|
||||||
|
*/
|
||||||
|
format?: "a3" | "a4" | "a5" | "letter" | "legal";
|
||||||
|
/**
|
||||||
|
* 页面方向
|
||||||
|
* @default "portrait"
|
||||||
|
*/
|
||||||
|
orientation?: "portrait" | "landscape";
|
||||||
|
/**
|
||||||
|
* 缩放比例
|
||||||
|
* @default 2
|
||||||
|
*/
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOCX 转换选项
|
||||||
|
*/
|
||||||
|
export interface DocxOptions {
|
||||||
|
/**
|
||||||
|
* 代码块字体
|
||||||
|
* @default "Courier New"
|
||||||
|
*/
|
||||||
|
codeFont?: string;
|
||||||
|
/**
|
||||||
|
* 代码块字号(半磅)
|
||||||
|
* @default 22 (11pt)
|
||||||
|
*/
|
||||||
|
codeFontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DOCX Converter
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 内容转换为 DOCX 文件并下载
|
||||||
|
*
|
||||||
|
* @param markdown - Markdown 文本内容
|
||||||
|
* @param filename - 文件名(不含扩展名,或包含 .md 扩展名)
|
||||||
|
* @param options - 转换选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await downloadMarkdownAsDocx("# Hello World", "document");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadMarkdownAsDocx(
|
||||||
|
markdown: string,
|
||||||
|
filename: string,
|
||||||
|
options: DocxOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const { codeFont = "Courier New", codeFontSize = 22 } = options;
|
||||||
|
|
||||||
|
const tokens = marked.lexer(markdown);
|
||||||
|
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize });
|
||||||
|
|
||||||
|
const doc = new DocxDocument({
|
||||||
|
sections: [{ children }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await Packer.toBlob(doc);
|
||||||
|
downloadBlob(blob, normalizeFilename(filename, ".docx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PDF Converter
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 内容转换为 PDF 文件并下载
|
||||||
|
*
|
||||||
|
* @param markdown - Markdown 文本内容
|
||||||
|
* @param filename - 文件名(不含扩展名,或包含 .md 扩展名)
|
||||||
|
* @param options - 转换选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await downloadMarkdownAsPdf("# Hello World", "document");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadMarkdownAsPdf(
|
||||||
|
markdown: string,
|
||||||
|
filename: string,
|
||||||
|
options: PdfOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const html2pdf = await loadHtml2Pdf();
|
||||||
|
|
||||||
|
const {
|
||||||
|
margin = [15, 15, 15, 15],
|
||||||
|
format = "a4",
|
||||||
|
orientation = "portrait",
|
||||||
|
scale = 2,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 解析 Markdown 为 HTML
|
||||||
|
const htmlContent = await marked.parse(markdown);
|
||||||
|
|
||||||
|
// 创建容器并应用样式
|
||||||
|
const container = createStyledContainer(htmlContent);
|
||||||
|
|
||||||
|
// 配置 html2pdf
|
||||||
|
const opt = {
|
||||||
|
margin,
|
||||||
|
filename: normalizeFilename(filename, ".pdf"),
|
||||||
|
image: { type: "jpeg" as const, quality: 0.98 },
|
||||||
|
html2canvas: {
|
||||||
|
scale,
|
||||||
|
useCORS: true,
|
||||||
|
logging: false,
|
||||||
|
onclone: fixColorsForHtml2Canvas,
|
||||||
|
},
|
||||||
|
jsPDF: { unit: "mm" as const, format, orientation },
|
||||||
|
};
|
||||||
|
|
||||||
|
await html2pdf().set(opt).from(container).save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Internal Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态加载 html2pdf.js
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
|
async function loadHtml2Pdf(): Promise<Function> {
|
||||||
|
const html2pdf = await import("html2pdf.js");
|
||||||
|
return html2pdf.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带样式的 HTML 容器
|
||||||
|
*/
|
||||||
|
function createStyledContainer(htmlContent: string): HTMLDivElement {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.innerHTML = htmlContent;
|
||||||
|
|
||||||
|
// 容器基础样式
|
||||||
|
container.style.cssText = `
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #ffffff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 应用元素样式
|
||||||
|
applyElementStyles(container);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用元素样式
|
||||||
|
*/
|
||||||
|
function applyElementStyles(container: HTMLElement): void {
|
||||||
|
// 标题
|
||||||
|
container.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
|
||||||
|
const el = h as HTMLElement;
|
||||||
|
el.style.marginTop = "1.5em";
|
||||||
|
el.style.marginBottom = "0.5em";
|
||||||
|
el.style.fontWeight = "600";
|
||||||
|
el.style.color = "#1a1a1a";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 段落
|
||||||
|
container.querySelectorAll("p").forEach((p) => {
|
||||||
|
(p as HTMLElement).style.marginBottom = "1em";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 代码块
|
||||||
|
container.querySelectorAll("pre, code").forEach((code) => {
|
||||||
|
const el = code as HTMLElement;
|
||||||
|
el.style.fontFamily = "'SF Mono', 'Fira Code', Consolas, monospace";
|
||||||
|
el.style.backgroundColor = "#f5f5f5";
|
||||||
|
el.style.color = "#333333";
|
||||||
|
el.style.fontSize = "13px";
|
||||||
|
if (code.tagName === "PRE") {
|
||||||
|
el.style.padding = "12px";
|
||||||
|
el.style.borderRadius = "6px";
|
||||||
|
el.style.overflow = "auto";
|
||||||
|
} else {
|
||||||
|
el.style.padding = "2px 4px";
|
||||||
|
el.style.borderRadius = "3px";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
container.querySelectorAll("ul, ol").forEach((list) => {
|
||||||
|
const el = list as HTMLElement;
|
||||||
|
el.style.marginBottom = "1em";
|
||||||
|
el.style.paddingLeft = "2em";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 引用块
|
||||||
|
container.querySelectorAll("blockquote").forEach((bq) => {
|
||||||
|
const el = bq as HTMLElement;
|
||||||
|
el.style.borderLeft = "4px solid #dddddd";
|
||||||
|
el.style.marginLeft = "0";
|
||||||
|
el.style.paddingLeft = "16px";
|
||||||
|
el.style.color = "#666666";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格
|
||||||
|
container.querySelectorAll("table").forEach((table) => {
|
||||||
|
const el = table as HTMLElement;
|
||||||
|
el.style.borderCollapse = "collapse";
|
||||||
|
el.style.width = "100%";
|
||||||
|
el.style.marginBottom = "1em";
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll("th, td").forEach((cell) => {
|
||||||
|
const el = cell as HTMLElement;
|
||||||
|
el.style.border = "1px solid #dddddd";
|
||||||
|
el.style.padding = "8px";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 链接
|
||||||
|
container.querySelectorAll("a").forEach((link) => {
|
||||||
|
const el = link as HTMLElement;
|
||||||
|
el.style.color = "#0066cc";
|
||||||
|
el.style.textDecoration = "underline";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分割线
|
||||||
|
container.querySelectorAll("hr").forEach((hr) => {
|
||||||
|
const el = hr as HTMLElement;
|
||||||
|
el.style.border = "none";
|
||||||
|
el.style.borderTop = "1px solid #dddddd";
|
||||||
|
el.style.margin = "2em 0";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修复 html2canvas 不支持的颜色函数
|
||||||
|
*/
|
||||||
|
function fixColorsForHtml2Canvas(clonedDoc: Document): void {
|
||||||
|
// 移除外部样式表(可能包含 lab、oklab 等不支持的颜色)
|
||||||
|
clonedDoc
|
||||||
|
.querySelectorAll<
|
||||||
|
HTMLStyleElement | HTMLLinkElement
|
||||||
|
>('link[rel="stylesheet"], style')
|
||||||
|
.forEach((sheet) => sheet.remove());
|
||||||
|
|
||||||
|
// 重置所有元素的颜色属性为安全值
|
||||||
|
clonedDoc.querySelectorAll<HTMLElement>("*").forEach((el) => {
|
||||||
|
const props = [
|
||||||
|
"color",
|
||||||
|
"background-color",
|
||||||
|
"border-color",
|
||||||
|
"border-top-color",
|
||||||
|
"border-bottom-color",
|
||||||
|
"border-left-color",
|
||||||
|
"border-right-color",
|
||||||
|
"outline-color",
|
||||||
|
"text-decoration-color",
|
||||||
|
"caret-color",
|
||||||
|
"column-rule-color",
|
||||||
|
"accent-color",
|
||||||
|
"fill",
|
||||||
|
"stroke",
|
||||||
|
];
|
||||||
|
|
||||||
|
props.forEach((prop) => el.style.removeProperty(prop));
|
||||||
|
|
||||||
|
el.style.color = "#333333";
|
||||||
|
el.style.backgroundColor = "transparent";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置 body 背景
|
||||||
|
const body = clonedDoc.body;
|
||||||
|
body.style.color = "#333333";
|
||||||
|
body.style.backgroundColor = "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Markdown Token 为 DOCX Paragraph
|
||||||
|
*/
|
||||||
|
function parseTokensToDocx(
|
||||||
|
tokens: MarkdownToken[],
|
||||||
|
options: Required<DocxOptions>,
|
||||||
|
): Paragraph[] {
|
||||||
|
const paragraphs: Paragraph[] = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
switch (token.type) {
|
||||||
|
case "heading": {
|
||||||
|
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs,
|
||||||
|
heading: getHeadingLevel(token.depth),
|
||||||
|
spacing: { before: 240, after: 120 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "paragraph": {
|
||||||
|
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||||
|
spacing: { after: 200 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "code": {
|
||||||
|
const lines = token.text.split("\n");
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: line.length > 0 ? line : " ",
|
||||||
|
font: options.codeFont,
|
||||||
|
size: options.codeFontSize,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
shading: { fill: "F5F5F5" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
paragraphs.push(new Paragraph({ children: [] }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
token.items?.forEach((item: MarkdownToken) => {
|
||||||
|
const runs = parseInlineTokens(
|
||||||
|
item.tokens?.[0]?.tokens ?? [],
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||||
|
bullet: { level: 0 },
|
||||||
|
spacing: { after: 80 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "blockquote": {
|
||||||
|
const runs = parseInlineTokens(
|
||||||
|
token.tokens?.[0]?.tokens ?? [],
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||||
|
indent: { left: 720 },
|
||||||
|
border: { left: { style: "single", size: 12, color: "CCCCCC" } },
|
||||||
|
spacing: { after: 200 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "hr": {
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [new TextRun({ text: "─".repeat(50), color: "CCCCCC" })],
|
||||||
|
spacing: { before: 200, after: 200 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "space": {
|
||||||
|
paragraphs.push(new Paragraph({ children: [] }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析行内 Token 为 TextRun
|
||||||
|
*/
|
||||||
|
function parseInlineTokens(
|
||||||
|
tokens: MarkdownToken[],
|
||||||
|
options: Required<DocxOptions>,
|
||||||
|
): TextRun[] {
|
||||||
|
const runs: TextRun[] = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
switch (token.type) {
|
||||||
|
case "text":
|
||||||
|
runs.push(new TextRun(token.raw ?? token.text ?? ""));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "strong":
|
||||||
|
runs.push(new TextRun({ text: token.text, bold: true }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "em":
|
||||||
|
runs.push(new TextRun({ text: token.text, italics: true }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "codespan":
|
||||||
|
runs.push(
|
||||||
|
new TextRun({
|
||||||
|
text: token.text,
|
||||||
|
font: options.codeFont,
|
||||||
|
shading: { fill: "F0F0F0" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "link":
|
||||||
|
runs.push(
|
||||||
|
new TextRun({
|
||||||
|
text: token.text,
|
||||||
|
color: "0066CC",
|
||||||
|
underline: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "br":
|
||||||
|
runs.push(new TextRun({ text: "", break: 1 }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
runs.push(new TextRun(token.raw ?? ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取标题级别
|
||||||
|
*/
|
||||||
|
function getHeadingLevel(
|
||||||
|
depth: number,
|
||||||
|
): (typeof HeadingLevel)[keyof typeof HeadingLevel] | undefined {
|
||||||
|
const levels = [
|
||||||
|
HeadingLevel.HEADING_1,
|
||||||
|
HeadingLevel.HEADING_2,
|
||||||
|
HeadingLevel.HEADING_3,
|
||||||
|
HeadingLevel.HEADING_4,
|
||||||
|
HeadingLevel.HEADING_5,
|
||||||
|
HeadingLevel.HEADING_6,
|
||||||
|
];
|
||||||
|
return levels[depth - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化文件名
|
||||||
|
*/
|
||||||
|
function normalizeFilename(filename: string, extension: string): string {
|
||||||
|
return filename.replace(/\.md$/i, "") + extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发 Blob 下载
|
||||||
|
*/
|
||||||
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Markdown 文档下载工具
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 将 Markdown 内容转换为 DOCX 或 PDF 格式并下载。
|
||||||
|
* 可在任何 React + TypeScript 项目中使用。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // React Hook 使用方式
|
||||||
|
* import { useMarkdownDownload } from "./markdown-download";
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { downloadAsDocx, downloadAsPdf, isDownloading } = useMarkdownDownload();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <button onClick={() => downloadAsDocx("# Hello", "doc")}>
|
||||||
|
* Download DOCX
|
||||||
|
* </button>
|
||||||
|
* <button onClick={() => downloadAsPdf("# Hello", "doc")}>
|
||||||
|
* Download PDF
|
||||||
|
* </button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // 非 React 环境直接使用转换函数
|
||||||
|
* import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./markdown-download";
|
||||||
|
*
|
||||||
|
* await downloadMarkdownAsDocx("# Hello World", "document");
|
||||||
|
* await downloadMarkdownAsPdf("# Hello World", "document", { format: "a4" });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// React Hook
|
||||||
|
export { useMarkdownDownload } from "./use-markdown-download";
|
||||||
|
|
||||||
|
// 类型
|
||||||
|
export type {
|
||||||
|
UseMarkdownDownloadOptions,
|
||||||
|
UseMarkdownDownloadReturn,
|
||||||
|
} from "./use-markdown-download";
|
||||||
|
export type { PdfOptions, DocxOptions } from "./converter";
|
||||||
|
|
||||||
|
// 转换函数(供非 React 环境使用)
|
||||||
|
export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 下载 Hook 配置选项
|
||||||
|
*/
|
||||||
|
export interface UseMarkdownDownloadOptions {
|
||||||
|
/**
|
||||||
|
* 下载开始时的回调
|
||||||
|
*/
|
||||||
|
onDownloadStart?: (format: "docx" | "pdf") => void;
|
||||||
|
/**
|
||||||
|
* 下载完成时的回调
|
||||||
|
*/
|
||||||
|
onDownloadEnd?: (format: "docx" | "pdf") => void;
|
||||||
|
/**
|
||||||
|
* 下载失败时的回调
|
||||||
|
*/
|
||||||
|
onError?: (error: Error, format: "docx" | "pdf") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 下载 Hook 返回值
|
||||||
|
*/
|
||||||
|
export interface UseMarkdownDownloadReturn {
|
||||||
|
/**
|
||||||
|
* 当前下载状态
|
||||||
|
*/
|
||||||
|
isDownloading: "docx" | "pdf" | null;
|
||||||
|
/**
|
||||||
|
* 下载为 DOCX
|
||||||
|
*/
|
||||||
|
downloadAsDocx: (markdown: string, filename: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 下载为 PDF
|
||||||
|
*/
|
||||||
|
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 是否可以下载(没有正在进行的下载)
|
||||||
|
*/
|
||||||
|
canDownload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 文档下载 Hook
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 将 Markdown 内容转换为 DOCX 或 PDF 格式并下载。
|
||||||
|
* 可在任何 React + TypeScript 项目中使用。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { useMarkdownDownload } from "./hooks/use-markdown-download";
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { downloadAsDocx, downloadAsPdf, isDownloading, canDownload } = useMarkdownDownload({
|
||||||
|
* onError: (error, format) => {
|
||||||
|
* console.error(`Failed to download as ${format}:`, error);
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const handleDownload = () => {
|
||||||
|
* downloadAsDocx("# Hello World", "document");
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <button onClick={handleDownload} disabled={!canDownload}>
|
||||||
|
* {isDownloading === "docx" ? "Converting..." : "Download DOCX"}
|
||||||
|
* </button>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useMarkdownDownload(
|
||||||
|
options: UseMarkdownDownloadOptions = {},
|
||||||
|
): UseMarkdownDownloadReturn {
|
||||||
|
const { onDownloadStart, onDownloadEnd, onError } = options;
|
||||||
|
|
||||||
|
const [isDownloading, setIsDownloading] = useState<"docx" | "pdf" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadAsDocx = useCallback(
|
||||||
|
async (markdown: string, filename: string) => {
|
||||||
|
if (isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading("docx");
|
||||||
|
onDownloadStart?.("docx");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadMarkdownAsDocx(markdown, filename);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
"docx",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(null);
|
||||||
|
onDownloadEnd?.("docx");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDownloading, onDownloadStart, onDownloadEnd, onError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadAsPdf = useCallback(
|
||||||
|
async (markdown: string, filename: string) => {
|
||||||
|
if (isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading("pdf");
|
||||||
|
onDownloadStart?.("pdf");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadMarkdownAsPdf(markdown, filename);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
"pdf",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(null);
|
||||||
|
onDownloadEnd?.("pdf");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDownloading, onDownloadStart, onDownloadEnd, onError],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDownloading,
|
||||||
|
downloadAsDocx,
|
||||||
|
downloadAsPdf,
|
||||||
|
canDownload: isDownloading === null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出转换函数,供非 React 环境使用
|
||||||
|
export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
|
|
@ -4,8 +4,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
POST_MESSAGE_TYPES,
|
POST_MESSAGE_TYPES,
|
||||||
RECEIVE_MESSAGE_TYPES,
|
RECEIVE_MESSAGE_TYPES,
|
||||||
|
isSelectedSkillMessage,
|
||||||
sendToParent,
|
sendToParent,
|
||||||
type SelectedSkillMessage,
|
|
||||||
} from "@/core/iframe-messages";
|
} from "@/core/iframe-messages";
|
||||||
|
|
||||||
// Skill 数据类型
|
// Skill 数据类型
|
||||||
|
|
@ -53,10 +53,15 @@ export function useIframeSkill(): UseIframeSkillReturn {
|
||||||
// 2. 监听宿主页 postMessage
|
// 2. 监听宿主页 postMessage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
const { id, title } = event.data as SelectedSkillMessage;
|
return;
|
||||||
setSelectedSkill({ skill_id: String(id), title });
|
|
||||||
}
|
}
|
||||||
|
if (!isSelectedSkillMessage(event.data)) {
|
||||||
|
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id, title } = event.data;
|
||||||
|
setSelectedSkill({ skill_id: String(id), title });
|
||||||
};
|
};
|
||||||
window.addEventListener("message", handleMessage);
|
window.addEventListener("message", handleMessage);
|
||||||
return () => window.removeEventListener("message", handleMessage);
|
return () => window.removeEventListener("message", handleMessage);
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,9 @@ import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useCallback, useState, useRef } from "react";
|
import { useEffect, useCallback, useState, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { isSelectedSkillMessage } from "@/core/iframe-messages";
|
||||||
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
||||||
|
|
||||||
/** 宿主页发过来的 selectedSkill 消息结构 */
|
|
||||||
interface SelectedSkillMessage {
|
|
||||||
type: "selectedSkill";
|
|
||||||
id: number | string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 技能基础数据 */
|
/** 技能基础数据 */
|
||||||
interface SkillData {
|
interface SkillData {
|
||||||
skill_id: string;
|
skill_id: string;
|
||||||
|
|
@ -59,6 +53,15 @@ export function useSelectedSkillListener({
|
||||||
const performBootstrap = useCallback(
|
const performBootstrap = useCallback(
|
||||||
async (id: number | string, title: string) => {
|
async (id: number | string, title: string) => {
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
|
const contentId = Number(id);
|
||||||
|
if (!Number.isFinite(contentId) || contentId <= 0) {
|
||||||
|
console.warn("[useSelectedSkillListener] 忽略非法 skill id", id);
|
||||||
|
setSkillError({
|
||||||
|
title: `技能「${title}」加载失败`,
|
||||||
|
message: `非法 skill id: ${String(id)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const languageTypeRaw =
|
const languageTypeRaw =
|
||||||
searchParams.get("languageType")?.trim() ??
|
searchParams.get("languageType")?.trim() ??
|
||||||
|
|
@ -79,7 +82,7 @@ export function useSelectedSkillListener({
|
||||||
try {
|
try {
|
||||||
const result = await bootstrapRemoteSkill({
|
const result = await bootstrapRemoteSkill({
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
content_ids: [Number(id)],
|
content_ids: [contentId],
|
||||||
language_type: languageType,
|
language_type: languageType,
|
||||||
target_dir: "/mnt/user-data/uploads/skill",
|
target_dir: "/mnt/user-data/uploads/skill",
|
||||||
clear_target: true,
|
clear_target: true,
|
||||||
|
|
@ -126,8 +129,8 @@ export function useSelectedSkillListener({
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
(event: MessageEvent) => {
|
(event: MessageEvent) => {
|
||||||
const data = event.data as SelectedSkillMessage;
|
if (!isSelectedSkillMessage(event.data)) return;
|
||||||
if (data?.type !== "selectedSkill") return;
|
const data = event.data;
|
||||||
|
|
||||||
const { id, title } = data;
|
const { id, title } = data;
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,79 @@ export const externalLinkClass =
|
||||||
"text-primary underline underline-offset-2 hover:no-underline";
|
"text-primary underline underline-offset-2 hover:no-underline";
|
||||||
/** Link style without underline by default (e.g. for streaming/loading). */
|
/** Link style without underline by default (e.g. for streaming/loading). */
|
||||||
export const externalLinkClassNoUnderline = "text-primary hover:underline";
|
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: "copyToClipboard",
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInIframe && window.parent) {
|
||||||
|
try {
|
||||||
|
// Request parent window to copy
|
||||||
|
window.parent.postMessage(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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue