Files
timeline-svg/engine.js
tegwick 4576d066b3 feat: add inline file editing with modification tracking and save functionality
Add comprehensive inline editing capabilities for all project files with visual modification tracking and batch save functionality.

Features:
- Edit buttons next to each file load button (Project, SVG, CSS, CSV)
- Modal editor with syntax validation for JSON files
- Real-time preview updates after applying changes
- Visual "MODIFIED" badges on edited files
- "Save Changes" button to download all modified files
- Keyboard shortcuts (Escape to close editor)

Implementation:
- file-editor.js: New module handling all editor functionality
  - Editor modal with textarea for content editing
  - Modification tracking using Set data structure
  - File-specific validation (JSON syntax checking)
  - Batch download of all modified files
  - Visual badge updates for modified state

- index.html: UI updates
  - Edit buttons added to all file items
  - Modal HTML structure for editor
  - Save Changes button in controls area
  - CSS styling for editor modal and modified badges

- engine.js: Integration and data storage
  - Store CSV and CSS content when loaded (for editing)
  - Enable edit buttons when files are successfully loaded
  - Works with all loading methods (folder picker, individual files, auto-load)
  - CSS now loaded via fetch to store content (changed from link href)

- Makefile: Include file-editor.js in distribution build

User workflow:
1. Load project files (folder picker or individual files)
2. Click "✏️ Edit" button next to any file
3. Make changes in modal editor
4. Click "Apply Changes" to update and see immediate preview
5. Modified files show orange "MODIFIED" badge
6. Click "💾 Save Changes" to download all modified files at once

Technical details:
- Edit buttons start disabled, enabled when file loads
- JSON validation prevents saving invalid configurations
- Changes apply immediately to in-memory data
- Timeline regenerates automatically after edits
- Modified state persists until files are saved
- Optional clearing of modified state after download

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 17:15:36 +01:00

986 lines
36 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.timelineEngine = {
config: null,
template: null,
csvOverride: false,
cssOverride: false,
csvData: null, // Store current CSV text
cssData: null, // Store current CSS text
projectBasePath: '',
showTemplatePreview() {
if (!this.template) return;
console.log("Showing template preview");
// Detect template theme for appropriate colors
const isEnhanced = this.template.includes('Enhanced');
const isBlueTheme = this.template.includes('My Project');
const isGreenTheme = this.template.includes('Example');
// Color scheme selection
let primaryColor = '#0A4D8C'; // default
let secondaryColor = '#5C6B7A'; // default
if (isEnhanced) {
if (isBlueTheme) {
primaryColor = '#3b82f6';
secondaryColor = '#60a5fa';
} else if (isGreenTheme) {
primaryColor = '#2d8659';
secondaryColor = '#4a9b6b';
}
}
// Create sample month graphics matching the template style
const sampleMonths = isEnhanced ? `
<line x1="240" y1="75" x2="240" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="210" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="244" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Jan 25</text>
<line x1="360" y1="75" x2="360" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="330" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="364" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Feb 25</text>
<line x1="480" y1="75" x2="480" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="450" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="484" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Mar 25</text>
<line x1="600" y1="75" x2="600" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="570" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="604" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Apr 25</text>
` : `
<line x1="240" y1="75" x2="240" y2="300" stroke="#E3E8EF" />
<text x="244" y="90" fill="#5C6B7A" font-size="12">Jan 25</text>
<line x1="360" y1="75" x2="360" y2="300" stroke="#E3E8EF" />
<text x="364" y="90" fill="#5C6B7A" font-size="12">Feb 25</text>
<line x1="480" y1="75" x2="480" y2="300" stroke="#E3E8EF" />
<text x="484" y="90" fill="#5C6B7A" font-size="12">Mar 25</text>
<line x1="600" y1="75" x2="600" y2="300" stroke="#E3E8EF" />
<text x="604" y="90" fill="#5C6B7A" font-size="12">Apr 25</text>
`;
// Create sample lane content matching the template style
const sampleLanes = isEnhanced ? `
<rect x="40" y="116" width="640" height="80" fill="rgba(255,255,255,0.7)" stroke="${secondaryColor}" stroke-width="1" opacity="0.5" rx="8" />
<text x="56" y="136" fill="${primaryColor}" font-size="14" font-weight="700">Example Epic</text>
<circle cx="270" cy="156" r="6" fill="${primaryColor}" stroke="${secondaryColor}" stroke-width="2" />
<text x="282" y="160" font-size="12" fill="${primaryColor}" font-weight="500">
<tspan class="item-id">EX-1: </tspan>
<tspan class="item-title" fill="${primaryColor === '#3b82f6' ? '#1e40af' : '#1e5a3d'}">Sample Task Preview</tspan>
</text>
<rect x="40" y="212" width="640" height="80" fill="rgba(255,255,255,0.7)" stroke="${secondaryColor}" stroke-width="1" opacity="0.5" rx="8" />
<text x="56" y="232" fill="${primaryColor}" font-size="14" font-weight="700">Another Epic</text>
<circle cx="390" cy="252" r="6" fill="${primaryColor}" stroke="${secondaryColor}" stroke-width="2" />
<text x="402" y="256" font-size="12" fill="${primaryColor}" font-weight="500">
<tspan class="item-id">EX-2: </tspan>
<tspan class="item-title" fill="${primaryColor === '#3b82f6' ? '#1e40af' : '#1e5a3d'}">Template Preview</tspan>
</text>
` : `
<rect x="40" y="116" width="640" height="80" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />
<text x="56" y="136" fill="#0B1F3B" font-size="14" font-weight="600">Example Epic</text>
<circle cx="270" cy="156" r="5" fill="#0A4D8C" />
<text x="282" y="160" font-size="12" fill="#0B1F3B">
<tspan class="item-id">EX-1: </tspan>
<tspan class="item-title">Sample Task Preview</tspan>
</text>
<rect x="40" y="212" width="640" height="80" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />
<text x="56" y="232" fill="#0B1F3B" font-size="14" font-weight="600">Another Epic</text>
<circle cx="390" cy="252" r="5" fill="#0A4D8C" />
<text x="402" y="256" font-size="12" fill="#0B1F3B">
<tspan class="item-id">EX-2: </tspan>
<tspan class="item-title">Template Preview</tspan>
</text>
`;
// Replace placeholders with sample data
let previewSvg = this.template
.replace("{{MONTHS}}", sampleMonths)
.replace("{{LANES}}", sampleLanes);
// Add dimensions for proper preview display (using reasonable default size)
const previewWidth = 720;
const previewHeight = 360;
previewSvg = previewSvg.replace(
/<svg([^>]*?)>/,
`<svg$1 width="${previewWidth}" height="${previewHeight}" viewBox="0 0 ${previewWidth} ${previewHeight}">`
);
document.getElementById("viewer").innerHTML = previewSvg;
// Initialize zoom functionality for the preview
if (window.svgViewer) {
window.svgViewer.resetZoom();
window.svgViewer.updateZoomControls();
}
console.log("Template preview displayed with theme:", { isEnhanced, isBlueTheme, isGreenTheme });
},
updateFileStatus(fileType, filename, status = 'loaded') {
const statusElement = document.getElementById(`${fileType}File`);
if (statusElement) {
if (filename && status === 'loaded') {
statusElement.textContent = filename;
statusElement.style.color = '#28a745';
statusElement.title = `Loaded: ${filename}`;
} else if (filename && status === 'error') {
statusElement.textContent = `Error: ${filename}`;
statusElement.style.color = '#dc3545';
statusElement.title = `Failed to load: ${filename}`;
} else {
statusElement.textContent = 'Not loaded';
statusElement.style.color = '#6c757d';
statusElement.title = 'No file loaded';
}
}
// Add visual feedback with brief highlight animation
if (statusElement && filename) {
statusElement.style.transform = 'scale(1.05)';
setTimeout(() => {
statusElement.style.transform = 'scale(1)';
}, 200);
}
},
resolveProjectPath(relativePath) {
if (!relativePath) return relativePath;
// If path is already absolute (starts with http/https) or has no base path, return as-is
if (relativePath.startsWith('http') || !this.projectBasePath) {
return relativePath;
}
const resolvedPath = this.projectBasePath + relativePath;
console.log("Resolved path:", relativePath, "->", resolvedPath);
return resolvedPath;
},
async autoLoadDefaultProject() {
console.log("Starting autoLoadDefaultProject");
// Try projects in order: binect, my-project, example
const candidates = [
{ path: "binect/project.json", basePath: "binect/" },
{ path: "my-project/project.json", basePath: "my-project/" },
{ path: "example/project.json", basePath: "example/" }
];
for (const candidate of candidates) {
try {
const res = await fetch(candidate.path);
if (!res.ok) continue;
const cfg = await res.json();
this.projectBasePath = candidate.basePath;
await this.loadProjectConfigObject(cfg);
console.log("Project loaded successfully from:", candidate.path);
return;
} catch (e) {
continue;
}
}
console.log("No project could be auto-loaded");
},
async loadProjectConfigObject(cfg) {
this.config = cfg;
const name = cfg.name || "Timeline";
document.getElementById("projectName").textContent = name;
document.getElementById("projectSubtitle").textContent =
cfg.description || "Projektkonfiguration geladen.";
// Update project status
this.updateFileStatus('project', name, 'loaded');
// Enable project edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('project');
}
// Show field mappings in debug panel
this.showFieldMappings();
// Track loading errors for user feedback
const loadingErrors = [];
// Stylesheet
if (cfg.stylesheet && !this.cssOverride) {
const stylesheetPath = this.resolveProjectPath(cfg.stylesheet);
// Load CSS via fetch to store content for editing
try {
const response = await fetch(stylesheetPath);
if (response.ok) {
const cssText = await response.text();
this.cssData = cssText; // Store for editing
// Apply CSS
const blob = new Blob([cssText], { type: "text/css" });
const linkElement = document.getElementById("dynamicCss");
linkElement.href = URL.createObjectURL(blob);
this.updateFileStatus('css', cfg.stylesheet + ' ✨', 'loaded');
// Enable CSS edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('css');
}
console.log("Stylesheet loaded successfully:", stylesheetPath);
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (e) {
console.error("Stylesheet could not be loaded:", e);
this.updateFileStatus('css', cfg.stylesheet, 'error');
loadingErrors.push(`Stylesheet: ${cfg.stylesheet}`);
}
}
// SVG template
if (cfg.svgTemplate) {
try {
const templatePath = this.resolveProjectPath(cfg.svgTemplate);
console.log("Attempting to fetch SVG template from:", templatePath);
const response = await fetch(templatePath);
console.log("SVG template fetch response:", response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.template = await response.text();
console.log("SVG template loaded, length:", this.template.length);
this.updateFileStatus('svg', cfg.svgTemplate, 'loaded');
// Enable SVG edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('svg');
}
// Show template fields in debug panel
this.showTemplateFields();
// Show template preview if no CSV data is loaded yet
if (!this.csvOverride && document.querySelector("#viewer").innerHTML.includes("Keine Timeline verfügbar")) {
this.showTemplatePreview();
}
} catch (e) {
console.error("SVG template could not be loaded:", e);
console.error("Failed SVG template path was:", this.resolveProjectPath(cfg.svgTemplate));
this.updateFileStatus('svg', cfg.svgTemplate, 'error');
loadingErrors.push(`SVG template: ${cfg.svgTemplate}`);
}
}
// CSV data
if (cfg.dataSource && !this.csvOverride) {
try {
const csvPath = this.resolveProjectPath(cfg.dataSource);
console.log("Attempting to fetch CSV from:", csvPath);
const response = await fetch(csvPath);
console.log("CSV fetch response:", response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const csvText = await response.text();
console.log("CSV text loaded, length:", csvText.length);
console.log("CSV preview:", csvText.substring(0, 200));
this.updateFileStatus('csv', cfg.dataSource, 'loaded');
// Enable CSV edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('csv');
}
this.processCsv(csvText);
} catch (e) {
console.error("CSV could not be loaded:", e);
console.error("Failed CSV path was:", this.resolveProjectPath(cfg.dataSource));
this.updateFileStatus('csv', cfg.dataSource, 'error');
loadingErrors.push(`CSV data: ${cfg.dataSource}`);
}
}
// Show user feedback if there were loading errors
if (loadingErrors.length > 0) {
setTimeout(() => {
const viewer = document.getElementById("viewer");
if (viewer && !viewer.innerHTML.includes('<svg')) {
viewer.innerHTML = `
<div style="text-align:center; padding:40px 20px; color:#6c757d;">
<div style="font-size:48px; margin-bottom:16px;">⚠️</div>
<h4 style="margin:0 0 8px 0; color:#dc3545;">Projektdateien konnten nicht geladen werden</h4>
<p style="margin:0 0 12px 0; font-size:14px;">
<strong>Fehlgeschlagene Dateien:</strong><br>
${loadingErrors.map(err => `${err}`).join('<br>')}
</p>
<p style="margin:0; font-size:12px; color:#6c757d;">
💡 <strong>Tipp:</strong> Lade die Dateien manuell mit den Load-Buttons oben oder
verwende einen lokalen Server (<code>make serve</code>).
</p>
</div>
`;
}
}, 500); // Small delay to let other operations complete
}
},
processCsv(text) {
console.log("processCsv called with text length:", text?.length);
// Store CSV data for editing
this.csvData = text;
if (!this.config || !this.config.fieldMapping) {
console.error("No config or fieldMapping found.");
return;
}
if (typeof Papa === 'undefined') {
console.error("Papa Parse library not available");
document.getElementById("viewer").innerHTML =
"<em style='color:#dc3545;'>Fehler: CSV Parser nicht verfügbar.</em>";
return;
}
const m = this.config.fieldMapping;
const self = this; // Capture 'this' context
Papa.parse(text, {
header: true,
skipEmptyLines: true,
complete: (res) => {
console.log("Papa.parse complete, found", res.data.length, "rows");
const rows = res.data;
// Show CSV preview in debug panel
self.showCSVPreview(text, rows);
const items = rows.map((r) => {
const dueField = (m.due || []).find(f => r[f]);
const item = {
id: m.id ? r[m.id] : (r["ID"] || ""),
title: m.title ? r[m.title] : (r["Title"] || ""),
lane: m.lane ? (r[m.lane] || "Ohne Epic") : (r["Lane"] || "Default"),
due: self.parseDate(dueField ? r[dueField] : null)
};
return item;
}).filter(i => i.title && i.due);
console.log("Filtered to", items.length, "valid items");
if (!items.length) {
document.getElementById("viewer").innerHTML =
"<em style='color:#999;'>Keine gültigen Items gefunden.</em>";
return;
}
// Ensure generator is available
if (!window.timelineGenerator) {
console.error("Timeline generator not available");
document.getElementById("viewer").innerHTML =
"<em style='color:#dc3545;'>Fehler: Timeline Generator nicht verfügbar.</em>";
return;
}
try {
console.log("Generating timeline with:", items, self.config, self.template ? "template loaded" : "no template");
const svg = window.timelineGenerator.generate(items, self.config, self.template);
document.getElementById("viewer").innerHTML = svg;
const dlBtn = document.getElementById("downloadSvg");
dlBtn.disabled = false;
dlBtn.style.opacity = 1;
// Initialize zoom functionality for the new SVG
if (window.svgViewer) {
window.svgViewer.resetZoom();
window.svgViewer.updateZoomControls();
}
console.log("Timeline generated successfully");
} catch (error) {
console.error("Error generating timeline:", error);
document.getElementById("viewer").innerHTML =
"<em style='color:#dc3545;'>Fehler beim Generieren der Timeline.</em>";
}
}
});
},
parseDate(str) {
if (!str) return null;
str = String(str).trim();
let d = null;
// YYYY/MM/DD oder YYYY-MM-DD
let m = str.match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})$/);
if (m) {
const [_, yy, mm, dd] = m;
d = new Date(Number(yy), Number(mm) - 1, Number(dd));
return isNaN(d.getTime()) ? null : d;
}
// DD.MM.YYYY
m = str.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) {
const [_, dd, mm, yy] = m;
d = new Date(Number(yy), Number(mm) - 1, Number(dd));
return isNaN(d.getTime()) ? null : d;
}
// Fallback
d = new Date(str);
return isNaN(d.getTime()) ? null : d;
},
// --------- Debug Display Functions ---------
showFieldMappings() {
if (!this.config || !this.config.fieldMapping) return;
const debugInfo = document.getElementById("debugInfo");
const fieldMappingInfo = document.getElementById("fieldMappingInfo");
const fieldMappingDisplay = document.getElementById("fieldMappingDisplay");
if (debugInfo && fieldMappingInfo && fieldMappingDisplay) {
debugInfo.style.display = "block";
fieldMappingInfo.style.display = "block";
const mapping = this.config.fieldMapping;
let display = "CSV Column → Timeline Field\n";
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
display += `ID: ${JSON.stringify(mapping.id)}\n`;
display += `Title: ${JSON.stringify(mapping.title)}\n`;
display += `Lane: ${JSON.stringify(mapping.lane)}\n`;
display += `Due: ${JSON.stringify(mapping.due)}\n`;
if (mapping.epic) display += `Epic: ${JSON.stringify(mapping.epic)}\n`;
if (mapping.type) display += `Type: ${JSON.stringify(mapping.type)}\n`;
if (mapping.color) display += `Color: ${JSON.stringify(mapping.color)}\n`;
fieldMappingDisplay.textContent = display;
}
},
showTemplateFields() {
if (!this.template) return;
const debugInfo = document.getElementById("debugInfo");
const templateFieldsInfo = document.getElementById("templateFieldsInfo");
const templateFieldsDisplay = document.getElementById("templateFieldsDisplay");
if (debugInfo && templateFieldsInfo && templateFieldsDisplay) {
debugInfo.style.display = "block";
templateFieldsInfo.style.display = "block";
// Extract placeholders from template
const placeholders = new Set();
const placeholderRegex = /\{\{([A-Z_]+)\}\}/g;
let match;
while ((match = placeholderRegex.exec(this.template)) !== null) {
placeholders.add(match[1]);
}
let display = "Required Template Placeholders:\n";
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
// Group by category
const monthFields = Array.from(placeholders).filter(p => p.includes('MONTH'));
const laneFields = Array.from(placeholders).filter(p => p.includes('LANE'));
const taskFields = Array.from(placeholders).filter(p => p.includes('TASK') || p.includes('TEXT'));
const otherFields = Array.from(placeholders).filter(p =>
!monthFields.includes(p) && !laneFields.includes(p) && !taskFields.includes(p)
);
if (monthFields.length > 0) {
display += "\nMonth Fields:\n";
monthFields.forEach(f => display += `${f}\n`);
}
if (laneFields.length > 0) {
display += "\nLane Fields:\n";
laneFields.forEach(f => display += `${f}\n`);
}
if (taskFields.length > 0) {
display += "\nTask Fields:\n";
taskFields.forEach(f => display += `${f}\n`);
}
if (otherFields.length > 0) {
display += "\nOther Fields:\n";
otherFields.forEach(f => display += `${f}\n`);
}
templateFieldsDisplay.textContent = display;
}
},
showCSVPreview(csvText, parsedData) {
const debugInfo = document.getElementById("debugInfo");
const csvDataInfo = document.getElementById("csvDataInfo");
const csvDataDisplay = document.getElementById("csvDataDisplay");
if (debugInfo && csvDataInfo && csvDataDisplay) {
debugInfo.style.display = "block";
csvDataInfo.style.display = "block";
const lines = csvText.trim().split('\n');
const headers = lines[0] ? lines[0].split(',') : [];
const firstDataLine = lines[1] ? lines[1].split(',') : [];
let display = "CSV Structure Preview:\n";
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
display += `Headers: ${headers.join(', ')}\n\n`;
if (firstDataLine.length > 0) {
display += "First Data Row:\n";
headers.forEach((header, i) => {
display += ` ${header}: "${firstDataLine[i] || ''}"\n`;
});
}
if (parsedData) {
display += `\nParsed Rows: ${parsedData.length}\n`;
const validCount = parsedData.filter(r => {
const mapping = this.config?.fieldMapping || {};
const titleField = mapping.title;
const dueField = Array.isArray(mapping.due) ? mapping.due.find(f => r[f]) : mapping.due;
return r[titleField] && r[dueField];
}).length;
display += `Valid Items: ${validCount}\n`;
if (validCount === 0 && parsedData.length > 0) {
display += "\n⚠ WARNING: No valid items found!\n";
display += "Check that:\n";
display += " • CSV headers match field mappings\n";
display += " • Title and Due fields have values\n";
display += " • Date format is parseable (e.g., YYYY-MM-DD)\n";
}
}
csvDataDisplay.textContent = display;
}
}
};
// --------- SVG Viewer with Zoom functionality ---------
window.svgViewer = {
currentZoom: 1,
minZoom: 0.25,
maxZoom: 3,
zoomStep: 0.25,
initializeZoom() {
const viewer = document.getElementById("viewer");
const zoomControls = document.getElementById("zoomControls");
const zoomIn = document.getElementById("zoomIn");
const zoomOut = document.getElementById("zoomOut");
const zoomReset = document.getElementById("zoomReset");
if (!viewer || !zoomControls || !zoomIn || !zoomOut || !zoomReset) {
console.warn("Zoom controls not found in DOM");
return;
}
// Show zoom controls when SVG is present
this.updateZoomControls();
// Zoom in
zoomIn.addEventListener("click", () => {
this.zoomIn();
});
// Zoom out
zoomOut.addEventListener("click", () => {
this.zoomOut();
});
// Reset zoom
zoomReset.addEventListener("click", () => {
this.resetZoom();
});
// Mouse wheel zoom (with Ctrl key)
viewer.addEventListener("wheel", (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (e.deltaY < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
});
console.log("SVG zoom functionality initialized");
},
showZoomControls() {
const zoomControls = document.getElementById("zoomControls");
if (zoomControls) {
zoomControls.style.display = "block";
}
},
hideZoomControls() {
const zoomControls = document.getElementById("zoomControls");
if (zoomControls) {
zoomControls.style.display = "none";
}
},
zoomIn() {
if (this.currentZoom < this.maxZoom) {
this.currentZoom = Math.min(this.maxZoom, this.currentZoom + this.zoomStep);
this.applyZoom();
}
},
zoomOut() {
if (this.currentZoom > this.minZoom) {
this.currentZoom = Math.max(this.minZoom, this.currentZoom - this.zoomStep);
this.applyZoom();
}
},
resetZoom() {
this.currentZoom = 1;
this.applyZoom();
},
applyZoom() {
const viewer = document.getElementById("viewer");
const container = document.getElementById("viewerContainer");
if (viewer && container) {
viewer.style.transform = `scale(${this.currentZoom})`;
// Adjust container overflow behavior based on zoom level
if (this.currentZoom !== 1) {
viewer.classList.add("zoomed");
container.style.overflow = "auto";
} else {
viewer.classList.remove("zoomed");
container.style.overflow = "hidden";
}
this.updateZoomControls();
}
},
updateZoomControls() {
const zoomIn = document.getElementById("zoomIn");
const zoomOut = document.getElementById("zoomOut");
const zoomReset = document.getElementById("zoomReset");
if (zoomIn && zoomOut && zoomReset) {
// Update button states
zoomIn.disabled = this.currentZoom >= this.maxZoom;
zoomOut.disabled = this.currentZoom <= this.minZoom;
// Update zoom percentage display
const percentage = Math.round(this.currentZoom * 100);
zoomReset.textContent = `${percentage}%`;
// Show/hide controls based on whether SVG is present
const hasSvg = document.querySelector("#viewer svg") !== null;
if (hasSvg) {
this.showZoomControls();
} else {
this.hideZoomControls();
}
}
}
};
// --------- UI event handlers ---------
window.setupEventHandlers = function() {
// Handler for loading entire project folder
const folderInput = document.getElementById("folderInput");
if (folderInput) {
folderInput.addEventListener("change", async (ev) => {
const files = Array.from(ev.target.files);
if (!files.length) return;
console.log("Folder selected with", files.length, "files");
try {
// Find project.json in the uploaded files
const projectFile = files.find(f => f.name === 'project.json');
if (!projectFile) {
alert('No project.json found in selected folder. Please select a folder containing a project.json file.');
return;
}
// Parse project.json
const projectText = await projectFile.text();
const cfg = JSON.parse(projectText);
console.log("Loaded project configuration:", cfg);
// Clear projectBasePath since we're loading from uploaded files
window.timelineEngine.projectBasePath = '';
// Update project status
window.timelineEngine.updateFileStatus('project', projectFile.name, 'loaded');
// Enable project edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('project');
}
// Load referenced files from the folder
const errors = [];
// Load stylesheet
if (cfg.stylesheet) {
const cssFile = files.find(f => f.name === cfg.stylesheet || f.webkitRelativePath.endsWith(cfg.stylesheet));
if (cssFile) {
const cssText = await cssFile.text();
window.timelineEngine.cssData = cssText; // Store for editing
window.timelineEngine.cssOverride = true;
const blob = new Blob([cssText], { type: "text/css" });
document.getElementById("dynamicCss").href = URL.createObjectURL(blob);
window.timelineEngine.updateFileStatus('css', cssFile.name, 'loaded');
// Enable CSS edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('css');
}
console.log("Loaded stylesheet:", cssFile.name);
} else {
errors.push(`Stylesheet: ${cfg.stylesheet}`);
window.timelineEngine.updateFileStatus('css', cfg.stylesheet, 'error');
}
}
// Load SVG template
if (cfg.svgTemplate) {
const svgFile = files.find(f => f.name === cfg.svgTemplate || f.webkitRelativePath.endsWith(cfg.svgTemplate));
if (svgFile) {
window.timelineEngine.template = await svgFile.text();
window.timelineEngine.updateFileStatus('svg', svgFile.name, 'loaded');
window.timelineEngine.showTemplateFields();
// Enable SVG edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('svg');
}
console.log("Loaded SVG template:", svgFile.name);
} else {
errors.push(`SVG template: ${cfg.svgTemplate}`);
window.timelineEngine.updateFileStatus('svg', cfg.svgTemplate, 'error');
}
}
// Load CSV data
if (cfg.dataSource) {
const csvFile = files.find(f => f.name === cfg.dataSource || f.webkitRelativePath.endsWith(cfg.dataSource));
if (csvFile) {
const csvText = await csvFile.text();
window.timelineEngine.csvOverride = true;
window.timelineEngine.updateFileStatus('csv', csvFile.name, 'loaded');
// Enable CSV edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('csv');
}
console.log("Loaded CSV data:", csvFile.name);
// Set config and process CSV
window.timelineEngine.config = cfg;
window.timelineEngine.processCsv(csvText);
} else {
errors.push(`CSV data: ${cfg.dataSource}`);
window.timelineEngine.updateFileStatus('csv', cfg.dataSource, 'error');
}
}
// Update project name and description
const name = cfg.name || "Timeline";
document.getElementById("projectName").textContent = name;
document.getElementById("projectSubtitle").textContent = cfg.description || "Project loaded from folder.";
// Show field mappings
window.timelineEngine.showFieldMappings();
// Show any errors
if (errors.length > 0) {
setTimeout(() => {
alert(`⚠️ Some files could not be loaded:\n\n${errors.join('\n')}\n\nMake sure all referenced files are in the selected folder.`);
}, 500);
} else {
console.log("✅ All project files loaded successfully from folder");
}
} catch (error) {
console.error("Error loading project folder:", error);
alert(`Error loading project: ${error.message}`);
}
});
}
const projectInput = document.getElementById("projectInput");
if (projectInput) {
projectInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
try {
const text = await file.text();
const cfg = JSON.parse(text);
// For manually loaded projects, try to infer base path from filename
// If it's example/project.json or binect/project.json, set appropriate base path
const filename = file.name;
if (filename === 'project.json') {
// Try to detect if this is a known project by checking the config
if (cfg.name && cfg.name.includes('Example')) {
window.timelineEngine.projectBasePath = 'example/';
console.log("Detected example project, setting base path to example/");
} else if (cfg.name && (cfg.name.includes('My Project') || cfg.name.includes('Roadmap'))) {
window.timelineEngine.projectBasePath = 'my-project/';
console.log("Detected my-project, setting base path to my-project/");
} else {
window.timelineEngine.projectBasePath = '';
console.log("Unknown project, clearing base path");
}
} else {
window.timelineEngine.projectBasePath = '';
}
window.timelineEngine.updateFileStatus('project', file.name, 'loaded');
// Enable project edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('project');
}
await window.timelineEngine.loadProjectConfigObject(cfg);
// Show message about relative paths if project has data sources
if (cfg.dataSource || cfg.stylesheet || cfg.svgTemplate) {
const viewer = document.getElementById("viewer");
if (viewer && (viewer.innerHTML.includes("could not be loaded") || viewer.innerHTML.includes("Keine gültigen Items"))) {
viewer.innerHTML += "<br><br><em style='color:#6c757d; font-size:12px;'>💡 Hinweis: Stelle sicher, dass sich die referenzierten Dateien (CSV, CSS, SVG) im gleichen Verzeichnis wie die HTML-Datei befinden.</em>";
}
}
} catch (error) {
console.error("Error loading project:", error);
window.timelineEngine.updateFileStatus('project', file.name, 'error');
}
});
}
const csvInput = document.getElementById("csvInput");
if (csvInput) {
csvInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
const text = await file.text();
window.timelineEngine.csvOverride = true;
window.timelineEngine.updateFileStatus('csv', file.name, 'loaded');
// Enable CSV edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('csv');
}
window.timelineEngine.processCsv(text);
});
}
const cssInput = document.getElementById("cssInput");
if (cssInput) {
cssInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
const cssText = await file.text();
window.timelineEngine.cssData = cssText; // Store for editing
window.timelineEngine.cssOverride = true;
const blob = new Blob([cssText], { type: "text/css" });
document.getElementById("dynamicCss").href = URL.createObjectURL(blob);
window.timelineEngine.updateFileStatus('css', file.name, 'loaded');
// Enable CSS edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('css');
}
});
}
const svgInput = document.getElementById("svgInput");
if (svgInput) {
svgInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
window.timelineEngine.template = await file.text();
window.timelineEngine.updateFileStatus('svg', file.name, 'loaded');
// Enable SVG edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('svg');
}
// Show template fields in debug panel
window.timelineEngine.showTemplateFields();
// Show template preview immediately when manually loaded
window.timelineEngine.showTemplatePreview();
});
}
const downloadSvg = document.getElementById("downloadSvg");
if (downloadSvg) {
downloadSvg.addEventListener("click", () => {
const svg = document.querySelector("#viewer svg");
if (!svg) return;
// Always external view for export: IDs ausblenden
svg.querySelectorAll(".item-id").forEach(el => el.style.display = "none");
const now = new Date();
const ts = String(now.getFullYear()).slice(2)
+ String(now.getMonth() + 1).padStart(2, "0")
+ String(now.getDate()).padStart(2, "0")
+ "T"
+ String(now.getHours()).padStart(2, "0")
+ String(now.getMinutes()).padStart(2, "0");
const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${ts}-timeline.svg`;
a.click();
URL.revokeObjectURL(url);
});
}
const toggleView = document.getElementById("toggleView");
if (toggleView) {
toggleView.addEventListener("click", () => {
const body = document.body;
const isInternal = body.classList.contains("internal-mode");
if (isInternal) {
body.classList.remove("internal-mode");
body.classList.add("external-mode");
toggleView.textContent = "Switch to Internal View";
// Hide IDs in external mode
document.querySelectorAll(".item-id").forEach(el => el.style.display = "none");
} else {
body.classList.remove("external-mode");
body.classList.add("internal-mode");
toggleView.textContent = "Switch to External View";
// Show IDs in internal mode
document.querySelectorAll(".item-id").forEach(el => el.style.display = "inline");
}
});
}
}