Files
buzz-sheet/components/public/guide-image-export.tsx
T
2026-09-14 18:02:18 +07:00

161 lines
5.0 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { LocaleBoundary, useTranslation } from "@/components/i18n/locale-provider";
import type { Locale } from "@/lib/i18n/locales";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { exportText, withoutNotesFileName } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
const EXPORT_WIDTH = 1200;
function downloadFileName(value: string): string {
return value.replace(/[\\/:*?"<>|]/gu, "-");
}
export function GuideImageExport({
children,
fileName,
width = EXPORT_WIDTH,
height,
format = "png",
disabled = false,
captureClassName,
buttonLabel,
allowWithoutNote = false,
}: {
children: ReactNode;
fileName: string;
width?: number;
height?: number;
format?: "png" | "webp";
disabled?: boolean;
captureClassName?: string;
buttonLabel?: string;
allowWithoutNote?: boolean;
}) {
const { e, locale } = useTranslation();
const captureRef = useRef<HTMLDivElement>(null);
const [captureOptions, setCaptureOptions] = useState<{ mode: "with-notes" | "without-notes"; locale: Locale; fileName: string } | null>(null);
const captureMode = captureOptions?.mode;
const capturing = captureOptions !== null;
useEffect(() => {
if (!captureOptions) return;
const exportLocale = captureOptions.locale;
const exportFileName = captureOptions.fileName;
let cancelled = false;
async function capture() {
try {
const node = captureRef.current;
if (!node) throw new Error("Capture target is unavailable");
if (captureMode === "without-notes") {
node.querySelectorAll<HTMLElement>("[data-export-note]").forEach((note) => {
note.style.display = "none";
});
// A note can be the only remaining content in the artifact right column.
const right = node.querySelector<HTMLElement>(".artifact-export-right");
if (right) {
const sections = right.querySelectorAll<HTMLElement>(
"[data-artifact-export-section]",
);
const hasVisibleSection = Array.from(sections).some(
(section) => getComputedStyle(section).display !== "none",
);
if (!hasVisibleSection) right.style.display = "none";
}
}
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
const blob = await domToBlob(node, {
backgroundColor: getComputedStyle(node).backgroundColor,
type: `image/${format}`,
scale: 1,
width,
height,
});
if (cancelled) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = downloadFileName(
captureMode === "without-notes"
? withoutNotesFileName(exportFileName)
: exportFileName,
);
link.href = url;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
} catch {
if (!cancelled) toast.error(exportText("exportError", {}, exportLocale));
} finally {
if (!cancelled) setCaptureOptions(null);
}
}
void capture();
return () => {
cancelled = true;
};
}, [captureOptions, captureMode, format, height, width]);
return (
<>
<div className="flex flex-wrap gap-2">
{(["with-notes", ...(allowWithoutNote ? ["without-notes"] as const : [])] as const).map((mode) => (
<Button
key={mode}
type="button"
variant="outline"
size="sm"
disabled={capturing || disabled}
onClick={() => setCaptureOptions({ mode, locale, fileName })}
>
{captureMode === mode ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
{captureMode === mode
? e("exporting")
: mode === "without-notes"
? e("withoutNote")
: buttonLabel ?? e("exportImage")}
</Button>
))}
</div>
{capturing
? createPortal(
<div
aria-hidden="true"
className="pointer-events-none fixed top-0 -left-[10000px]"
>
<div
ref={captureRef}
className={cn(
"bg-background p-10 text-foreground",
captureClassName,
)}
style={{ width, height }}
>
<LocaleBoundary locale={captureOptions!.locale}>{children}</LocaleBoundary>
</div>
</div>,
document.body,
)
: null}
</>
);
}