o2o-infinith-demo/src/hooks/useExportPDF.ts
Haewon Kam da267fd744 feat: Content Director engine + report diagnosis/roadmap/KPI overhaul + PDF export fix
- Content Director (contentDirector.ts): deterministic 4-week editorial
  calendar engine — pillar-service matrix, channel-format slots, weekly
  themes (브랜드 정비 → 콘텐츠 엔진 → 소셜 증거 → 전환 최적화)
- transformPlan.ts: buildCalendar() delegates to Content Director with
  enrichment data (YouTube videos for repurposing)
- transformReport.ts: buildTransformation() generates rich per-channel
  platform strategies; buildRoadmap() creates Foundation/Content Engine/
  Optimization 3-phase plan; buildKpiDashboard() generates 10+ channel-
  specific metrics with targets
- ProblemDiagnosis: clustered 3 core issues (brand/content/funnel) in
  glass cards + expandable detail list
- RoadmapTimeline: Foundation/Content Engine/Optimization structure
- KPIDashboard: formatKpiValue() for human-readable numbers (150K, 1.5M)
- YouTubeAudit: metric-based diagnosis rows (subscriber ratio, upload freq)
- ContentCalendar: week theme labels, channel symbols, compact entries
- useExportPDF: triggerAllAnimations() scrolls all sections to fire
  whileInView before capture; isDarkSection() keeps dark sections whole;
  forceVisible() 2-pass opacity/transform override

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:07:39 +09:00

286 lines
9.5 KiB
TypeScript

import { useState, useCallback } from 'react';
/**
* PDF Export — block-based strategy.
*
* Each section is broken into atomic sub-blocks that are never split across
* pages. Dark sections with Framer Motion animations require special handling:
* we scroll every section into view to trigger `whileInView`, then force all
* inline opacity/transform to their final state before html2canvas captures.
*/
function isDarkSection(section: HTMLElement): boolean {
const bg = window.getComputedStyle(section).backgroundColor;
if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return false;
// Parse rgb(r, g, b) — dark if luminance is low
const match = bg.match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!match) return section.classList.contains('bg-[#0A1128]');
const [, r, g, b] = match.map(Number);
return (r * 0.299 + g * 0.587 + b * 0.114) < 50;
}
function collectSubBlocks(section: HTMLElement): HTMLElement[] {
// Dark sections should be captured whole — sub-blocks would lose the dark background
if (isDarkSection(section)) return [section];
if (section.offsetHeight <= 900) return [section];
const children = Array.from(section.children) as HTMLElement[];
if (children.length <= 1) return [section];
const blocks: HTMLElement[] = [];
for (const child of children) {
if (child.offsetHeight === 0 || child.offsetWidth === 0) continue;
blocks.push(child);
}
return blocks.length > 0 ? blocks : [section];
}
/**
* Scroll through every section to trigger all `whileInView` animations,
* then wait for them to settle. This ensures Framer Motion has applied
* its final inline styles before we snapshot.
*/
async function triggerAllAnimations(contentEl: HTMLElement): Promise<void> {
const sections = Array.from(contentEl.children) as HTMLElement[];
for (const section of sections) {
section.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
// Give framer-motion time to observe intersection and apply styles
await new Promise((r) => setTimeout(r, 80));
// Also scroll sub-children into view for nested whileInView
const motionChildren = section.querySelectorAll('[style]');
for (let i = 0; i < Math.min(motionChildren.length, 20); i++) {
(motionChildren[i] as HTMLElement).scrollIntoView({ behavior: 'instant' as ScrollBehavior });
await new Promise((r) => setTimeout(r, 30));
}
}
// Scroll back to top and let everything settle
window.scrollTo(0, 0);
await new Promise((r) => setTimeout(r, 200));
}
/**
* Force ALL elements to be fully visible — opacity 1, no transforms.
* This catches any remaining elements that whileInView didn't reach.
*/
function forceVisible(contentEl: HTMLElement): (() => void) {
document.documentElement.style.setProperty('--motion-duration', '0s');
const saved: { el: HTMLElement; cssText: string }[] = [];
contentEl.querySelectorAll('*').forEach((node) => {
const el = node as HTMLElement;
const s = el.style;
// Check both inline and computed
const needsFix =
(s.opacity !== '' && s.opacity !== '1') ||
(s.transform !== '' && s.transform !== 'none') ||
s.visibility === 'hidden';
if (needsFix) {
saved.push({ el, cssText: s.cssText });
s.opacity = '1';
s.transform = 'none';
s.visibility = 'visible';
}
});
// Second pass: catch computed opacity < 1 (motion might set via class)
contentEl.querySelectorAll('*').forEach((node) => {
const el = node as HTMLElement;
const computed = window.getComputedStyle(el);
if (parseFloat(computed.opacity) < 0.99) {
if (!saved.find((s) => s.el === el)) {
saved.push({ el, cssText: el.style.cssText });
}
el.style.opacity = '1';
el.style.transform = 'none';
}
});
return () => {
saved.forEach(({ el, cssText }) => {
el.style.cssText = cssText;
});
document.documentElement.style.removeProperty('--motion-duration');
};
}
/**
* CSS overrides for export: unwrap overflow, prevent clipping.
*/
function addExportCSS(): (() => void) {
const style = document.createElement('style');
style.id = 'pdf-export-overrides';
style.textContent = `
[data-report-content] .overflow-x-auto,
[data-plan-content] .overflow-x-auto {
overflow: visible !important;
flex-wrap: wrap !important;
}
[data-report-content] .scrollbar-thin,
[data-plan-content] .scrollbar-thin {
overflow: visible !important;
}
[data-report-content] .shrink-0,
[data-plan-content] .shrink-0 {
flex-shrink: 1 !important;
min-width: 0 !important;
}
`;
document.head.appendChild(style);
return () => style.remove();
}
export function useExportPDF() {
const [isExporting, setIsExporting] = useState(false);
const exportPDF = useCallback(async (filename = 'INFINITH_Marketing_Intelligence_Report') => {
setIsExporting(true);
try {
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas-pro'),
import('jspdf'),
]);
const contentEl =
(document.querySelector('[data-report-content]') as HTMLElement) ||
(document.querySelector('[data-plan-content]') as HTMLElement);
if (!contentEl) throw new Error('Report content element not found');
// Step 1: Scroll through all sections to trigger whileInView animations
await triggerAllAnimations(contentEl);
// Step 2: Force everything visible (catch stragglers)
const restoreVisible = forceVisible(contentEl);
// Step 3: CSS overrides
const restoreCSS = addExportCSS();
// Step 4: Hide UI elements
const hideSelectors = [
'[data-report-nav]',
'[data-plan-nav]',
'nav',
'[data-cta-card]',
'[data-no-print]',
];
const hiddenEls: { el: HTMLElement; display: string }[] = [];
hideSelectors.forEach((sel) => {
document.querySelectorAll(sel).forEach((el) => {
const htmlEl = el as HTMLElement;
hiddenEls.push({ el: htmlEl, display: htmlEl.style.display });
htmlEl.style.display = 'none';
});
});
await new Promise((r) => setTimeout(r, 150));
// Step 5: PDF generation
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = 210;
const pageHeight = 297;
const margin = 8;
const usableWidth = pageWidth - margin * 2;
const footerSpace = 10;
const maxContentY = pageHeight - margin - footerSpace;
let currentY = margin;
const sections = Array.from(contentEl.children) as HTMLElement[];
for (const section of sections) {
if (section.offsetHeight === 0 || section.offsetWidth === 0) continue;
if (window.getComputedStyle(section).display === 'none') continue;
const blocks = collectSubBlocks(section);
for (const block of blocks) {
if (block.offsetHeight === 0) continue;
const canvas = await html2canvas(block, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: null,
windowWidth: 1280,
removeContainer: true,
});
const blockHeightMM = (canvas.height * usableWidth) / canvas.width;
// New page if block doesn't fit
if (currentY + blockHeightMM > maxContentY && currentY > margin + 5) {
pdf.addPage();
currentY = margin;
}
// Tall block: slice across pages
if (blockHeightMM > maxContentY - margin) {
const pxPerMM = canvas.width / usableWidth;
let srcY = 0;
let remainPx = canvas.height;
let isFirst = true;
while (remainPx > 0) {
if (!isFirst) { pdf.addPage(); currentY = margin; }
isFirst = false;
const availPx = (maxContentY - currentY) * pxPerMM;
const sliceH = Math.min(remainPx, availPx);
const sliceCanvas = document.createElement('canvas');
sliceCanvas.width = canvas.width;
sliceCanvas.height = Math.ceil(sliceH);
const ctx = sliceCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(canvas, 0, srcY, canvas.width, Math.ceil(sliceH), 0, 0, canvas.width, Math.ceil(sliceH));
}
const sliceMM = sliceH / pxPerMM;
pdf.addImage(sliceCanvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, sliceMM);
currentY += sliceMM;
srcY += sliceH;
remainPx -= sliceH;
}
} else {
pdf.addImage(canvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, blockHeightMM);
currentY += blockHeightMM;
}
}
currentY += 2; // Gap between sections
}
// Step 6: Restore
hiddenEls.forEach(({ el, display }) => { el.style.display = display; });
restoreCSS();
restoreVisible();
// Step 7: Footers
const totalPages = pdf.getNumberOfPages();
const footerLabel = filename.includes('Plan')
? 'INFINITH Marketing Plan'
: 'INFINITH Marketing Intelligence Report';
for (let i = 1; i <= totalPages; i++) {
pdf.setPage(i);
pdf.setFontSize(7);
pdf.setTextColor(180);
pdf.text(`${footerLabel} | Page ${i} / ${totalPages}`, pageWidth / 2, pageHeight - 5, { align: 'center' });
}
pdf.save(`${filename}.pdf`);
} catch (err) {
console.error('PDF export failed:', err);
} finally {
setIsExporting(false);
}
}, []);
return { exportPDF, isExporting };
}