import React, { useState, useRef, useEffect } from 'react'; import { Plus, Trash2, ChevronDown, ChevronUp, Calendar, Download, Printer, ArrowLeftRight, FileJson, Upload, Indent, Outdent, Globe, AlignCenter, AlignLeft, AlignRight, Sun, Moon } from 'lucide-react'; // --- Translations --- const TRANSLATIONS = { en: { save: "Save Template", load: "Load Template", pdf: "Export PDF", image: "Export Image", addStart: "Add Start", addEnd: "Add End", addStartTooltip: "Add Start Node", addEndTooltip: "Add End Node", titlePlaceholder: "Enter Title...", subtitlePlaceholder: "Enter Subtitle...", defaultTitle: "Hierarchical Timeline Builder", // Used for initial state only promote: "Promote", demote: "Demote", switchSide: "Switch Side", moveUp: "Move Up", moveDown: "Move Down", delete: "Delete", datePlaceholder: "DATE", titleNodePlaceholder: "Event Title...", descNodePlaceholder: "Description...", subTitleNodePlaceholder: "Sub-event Title...", alignCenter: "Align Center", alignSide: "Align Start", subtitleDefault: "Hierarchical Timeline. Use Ctrl+B/I/U for formatting." }, he: { save: "שמור תבנית", load: "טען תבנית", pdf: "ייצוא PDF", image: "ייצוא תמונה", addStart: "הוסף התחלה", addEnd: "הוסף סיום", addStartTooltip: "הוסף התחלה", addEndTooltip: "הוסף סיום", titlePlaceholder: "הכנס כותרת...", subtitlePlaceholder: "הכנס כותרת משנה...", defaultTitle: "בונה רצף זמנים היררכי", promote: "הפוך לראשי", demote: "הפוך למשני", switchSide: "החלף צד", moveUp: "הזז למעלה", moveDown: "הזז למטה", delete: "מחק", datePlaceholder: "תאריך", titleNodePlaceholder: "כותרת האירוע...", descNodePlaceholder: "תיאור...", subTitleNodePlaceholder: "כותרת תת-אירוע...", alignCenter: "יישור למרכז", alignSide: "יישור להתחלה", subtitleDefault: "ציר זמן היררכי. השתמשו ב-Ctrl+B/I/U לעיצוב." } }; // --- Rich Text Editor Component --- const RichTextEditor = ({ value, onChange, className, placeholder, singleLine = false, align = 'start' }) => { const editorRef = useRef(null); useEffect(() => { if (editorRef.current && editorRef.current.innerHTML !== value) { if (document.activeElement !== editorRef.current) { editorRef.current.innerHTML = value || ''; } } }, [value]); const handleInput = (e) => { onChange(e.currentTarget.innerHTML); }; const handleKeyDown = (e) => { if (singleLine && e.key === 'Enter') { e.preventDefault(); return; } }; const alignClass = align === 'center' ? 'text-center' : 'text-start'; return (
); }; // --- Extracted Components --- const NodeControls = ({ node, index, isSub, totalNodes, onIndent, onOutdent, onToggleSide, onMove, onDelete, t, hoverClass }) => (
{isSub ? ( ) : ( )}
); const SubNodeRenderer = ({ node, index, totalNodes, onUpdate, onDelete, onToggleDate, onIndent, onOutdent, onToggleSide, onMove, t }) => { return (
{/* Visual Hierarchy Line */}
{/* Controls - Only show when hovering this specific sub-node container */}
{/* Date Row */} {node.date !== null && ( onUpdate(node.id, 'date', e.target.value)} className="w-full font-semibold uppercase tracking-wider bg-transparent border-none focus:outline-none placeholder-blue-300 text-xs text-blue-600 dark:text-blue-400 dark:placeholder-blue-700" placeholder={t.datePlaceholder} /> )} {/* Title */} onUpdate(node.id, 'title', val)} singleLine={true} className="w-full font-bold text-slate-800 dark:text-slate-100 text-lg border-b border-transparent hover:border-slate-200 dark:hover:border-slate-600 focus:border-blue-500 transition-colors py-1" placeholder={t.subTitleNodePlaceholder} /> {/* Content */} onUpdate(node.id, 'content', val)} className="w-full text-slate-600 dark:text-slate-300 text-sm min-h-[20px]" placeholder={t.descNodePlaceholder} />
{/* Mini Actions for Subnode */}
); }; const TimelineApp = () => { const fileInputRef = useRef(null); const containerRef = useRef(null); // --- Global State --- const [uiLanguage, setUiLanguage] = useState('en'); const [darkMode, setDarkMode] = useState(false); const t = TRANSLATIONS[uiLanguage]; // Title State - Defaults set directly const [appTitle, setAppTitle] = useState("Hierarchical Timeline Builder"); const [appSubtitle, setAppSubtitle] = useState("Hierarchical Timeline. Use Ctrl+B/I/U for formatting."); // Hover tracking for insert button const [hoverY, setHoverY] = useState(null); const [insertIndex, setInsertIndex] = useState(null); // Update subtitle when language changes if it matches the default of the other language useEffect(() => { if (appSubtitle === TRANSLATIONS['en'].subtitleDefault || appSubtitle === TRANSLATIONS['he'].subtitleDefault) { setAppSubtitle(t.subtitleDefault); } }, [uiLanguage, t.subtitleDefault, appSubtitle]); useEffect(() => { if (darkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [darkMode]); // Alignment State (Consolidated) const [headerAlign, setHeaderAlign] = useState('center'); // --- Nodes State --- const [nodes, setNodes] = useState(() => { try { const saved = localStorage.getItem('timeline_nodes'); if (saved) { let parsed = JSON.parse(saved); return parsed.map((n, i) => ({ ...n, side: n.side || (i % 2 === 0 ? 'right' : 'left'), type: 'event', isSub: n.isSub || false })); } } catch (e) { console.error("Failed to load from local storage", e); } // Default initial state return [ { "id": "root-mech", "type": "event", "side": "center", "title": "Mechanical Roots", "date": "19th Century", "content": "Before electricity, computer concepts were born as mechanical machines.", "comments": [], "isSub": false }, { "id": "init-2", "type": "event", "side": "right", "title": "Charles Babbage & Analytical Engine", "date": "1837", "content": "Babbage designed a mechanical machine with all modern computer elements: CPU (Mill), Memory (Store).", "comments": [], "isSub": true }, { "id": "bexxiyo30", "type": "event", "side": "left", "title": "Ada Lovelace", "date": "1843", "content": "Wrote the first algorithm intended for a machine, realizing it could process symbols, not just numbers.", "comments": [], "isSub": true }, { "id": "root-theory", "type": "event", "side": "center", "title": "Theory & War", "date": "1930s-40s", "content": "WWII drove massive demand for calculation power for ballistics and cryptography.", "comments": [], "isSub": false }, { "id": "turing-univ", "type": "event", "side": "right", "title": "Alan Turing & Universal Machine", "date": "1936", "content": "Laid theoretical foundations for CS with the 'Turing Machine' model.", "comments": [], "isSub": true } ]; }); useEffect(() => { localStorage.setItem('timeline_nodes', JSON.stringify(nodes)); }, [nodes]); // Load external libraries useEffect(() => { const loadScript = (src, id) => { if (!document.getElementById(id)) { const script = document.createElement('script'); script.src = src; script.id = id; script.async = true; document.body.appendChild(script); } }; loadScript("https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js", "html2canvas-script"); loadScript("https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js", "jspdf-script"); }, []); const generateId = () => Math.random().toString(36).substr(2, 9); // --- Grouping Logic for Rendering --- const getRenderGroups = () => { const groups = []; let currentGroup = null; nodes.forEach((node, index) => { if (node.isSub && currentGroup) { currentGroup.children.push({ ...node, originalIndex: index }); } else { if (currentGroup) groups.push(currentGroup); currentGroup = { type: 'group', main: { ...node, originalIndex: index }, children: [] }; } }); if (currentGroup) groups.push(currentGroup); return groups; }; const renderGroups = getRenderGroups(); // --- Node Operations --- const addNode = (index) => { const prevNode = index > 0 ? nodes[index - 1] : null; const prevSide = prevNode ? prevNode.side : 'left'; const newSide = prevSide === 'right' ? 'left' : 'right'; const newNode = { id: generateId(), type: 'event', side: newSide, title: 'New Event', date: '', content: '', isSub: false }; const newNodes = [...nodes]; newNodes.splice(index, 0, newNode); setNodes(newNodes); setHoverY(null); // Clear insertion marker }; const deleteNode = (id) => { if (nodes.length <= 1) return; setNodes(nodes.filter(n => n.id !== id)); }; const moveNode = (index, direction) => { if (direction === 'up' && index === 0) return; if (direction === 'down' && index === nodes.length - 1) return; const newNodes = [...nodes]; const targetIndex = direction === 'up' ? index - 1 : index + 1; // Swap const temp = newNodes[index]; newNodes[index] = newNodes[targetIndex]; newNodes[targetIndex] = temp; setNodes(newNodes); }; const updateNode = (id, field, value) => { setNodes(nodes.map(n => n.id === id ? { ...n, [field]: value } : n)); }; const indentNode = (index) => { const newNodes = [...nodes]; newNodes[index] = { ...newNodes[index], isSub: true }; setNodes(newNodes); }; const outdentNode = (index) => { const newNodes = [...nodes]; newNodes[index] = { ...newNodes[index], isSub: false }; setNodes(newNodes); }; const toggleNodeSide = (id) => { setNodes(nodes.map(n => n.id === id ? { ...n, side: n.side === 'right' ? 'left' : 'right' } : n )); }; const toggleDate = (id) => { setNodes(nodes.map(n => { if (n.id === id) { return { ...n, date: n.date === null ? '' : (n.date !== undefined && n.date !== null ? null : '') }; } return n; })); }; // --- Hover Logic for Center Line --- const handleMouseMove = (e) => { if (!containerRef.current) return; // Check if mouse is near center line const containerRect = containerRef.current.getBoundingClientRect(); const centerX = containerRect.left + (containerRect.width / 2); const mouseX = e.clientX; const distance = Math.abs(mouseX - centerX); if (distance > 50) { // Hover zone width setHoverY(null); return; } const mouseY = e.clientY; // Find gaps between nodes const groupElements = document.querySelectorAll('[data-group-index]'); let found = false; Array.from(groupElements).forEach((el, idx) => { if (found) return; const rect = el.getBoundingClientRect(); // Check if mouse is above this node (start gap) if (idx === 0 && mouseY < rect.top) { // Covered by static button } // Check if mouse is below this node (gap to next) else if (mouseY > rect.bottom) { const nextEl = groupElements[idx + 1]; // If there's a next element, check if we are in the gap if (nextEl) { const nextRect = nextEl.getBoundingClientRect(); if (mouseY < nextRect.top) { // In the gap! setHoverY(mouseY - containerRect.top); // Precise position relative to container const nodeIndex = parseInt(el.getAttribute('data-last-index')); setInsertIndex(nodeIndex + 1); found = true; } } } }); if (!found) setHoverY(null); }; const handleMouseLeave = () => { setHoverY(null); }; // --- Export Handlers --- const handleExportImage = async () => { if (window.html2canvas) { const element = document.getElementById('timeline-content'); const originalBg = element.style.backgroundColor; const isDark = document.documentElement.classList.contains('dark'); element.style.backgroundColor = isDark ? '#1e293b' : '#f8fafc'; // slate-800 : slate-50 try { const canvas = await window.html2canvas(element, { backgroundColor: isDark ? '#1e293b' : '#f8fafc', scale: 2, useCORS: true, ignoreElements: (element) => element.classList.contains('no-print') }); const link = document.createElement('a'); link.download = 'timeline.png'; link.href = canvas.toDataURL(); link.click(); } finally { element.style.backgroundColor = ''; } } else { alert("Loading libraries..."); } }; const handleExportPDF = async () => { if (window.html2canvas && window.jspdf) { const { jsPDF } = window.jspdf; const element = document.getElementById('timeline-content'); const isDark = document.documentElement.classList.contains('dark'); const originalBg = element.style.backgroundColor; // For PDF we usually want white background unless user really wants dark PDF // Let's stick to WYSIWYG element.style.backgroundColor = isDark ? '#1e293b' : '#ffffff'; try { const canvas = await window.html2canvas(element, { backgroundColor: isDark ? '#1e293b' : '#ffffff', scale: 2, useCORS: true, ignoreElements: (element) => element.classList.contains('no-print') }); const imgData = canvas.toDataURL('image/png'); const pdf = new jsPDF('p', 'mm', 'a4'); const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = pdf.internal.pageSize.getHeight(); const imgProps = pdf.getImageProperties(imgData); const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; if (imgHeight > pdfHeight) { const customPdf = new jsPDF('p', 'mm', [pdfWidth, imgHeight + 20]); customPdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight); customPdf.save('timeline.pdf'); } else { pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight); pdf.save('timeline.pdf'); } } finally { element.style.backgroundColor = originalBg; } } else { alert("PDF library loading..."); } }; const handleSaveTemplate = () => { // Create a clean copy of nodes to match the provided format exactly const cleanNodes = nodes.map(node => ({ id: node.id, type: node.type || 'event', side: node.side, title: node.title, date: node.date, content: node.content, comments: node.comments || [], isSub: node.isSub })); const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(cleanNodes, null, 2)); const downloadAnchorNode = document.createElement('a'); downloadAnchorNode.setAttribute("href", dataStr); downloadAnchorNode.setAttribute("download", "timeline_template.json"); document.body.appendChild(downloadAnchorNode); downloadAnchorNode.click(); downloadAnchorNode.remove(); }; const handleLoadTemplateClick = () => { fileInputRef.current.click(); }; const handleFileChange = (e) => { const fileReader = new FileReader(); fileReader.readAsText(e.target.files[0], "UTF-8"); fileReader.onload = e => { try { const parsed = JSON.parse(e.target.result); if (Array.isArray(parsed)) setNodes(parsed); else alert("Invalid file format"); } catch (err) { alert("Error parsing file"); } }; e.target.value = null; }; const currentDir = uiLanguage === 'he' ? 'rtl' : 'ltr'; return (
{/* Language Switcher & Dark Mode */}
{/* Header */}
{/* Text Blocks */}
{/* Title */}
{/* Subtitle */}
{/* Single Alignment Toggle (Manages both) */}
{/* Export Controls - Framed Buttons */}
{/* Timeline Container - Force LTR so nodes don't swap sides when language changes */}
{/* Central Line */}
{/* Static Add Start Button (No Text) */}
{/* Mouse Tracker Insert Button */} {hoverY !== null && insertIndex !== null && (
{/* Line decoration - makes it look like it's splitting the line */}
)} {/* Render Groups Loop */}
{renderGroups.map((group, groupIndex) => { const mainNode = group.main; const children = group.children || []; const index = mainNode.originalIndex; // Calculate last index in this group to know where to insert after const lastIndex = index + children.length; const isRightSide = mainNode.side === 'right'; const layoutClass = isRightSide ? 'md:flex-row' : 'md:flex-row-reverse'; return (
{/* Dot */}
{/* Spacer */} {/* Card Container */}
{/* Main Node Content - group/main-content SCOPED HOVER */}
{/* Controls - Only show on hovering THIS div */}
{mainNode.date !== null && ( updateNode(mainNode.id, 'date', e.target.value)} className="w-full font-semibold uppercase tracking-wider bg-transparent border-none focus:outline-none placeholder-blue-300 text-xs text-blue-600 dark:text-blue-400 dark:placeholder-blue-700" placeholder={t.datePlaceholder} /> )} updateNode(mainNode.id, 'title', val)} singleLine={true} className="w-full font-bold text-slate-800 dark:text-slate-100 text-lg border-b border-transparent hover:border-slate-200 dark:hover:border-slate-600 focus:border-blue-500 transition-colors py-1" placeholder={t.titleNodePlaceholder} /> updateNode(mainNode.id, 'content', val)} className="w-full text-slate-600 dark:text-slate-300 text-sm min-h-[40px]" placeholder={t.descNodePlaceholder} />
{/* Main Actions */}
{/* RENDER SUB-NODES (CONCATENATED) */} {children.length > 0 && (
{children.map((child) => ( ))}
)}
)})}
{/* Helper Styles for Buttons */}
); }; export default TimelineApp;