263 lines
8.5 KiB
TypeScript
263 lines
8.5 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 {
|
|
ExportLanguageToggle,
|
|
useExportLanguage,
|
|
type ExportLanguage,
|
|
} from "@/components/public/export-language";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
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 [selectedMode, setSelectedMode] = useState<"with-notes" | "without-notes">(
|
|
"with-notes",
|
|
);
|
|
const [modeDialogOpen, setModeDialogOpen] = useState(false);
|
|
|
|
function requestCapture() {
|
|
setModeDialogOpen(false);
|
|
setCaptureMode(selectedMode);
|
|
}
|
|
|
|
function exportButton(label: string) {
|
|
return (
|
|
<Button
|
|
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={() => setModeDialogOpen(true)}
|
|
>
|
|
{capturing ? (
|
|
<Spinner data-icon="inline-start" />
|
|
) : (
|
|
<DownloadIcon data-icon="inline-start" />
|
|
)}
|
|
<span className={mobileIconOnly ? "sr-only sm:not-sr-only" : undefined}>
|
|
{capturing ? "กำลังส่งออก…" : label}
|
|
</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
const exportSettings = (
|
|
<>
|
|
{showLanguageSelector || promptForLanguage ? (
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-sm font-medium">ภาษาในภาพ</p>
|
|
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
|
|
</div>
|
|
) : null}
|
|
{allowWithoutNotes ? (
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-sm font-medium">โน้ต</p>
|
|
<ToggleGroup
|
|
aria-label="การแสดงโน้ตในภาพส่งออก"
|
|
variant="outline"
|
|
value={[selectedMode]}
|
|
onValueChange={(values) => {
|
|
const next = values[0];
|
|
if (next === "with-notes" || next === "without-notes") setSelectedMode(next);
|
|
}}
|
|
>
|
|
<ToggleGroupItem value="with-notes">รวมโน้ต</ToggleGroupItem>
|
|
<ToggleGroupItem value="without-notes">ไม่รวมโน้ต</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</div>
|
|
) : null}
|
|
<Button type="button" disabled={capturing || disabled} onClick={requestCapture}>
|
|
{capturing ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
|
|
{capturing ? "กำลังส่งออก…" : buttonLabel}
|
|
</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 (
|
|
<>
|
|
{modeSelection === "inline" ? (
|
|
exportSettings
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">{exportButton(buttonLabel)}</div>
|
|
)}
|
|
{modeSelection === "dialog" ? (
|
|
<Dialog open={modeDialogOpen} onOpenChange={setModeDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>ตั้งค่าการส่งออกภาพ</DialogTitle>
|
|
<DialogDescription>เลือกตัวเลือกที่ต้องการก่อนส่งออกภาพ</DialogDescription>
|
|
</DialogHeader>
|
|
{exportSettings}
|
|
</DialogContent>
|
|
</Dialog>
|
|
) : null}
|
|
{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}
|
|
</>
|
|
);
|
|
}
|