278 lines
8.9 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|