Files
buzz-sheet/components/public/guide-image-export.tsx
T
gunshiz bfd9f1765d
CI / Verify (push) Successful in 1m37s
CI / Build immutable images and deploy (push) Successful in 2m5s
fix : allow team combo break line
2026-09-26 14:40:03 +07:00

516 lines
17 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",
}: {
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";
additionalOptions?: ReactNode;
previewPosition?: "top" | "right";
}) {
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;
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>
);
}
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" 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="flex min-h-32 items-center justify-center overflow-hidden rounded-lg border bg-muted/30 p-2">
{previewing ? (
<Spinner className="size-6" />
) : 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="ตัวอย่างภาพที่จะส่งออก" className="max-h-[50svh] max-w-full object-contain" />
) : (
<p className="text-sm text-muted-foreground">ไม่สามารถสร้างตัวอย่างได้</p>
)}
</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" && "sm:w-fit sm:max-w-[calc(100%-2rem)]",
)}
>
<DialogHeader>
<DialogTitle>ตั้งค่าการส่งออกภาพ</DialogTitle>
<DialogDescription>เลือกตัวเลือกที่ต้องการก่อนส่งออกภาพ</DialogDescription>
</DialogHeader>
{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>
</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}
</>
);
}