Files
buzz-sheet/components/public/guide-image-export.tsx
T

278 lines
8.9 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 { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ExportLanguageDialog } from "@/components/public/export-language-dialog";
import {
ExportLanguageToggle,
useExportLanguage,
type ExportLanguage,
} from "@/components/public/export-language";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
const EXPORT_WIDTH = 1200;
function withoutNotesFileName(fileName: string): string {
return /\.[^.]+$/u.test(fileName)
? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
: `${fileName}-without-notes`;
}
function downloadFileName(value: string): string {
return value.replace(/[\\/:*?"<>|]/gu, "-");
}
export function imageExportFileName(
fileName: string,
language: ExportLanguage,
withoutNotes: boolean,
localized = true,
): string {
const selectedFileName = withoutNotes ? withoutNotesFileName(fileName) : fileName;
const languageFileName = localized
? (/\.[^.]+$/u.test(selectedFileName)
? selectedFileName.replace(/(\.[^.]+)$/u, `-${language}$1`)
: `${selectedFileName}-${language}`)
: selectedFileName;
return downloadFileName(languageFileName);
}
export function GuideImageExport({
children,
fileName,
width = EXPORT_WIDTH,
height,
format = "png",
disabled = false,
allowWithoutNotes = true,
captureClassName,
buttonLabel = "ส่งออกภาพ",
mobileIconOnly = false,
language: controlledLanguage,
showLanguageSelector = true,
promptForLanguage = false,
localizeFileName = true,
modeSelection = "dialog",
}: {
children: ReactNode;
fileName: string;
width?: number;
height?: number;
format?: "png" | "webp";
disabled?: boolean;
allowWithoutNotes?: boolean;
captureClassName?: string;
buttonLabel?: string;
mobileIconOnly?: boolean;
language?: ExportLanguage;
showLanguageSelector?: boolean;
promptForLanguage?: boolean;
localizeFileName?: boolean;
modeSelection?: "dialog" | "inline";
}) {
const captureRef = useRef<HTMLDivElement>(null);
const [captureMode, setCaptureMode] = useState<
"with-notes" | "without-notes" | null
>(null);
const capturing = captureMode !== null;
const [storedLanguage, setStoredLanguage] = useExportLanguage();
const language = controlledLanguage ?? storedLanguage;
const [pendingCaptureMode, setPendingCaptureMode] = useState<
"with-notes" | "without-notes" | null
>(null);
const [modeDialogOpen, setModeDialogOpen] = useState(false);
function requestCapture(mode: "with-notes" | "without-notes") {
setModeDialogOpen(false);
if (promptForLanguage) setPendingCaptureMode(mode);
else setCaptureMode(mode);
}
function selectLanguage(nextLanguage: ExportLanguage) {
if (!pendingCaptureMode) return;
setStoredLanguage(nextLanguage);
setCaptureMode(pendingCaptureMode);
setPendingCaptureMode(null);
}
function exportButton(
mode: "with-notes" | "without-notes" | null,
label: string,
) {
const loading = capturing && (mode === null || captureMode === mode);
return (
<Button
key={mode ?? "export"}
type="button"
variant="outline"
size="sm"
className={mobileIconOnly ? "max-sm:size-7 max-sm:px-0 max-sm:has-data-[icon=inline-start]:pl-0" : undefined}
disabled={capturing || disabled}
onClick={() => {
if (mode) requestCapture(mode);
else if (allowWithoutNotes) setModeDialogOpen(true);
else requestCapture("with-notes");
}}
>
{loading ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
<span className={mobileIconOnly ? "sr-only sm:not-sr-only" : undefined}>
{loading ? "กำลังส่งออก…" : label}
</span>
</Button>
);
}
useEffect(() => {
if (!capturing) return;
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 = imageExportFileName(
fileName,
language,
captureMode === "without-notes",
localizeFileName,
);
link.href = url;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
} catch {
if (!cancelled) toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
if (!cancelled) setCaptureMode(null);
}
}
void capture();
return () => {
cancelled = true;
};
}, [capturing, captureMode, fileName, format, height, language, localizeFileName, width]);
return (
<>
{showLanguageSelector ? (
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
) : null}
<div className="flex flex-wrap gap-2">
{allowWithoutNotes && modeSelection === "inline"
? [
exportButton("with-notes", buttonLabel),
exportButton("without-notes", "ส่งออกภาพโดยไม่รวมโน้ต"),
]
: exportButton(null, buttonLabel)}
</div>
{allowWithoutNotes && modeSelection === "dialog" ? (
<Dialog open={modeDialogOpen} onOpenChange={setModeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>เลือกรูปแบบการส่งออก</DialogTitle>
<DialogDescription>เลือกว่าจะรวมโน้ตไว้ในภาพหรือไม่</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => requestCapture("with-notes")}
>
ส่งออกพร้อมโน้ต
</Button>
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => requestCapture("without-notes")}
>
ส่งออกโดยไม่รวมโน้ต
</Button>
</div>
</DialogContent>
</Dialog>
) : null}
<ExportLanguageDialog
open={pendingCaptureMode !== null}
onOpenChange={(open) => {
if (!open) setPendingCaptureMode(null);
}}
onSelect={selectLanguage}
/>
{capturing
? createPortal(
<div
aria-hidden="true"
className="pointer-events-none fixed top-0 left-[-10000px]"
>
<div
ref={captureRef}
data-export-language={language}
className={cn(
"bg-background p-10 text-foreground",
captureClassName,
)}
style={{ width, height }}
>
<style>{'[data-export-language="th"] [data-export-lang="en"],[data-export-language="en"] [data-export-lang="th"]{display:none!important}'}</style>
{children}
</div>
</div>,
document.body,
)
: null}
</>
);
}