533 lines
18 KiB
TypeScript
533 lines
18 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,
|
|
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<HTMLElement, OriginalTextStyles>();
|
|
|
|
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<HTMLElement>("[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<HTMLElement>("*"))
|
|
.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<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);
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(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 (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className={mobileIconOnly ? "max-sm:size-11 max-sm:px-0 max-sm:has-data-[icon=inline-start]:pl-0" : "max-sm:min-h-11"}
|
|
disabled={capturing || disabled}
|
|
onClick={() => {
|
|
if (modeSelection === "direct") setCaptureMode("with-notes");
|
|
else 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>
|
|
);
|
|
}
|
|
|
|
function applyNoteMode(
|
|
node: HTMLDivElement,
|
|
mode: "with-notes" | "without-notes",
|
|
) {
|
|
node.querySelectorAll<HTMLElement>("[data-export-note]").forEach((note) => {
|
|
note.style.display = mode === "without-notes" ? "none" : "";
|
|
});
|
|
|
|
const right = node.querySelector<HTMLElement>(".artifact-export-right");
|
|
if (!right) return;
|
|
right.style.display = "";
|
|
if (mode === "without-notes") {
|
|
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 exportOptions = (
|
|
<>
|
|
{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}
|
|
</>
|
|
);
|
|
|
|
const exportAction = (
|
|
<Button type="button" className="max-sm:min-h-11" disabled={capturing || disabled} onClick={requestCapture}>
|
|
{capturing ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
|
|
{capturing ? "กำลังบันทึก…" : buttonLabel}
|
|
</Button>
|
|
);
|
|
|
|
const exportPreview = (
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-sm font-medium">ตัวอย่างภาพส่งออก</p>
|
|
<div
|
|
className={cn(
|
|
"relative flex h-[clamp(8rem,50svh,32rem)] items-center justify-center overflow-hidden rounded-lg border bg-muted/30 p-2",
|
|
modeSelection === "dialog" && squarePreview && "lg:h-80",
|
|
)}
|
|
>
|
|
{previewUrl ? (
|
|
// This is a temporary client-generated blob, not an optimizable remote asset.
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={previewUrl} alt="Preview image" className="size-full object-contain" />
|
|
) : previewing ? (
|
|
<Spinner className="size-6" />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">เกิดข้อผิดพลาดในการโชว์ Preview</p>
|
|
)}
|
|
{previewing && previewUrl ? (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
|
<Spinner className="size-6" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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" ? (
|
|
<div className="grid min-h-0 gap-4 lg:grid-cols-[16rem_fit-content(42rem)]">
|
|
<div className="flex min-w-0 flex-col gap-4">
|
|
{additionalOptions}
|
|
{exportOptions}
|
|
</div>
|
|
<div className="min-w-0">{exportPreview}</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{additionalOptions}
|
|
{exportPreview}
|
|
{exportOptions}
|
|
</>
|
|
)}
|
|
<DialogFooter className="sticky bottom-0">
|
|
{exportAction}
|
|
</DialogFooter>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">{exportButton(buttonLabel)}</div>
|
|
)}
|
|
{modeSelection === "dialog" ? (
|
|
<Dialog open={modeDialogOpen} onOpenChange={setModeDialogOpen}>
|
|
<DialogContent
|
|
className={cn(
|
|
"max-h-[90svh] overflow-y-auto sm:max-w-3xl",
|
|
previewPosition === "right" && "lg:max-w-[calc(100%-2rem)]",
|
|
previewPosition === "right" && (squarePreview || compactDialog ? "lg:w-156" : "lg:w-236"),
|
|
)}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>ตั้งค่าภาพที่จะบันทึก</DialogTitle>
|
|
<DialogDescription>เลือกตัวเลือกที่ต้องการก่อนบันทึกภาพ</DialogDescription>
|
|
</DialogHeader>
|
|
{previewPosition === "right" ? (
|
|
<div className="grid min-h-0 gap-4 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
|
<div className="flex min-w-0 flex-col gap-4">
|
|
{additionalOptions}
|
|
{exportOptions}
|
|
</div>
|
|
<div className="w-full min-w-0">{exportPreview}</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{additionalOptions}
|
|
{exportPreview}
|
|
{exportOptions}
|
|
</>
|
|
)}
|
|
<DialogFooter className="sticky bottom-0">
|
|
{exportAction}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
) : null}
|
|
{previewEnabled && typeof document !== "undefined"
|
|
? createPortal(
|
|
<div aria-hidden="true" className="pointer-events-none fixed top-0 left-[-10000px]">
|
|
<div
|
|
ref={previewRef}
|
|
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}
|
|
{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}
|
|
</>
|
|
);
|
|
}
|