- 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>
286 lines
9.5 KiB
TypeScript
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 };
|
|
}
|