Files
distribution_erp/src/lib/capture-share.ts
T
chpark 23599b9c18
Deploy momo-erp / deploy (push) Failing after 42s
fix(capture): Windows Chrome 에서 이미지 캡처 실패 — 외부 폰트 임베드 회피
- 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>
2026-05-08 14:10:52 +09:00

103 lines
4.1 KiB
TypeScript

// 거래명세표 / 발주서 등 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>`,
});
}
}