feat: port all template categories to JSON format
- Ported Minimalist templates to JSON (Swiss Grid, Brutalist, etc.) - Ported Tech templates to JSON (SaaS, Terminal, Cyberpunk, etc.) - Ported Creative templates to JSON (Art Gallery, Zine, Pop Art, etc.) - Ported Industrial templates to JSON (Blueprint, Factory, Schematic, etc.) - Ported Nature templates to JSON (Botanical, Ocean, Mountain, etc.) - Ported Lifestyle templates to JSON (Cookbook, Travel, Coffee House, etc.) - Ported Vintage templates to JSON (Art Deco, Medieval, Retro 80s, etc.) - Updated README.md to reflect the new JSON-based style system (example configuration and contribution workflow) - Completed migration of over 150 styles to the new architecture
This commit is contained in:
@@ -1,374 +1,434 @@
|
||||
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';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink, Loader2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { PaperSize } from '../types';
|
||||
import { StyleOption } from '../types';
|
||||
import { getPreviewCss } from '../services/templateRenderer';
|
||||
import { generateDocxDocument } from '../utils/docxConverter';
|
||||
import ExportOptionsModal from './ExportOptionsModal';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
|
||||
|
||||
interface PreviewProps {
|
||||
htmlContent: string;
|
||||
onBack: () => void;
|
||||
paperSize: PaperSize;
|
||||
selectedStyleId?: string | null;
|
||||
inputFileName?: string;
|
||||
uiZoom: number;
|
||||
onZoomChange: (zoom: number) => void;
|
||||
templates: StyleOption[];
|
||||
}
|
||||
|
||||
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);
|
||||
// Zoom Control Component
|
||||
const ZoomControl: React.FC<{ zoom: number; onZoomChange: (zoom: number) => void }> = ({ zoom, onZoomChange }) => {
|
||||
const decreaseZoom = () => onZoomChange(Math.max(50, zoom - 10));
|
||||
const increaseZoom = () => onZoomChange(Math.min(200, zoom + 10));
|
||||
|
||||
// Get current style
|
||||
const style = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0];
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-zinc-900/80 rounded-lg border border-zinc-800 px-2 py-1">
|
||||
<motion.button
|
||||
onClick={decreaseZoom}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</motion.button>
|
||||
<span className="text-xs font-medium text-zinc-300 min-w-[3rem] text-center">{zoom}%</span>
|
||||
<motion.button
|
||||
onClick={increaseZoom}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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);
|
||||
// Font download component
|
||||
const FontList: React.FC<{ fonts: string[] }> = ({ fonts }) => {
|
||||
const [downloadingFont, setDownloadingFont] = useState<string | null>(null);
|
||||
const [downloadStatus, setDownloadStatus] = useState<Record<string, string>>({});
|
||||
|
||||
// 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 downloadFont = async (fontName: string) => {
|
||||
setDownloadingFont(fontName);
|
||||
setDownloadStatus(prev => ({ ...prev, [fontName]: 'Downloading...' }));
|
||||
try {
|
||||
const encodedName = encodeURIComponent(fontName);
|
||||
const downloadUrl = `https://fonts.google.com/download?family=${encodedName}`;
|
||||
await open(downloadUrl);
|
||||
setDownloadStatus(prev => ({ ...prev, [fontName]: 'Opened in browser' }));
|
||||
setTimeout(() => {
|
||||
setDownloadStatus(prev => { const newStatus = { ...prev }; delete newStatus[fontName]; return newStatus; });
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setDownloadStatus(prev => ({ ...prev, [fontName]: 'Failed' }));
|
||||
} finally {
|
||||
setDownloadingFont(null);
|
||||
}
|
||||
};
|
||||
|
||||
const generateDocx = async (styleId: string) => {
|
||||
const openGoogleFonts = async (fontName: string) => {
|
||||
try {
|
||||
const url = `https://fonts.google.com/specimen/${fontName.replace(/\s+/g, '+')}`;
|
||||
await open(url);
|
||||
} catch (err) {
|
||||
window.open(`https://fonts.google.com/specimen/${fontName.replace(/\s+/g, '+')}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<span className="hidden md:inline">Fonts:</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{fonts.map((font, index) => (
|
||||
<motion.div key={font} className="relative group" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: index * 0.05 }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.button
|
||||
onClick={() => downloadFont(font)}
|
||||
disabled={downloadingFont === font}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-2 py-1 bg-zinc-900 border border-zinc-800 hover:border-zinc-600 rounded-l text-zinc-300 hover:text-white transition-all flex items-center gap-1.5 text-xs font-medium"
|
||||
>
|
||||
{downloadingFont === font ? <Loader2 size={10} className="animate-spin" /> : <Download size={10} />}
|
||||
{font}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => openGoogleFonts(font)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-1.5 py-1 bg-zinc-900 border border-l-0 border-zinc-800 hover:border-zinc-600 rounded-r text-zinc-400 hover:text-white transition-all"
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</motion.button>
|
||||
</div>
|
||||
{downloadStatus[font] && (
|
||||
<motion.div className="absolute top-full left-0 mt-1 px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-xs whitespace-nowrap z-50" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }}>
|
||||
{downloadStatus[font]}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Preview: React.FC<PreviewProps> = ({
|
||||
htmlContent,
|
||||
onBack,
|
||||
paperSize,
|
||||
selectedStyleId,
|
||||
inputFileName = 'document',
|
||||
uiZoom,
|
||||
onZoomChange,
|
||||
templates
|
||||
}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const backButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const saveButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
|
||||
|
||||
// Get current style from templates
|
||||
const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null;
|
||||
|
||||
// Extract used fonts for display
|
||||
const usedFonts = style ? Array.from(new Set([
|
||||
style.typography?.fonts?.heading || style.wordConfig?.heading1?.font || 'Arial',
|
||||
style.typography?.fonts?.body || style.wordConfig?.body?.font || 'Arial'
|
||||
])).filter(Boolean) : [];
|
||||
|
||||
useKeyboardNavigation({
|
||||
onEscape: () => onBack(),
|
||||
onArrowLeft: () => { if (focusedElement === 'save') { setFocusedElement('back'); backButtonRef.current?.focus(); } },
|
||||
onArrowRight: () => { if (focusedElement === 'back') { setFocusedElement('save'); saveButtonRef.current?.focus(); } },
|
||||
onEnter: () => { if (focusedElement === 'back') onBack(); else if (focusedElement === 'save' && !isExporting) handleSave(); },
|
||||
onCtrlEnter: () => { if (!isExporting) handleSave(); },
|
||||
}, [focusedElement, isExporting, onBack]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => saveButtonRef.current?.focus(), 100);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setShowExportModal(true);
|
||||
};
|
||||
|
||||
const handleExportConfirm = async (useTableHeaders: boolean) => {
|
||||
setShowExportModal(false);
|
||||
const sid = selectedStyleId || 'swiss-grid';
|
||||
await generateDocx(sid, useTableHeaders);
|
||||
};
|
||||
|
||||
const generateDocx = async (styleId: string, useTableHeaders: boolean = false) => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const style = TYPOGRAPHY_STYLES.find(s => s.id === styleId) || TYPOGRAPHY_STYLES[0];
|
||||
const cfg = style.wordConfig;
|
||||
const template = templates.find(s => s.id === styleId) || templates[0];
|
||||
|
||||
// PARSE HTML
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||
const nodes = Array.from(doc.body.childNodes);
|
||||
// Get base config from wordConfig
|
||||
const cfg = template.wordConfig || {
|
||||
heading1: { font: 'Arial', size: 24, color: '000000', align: 'left' },
|
||||
heading2: { font: 'Arial', size: 18, color: '333333', align: 'left' },
|
||||
body: { font: 'Arial', size: 11, color: '000000', align: 'left' },
|
||||
accentColor: '000000'
|
||||
};
|
||||
|
||||
const docxChildren = [];
|
||||
// Extract page background from template
|
||||
const pageBackground = template.typography?.colors?.background;
|
||||
|
||||
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;
|
||||
};
|
||||
// Get fonts from template
|
||||
const codeFont = template.typography?.fonts?.code || template.typography?.fonts?.body || 'Consolas';
|
||||
|
||||
// Detect dark theme
|
||||
const isDarkTheme = pageBackground ? parseInt(pageBackground, 16) < 0x444444 : false;
|
||||
|
||||
// --- 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;
|
||||
// DEBUG: Specific markdown constructs for DOCX
|
||||
console.log('=== PREVIEW CALLING DOCX (h3, strong, table, ul/ol, li) ===');
|
||||
console.log('wordConfig h3:', JSON.stringify({ font: cfg.heading2?.font, size: cfg.body?.size, color: template.elements?.h3?.color }));
|
||||
console.log('elements h3:', JSON.stringify(template.elements?.h3));
|
||||
console.log('elements strong:', JSON.stringify(template.elements?.strong));
|
||||
console.log('elements table:', JSON.stringify(template.elements?.table));
|
||||
console.log('elements th:', JSON.stringify(template.elements?.th));
|
||||
console.log('elements td:', JSON.stringify(template.elements?.td));
|
||||
console.log('elements ul:', JSON.stringify(template.elements?.ul));
|
||||
console.log('elements ol:', JSON.stringify(template.elements?.ol));
|
||||
console.log('elements li:', JSON.stringify(template.elements?.li));
|
||||
console.log('useTableHeaders:', useTableHeaders);
|
||||
console.log('=== END PREVIEW CALL ===');
|
||||
|
||||
// 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 generateDocxDocument(htmlContent, {
|
||||
paperSize,
|
||||
heading1: cfg.heading1,
|
||||
heading2: cfg.heading2,
|
||||
body: cfg.body,
|
||||
accentColor: cfg.accentColor,
|
||||
pageBackground,
|
||||
codeFont,
|
||||
isDarkTheme,
|
||||
elements: template.elements,
|
||||
fonts: template.typography?.fonts,
|
||||
palette: template.typography?.colors,
|
||||
id: template.id,
|
||||
page: template.page,
|
||||
useTableHeaders
|
||||
});
|
||||
|
||||
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);
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
const defaultName = `${inputFileName}.docx`;
|
||||
const savePath = await save({
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'Word Document', extensions: ['docx'] }]
|
||||
});
|
||||
|
||||
if (savePath) {
|
||||
await writeFile(savePath, uint8Array);
|
||||
setSuccessMsg(true);
|
||||
setTimeout(() => setSuccessMsg(false), 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Docx Gen Error", e);
|
||||
alert("Failed to generate DOCX");
|
||||
alert("Failed to generate DOCX: " + e);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Track blob URL for cleanup
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Render preview whenever dependencies change
|
||||
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];
|
||||
if (!iframeRef.current || !style) return;
|
||||
|
||||
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};
|
||||
// DEBUG: Specific markdown constructs for Preview
|
||||
console.log('=== PREVIEW DEBUG (h3, strong, table, ul/ol, li) ===');
|
||||
console.log('h3:', JSON.stringify(style.elements?.h3));
|
||||
console.log('strong:', JSON.stringify(style.elements?.strong));
|
||||
console.log('table:', JSON.stringify(style.elements?.table));
|
||||
console.log('th:', JSON.stringify(style.elements?.th));
|
||||
console.log('td:', JSON.stringify(style.elements?.td));
|
||||
console.log('ul:', JSON.stringify(style.elements?.ul));
|
||||
console.log('ol:', JSON.stringify(style.elements?.ol));
|
||||
console.log('li:', JSON.stringify(style.elements?.li));
|
||||
console.log('=== END PREVIEW DEBUG ===');
|
||||
|
||||
/* 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();
|
||||
// Cleanup old blob URL
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
}, [htmlContent, paperSize, selectedStyleId]);
|
||||
|
||||
// Get CSS from template - this generates from elements structure
|
||||
const templateCss = getPreviewCss(style);
|
||||
console.log('Generated preview CSS (first 500 chars):', templateCss.substring(0, 500));
|
||||
|
||||
// Get page background from template
|
||||
const pageBg = style.typography?.colors?.background || 'ffffff';
|
||||
|
||||
// Build the complete CSS - template CSS now includes .page styles
|
||||
const allCss = [
|
||||
/* Reset styles first */
|
||||
'::-webkit-scrollbar { width: 6px !important; height: 6px !important; }',
|
||||
'::-webkit-scrollbar-track { background: transparent !important; }',
|
||||
'::-webkit-scrollbar-thumb { background: #71717a !important; border-radius: 3px !important; }',
|
||||
'* { scrollbar-width: thin; scrollbar-color: #71717a transparent; box-sizing: border-box; }',
|
||||
'html, body { margin: 0; padding: 0; min-height: 100%; }',
|
||||
|
||||
/* Dark outer background */
|
||||
'body { background-color: #18181b; padding: 40px 20px; }',
|
||||
|
||||
/* Template CSS - includes .page styles with fonts, colors, etc. */
|
||||
templateCss,
|
||||
|
||||
/* Page dimensions (not included in template CSS) */
|
||||
`.page {`,
|
||||
` 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;`,
|
||||
` margin: 0 auto;`,
|
||||
`}`,
|
||||
|
||||
/* Utilities */
|
||||
'.page img { max-width: 100%; }'
|
||||
].join('\n');
|
||||
|
||||
// Inject CSS directly as inline style tag
|
||||
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="${style.googleFontsImport || 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'}" rel="stylesheet">
|
||||
<style>
|
||||
${allCss}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${htmlContent}
|
||||
<script>
|
||||
console.log('--- STAGE 2: PREVIEW COMPUTED STYLES ---');
|
||||
setTimeout(() => {
|
||||
['h1', 'h2', 'h3', 'p', 'table', 'th', 'td', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre'].forEach(tag => {
|
||||
const el = document.querySelector('.page ' + tag);
|
||||
if (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
|
||||
// Get borders specifically
|
||||
const borderTop = style.borderTopWidth + ' ' + style.borderTopStyle + ' ' + style.borderTopColor;
|
||||
const borderBottom = style.borderBottomWidth + ' ' + style.borderBottomStyle + ' ' + style.borderBottomColor;
|
||||
const borderLeft = style.borderLeftWidth + ' ' + style.borderLeftStyle + ' ' + style.borderLeftColor;
|
||||
const borderRight = style.borderRightWidth + ' ' + style.borderRightStyle + ' ' + style.borderRightColor;
|
||||
|
||||
console.log(\`PREVIEW \${tag.toUpperCase()}: \`, {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
fontWeight: style.fontWeight,
|
||||
border: \`T:\${borderTop} R:\${borderRight} B:\${borderBottom} L:\${borderLeft}\`,
|
||||
padding: style.padding,
|
||||
margin: style.margin
|
||||
});
|
||||
} else {
|
||||
console.log(\`PREVIEW \${tag.toUpperCase()}: Not found\`);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// Create blob URL for CSP compliance
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = blobUrl;
|
||||
iframeRef.current.src = blobUrl;
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [htmlContent, paperSize, selectedStyleId, templates, style]);
|
||||
|
||||
if (!style) {
|
||||
return <div className="h-screen flex items-center justify-center text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
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">
|
||||
<motion.div className="h-screen flex flex-col bg-zinc-950" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
|
||||
<motion.div className="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 p-4" initial={{ y: -20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}>
|
||||
<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"
|
||||
>
|
||||
<motion.button ref={backButtonRef} onClick={onBack} onFocus={() => setFocusedElement('back')} whileHover={{ x: -3 }} whileTap={{ scale: 0.95 }} className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-zinc-950 rounded-lg px-2 py-1">
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Editor</span>
|
||||
</button>
|
||||
</motion.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>
|
||||
|
||||
<FontList fonts={usedFonts} />
|
||||
<div className="h-4 w-px bg-zinc-800 hidden sm:block" />
|
||||
<ZoomControl zoom={uiZoom} onZoomChange={onZoomChange} />
|
||||
<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);
|
||||
}}
|
||||
<span className="text-zinc-500 text-sm hidden sm:inline">Format: {paperSize}</span>
|
||||
<motion.button
|
||||
ref={saveButtonRef}
|
||||
onClick={handleSave}
|
||||
onFocus={() => setFocusedElement('save')}
|
||||
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' : ''}`}
|
||||
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
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>
|
||||
<AnimatePresence mode="wait">
|
||||
{successMsg ? (
|
||||
<motion.div key="success" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2">
|
||||
<CheckCircle2 size={18} />
|
||||
<span>Saved!</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="default" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2">
|
||||
{isExporting ? <Loader2 size={18} className="animate-spin" /> : <FileText size={18} />}
|
||||
<span>{isExporting ? 'Generating...' : 'Save Word Doc'}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-grow relative bg-zinc-950 overflow-hidden preview-container">
|
||||
<iframe ref={iframeRef} className="w-full h-full border-0 block" title="Report Preview" />
|
||||
</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>
|
||||
<ExportOptionsModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
onExport={handleExportConfirm}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user