import React, { useEffect, useRef, useState } from 'react';
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[];
}
// 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));
return (
{zoom}%
);
};
// Font download component
const FontList: React.FC<{ fonts: string[] }> = ({ fonts }) => {
const [downloadingFont, setDownloadingFont] = useState(null);
const [downloadStatus, setDownloadStatus] = useState>({});
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 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 (
Fonts:
{fonts.map((font, index) => (
downloadFont(font)}
disabled={downloadingFont === font}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={`Download ${font} font`}
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 ? : }
{font}
openGoogleFonts(font)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label="View on Google Fonts"
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"
>
{downloadStatus[font] && (
{downloadStatus[font]}
)}
))}
);
};
export const Preview: React.FC = ({
htmlContent,
onBack,
paperSize,
selectedStyleId,
inputFileName = 'document',
uiZoom,
onZoomChange,
templates
}) => {
const iframeRef = useRef(null);
const backButtonRef = useRef(null);
const saveButtonRef = useRef(null);
const [isExporting, setIsExporting] = useState(false);
const [successMsg, setSuccessMsg] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
const [exportError, setExportError] = useState(null);
// 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 template = templates.find(s => s.id === styleId) || templates[0];
// 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'
};
// Extract page background from template
const pageBackground = template.typography?.colors?.background;
// 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;
// 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 ===');
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 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);
setExportError("Failed to generate DOCX: " + e);
} finally {
setIsExporting(false);
}
};
// Track blob URL for cleanup
const blobUrlRef = useRef(null);
// Render preview whenever dependencies change
useEffect(() => {
if (!iframeRef.current || !style) return;
// 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 ===');
// Cleanup old blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
// 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 = `
${htmlContent}
`;
// 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 Loading...
;
}
return (
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">
Back to Editor
Format: {paperSize}
setFocusedElement('save')}
disabled={isExporting}
aria-live="polite"
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 ? (
Saved!
) : (
{isExporting ? : }
{isExporting ? 'Generating...' : 'Save Word Doc'}
)}
setShowExportModal(false)}
onExport={handleExportConfirm}
/>
{exportError && (
{exportError}
)}
);
};