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 ? 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">
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 ? ( ) : ( {isExporting ? )}