374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink } from 'lucide-react';
|
|
import { PaperSize, DocxStyleConfig, DocxBorder } from '../types';
|
|
import { TYPOGRAPHY_STYLES } from '../constants';
|
|
// @ts-ignore
|
|
import * as docx from 'docx';
|
|
|
|
interface PreviewProps {
|
|
htmlContent: string;
|
|
onBack: () => void;
|
|
paperSize: PaperSize;
|
|
selectedStyleId?: string | null;
|
|
}
|
|
|
|
export const Preview: React.FC<PreviewProps> = ({ htmlContent, onBack, paperSize, selectedStyleId }) => {
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [successMsg, setSuccessMsg] = useState(false);
|
|
|
|
// Get current style
|
|
const style = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0];
|
|
|
|
// Extract unique fonts for display
|
|
const usedFonts = Array.from(new Set([
|
|
style.wordConfig.heading1.font,
|
|
style.wordConfig.heading2.font,
|
|
style.wordConfig.body.font
|
|
])).filter(Boolean);
|
|
|
|
// Helper to convert Points to Half-Points (docx standard)
|
|
const pt = (points: number) => points * 2;
|
|
|
|
// Helper to convert Inches/MM to Twips
|
|
const inchesToTwips = (inches: number) => Math.round(inches * 1440);
|
|
const mmToTwips = (mm: number) => Math.round(mm * (1440 / 25.4));
|
|
|
|
// Helper to map Border config to Docx Border
|
|
const mapBorder = (b?: DocxBorder) => {
|
|
if (!b) return undefined;
|
|
let style = docx.BorderStyle.SINGLE;
|
|
if (b.style === 'double') style = docx.BorderStyle.DOUBLE;
|
|
if (b.style === 'dotted') style = docx.BorderStyle.DOTTED;
|
|
if (b.style === 'dashed') style = docx.BorderStyle.DASHED;
|
|
|
|
return {
|
|
color: b.color,
|
|
space: b.space,
|
|
style: style,
|
|
size: b.size
|
|
};
|
|
};
|
|
|
|
const generateDocx = async (styleId: string) => {
|
|
setIsExporting(true);
|
|
try {
|
|
const style = TYPOGRAPHY_STYLES.find(s => s.id === styleId) || TYPOGRAPHY_STYLES[0];
|
|
const cfg = style.wordConfig;
|
|
|
|
// PARSE HTML
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(htmlContent, 'text/html');
|
|
const nodes = Array.from(doc.body.childNodes);
|
|
|
|
const docxChildren = [];
|
|
|
|
for (const node of nodes) {
|
|
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
const el = node as HTMLElement;
|
|
const tagName = el.tagName.toLowerCase();
|
|
|
|
// --- Run Parser (Inline Styles) ---
|
|
const parseRuns = (element: HTMLElement, baseConfig: DocxStyleConfig) => {
|
|
const runs = [];
|
|
for (const child of Array.from(element.childNodes)) {
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
runs.push(new docx.TextRun({
|
|
text: child.textContent || '',
|
|
font: baseConfig.font,
|
|
size: pt(baseConfig.size),
|
|
color: baseConfig.color,
|
|
underline: baseConfig.underline ? { type: docx.UnderlineType.SINGLE, color: baseConfig.color } : undefined,
|
|
}));
|
|
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
const childEl = child as HTMLElement;
|
|
const isBold = childEl.tagName === 'STRONG' || childEl.tagName === 'B';
|
|
const isItalic = childEl.tagName === 'EM' || childEl.tagName === 'I';
|
|
runs.push(new docx.TextRun({
|
|
text: childEl.textContent || '',
|
|
bold: isBold,
|
|
italics: isItalic,
|
|
font: baseConfig.font,
|
|
size: pt(baseConfig.size),
|
|
color: baseConfig.color,
|
|
underline: baseConfig.underline ? { type: docx.UnderlineType.SINGLE, color: baseConfig.color } : undefined,
|
|
}));
|
|
}
|
|
}
|
|
return runs;
|
|
};
|
|
|
|
|
|
// --- Block Parser ---
|
|
|
|
// 1. HEADINGS
|
|
if (tagName.match(/^h[1-6]$/)) {
|
|
const level = parseInt(tagName.replace('h', ''));
|
|
|
|
// Select config based on level (simplified to H1 or H2, fallback H2 for others)
|
|
const hCfg = level === 1 ? cfg.heading1 : cfg.heading2;
|
|
const headingLevel = level === 1 ? docx.HeadingLevel.HEADING_1 : docx.HeadingLevel.HEADING_2;
|
|
|
|
// Border Mapping
|
|
const borderConfig: any = {};
|
|
if (hCfg.border) {
|
|
if (hCfg.border.top) borderConfig.top = mapBorder(hCfg.border.top);
|
|
if (hCfg.border.bottom) borderConfig.bottom = mapBorder(hCfg.border.bottom);
|
|
if (hCfg.border.left) borderConfig.left = mapBorder(hCfg.border.left);
|
|
if (hCfg.border.right) borderConfig.right = mapBorder(hCfg.border.right);
|
|
}
|
|
|
|
// Alignment Mapping
|
|
let align = docx.AlignmentType.LEFT;
|
|
if (hCfg.align === 'center') align = docx.AlignmentType.CENTER;
|
|
if (hCfg.align === 'right') align = docx.AlignmentType.RIGHT;
|
|
if (hCfg.align === 'both') align = docx.AlignmentType.BOTH;
|
|
|
|
docxChildren.push(new docx.Paragraph({
|
|
children: [
|
|
new docx.TextRun({
|
|
text: el.textContent || '',
|
|
font: hCfg.font,
|
|
bold: hCfg.bold,
|
|
italics: hCfg.italic,
|
|
underline: hCfg.underline ? { type: docx.UnderlineType.SINGLE, color: hCfg.color } : undefined,
|
|
size: pt(hCfg.size),
|
|
color: hCfg.color,
|
|
allCaps: hCfg.allCaps,
|
|
smallCaps: hCfg.smallCaps,
|
|
characterSpacing: hCfg.tracking
|
|
})
|
|
],
|
|
heading: headingLevel,
|
|
alignment: align,
|
|
spacing: {
|
|
before: hCfg.spacing?.before,
|
|
after: hCfg.spacing?.after,
|
|
line: hCfg.spacing?.line
|
|
},
|
|
border: borderConfig,
|
|
shading: hCfg.shading ? {
|
|
fill: hCfg.shading.fill,
|
|
color: hCfg.shading.color,
|
|
type: docx.ShadingType.CLEAR // usually clear to show fill
|
|
} : undefined,
|
|
keepNext: true,
|
|
keepLines: true
|
|
}));
|
|
}
|
|
|
|
// 2. PARAGRAPHS
|
|
else if (tagName === 'p') {
|
|
let align = docx.AlignmentType.LEFT;
|
|
if (cfg.body.align === 'center') align = docx.AlignmentType.CENTER;
|
|
if (cfg.body.align === 'right') align = docx.AlignmentType.RIGHT;
|
|
if (cfg.body.align === 'both') align = docx.AlignmentType.BOTH;
|
|
|
|
docxChildren.push(new docx.Paragraph({
|
|
children: parseRuns(el, cfg.body),
|
|
spacing: {
|
|
before: cfg.body.spacing?.before,
|
|
after: cfg.body.spacing?.after,
|
|
line: cfg.body.spacing?.line,
|
|
lineRule: docx.LineRuleType.AUTO
|
|
},
|
|
alignment: align
|
|
}));
|
|
}
|
|
|
|
// 3. BLOCKQUOTES
|
|
else if (tagName === 'blockquote') {
|
|
docxChildren.push(new docx.Paragraph({
|
|
children: parseRuns(el, { ...cfg.body, size: cfg.body.size + 1, color: cfg.accentColor, italic: true } as DocxStyleConfig),
|
|
indent: { left: 720 }, // 0.5 inch
|
|
border: { left: { color: cfg.accentColor, space: 10, style: docx.BorderStyle.SINGLE, size: 24 } },
|
|
shading: { fill: "F8F8F8", type: docx.ShadingType.CLEAR, color: "auto" }, // Default light grey background for quotes
|
|
spacing: { before: 200, after: 200, line: 300 }
|
|
}));
|
|
}
|
|
|
|
// 4. LISTS
|
|
else if (tagName === 'ul' || tagName === 'ol') {
|
|
const listItems = Array.from(el.children);
|
|
for (const li of listItems) {
|
|
docxChildren.push(new docx.Paragraph({
|
|
children: parseRuns(li as HTMLElement, cfg.body),
|
|
bullet: { level: 0 },
|
|
spacing: { before: 80, after: 80 }
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create Document
|
|
const docxFile = new docx.Document({
|
|
sections: [{
|
|
properties: {
|
|
page: {
|
|
size: {
|
|
width: paperSize === 'A4' ? mmToTwips(210) : inchesToTwips(8.5),
|
|
height: paperSize === 'A4' ? mmToTwips(297) : inchesToTwips(11),
|
|
},
|
|
margin: {
|
|
top: inchesToTwips(1),
|
|
right: inchesToTwips(1.2),
|
|
bottom: inchesToTwips(1),
|
|
left: inchesToTwips(1.2),
|
|
}
|
|
}
|
|
},
|
|
children: docxChildren
|
|
}]
|
|
});
|
|
|
|
const blob = await docx.Packer.toBlob(docxFile);
|
|
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `typogenie-${style.name.replace(/\s+/g, '-').toLowerCase()}.docx`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
setSuccessMsg(true);
|
|
setTimeout(() => setSuccessMsg(false), 3000);
|
|
|
|
} catch (e) {
|
|
console.error("Docx Gen Error", e);
|
|
alert("Failed to generate DOCX");
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!iframeRef.current) return;
|
|
|
|
// We already have 'style' from the component scope, but useEffect needs to be robust
|
|
const currentStyle = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0];
|
|
|
|
const doc = iframeRef.current.contentDocument;
|
|
if (doc) {
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="${currentStyle.googleFontsImport}" rel="stylesheet">
|
|
<style>
|
|
body {
|
|
background-color: #52525b;
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 40px;
|
|
margin: 0;
|
|
font-family: sans-serif;
|
|
}
|
|
.page {
|
|
background: white;
|
|
width: ${paperSize === 'A4' ? '210mm' : '8.5in'};
|
|
min-height: ${paperSize === 'A4' ? '297mm' : '11in'};
|
|
padding: 25mm;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4);
|
|
box-sizing: border-box;
|
|
|
|
/* Inject default body color to ensure visibility in dark themes */
|
|
color: #${currentStyle.wordConfig.body.color};
|
|
|
|
/* User Selected Typography */
|
|
${currentStyle.previewCss}
|
|
}
|
|
|
|
.page * { box-sizing: border-box; }
|
|
.page img { max-width: 100%; }
|
|
.page table { width: 100%; border-collapse: collapse; margin-bottom: 1em; }
|
|
.page th, .page td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
${htmlContent}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
doc.open();
|
|
doc.write(html);
|
|
doc.close();
|
|
}
|
|
}, [htmlContent, paperSize, selectedStyleId]);
|
|
|
|
return (
|
|
<div className="h-screen flex flex-col bg-zinc-900">
|
|
{/* Toolbar */}
|
|
<div className="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 p-4">
|
|
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
<span>Back to Editor</span>
|
|
</button>
|
|
|
|
<div className="flex flex-col sm:flex-row items-center gap-6">
|
|
|
|
{/* Font List */}
|
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
|
<span className="hidden md:inline">Fonts:</span>
|
|
<div className="flex gap-2">
|
|
{usedFonts.map(font => (
|
|
<a
|
|
key={font}
|
|
href={`https://fonts.google.com/specimen/${font.replace(/\s+/g, '+')}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-2 py-1 bg-zinc-900 border border-zinc-800 hover:border-zinc-600 rounded text-zinc-300 hover:text-white transition-all flex items-center gap-1.5 text-xs font-medium"
|
|
title={`Download ${font} from Google Fonts`}
|
|
>
|
|
{font}
|
|
<ExternalLink size={10} />
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-4 w-px bg-zinc-800 hidden sm:block" />
|
|
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-zinc-500 text-sm hidden sm:inline">
|
|
Format: {paperSize}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
// Ensure we pass a string for styleId
|
|
const sid = selectedStyleId || 'swiss-grid';
|
|
generateDocx(sid);
|
|
}}
|
|
disabled={isExporting}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-semibold transition-colors shadow-lg
|
|
${successMsg ? 'bg-emerald-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-500'}
|
|
${isExporting ? 'opacity-50 cursor-wait' : ''}`}
|
|
>
|
|
{successMsg ? (
|
|
<><CheckCircle2 size={18} /><span>Downloaded!</span></>
|
|
) : (
|
|
<><FileText size={18} /><span>{isExporting ? 'Generating...' : 'Download Word Doc'}</span></>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-grow relative bg-zinc-800 overflow-hidden">
|
|
<iframe
|
|
ref={iframeRef}
|
|
className="w-full h-full border-0 block"
|
|
title="Report Preview"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |