"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, DialogFooter, 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; type OriginalTextStyles = { fontSize: string; overflowWrap: string; textOverflow: string; webkitLineClamp: string; whiteSpace: string; wordBreak: string; }; const originalTextStyles = new WeakMap(); export function scaledExportFontSize( fontSize: number, contentWidth: number, availableWidth: number, ): number { if (fontSize <= 0 || contentWidth <= 0 || availableWidth <= 0) return fontSize; return Math.min(fontSize, fontSize * availableWidth / contentWidth); } function directTextContent(element: HTMLElement): boolean { return Array.from(element.childNodes).some( (node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(), ); } function restoreExportText(root: HTMLElement) { root.querySelectorAll("[data-export-fitted-text]").forEach((element) => { const original = originalTextStyles.get(element); if (!original) return; element.style.fontSize = original.fontSize; element.style.overflowWrap = original.overflowWrap; element.style.textOverflow = original.textOverflow; element.style.setProperty("-webkit-line-clamp", original.webkitLineClamp); element.style.whiteSpace = original.whiteSpace; element.style.wordBreak = original.wordBreak; element.removeAttribute("data-export-fitted-text"); originalTextStyles.delete(element); }); } function availableTextWidth(element: HTMLElement): number { if (element.clientWidth > 0) { const style = getComputedStyle(element); return element.clientWidth - Number.parseFloat(style.paddingLeft || "0") - Number.parseFloat(style.paddingRight || "0"); } const parent = element.parentElement; if (!parent) return 0; const style = getComputedStyle(parent); return parent.clientWidth - Number.parseFloat(style.paddingLeft || "0") - Number.parseFloat(style.paddingRight || "0"); } function textContentWidth(element: HTMLElement): number { if (element.scrollWidth > element.clientWidth) return element.scrollWidth; const range = document.createRange(); range.selectNodeContents(element); return range.getBoundingClientRect().width; } function fitExportText(root: HTMLElement) { restoreExportText(root); const candidates = Array.from(root.querySelectorAll("*")) .filter((element) => directTextContent(element)) .filter((element) => !element.closest("[data-export-allow-wrap]")) .filter((element) => { const style = getComputedStyle(element); return style.display !== "none" && style.visibility !== "hidden"; }) .filter((element, _index, elements) => !elements.some((parent) => parent !== element && parent.contains(element)), ); const availableWidths = new Map( candidates.map((element) => [element, availableTextWidth(element)]), ); candidates.forEach((element) => { originalTextStyles.set(element, { fontSize: element.style.fontSize, overflowWrap: element.style.overflowWrap, textOverflow: element.style.textOverflow, webkitLineClamp: element.style.getPropertyValue("-webkit-line-clamp"), whiteSpace: element.style.whiteSpace, wordBreak: element.style.wordBreak, }); element.dataset.exportFittedText = ""; element.style.setProperty("overflow-wrap", "normal", "important"); element.style.setProperty("text-overflow", "clip", "important"); element.style.setProperty("-webkit-line-clamp", "unset", "important"); element.style.setProperty("white-space", "nowrap", "important"); element.style.setProperty("word-break", "normal", "important"); }); candidates.forEach((element) => { const availableWidth = availableWidths.get(element) ?? 0; const contentWidth = textContentWidth(element); const fontSize = Number.parseFloat(getComputedStyle(element).fontSize); const fittedSize = scaledExportFontSize(fontSize, contentWidth, availableWidth); if (fittedSize < fontSize) { element.style.setProperty("font-size", `${fittedSize}px`, "important"); } }); } 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", additionalOptions, previewPosition = "top", compactDialog = false, }: { 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" | "direct"; additionalOptions?: ReactNode; previewPosition?: "top" | "right"; compactDialog?: boolean; }) { const captureRef = useRef(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); const previewRef = useRef(null); const [previewUrl, setPreviewUrl] = useState(null); const [previewing, setPreviewing] = useState(false); const previewEnabled = modeSelection === "inline" || modeDialogOpen; const squarePreview = height !== undefined && width === height; function requestCapture() { setModeDialogOpen(false); setCaptureMode(selectedMode); } function exportButton(label: string) { return ( ); } function applyNoteMode( node: HTMLDivElement, mode: "with-notes" | "without-notes", ) { node.querySelectorAll("[data-export-note]").forEach((note) => { note.style.display = mode === "without-notes" ? "none" : ""; }); const right = node.querySelector(".artifact-export-right"); if (!right) return; right.style.display = ""; if (mode === "without-notes") { const sections = right.querySelectorAll( "[data-artifact-export-section]", ); const hasVisibleSection = Array.from(sections).some( (section) => getComputedStyle(section).display !== "none", ); if (!hasVisibleSection) right.style.display = "none"; } } const exportOptions = ( <> {showLanguageSelector || promptForLanguage ? (

ภาษาในภาพ

) : null} {allowWithoutNotes ? (

โน้ต

{ const next = values[0]; if (next === "with-notes" || next === "without-notes") setSelectedMode(next); }} > รวมโน้ต ไม่รวมโน้ต
) : null} ); const exportAction = ( ); const exportPreview = (

ตัวอย่างภาพส่งออก

{previewUrl ? ( // This is a temporary client-generated blob, not an optimizable remote asset. // eslint-disable-next-line @next/next/no-img-element Preview image ) : previewing ? ( ) : (

เกิดข้อผิดพลาดในการโชว์ Preview

)} {previewing && previewUrl ? (
) : null}
); useEffect(() => { if (!previewEnabled) return; let cancelled = false; let nextUrl: string | null = null; async function createPreview() { setPreviewing(true); try { const node = previewRef.current; if (!node) throw new Error("Preview target is unavailable"); applyNoteMode(node, selectedMode); const { domToBlob, waitUntilLoad } = await import("modern-screenshot"); await Promise.all([document.fonts.ready, waitUntilLoad(node)]); fitExportText(node); const blob = await domToBlob(node, { backgroundColor: getComputedStyle(node).backgroundColor, type: `image/${format}`, scale: 1, width, height, }); if (cancelled) return; nextUrl = URL.createObjectURL(blob); setPreviewUrl((current) => { if (current) URL.revokeObjectURL(current); return nextUrl; }); } catch { if (!cancelled) setPreviewUrl(null); } finally { if (!cancelled) setPreviewing(false); } } void createPreview(); return () => { cancelled = true; if (nextUrl) URL.revokeObjectURL(nextUrl); }; }, [children, format, height, language, previewEnabled, selectedMode, width]); useEffect(() => { if (!capturing) return; let cancelled = false; async function capture() { const activeCaptureMode = captureMode; if (!activeCaptureMode) return; try { const node = captureRef.current; if (!node) throw new Error("Capture target is unavailable"); applyNoteMode(node, activeCaptureMode); const { domToBlob, waitUntilLoad } = await import("modern-screenshot"); await Promise.all([document.fonts.ready, waitUntilLoad(node)]); fitExportText(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, activeCaptureMode === "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" ? ( <> {previewPosition === "right" ? (
{additionalOptions} {exportOptions}
{exportPreview}
) : ( <> {additionalOptions} {exportPreview} {exportOptions} )} {exportAction} ) : (
{exportButton(buttonLabel)}
)} {modeSelection === "dialog" ? ( ตั้งค่าภาพที่จะบันทึก เลือกตัวเลือกที่ต้องการก่อนบันทึกภาพ {previewPosition === "right" ? (
{additionalOptions} {exportOptions}
{exportPreview}
) : ( <> {additionalOptions} {exportPreview} {exportOptions} )} {exportAction}
) : null} {previewEnabled && typeof document !== "undefined" ? createPortal( , document.body, ) : null} {capturing ? createPortal( , document.body, ) : null} ); }