deerflow2/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx

1760 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ExcelJS from "exceljs";
import JSZip from "jszip";
import {
DownloadIcon,
FileTextIcon,
LoaderIcon,
FileTypeIcon,
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ComponentProps,
type HTMLAttributes,
} from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import {
Artifact,
ArtifactAction,
ArtifactActions,
ArtifactContent,
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { Slider } from "@/components/ui/slider";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { useMarkdownDownload } from "@/core/utils/markdown-download";
import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { useArtifacts } from "./context";
const POST_MESSAGE_TYPES = {
FULLSCREEN: "fullscreen",
} as const;
type RevoGridColumn = { prop: string; name: string };
type RevoGridRow = Record<string, string>;
type RevoGridSheetData = {
columns: RevoGridColumn[];
rows: RevoGridRow[];
};
type RevoGridElement = HTMLElement & {
columns: RevoGridColumn[];
source: RevoGridRow[];
readonly: boolean;
resize: boolean;
rowHeaders: boolean;
theme: string;
};
let revoGridLoaderPromise: Promise<void> | null = null;
function ensureRevoGridDefined() {
if (typeof window === "undefined") return Promise.resolve();
if (window.customElements.get("revo-grid")) return Promise.resolve();
revoGridLoaderPromise ??= import("@revolist/revogrid/loader").then(
({ defineCustomElements }) => {
defineCustomElements(window);
},
);
return revoGridLoaderPromise;
}
function toExcelColumnLabel(index: number): string {
let n = index;
let label = "";
while (n > 0) {
const remainder = (n - 1) % 26;
label = String.fromCharCode(65 + remainder) + label;
n = Math.floor((n - 1) / 26);
}
return label || "A";
}
function toGridCellText(cell: ExcelJS.Cell): string {
if (cell.text) return cell.text;
const value = cell.value;
if (value == null) return "";
if (value instanceof Date) return value.toISOString();
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (typeof value === "object") {
if ("result" in value && value.result != null) {
const result = value.result;
if (
typeof result === "string" ||
typeof result === "number" ||
typeof result === "boolean" ||
typeof result === "bigint"
) {
return String(result);
}
}
if ("text" in value && value.text) {
const text = value.text;
if (
typeof text === "string" ||
typeof text === "number" ||
typeof text === "boolean" ||
typeof text === "bigint"
) {
return String(text);
}
}
if ("hyperlink" in value && value.hyperlink) {
const hyperlink = value.hyperlink;
if (typeof hyperlink === "string") {
return hyperlink;
}
}
}
return "";
}
function toRevoGridSheetData(worksheet: ExcelJS.Worksheet): RevoGridSheetData {
const maxColumns = Math.max(worksheet.columnCount, 1);
const headerRow = worksheet.getRow(1);
const columns = Array.from({ length: maxColumns }, (_, idx) => {
const columnIndex = idx + 1;
const key = `c${columnIndex}`;
const header = toGridCellText(headerRow.getCell(columnIndex)).trim();
return {
prop: key,
name: header || toExcelColumnLabel(columnIndex),
};
});
const rows: RevoGridRow[] = [];
const lastRow = Math.max(worksheet.actualRowCount, worksheet.rowCount);
for (let rowIndex = 2; rowIndex <= lastRow; rowIndex += 1) {
const row = worksheet.getRow(rowIndex);
const rowData: RevoGridRow = {};
let hasContent = false;
for (let columnIndex = 1; columnIndex <= maxColumns; columnIndex += 1) {
const value = toGridCellText(row.getCell(columnIndex));
rowData[`c${columnIndex}`] = value;
if (!hasContent && value !== "") hasContent = true;
}
if (hasContent) rows.push(rowData);
}
return { columns, rows };
}
function sendToParent(message: unknown): void {
if (window.parent !== window) {
window.parent.postMessage(message, "*");
}
}
export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
}: {
className?: string;
filepath: string;
threadId: string;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select, fullscreen, setFullscreen } =
useArtifacts();
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
const filepath = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepathFromProps);
return decodeURIComponent(url.pathname);
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
// 获取文件名(不含路径)
const fileName = useMemo(() => getFileName(filepath), [filepath]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
const { isCodeFile, language } = useMemo(() => {
if (isWriteFile) {
let language = checkCodeFile(filepath).language;
language ??= "text";
return { isCodeFile: true, language };
}
// Treat .skill files as markdown (they contain SKILL.md)
if (isSkillFile) {
return { isCodeFile: true, language: "markdown" };
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]);
const previewable = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const artifactUrl = useMemo(() => {
if (!threadId) {
return "";
}
return urlOfArtifact({ filepath, threadId });
}, [filepath, threadId]);
const artifactPreviewKind = useMemo(() => {
return getArtifactPreviewKind(filepath);
}, [filepath]);
const artifactViewerSrcDoc = useMemo(() => {
if (!artifactUrl) {
return undefined;
}
return buildArtifactViewerSrcDoc({
artifactUrl,
fileName,
kind: artifactPreviewKind,
pdfPreviewMessage: t.artifactPreview.pdfPreviewFailed,
unsupportedTypeMessage: t.artifactPreview.unsupportedType,
openInNewTabLabel: t.artifactPreview.openInNewTab,
});
}, [
artifactUrl,
fileName,
artifactPreviewKind,
t.artifactPreview.openInNewTab,
t.artifactPreview.pdfPreviewFailed,
t.artifactPreview.unsupportedType,
]);
// Native PDF iframe rendering is intentionally disabled; PDFs are rendered via pdf.js.
const artifactViewerSrc = useMemo(() => {
return undefined;
}, []);
const artifactViewerSandbox =
"allow-same-origin allow-scripts allow-downloads";
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: Boolean(threadId) && isCodeFile && !isWriteFile,
});
const displayContent = content ?? "";
const [isPackagingMarkdownBundle, setIsPackagingMarkdownBundle] =
useState(false);
const artifactOptions = useMemo(() => {
return (artifacts ?? []).map((artifactPath) => ({
value: artifactPath,
label: getFileName(artifactPath),
}));
}, [artifacts]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [zoom, setZoom] = useState(80);
// 是否可以转换为docx/pdf仅markdown文件支持
const canConvertToDocxPdf = language === "markdown";
// 使用 Markdown 下载 hook
const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({
onError: (error, format) => {
console.error(`Failed to download as ${format}:`, error);
toast.error(`Failed to download as ${format.toUpperCase()}`);
},
});
const resolveMarkdownAssetUrlForDownload = useCallback(
(rawPath: string): string | null => {
const normalizedRef = normalizeReference(rawPath);
if (!normalizedRef) return null;
if (isExternalReference(normalizedRef)) return normalizedRef;
if (normalizedRef.startsWith("/mnt/user-data/")) {
return urlOfArtifact({ filepath: normalizedRef, threadId });
}
if (normalizedRef.startsWith("mnt/user-data/")) {
return urlOfArtifact({ filepath: `/${normalizedRef}`, threadId });
}
const resolvedVirtualPath = resolveReferencedVirtualPath(
normalizedRef,
filepath,
);
if (!resolvedVirtualPath) return null;
return urlOfArtifact({ filepath: resolvedVirtualPath, threadId });
},
[filepath, threadId],
);
// 下载为 DOCX
const handleDownloadDocx = useCallback(() => {
if (content) {
void downloadAsDocx(content, fileName, {
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
});
}
}, [content, fileName, downloadAsDocx, resolveMarkdownAssetUrlForDownload]);
// 下载为 PDF
const handleDownloadPdf = useCallback(() => {
if (content) {
void downloadAsPdf(content, fileName, {
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
});
}
}, [content, fileName, downloadAsPdf, resolveMarkdownAssetUrlForDownload]);
const handleDownloadMarkdownBundle = useCallback(async () => {
if (!threadId || !content) return;
setIsPackagingMarkdownBundle(true);
try {
const zip = new JSZip();
const markdownEntryPath = toWorkspaceRelativePath(filepath) ?? fileName;
const referencedTargets = collectMarkdownAssetTargets(content);
const refToVirtualPath = new Map<string, string>();
for (const ref of referencedTargets) {
const resolved = resolveReferencedVirtualPath(ref, filepath);
if (resolved) {
refToVirtualPath.set(ref, resolved);
}
}
const refToRelativeZipPath = new Map<string, string>();
const addedVirtualPaths = new Set<string>();
for (const [ref, virtualPath] of refToVirtualPath) {
const artifactEntryPath = toWorkspaceRelativePath(virtualPath);
if (!artifactEntryPath) continue;
const relativeFromMarkdown = toRelativePath(
dirnamePosix(markdownEntryPath),
artifactEntryPath,
);
refToRelativeZipPath.set(
ref,
relativeFromMarkdown || getFileName(artifactEntryPath),
);
if (addedVirtualPaths.has(virtualPath)) continue;
addedVirtualPaths.add(virtualPath);
const response = await fetch(
urlOfArtifact({
filepath: virtualPath,
threadId,
}),
);
if (!response.ok) {
continue;
}
const data = await response.arrayBuffer();
zip.file(artifactEntryPath, data);
}
const rewrittenMarkdown = rewriteMarkdownLinksForBundle(
content,
refToRelativeZipPath,
);
zip.file(markdownEntryPath, rewrittenMarkdown);
const zipBlob = await zip.generateAsync({ type: "blob" });
const zipName = `${fileName.replace(/\.md$/i, "") || "document"}.zip`;
const url = URL.createObjectURL(zipBlob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = zipName;
anchor.click();
URL.revokeObjectURL(url);
toast.success(t.common.exportSuccess);
} catch (error) {
console.error("Failed to package markdown bundle:", error);
toast.error("Failed to package markdown with referenced files.");
} finally {
setIsPackagingMarkdownBundle(false);
}
}, [threadId, content, filepath, fileName, t.common.exportSuccess]);
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
const newFullscreen = !fullscreen;
setFullscreen(newFullscreen);
sendToParent({
type: POST_MESSAGE_TYPES.FULLSCREEN,
fullscreen: newFullscreen,
});
}, [fullscreen, setFullscreen]);
useEffect(() => {
if (previewable) {
setViewMode("preview");
} else {
setViewMode("code");
}
}, [previewable]);
return (
// 给滚动遮挡头部定位relative
<Artifact
className={cn(
"bg-background relative h-full overflow-hidden rounded-2xl",
className,
)}
>
<ArtifactHeader className="grid grid-cols-24">
<div className="col-span-7 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
{previewable && (
<ToggleGroup
type="single"
variant={null}
size="default"
className="h-[28px] bg-white"
value={viewMode}
onValueChange={(value) => {
if (value) {
setViewMode(value as "code" | "preview");
}
}}
>
<ToggleGroupItem value="code">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 6L2 9L5 12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11 3L7 15"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 6L16 9L13 12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ToggleGroupItem>
<ToggleGroupItem value="preview">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
>
<path
d="M8 0.5C10.4943 0.5 12.8473 1.84466 14.792 4.21973C15.1644 4.67466 15.1644 5.32534 14.792 5.78027C12.8473 8.15534 10.4943 9.5 8 9.5C5.50561 9.49989 3.15269 8.15543 1.20801 5.78027C0.835561 5.32534 0.835562 4.67466 1.20801 4.21973C3.15269 1.84457 5.50561 0.500106 8 0.5Z"
stroke="currentColor"
/>
<circle cx="8" cy="5" r="1.5" stroke="currentColor" />
</svg>
</ToggleGroupItem>
</ToggleGroup>
)}
{/* 代码视图显示缩放控制Markdown 预览也显示缩放控制 */}
{(isCodeFile && viewMode === "code") ||
(language === "markdown" && viewMode === "preview") ? (
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
) : null}
</div>
<div className="col-span-10 flex min-w-0 items-center justify-center px-1">
<ArtifactTitle>
{isWriteFile ? (
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
{truncateMiddle(getFileName(filepath), 20)}
</div>
) : (
<DropdownSelector
value={filepath}
options={artifactOptions}
onChange={select}
/>
)}
</ArtifactTitle>
</div>
<div className="col-span-7 flex min-w-0 items-center justify-end overflow-hidden">
<ArtifactActions>
{isCodeFile && (
<ArtifactAction
label={t.clipboard.copyToClipboard}
disabled={!content}
onClick={async () => {
try {
await copyToClipboard(displayContent ?? "");
toast.success(t.clipboard.copiedToClipboard);
} catch (error) {
toast.error("Failed to copy to clipboard");
console.error(error);
}
}}
tooltip={t.clipboard.copyToClipboard}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2H13C14.1046 2 15 2.89543 15 4V13"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect
x="2.5"
y="4.5"
width="10"
height="11"
rx="1.5"
stroke="currentColor"
/>
</svg>
</ArtifactAction>
)}
{!isWriteFile && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ArtifactAction
label={t.common.download}
tooltip={t.common.download}
>
{isDownloading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
stroke="currentColor"
strokeLinecap="round"
/>
<path
d="M9 2V13M9 13L5 9M9 13L13 9"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</ArtifactAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
{language === "markdown" ? (
<DropdownMenuItem
onClick={handleDownloadMarkdownBundle}
disabled={isPackagingMarkdownBundle}
className="cursor-pointer"
>
{isPackagingMarkdownBundle ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{t.common.downloadOriginal}
</DropdownMenuItem>
) : (
<DropdownMenuItem asChild>
<a
href={urlOfArtifact({
filepath,
threadId: threadId ?? "",
download: true,
})}
target="_blank"
className="w-full cursor-pointer"
>
<DownloadIcon className="size-4" />
{t.common.downloadOriginal}
</a>
</DropdownMenuItem>
)}
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
{canConvertToDocxPdf && (
<>
<DropdownMenuItem
onClick={handleDownloadDocx}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTextIcon className="size-4" />
{isDownloading === "docx"
? t.common.loading
: t.common.downloadAsDocx}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadPdf}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTypeIcon className="size-4" />
{isDownloading === "pdf"
? t.common.loading
: t.common.downloadAsPdf}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 全屏按钮 */}
<ArtifactAction
label={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
onClick={handleFullscreenToggle}
tooltip={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
>
{fullscreen ? (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2V4C6 5.10457 5.10457 6 4 6H2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 16V14C6 12.8954 5.10457 12 4 12H2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 2V4C12 5.10457 12.8954 6 14 6H16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 16V14C12 12.8954 12.8954 12 14 12H16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.75 15.5H4.5C3.39543 15.5 2.5 14.6046 2.5 13.5V12.25M2.5 5.75V4.5C2.5 3.39543 3.39543 2.5 4.5 2.5H5.75M12.25 2.5H13.5C14.6046 2.5 15.5 3.39543 15.5 4.5V5.75M15.5 12.25V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H12.25"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</ArtifactAction>
{!fullscreen && (
<ArtifactAction
label={t.common.close}
onClick={() => setOpen(false)}
tooltip={t.common.close}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="currentColor"
strokeLinecap="round"
/>
</svg>
</ArtifactAction>
)}
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent>
{/* 遮挡多余的滚动顶部 */}
{/* <div className="absolute w-[calc(100%-40px)] bg-white z-20 h-5 rounded-t-[10px] top-[57px]"></div> */}
{previewable &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
zoom={zoom}
threadId={threadId}
filepath={filepath}
/>
)}
{isCodeFile && viewMode === "code" && (
<div className="mb-0 mb-[207px] min-h-full rounded-b-[10px] bg-white p-0">
<CodeEditor
className="size-full resize-none rounded-none border-none py-[20px]"
value={displayContent ?? ""}
zoom={zoom}
readonly
/>
</div>
)}
{!isCodeFile &&
(artifactPreviewKind === "pdf" ? (
<ArtifactPdfPreview
className="mb-[207px] h-full"
artifactUrl={artifactUrl}
fileName={fileName}
/>
) : isOfficePreviewKind(artifactPreviewKind) ? (
<ArtifactOfficePreview
className="mb-[207px] h-full"
kind={artifactPreviewKind}
artifactUrl={artifactUrl}
fileName={fileName}
/>
) : (
<PreviewIframe
className="size-full border-0"
containerClassName="h-full mb-[207px]"
src={artifactViewerSrc}
srcDoc={artifactViewerSrcDoc}
sandbox={artifactViewerSandbox}
title={`Artifact preview: ${fileName}`}
/>
))}
</ArtifactContent>
</Artifact>
);
}
const USER_DATA_PREFIX = "/mnt/user-data/";
function normalizePosixPath(path: string): string {
const isAbs = path.startsWith("/");
const parts = path.split("/").filter((part) => part.length > 0);
const stack: string[] = [];
for (const part of parts) {
if (part === ".") continue;
if (part === "..") {
if (stack.length > 0) stack.pop();
continue;
}
stack.push(part);
}
return `${isAbs ? "/" : ""}${stack.join("/")}`;
}
function dirnamePosix(path: string): string {
const normalized = normalizePosixPath(path);
const index = normalized.lastIndexOf("/");
if (index <= 0) return "";
return normalized.slice(0, index);
}
function toWorkspaceRelativePath(virtualPath: string): string | null {
const normalized = normalizePosixPath(virtualPath);
if (!normalized.startsWith(USER_DATA_PREFIX)) return null;
return normalized.slice(USER_DATA_PREFIX.length) || "artifact";
}
function toRelativePath(fromDir: string, targetPath: string): string {
const from = normalizePosixPath(fromDir).split("/").filter(Boolean);
const to = normalizePosixPath(targetPath).split("/").filter(Boolean);
let i = 0;
while (i < from.length && i < to.length && from[i] === to[i]) {
i += 1;
}
const up = new Array(from.length - i).fill("..");
const down = to.slice(i);
return [...up, ...down].join("/") || ".";
}
function normalizeReference(ref: string): string {
const trimmed = ref.trim().replace(/^<|>$/g, "");
return trimmed.split(/[ \t]/)[0] ?? "";
}
function isExternalReference(ref: string): boolean {
return (
!ref ||
ref.startsWith("#") ||
ref.startsWith("//") ||
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref)
);
}
function resolveReferencedVirtualPath(
ref: string,
baseFilepath: string,
): string | null {
const normalizedRef = normalizeReference(ref);
if (isExternalReference(normalizedRef)) return null;
let withoutHash = normalizedRef.split("#")[0] ?? normalizedRef;
withoutHash = withoutHash.split("?")[0] ?? withoutHash;
if (!withoutHash) return null;
if (withoutHash.startsWith("/mnt/user-data/")) {
return normalizePosixPath(withoutHash);
}
if (withoutHash.startsWith("mnt/user-data/")) {
return normalizePosixPath(`/${withoutHash}`);
}
if (withoutHash.startsWith("/")) {
return null;
}
const baseDir = dirnamePosix(baseFilepath);
const combined = normalizePosixPath(`${baseDir}/${withoutHash}`);
if (!combined.startsWith(USER_DATA_PREFIX)) return null;
return combined;
}
function collectMarkdownAssetTargets(markdown: string): Set<string> {
const targets = new Set<string>();
const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
const htmlAttrRegex =
/<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi;
for (const match of markdown.matchAll(markdownRefRegex)) {
const raw = match[1]?.trim();
if (raw) targets.add(raw);
}
for (const match of markdown.matchAll(htmlAttrRegex)) {
const raw = match[2]?.trim();
if (raw) targets.add(raw);
}
return targets;
}
function rewriteMarkdownLinksForBundle(
markdown: string,
refToRelativeZipPath: Map<string, string>,
): string {
const rewriteTarget = (rawTarget: string): string => {
const normalized = normalizeReference(rawTarget);
return refToRelativeZipPath.get(normalized) ?? rawTarget;
};
const markdownRewritten = markdown.replace(
/(!?\[[^\]]*\]\()([^)]+)(\))/g,
(_full, prefix, target, suffix) => {
return `${prefix}${rewriteTarget(String(target))}${suffix}`;
},
);
return markdownRewritten.replace(
/(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*["'])([^"']+)(["'])/gi,
(_full, prefix, target, suffix) => {
return `${prefix}${rewriteTarget(String(target))}${suffix}`;
},
);
}
export function ArtifactFilePreview({
content,
language,
zoom = 100,
threadId,
filepath,
}: {
content: string;
language: string;
zoom?: number;
threadId: string;
filepath?: string;
}) {
const zoomScale = zoom / 100;
const normalizedContent = useMemo(() => {
return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
}, [content, threadId, filepath]);
if (language === "markdown") {
return (
<div
className={cn("mb-[207px] w-full bg-white p-[20px]")}
style={{ "--zoom-scale": zoomScale } as CSSProperties}
>
<Streamdown
className="w-full"
{...streamdownPlugins}
components={{ a: CitationLink }}
>
{normalizedContent}
</Streamdown>
</div>
);
}
if (language === "html") {
return (
<PreviewIframe
className="size-full"
containerClassName="h-full mb-[207px]"
title="Artifact preview"
srcDoc={normalizedContent}
sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }}
/>
);
}
return null;
}
function PreviewIframe({
className,
containerClassName,
onLoad,
src,
srcDoc,
...props
}: ComponentProps<"iframe"> & {
containerClassName?: string;
}) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
}, [src, srcDoc]);
return (
<div className={cn("relative", containerClassName)}>
<iframe
className={className}
src={src}
srcDoc={srcDoc}
onLoad={(event) => {
setIsLoading(false);
onLoad?.(event);
}}
{...props}
/>
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/85">
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</div>
);
}
function ArtifactPdfPreview({
className,
artifactUrl,
fileName,
}: {
className?: string;
artifactUrl: string;
fileName: string;
}) {
const { t } = useI18n();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pageCount, setPageCount] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let disposed = false;
async function renderPdf() {
if (!artifactUrl || !containerRef.current) return;
setIsLoading(true);
setError(null);
setPageCount(0);
containerRef.current.innerHTML = "";
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.arrayBuffer();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
const loadingTask = pdfjs.getDocument({ data });
const pdf = await loadingTask.promise;
if (disposed || !containerRef.current) {
await loadingTask.destroy();
return;
}
setPageCount(pdf.numPages);
const hostWidth = Math.max(
640,
Math.min(containerRef.current.clientWidth || 960, 1200),
);
const dpr = window.devicePixelRatio || 1;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
if (disposed || !containerRef.current) break;
const page = await pdf.getPage(pageNum);
const baseViewport = page.getViewport({ scale: 1 });
const scale = (hostWidth - 32) / baseViewport.width;
const viewport = page.getViewport({ scale });
const pageWrapper = document.createElement("div");
pageWrapper.className =
"mx-auto mb-4 w-fit rounded-md border border-ws-e4e7ec bg-white p-2 shadow-sm";
const canvas = document.createElement("canvas");
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
const context = canvas.getContext("2d");
if (!context) continue;
context.scale(dpr, dpr);
pageWrapper.appendChild(canvas);
containerRef.current.appendChild(pageWrapper);
const renderTask = page.render({
canvas,
canvasContext: context,
viewport,
});
await renderTask.promise;
}
} catch (err) {
console.error("Failed to render pdf preview:", err);
if (!disposed) {
setError(t.artifactPreview.pdfPreviewFailed);
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void renderPdf();
return () => {
disposed = true;
};
}, [artifactUrl, t.artifactPreview.pdfPreviewFailed]);
if (error) {
return (
<div className={cn("relative overflow-auto bg-ws-f9f8fa p-4", className)}>
<div className="mx-auto grid max-w-xl gap-3 rounded-md border border-ws-e4e7ec bg-white p-5 text-center">
<p className="text-sm font-medium break-all">{fileName}</p>
<p className="text-muted-foreground text-sm">{error}</p>
<a
className="text-sm font-semibold text-blue-600 hover:underline"
href={artifactUrl}
target="_blank"
rel="noopener noreferrer"
>
{t.artifactPreview.openInNewTab}
</a>
</div>
</div>
);
}
return (
<div className={cn("relative overflow-auto bg-ws-f9f8fa p-4", className)}>
<div className="mb-3 text-center text-xs text-ws-667085">
{pageCount > 0
? t.artifactPreview.pageCountLabel(fileName, pageCount)
: fileName}
</div>
<div ref={containerRef} />
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</div>
);
}
function ArtifactOfficePreview({
className,
kind,
artifactUrl,
fileName,
}: {
className?: string;
kind: ArtifactPreviewKind;
artifactUrl: string;
fileName: string;
}) {
const { t } = useI18n();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [activeSheet, setActiveSheet] = useState<string>("");
const [xlsxColumns, setXlsxColumns] = useState<RevoGridColumn[]>([]);
const [xlsxRows, setXlsxRows] = useState<RevoGridRow[]>([]);
const docxContainerRef = useRef<HTMLDivElement | null>(null);
const xlsxGridContainerRef = useRef<HTMLDivElement | null>(null);
const xlsxGridRef = useRef<RevoGridElement | null>(null);
const workbookRef = useRef<ExcelJS.Workbook | null>(null);
const canRenderDocx = kind === "docx";
const canRenderXlsx = kind === "xlsx";
const canRenderPptx = kind === "pptx";
useEffect(() => {
let disposed = false;
async function renderDocx() {
if (!canRenderDocx || !artifactUrl || !docxContainerRef.current) {
return;
}
setIsLoading(true);
setError(null);
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const { renderAsync } = await import("docx-preview");
if (disposed || !docxContainerRef.current) {
return;
}
docxContainerRef.current.innerHTML = "";
await renderAsync(blob, docxContainerRef.current, undefined, {
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
inWrapper: true,
});
} catch (err) {
console.error("Failed to render docx preview:", err);
if (!disposed) {
setError(t.artifactPreview.docxPreviewFailed);
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void renderDocx();
return () => {
disposed = true;
};
}, [artifactUrl, canRenderDocx, t.artifactPreview.docxPreviewFailed]);
useEffect(() => {
let disposed = false;
async function parseXlsx() {
if (!canRenderXlsx || !artifactUrl) {
return;
}
setIsLoading(true);
setError(null);
setSheetNames([]);
setActiveSheet("");
setXlsxColumns([]);
setXlsxRows([]);
workbookRef.current = null;
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const bytes = await response.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bytes);
workbookRef.current = workbook;
const names = workbook.worksheets.map((sheet) => sheet.name);
if (names.length === 0) {
throw new Error("Empty workbook");
}
if (disposed) return;
setSheetNames(names);
setActiveSheet(names[0] ?? "");
} catch (err) {
console.error("Failed to render xlsx preview:", err);
if (!disposed) {
setError(t.artifactPreview.excelPreviewFailed);
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void parseXlsx();
return () => {
disposed = true;
};
}, [artifactUrl, canRenderXlsx, t.artifactPreview.excelPreviewFailed]);
useEffect(() => {
if (!canRenderXlsx || !activeSheet || !workbookRef.current) {
return;
}
try {
const sheet = workbookRef.current.getWorksheet(activeSheet);
if (!sheet) return;
const { columns, rows } = toRevoGridSheetData(sheet);
setXlsxColumns(columns);
setXlsxRows(rows);
} catch (err) {
console.error("Failed to switch xlsx sheet:", err);
setError(t.artifactPreview.switchSheetFailed);
}
}, [activeSheet, canRenderXlsx, t.artifactPreview.switchSheetFailed]);
useEffect(() => {
if (!canRenderXlsx || !xlsxGridContainerRef.current) {
return;
}
let disposed = false;
async function renderGrid() {
try {
await ensureRevoGridDefined();
if (disposed || !xlsxGridContainerRef.current) return;
let grid = xlsxGridRef.current;
if (!grid) {
grid = document.createElement("revo-grid") as RevoGridElement;
grid.style.width = "100%";
grid.style.height = "100%";
grid.readonly = true;
grid.resize = true;
grid.rowHeaders = true;
grid.theme = "default";
xlsxGridContainerRef.current.innerHTML = "";
xlsxGridContainerRef.current.appendChild(grid);
xlsxGridRef.current = grid;
}
grid.columns = xlsxColumns;
grid.source = xlsxRows;
} catch (err) {
console.error("Failed to render RevoGrid preview:", err);
if (!disposed) {
setError(t.artifactPreview.excelGridPreviewFailed);
}
}
}
void renderGrid();
return () => {
disposed = true;
};
}, [
canRenderXlsx,
xlsxColumns,
xlsxRows,
t.artifactPreview.excelGridPreviewFailed,
]);
useEffect(() => {
if (!canRenderPptx) {
return;
}
setIsLoading(false);
setError(t.artifactPreview.pptxDownloadHint);
}, [canRenderPptx, t.artifactPreview.pptxDownloadHint]);
return (
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
{canRenderXlsx && sheetNames.length > 0 && (
<div className="border-border flex items-center gap-1 overflow-x-auto border-b p-2">
{sheetNames.map((sheetName) => (
<button
key={sheetName}
type="button"
className={cn(
"rounded px-4 py-3 text-xs whitespace-nowrap",
activeSheet === sheetName
? "bg-ws-1500331a text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveSheet(sheetName)}
>
{sheetName}
</button>
))}
</div>
)}
<div className="h-full overflow-auto p-4">
{canRenderDocx && (
<div
ref={docxContainerRef}
className="docx-preview-wrap mx-auto max-w-5xl"
/>
)}
{canRenderXlsx && (
<div
ref={xlsxGridContainerRef}
className="h-full min-h-[320px] overflow-hidden rounded border"
/>
)}
</div>
{error && (
<ArtifactPreviewFallback
fileName={fileName}
artifactUrl={artifactUrl}
message={error}
/>
)}
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/85">
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</div>
);
}
function ArtifactPreviewFallback({
message,
fileName,
artifactUrl,
}: {
message: string;
fileName: string;
artifactUrl: string;
}) {
const { t } = useI18n();
return (
<div className="absolute inset-0 z-20 grid place-content-center bg-white p-6 text-center">
<p className="text-foreground mb-2 text-sm font-medium">{fileName}</p>
<p className="text-muted-foreground mb-3 text-xs">{message}</p>
<a
className="text-primary text-sm font-medium underline"
href={artifactUrl}
target="_blank"
rel="noreferrer"
>
{t.artifactPreview.clickToDownload}
</a>
</div>
);
}
function rewriteArtifactImagePaths(
content: string,
threadId?: string,
baseFilepath?: string,
) {
if (!threadId) {
return content;
}
const toArtifactUrl = (rawPath: string) => {
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
return resolveArtifactURL(normalizedPath, threadId);
};
const toArtifactUrlFromRelative = (rawPath: string) => {
const trimmed = rawPath.trim();
if (!baseFilepath || !trimmed) return null;
if (trimmed.startsWith("/") || trimmed.startsWith("//")) return null;
if (trimmed.startsWith("#")) return null;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return null;
const baseDir = baseFilepath.replace(/[^/]*$/, "");
if (!baseDir.startsWith("/")) return null;
const absolutePath = new URL(trimmed, `file://${baseDir}`).pathname;
if (!absolutePath.startsWith("/mnt/user-data/")) return null;
return resolveArtifactURL(absolutePath, threadId);
};
const markdownRewritten = content.replace(
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
(_full, alt, rawPath) => {
return `![${alt}](${toArtifactUrl(rawPath)})`;
},
);
const markdownRelativeRewritten = markdownRewritten.replace(
/!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
(_full, alt, rawPath) => {
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
if (!absoluteUrl) {
return `![${alt}](${rawPath})`;
}
return `![${alt}](${absoluteUrl})`;
},
);
const shorthandMarkdownRewritten = markdownRelativeRewritten.replace(
/!(?!\[)([^\n()]+?)\s*[(]\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*[)]/g,
(_full, alt, rawPath) => {
return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`;
},
);
return shorthandMarkdownRewritten.replace(
/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
(_full, prefix, quote, rawPath) => {
if (/^\/?mnt\/user-data\/outputs\//.test(rawPath)) {
return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
}
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
if (absoluteUrl) {
return `${prefix}${quote}${absoluteUrl}${quote}`;
}
return `${prefix}${quote}${rawPath}${quote}`;
},
);
}
type ArtifactPreviewKind =
| "html"
| "image"
| "video"
| "audio"
| "pdf"
| "docx"
| "xlsx"
| "pptx"
| "other";
function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
const lower = filepath.toLowerCase();
if (/\.(html?|xhtml)$/.test(lower)) return "html";
if (/\.(png|jpe?g|gif|webp|bmp|svg|ico|avif|tiff?)$/.test(lower))
return "image";
if (/\.(mp4|webm|ogg|mov|m4v)$/.test(lower)) return "video";
if (/\.(mp3|wav|ogg|m4a|aac|flac)$/.test(lower)) return "audio";
if (lower.endsWith(".pdf")) return "pdf";
if (/\.(docx?)$/.test(lower)) return "docx";
if (/\.(xlsx?)$/.test(lower)) return "xlsx";
if (/\.(pptx?)$/.test(lower)) return "pptx";
return "other";
}
const OFFICE_PREVIEW_KINDS = new Set<ArtifactPreviewKind>([
"docx",
"xlsx",
"pptx",
]);
function isOfficePreviewKind(kind: ArtifactPreviewKind) {
return OFFICE_PREVIEW_KINDS.has(kind);
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function buildArtifactViewerSrcDoc({
artifactUrl,
fileName,
kind,
pdfPreviewMessage,
unsupportedTypeMessage,
openInNewTabLabel,
}: {
artifactUrl: string;
fileName: string;
kind: ArtifactPreviewKind;
pdfPreviewMessage: string;
unsupportedTypeMessage: string;
openInNewTabLabel: string;
}) {
const safeUrl = escapeHtml(artifactUrl);
const safeName = escapeHtml(fileName);
const safePdfPreviewMessage = escapeHtml(pdfPreviewMessage);
const safeUnsupportedTypeMessage = escapeHtml(unsupportedTypeMessage);
const safeOpenInNewTabLabel = escapeHtml(openInNewTabLabel);
const content = (() => {
if (kind === "image") {
return `<img class="preview image" src="${safeUrl}" alt="${safeName}" />`;
}
if (kind === "video") {
return `<video class="preview media" src="${safeUrl}" controls playsinline preload="metadata"></video>`;
}
if (kind === "audio") {
return `<div class="audio-wrap"><audio class="audio" src="${safeUrl}" controls preload="metadata"></audio></div>`;
}
if (kind === "pdf") {
return `<div class="fallback">
<p class="title">${safeName}</p>
<p class="desc">${safePdfPreviewMessage}</p>
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeOpenInNewTabLabel}</a>
</div>`;
}
if (kind === "html") {
return `<iframe class="preview frame" src="${safeUrl}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads"></iframe>`;
}
return `<div class="fallback">
<p class="title">${safeName}</p>
<p class="desc">${safeUnsupportedTypeMessage}</p>
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeOpenInNewTabLabel}</a>
</div>`;
})();
const bodyClass = kind === "image" ? "fullbleed" : "";
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root {
--bg: #f8f9fb;
--panel: #ffffff;
--text: #0f172a;
--muted: #667085;
--line: #e4e7ec;
--radius: 12px;
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
body {
display: flex;
align-items: stretch;
justify-content: stretch;
overflow: hidden;
padding: 12px;
}
body.fullbleed {
padding: 0;
}
.preview {
width: 100%;
height: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
display: block;
}
.image {
object-fit: contain;
object-position: center;
background:
linear-gradient(45deg, #f4f4f5 25%, transparent 25%, transparent 75%, #f4f4f5 75%, #f4f4f5) 0 0/16px 16px,
linear-gradient(45deg, #f4f4f5 25%, transparent 25%, transparent 75%, #f4f4f5 75%, #f4f4f5) 8px 8px/16px 16px,
#fff;
}
.media {
object-fit: contain;
background: #000;
}
.frame {
border: 1px solid var(--line);
}
.audio-wrap {
width: 100%;
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.audio {
width: min(720px, 100%);
}
.fallback {
width: 100%;
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
display: grid;
place-content: center;
text-align: center;
gap: 10px;
padding: 24px;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
word-break: break-all;
}
.desc {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.link {
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
.link:hover { text-decoration: underline; }
</style>
</head>
<body class="${bodyClass}">
${content}
</body>
</html>`;
}
// 缩放比例选项
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];
export type ArtifactZoomSelectorProps = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
> & {
value?: number;
onChange?: (value: number) => void;
};
export const ArtifactZoomSelector = ({
value = 100,
onChange,
className,
...props
}: ArtifactZoomSelectorProps) => {
const { t } = useI18n();
const resolvedIndex = useMemo(() => {
const exactIndex = ZOOM_LEVELS.indexOf(value);
if (exactIndex >= 0) return exactIndex;
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
ZOOM_LEVELS.forEach((level, index) => {
const distance = Math.abs(level - value);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
return nearestIndex;
}, [value]);
return (
<div className={cn("inline-flex", className)} {...props}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t.artifactPreview.zoomIn}
className={cn(
"bg-background border-border text-muted-foreground hover:text-foreground inline-flex h-[28px] w-[28px] items-center justify-center rounded-[10px] border transition-colors",
"hover:bg-muted/60",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="currentColor" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="currentColor"
/>
<path
d="M5.33325 7.5H9.7777M7.55547 5V10"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={8}
className="w-52 p-[20px]"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{ZOOM_LEVELS[0]}%
</span>
<span className="text-foreground text-xs font-medium">
{value}%
</span>
</div>
<Slider
min={0}
max={ZOOM_LEVELS.length - 1}
step={1}
value={[resolvedIndex]}
onValueChange={(values) => {
const nextIndex = values[0];
if (nextIndex === undefined) return;
const nextValue = ZOOM_LEVELS[nextIndex];
if (nextValue !== undefined) onChange?.(nextValue);
}}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};