From 4576d066b3bc147f48485c2f965704474bd85941 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 23 Jan 2026 17:15:36 +0100 Subject: [PATCH] feat: add inline file editing with modification tracking and save functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 4 +- engine.js | 110 ++++++++++++++++--- file-editor.js | 281 +++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 162 +++++++++++++++++++++++++--- 4 files changed, 521 insertions(+), 36 deletions(-) create mode 100644 file-editor.js diff --git a/Makefile b/Makefile index 6c9afa7..2598ac1 100644 --- a/Makefile +++ b/Makefile @@ -152,8 +152,8 @@ dist: @echo "📦 Created dist/ directory" @# Copy core application files - @cp index.html engine.js generator.js dist/ - @echo "✅ Copied core files (index.html, engine.js, generator.js)" + @cp index.html engine.js generator.js file-editor.js dist/ + @echo "✅ Copied core files (index.html, engine.js, generator.js, file-editor.js)" @# Copy project directories @cp -r example example-1 my-project dist/ diff --git a/engine.js b/engine.js index 8ab5fd1..824f22b 100644 --- a/engine.js +++ b/engine.js @@ -3,6 +3,8 @@ window.timelineEngine = { template: null, csvOverride: false, cssOverride: false, + csvData: null, // Store current CSV text + cssData: null, // Store current CSS text projectBasePath: '', @@ -190,6 +192,11 @@ window.timelineEngine = { // 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(); @@ -199,27 +206,35 @@ window.timelineEngine = { // Stylesheet if (cfg.stylesheet && !this.cssOverride) { const stylesheetPath = this.resolveProjectPath(cfg.stylesheet); - const linkElement = document.getElementById("dynamicCss"); - // Set up load/error event handlers before setting href - const handleLoad = () => { - console.log("Stylesheet loaded successfully:", stylesheetPath); - this.updateFileStatus('css', cfg.stylesheet + ' ✨', 'loaded'); - linkElement.removeEventListener('load', handleLoad); - linkElement.removeEventListener('error', handleError); - }; + // 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 - const handleError = () => { - console.error("Stylesheet could not be loaded:", stylesheetPath); + // 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}`); - linkElement.removeEventListener('load', handleLoad); - linkElement.removeEventListener('error', handleError); - }; - - linkElement.addEventListener('load', handleLoad); - linkElement.addEventListener('error', handleError); - linkElement.href = stylesheetPath; + } } // SVG template @@ -238,6 +253,11 @@ window.timelineEngine = { 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(); @@ -270,6 +290,12 @@ window.timelineEngine = { 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); @@ -306,6 +332,9 @@ window.timelineEngine = { 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; @@ -696,6 +725,11 @@ window.setupEventHandlers = function() { // 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 = []; @@ -704,10 +738,17 @@ window.setupEventHandlers = function() { 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}`); @@ -722,6 +763,12 @@ window.setupEventHandlers = function() { 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}`); @@ -736,6 +783,12 @@ window.setupEventHandlers = function() { 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 @@ -800,6 +853,12 @@ window.setupEventHandlers = function() { } 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 @@ -824,6 +883,12 @@ window.setupEventHandlers = function() { 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); }); } @@ -834,10 +899,16 @@ window.setupEventHandlers = function() { 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'); + } }); } @@ -849,6 +920,11 @@ window.setupEventHandlers = function() { 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(); diff --git a/file-editor.js b/file-editor.js new file mode 100644 index 0000000..a0f8ef1 --- /dev/null +++ b/file-editor.js @@ -0,0 +1,281 @@ +// File Editor Module +window.fileEditor = { + currentFile: null, + currentContent: null, + modifiedFiles: new Set(), + + init() { + // Set up edit button handlers + document.getElementById('editProjectBtn').addEventListener('click', () => { + this.openEditor('project', 'Project Configuration (project.json)', + JSON.stringify(window.timelineEngine.config, null, 2)); + }); + + document.getElementById('editSvgBtn').addEventListener('click', () => { + this.openEditor('svg', 'SVG Template', window.timelineEngine.template); + }); + + document.getElementById('editCssBtn').addEventListener('click', () => { + this.openEditor('css', 'Stylesheet', window.timelineEngine.cssData); + }); + + document.getElementById('editCsvBtn').addEventListener('click', () => { + this.openEditor('csv', 'CSV Data', window.timelineEngine.csvData); + }); + + // Set up save changes button + document.getElementById('saveChanges').addEventListener('click', () => { + this.saveAllChanges(); + }); + + // Close modal on background click + document.getElementById('editorModal').addEventListener('click', (e) => { + if (e.target.id === 'editorModal') { + this.closeEditor(); + } + }); + + // Keyboard shortcut: Escape to close + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && document.getElementById('editorModal').style.display === 'block') { + this.closeEditor(); + } + }); + + console.log('File editor initialized'); + }, + + openEditor(fileType, title, content) { + if (!content) { + alert(`No ${title} loaded. Please load a file first.`); + return; + } + + this.currentFile = fileType; + this.currentContent = content; + + document.getElementById('editorTitle').textContent = `Edit ${title}`; + document.getElementById('editorTextarea').value = content; + document.getElementById('editorModal').style.display = 'block'; + + console.log(`Opened editor for ${fileType}`); + }, + + closeEditor() { + document.getElementById('editorModal').style.display = 'none'; + this.currentFile = null; + this.currentContent = null; + }, + + applyChanges() { + const newContent = document.getElementById('editorTextarea').value; + + // Validate based on file type + if (this.currentFile === 'project' || this.currentFile === 'json') { + try { + JSON.parse(newContent); + } catch (e) { + alert(`Invalid JSON: ${e.message}\n\nPlease fix the syntax errors before applying.`); + return; + } + } + + // Check if content actually changed + if (newContent === this.currentContent) { + console.log('No changes detected'); + this.closeEditor(); + return; + } + + // Apply changes based on file type + console.log(`Applying changes to ${this.currentFile}`); + + try { + switch (this.currentFile) { + case 'project': + const cfg = JSON.parse(newContent); + window.timelineEngine.config = cfg; + + // Update UI + document.getElementById("projectName").textContent = cfg.name || "Timeline"; + document.getElementById("projectSubtitle").textContent = + cfg.description || "Project configuration updated."; + + // Show field mappings + window.timelineEngine.showFieldMappings(); + + // Regenerate timeline if we have CSV data + if (window.timelineEngine.csvData) { + window.timelineEngine.processCsv(window.timelineEngine.csvData); + } + break; + + case 'svg': + window.timelineEngine.template = newContent; + window.timelineEngine.showTemplateFields(); + + // Regenerate timeline if we have CSV data + if (window.timelineEngine.csvData) { + window.timelineEngine.processCsv(window.timelineEngine.csvData); + } else { + window.timelineEngine.showTemplatePreview(); + } + break; + + case 'css': + window.timelineEngine.cssData = newContent; + const blob = new Blob([newContent], { type: "text/css" }); + document.getElementById("dynamicCss").href = URL.createObjectURL(blob); + break; + + case 'csv': + window.timelineEngine.csvData = newContent; + window.timelineEngine.processCsv(newContent); + break; + } + + // Mark file as modified + this.modifiedFiles.add(this.currentFile); + this.updateModifiedBadges(); + this.updateSaveButton(); + + console.log(`✅ Changes applied to ${this.currentFile}`); + this.closeEditor(); + + } catch (error) { + console.error('Error applying changes:', error); + alert(`Error applying changes: ${error.message}`); + } + }, + + updateModifiedBadges() { + // Remove all existing badges + document.querySelectorAll('.modified-badge').forEach(badge => badge.remove()); + + // Add badges to modified files + this.modifiedFiles.forEach(fileType => { + let statusElement; + switch (fileType) { + case 'project': + statusElement = document.getElementById('projectFile'); + break; + case 'svg': + statusElement = document.getElementById('svgFile'); + break; + case 'css': + statusElement = document.getElementById('cssFile'); + break; + case 'csv': + statusElement = document.getElementById('csvFile'); + break; + } + + if (statusElement && !statusElement.querySelector('.modified-badge')) { + const badge = document.createElement('span'); + badge.className = 'modified-badge'; + badge.textContent = 'MODIFIED'; + statusElement.appendChild(badge); + } + }); + }, + + updateSaveButton() { + const saveBtn = document.getElementById('saveChanges'); + if (this.modifiedFiles.size > 0) { + saveBtn.disabled = false; + saveBtn.style.opacity = '1'; + } else { + saveBtn.disabled = true; + saveBtn.style.opacity = '0.6'; + } + }, + + saveAllChanges() { + if (this.modifiedFiles.size === 0) { + alert('No files have been modified.'); + return; + } + + const filesToSave = Array.from(this.modifiedFiles); + console.log('Saving modified files:', filesToSave); + + filesToSave.forEach(fileType => { + let content, filename, mimeType; + + switch (fileType) { + case 'project': + content = JSON.stringify(window.timelineEngine.config, null, 2); + filename = 'project.json'; + mimeType = 'application/json'; + break; + + case 'svg': + content = window.timelineEngine.template; + filename = 'template-v2.svg'; + mimeType = 'image/svg+xml'; + break; + + case 'css': + content = window.timelineEngine.cssData; + filename = 'style.css'; + mimeType = 'text/css'; + break; + + case 'csv': + content = window.timelineEngine.csvData; + filename = 'sample.csv'; + mimeType = 'text/csv'; + break; + + default: + console.warn(`Unknown file type: ${fileType}`); + return; + } + + // Create and trigger download + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + + console.log(`✅ Saved ${filename}`); + }); + + // Clear modified state after saving + setTimeout(() => { + if (confirm(`${filesToSave.length} file(s) downloaded.\n\nClear modified state?`)) { + this.modifiedFiles.clear(); + this.updateModifiedBadges(); + this.updateSaveButton(); + } + }, 500); + }, + + enableEditButton(fileType) { + let btnId; + switch (fileType) { + case 'project': + btnId = 'editProjectBtn'; + break; + case 'svg': + btnId = 'editSvgBtn'; + break; + case 'css': + btnId = 'editCssBtn'; + break; + case 'csv': + btnId = 'editCsvBtn'; + break; + default: + return; + } + + const btn = document.getElementById(btnId); + if (btn) { + btn.disabled = false; + } + } +}; diff --git a/index.html b/index.html index c64809d..431e08e 100644 --- a/index.html +++ b/index.html @@ -49,6 +49,96 @@ background: #343a40; } + .edit-btn { + background: #6c757d; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + transition: background-color 0.2s; + border: none; + user-select: none; + } + + .edit-btn:hover:not(:disabled) { + background: #545b62; + } + + .edit-btn:disabled { + background: #e9ecef; + color: #adb5bd; + cursor: not-allowed; + } + + .modified-badge { + display: inline-block; + background: #fd7e14; + color: white; + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + margin-left: 6px; + font-weight: 600; + } + + .editor-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.6); + z-index: 10000; + padding: 20px; + overflow: auto; + } + + .editor-content { + background: white; + max-width: 1200px; + margin: 0 auto; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0,0,0,0.3); + display: flex; + flex-direction: column; + max-height: 90vh; + } + + .editor-header { + padding: 16px 20px; + border-bottom: 1px solid #dee2e6; + display: flex; + justify-content: space-between; + align-items: center; + } + + .editor-body { + padding: 20px; + flex: 1; + overflow: auto; + } + + .editor-textarea { + width: 100%; + min-height: 400px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace; + font-size: 13px; + padding: 12px; + border: 1px solid #ced4da; + border-radius: 4px; + resize: vertical; + } + + .editor-footer { + padding: 16px 20px; + border-top: 1px solid #dee2e6; + display: flex; + gap: 12px; + justify-content: flex-end; + } + .file-status { border-top: 1px solid #f1f3f4; padding-top: 8px; @@ -226,6 +316,7 @@ + @@ -260,10 +351,13 @@
Project Configuration - +
+ + +
Not loaded @@ -273,10 +367,13 @@
SVG Template - +
+ + +
Not loaded @@ -286,10 +383,13 @@
Stylesheet - +
+ + +
Not loaded @@ -299,10 +399,13 @@
CSV Data - +
+ + +
Not loaded @@ -314,9 +417,13 @@ +
@@ -409,5 +516,26 @@ }); + +
+
+
+

Edit File

+ +
+
+ +
+ +
+
+