fix(capture): Windows Chrome 에서 이미지 캡처 실패 — 외부 폰트 임베드 회피
Deploy momo-erp / deploy (push) Failing after 42s
Deploy momo-erp / deploy (push) Failing after 42s
- html-to-image 의 toPng 가 Pretendard CDN 임베드 단계에서 fail 하면 캡처 전체가 깨짐 (Windows Chrome 에서 자주 발생). skipFonts + cacheBust + jpeg fallback 추가 - 거래명세표(orders) / 발주서(procurements) 양쪽이 같은 코드를 복붙으로 갖고 있던 걸 lib/capture-share.ts 로 통합 - 실패 시 err.message 를 swal 에 노출 (이전엔 "잠시 후 다시 시도하세요" 만 떠서 사용자가 원인 추적 불가) - navigator.share 의 AbortError(사용자 취소) 는 silent 처리 + 그 외엔 다운로드 폴백 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { Check, Download, X, RefreshCcw, Truck, AlertCircle, Package } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { captureAndShare } from "@/lib/capture-share";
|
||||
|
||||
interface Order {
|
||||
OBJID: string; ORDER_NO: string; ORDER_DATE: string;
|
||||
@@ -391,42 +392,14 @@ function StatementPreview({
|
||||
const editable = order.STATUS === "REQUESTED";
|
||||
|
||||
// 거래명세표를 이미지로 캡처 → 공유 또는 다운로드
|
||||
const captureAndShare = async () => {
|
||||
try {
|
||||
const mod = await import("html-to-image");
|
||||
if (!statementRef.current) return;
|
||||
// 거래처에 보낼 이미지에서 "현재고"는 내부 정보라 숨긴다.
|
||||
const hideEls = statementRef.current.querySelectorAll<HTMLElement>(".js-no-export");
|
||||
const prev: { el: HTMLElement; display: string }[] = [];
|
||||
hideEls.forEach((el) => { prev.push({ el, display: el.style.display }); el.style.display = "none"; });
|
||||
let dataUrl: string;
|
||||
try {
|
||||
dataUrl = await mod.toPng(statementRef.current, {
|
||||
backgroundColor: "#ffffff",
|
||||
pixelRatio: 2,
|
||||
});
|
||||
} finally {
|
||||
// 복원
|
||||
prev.forEach((p) => { p.el.style.display = p.display; });
|
||||
}
|
||||
const blob = await (await fetch(dataUrl)).blob();
|
||||
const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" });
|
||||
// Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유
|
||||
const navAny = navigator as Navigator & { canShare?: (data: { files: File[] }) => boolean };
|
||||
if (navAny.canShare && navAny.canShare({ files: [file] })) {
|
||||
await navigator.share({ files: [file], title: `${order.ORDER_NO} 거래명세표`, text: order.COMPANY_NAME });
|
||||
return;
|
||||
}
|
||||
// 폴백: 이미지 파일 다운로드
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = `${order.ORDER_NO}_거래명세표.png`;
|
||||
a.click();
|
||||
Swal.fire({ icon: "success", title: "이미지 저장 완료", text: "다운로드한 이미지를 카톡 등에 첨부해 보내세요.", timer: 1500, showConfirmButton: false });
|
||||
} catch (err) {
|
||||
console.error("[capture]", err);
|
||||
Swal.fire({ icon: "error", title: "이미지 캡처 실패", text: "잠시 후 다시 시도하세요." });
|
||||
}
|
||||
const handleCapture = async () => {
|
||||
if (!statementRef.current) return;
|
||||
await captureAndShare({
|
||||
node: statementRef.current,
|
||||
filename: `${order.ORDER_NO}_거래명세표`,
|
||||
shareTitle: `${order.ORDER_NO} 거래명세표`,
|
||||
shareText: order.COMPANY_NAME,
|
||||
});
|
||||
};
|
||||
|
||||
// 비고 저장
|
||||
@@ -484,7 +457,7 @@ function StatementPreview({
|
||||
<div className="flex justify-end gap-2 print:hidden flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={captureAndShare}
|
||||
onClick={handleCapture}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-amber-100 text-amber-800 text-xs font-bold hover:bg-amber-200"
|
||||
title="이미지로 캡처해 카톡 등에 공유"
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Plus, Send, Search, RefreshCcw, X, Download, Image as ImageIcon } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { captureAndShare } from "@/lib/capture-share";
|
||||
|
||||
interface ProcRow {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string;
|
||||
@@ -289,27 +290,14 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
const editable = detail.proc.STATUS === "OPEN";
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const captureAndShare = async () => {
|
||||
try {
|
||||
const mod = await import("html-to-image");
|
||||
if (!formRef.current) return;
|
||||
const dataUrl = await mod.toPng(formRef.current, { backgroundColor: "#ffffff", pixelRatio: 2 });
|
||||
const blob = await (await fetch(dataUrl)).blob();
|
||||
const file = new File([blob], `${detail.proc.PROC_NO}_발주서.png`, { type: "image/png" });
|
||||
const navAny = navigator as Navigator & { canShare?: (data: { files: File[] }) => boolean };
|
||||
if (navAny.canShare && navAny.canShare({ files: [file] })) {
|
||||
await navigator.share({ files: [file], title: `${detail.proc.PROC_NO} 발주서`, text: detail.proc.VENDOR_NAME ?? "" });
|
||||
return;
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = `${detail.proc.PROC_NO}_발주서.png`;
|
||||
a.click();
|
||||
Swal.fire({ icon: "success", title: "이미지 저장 완료", text: "다운로드한 이미지를 카톡 등에 첨부해 보내세요.", timer: 1500, showConfirmButton: false });
|
||||
} catch (err) {
|
||||
console.error("[capture]", err);
|
||||
Swal.fire({ icon: "error", title: "이미지 캡처 실패", text: "잠시 후 다시 시도하세요." });
|
||||
}
|
||||
const handleCapture = async () => {
|
||||
if (!formRef.current) return;
|
||||
await captureAndShare({
|
||||
node: formRef.current,
|
||||
filename: `${detail.proc.PROC_NO}_발주서`,
|
||||
shareTitle: `${detail.proc.PROC_NO} 발주서`,
|
||||
shareText: detail.proc.VENDOR_NAME ?? "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -318,7 +306,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
<div className="flex justify-end gap-2 mb-2 print:hidden flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={captureAndShare}
|
||||
onClick={handleCapture}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-amber-100 text-amber-800 text-xs font-bold hover:bg-amber-200"
|
||||
title="이미지로 캡처해 카톡 등에 공유"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// 거래명세표 / 발주서 등 DOM 영역을 PNG 로 캡처해 공유 또는 다운로드.
|
||||
// Windows Chrome 에서 html-to-image 의 toPng 가 외부 폰트(Pretendard CDN) 임베드 단계에서
|
||||
// 실패하는 케이스를 회피하기 위해 skipFonts + cacheBust + jpeg fallback 을 사용한다.
|
||||
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface CaptureShareOptions {
|
||||
/** 캡처 대상 DOM (영역 외 버튼은 미리 .js-no-export 클래스로 hide 처리됨) */
|
||||
node: HTMLElement;
|
||||
/** 저장 파일명 베이스 (확장자 제외) */
|
||||
filename: string;
|
||||
/** 공유 타이틀 (Web Share API) */
|
||||
shareTitle: string;
|
||||
/** 공유 텍스트 */
|
||||
shareText?: string;
|
||||
}
|
||||
|
||||
export async function captureAndShare({ node, filename, shareTitle, shareText }: CaptureShareOptions): Promise<void> {
|
||||
// 거래처에 보낼 이미지에서 내부 정보(.js-no-export)는 잠시 숨긴다.
|
||||
const hideEls = node.querySelectorAll<HTMLElement>(".js-no-export");
|
||||
const prev: { el: HTMLElement; display: string }[] = [];
|
||||
hideEls.forEach((el) => { prev.push({ el, display: el.style.display }); el.style.display = "none"; });
|
||||
|
||||
let dataUrl: string;
|
||||
try {
|
||||
const mod = await import("html-to-image");
|
||||
const baseOpts = {
|
||||
backgroundColor: "#ffffff",
|
||||
pixelRatio: 2,
|
||||
cacheBust: true,
|
||||
// Windows Chrome 에서 외부 CDN 폰트(Pretendard) 임베드 단계가
|
||||
// CORS/네트워크 문제로 실패하면 toPng 전체가 깨짐 → 폰트 임베드 스킵
|
||||
skipFonts: true,
|
||||
} as const;
|
||||
|
||||
try {
|
||||
dataUrl = await mod.toPng(node, baseOpts);
|
||||
} catch (pngErr) {
|
||||
console.warn("[capture] toPng 실패, toJpeg 로 재시도:", pngErr instanceof Error ? pngErr.message : pngErr);
|
||||
dataUrl = await mod.toJpeg(node, { ...baseOpts, quality: 0.95 });
|
||||
}
|
||||
} catch (err) {
|
||||
// 복원 후 에러 표시
|
||||
prev.forEach((p) => { p.el.style.display = p.display; });
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[capture] 캡처 자체 실패:", err);
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "이미지 캡처 실패",
|
||||
html: `<div class="text-left text-xs">${msg}</div><br><span class="text-xs text-slate-500">브라우저 콘솔(F12)에 자세한 로그가 있습니다.</span>`,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
// 캡처 끝나면 (성공/실패 무관) 숨겼던 요소 복원
|
||||
prev.forEach((p) => { p.el.style.display = p.display; });
|
||||
}
|
||||
|
||||
// 공유 또는 다운로드 단계
|
||||
try {
|
||||
const ext = dataUrl.startsWith("data:image/jpeg") ? "jpg" : "png";
|
||||
const mime = ext === "jpg" ? "image/jpeg" : "image/png";
|
||||
const blob = await (await fetch(dataUrl)).blob();
|
||||
const file = new File([blob], `${filename}.${ext}`, { type: mime });
|
||||
|
||||
// Web Share API (모바일 + 일부 데스크톱) - files 공유 지원 시
|
||||
const navAny = navigator as Navigator & { canShare?: (data: { files: File[] }) => boolean };
|
||||
if (navAny.canShare && navAny.canShare({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({ files: [file], title: shareTitle, text: shareText ?? "" });
|
||||
return;
|
||||
} catch (shareErr) {
|
||||
// 사용자가 취소했거나 share API 실패 → 다운로드로 폴백
|
||||
const name = (shareErr as Error)?.name;
|
||||
if (name === "AbortError") return; // 사용자 취소면 그냥 종료
|
||||
console.warn("[capture] navigator.share 실패, 다운로드로 폴백:", shareErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: 다운로드
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = `${filename}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
Swal.fire({
|
||||
icon: "success",
|
||||
title: "이미지 저장 완료",
|
||||
text: "다운로드한 이미지를 카톡 등에 첨부해 보내세요.",
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[capture] 공유/다운로드 단계 실패:", err);
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: "이미지 공유 실패",
|
||||
html: `<div class="text-left text-xs">${msg}</div>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user