/** * ds-visual-diff.js * Visual diff tool for comparing design changes using Pixelmatch */ import toolBridge from '../../services/tool-bridge.js'; import { ComponentHelpers } from '../../utils/component-helpers.js'; // Load Pixelmatch from CDN let pixelmatch = null; class DSVisualDiff extends HTMLElement { constructor() { super(); this.screenshots = []; this.beforeImage = null; this.afterImage = null; this.diffResult = null; this.isComparing = false; this.pixelmatchLoaded = false; } async connectedCallback() { this.render(); this.setupEventListeners(); await this.loadPixelmatch(); await this.loadScreenshots(); } /** * Load Pixelmatch library from CDN */ async loadPixelmatch() { if (this.pixelmatchLoaded) return; try { // Import pixelmatch from esm.sh CDN const module = await import('https://esm.sh/pixelmatch@5.3.0'); pixelmatch = module.default; this.pixelmatchLoaded = true; console.log('[DSVisualDiff] Pixelmatch loaded successfully'); } catch (error) { console.error('[DSVisualDiff] Failed to load Pixelmatch:', error); ComponentHelpers.showToast?.('Failed to load visual diff library', 'error'); } } /** * Load screenshots from IndexedDB (shared with ds-screenshot-gallery) */ async loadScreenshots() { try { const db = await this.openDB(); this.screenshots = await this.getAllScreenshots(db); this.renderSelectors(); } catch (error) { console.error('Failed to load screenshots:', error); } } async openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('ds-screenshots', 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); } async getAllScreenshots(db) { return new Promise((resolve, reject) => { const transaction = db.transaction(['screenshots'], 'readonly'); const store = transaction.objectStore('screenshots'); const request = store.getAll(); request.onsuccess = () => resolve(request.result.reverse()); request.onerror = () => reject(request.error); }); } setupEventListeners() { const compareBtn = this.querySelector('#visual-diff-compare-btn'); if (compareBtn) { compareBtn.addEventListener('click', () => this.compareImages()); } } /** * Load image data and decode to ImageData */ async loadImageData(imageDataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, img.width, img.height); resolve(imageData); }; img.onerror = () => reject(new Error('Failed to load image')); img.src = imageDataUrl; }); } /** * Compare two images using Pixelmatch */ async compareImages() { if (this.isComparing || !this.pixelmatchLoaded) return; const beforeSelect = this.querySelector('#before-image-select'); const afterSelect = this.querySelector('#after-image-select'); if (!beforeSelect || !afterSelect) return; const beforeId = parseInt(beforeSelect.value); const afterId = parseInt(afterSelect.value); if (!beforeId || !afterId) { ComponentHelpers.showToast?.('Please select both before and after images', 'error'); return; } if (beforeId === afterId) { ComponentHelpers.showToast?.('Please select different images to compare', 'error'); return; } this.isComparing = true; const compareBtn = this.querySelector('#visual-diff-compare-btn'); if (compareBtn) { compareBtn.disabled = true; compareBtn.textContent = '🔍 Comparing...'; } try { // Get screenshot objects this.beforeImage = this.screenshots.find(s => s.id === beforeId); this.afterImage = this.screenshots.find(s => s.id === afterId); if (!this.beforeImage || !this.afterImage) { throw new Error('Screenshots not found'); } // Load image data const beforeData = await this.loadImageData(this.beforeImage.imageData); const afterData = await this.loadImageData(this.afterImage.imageData); // Ensure images are same size if (beforeData.width !== afterData.width || beforeData.height !== afterData.height) { throw new Error(`Image dimensions don't match: ${beforeData.width}x${beforeData.height} vs ${afterData.width}x${afterData.height}`); } // Create diff canvas const diffCanvas = document.createElement('canvas'); diffCanvas.width = beforeData.width; diffCanvas.height = beforeData.height; const diffCtx = diffCanvas.getContext('2d'); const diffImageData = diffCtx.createImageData(beforeData.width, beforeData.height); // Run pixelmatch comparison const numDiffPixels = pixelmatch( beforeData.data, afterData.data, diffImageData.data, beforeData.width, beforeData.height, { threshold: 0.1, includeAA: false, alpha: 0.1, diffColor: [255, 0, 0] } ); // Put diff data on canvas diffCtx.putImageData(diffImageData, 0, 0); // Calculate difference percentage const totalPixels = beforeData.width * beforeData.height; const diffPercentage = ((numDiffPixels / totalPixels) * 100).toFixed(2); this.diffResult = { beforeImage: this.beforeImage, afterImage: this.afterImage, diffImageData: diffCanvas.toDataURL(), numDiffPixels, totalPixels, diffPercentage, timestamp: new Date() }; this.renderDiffResult(); ComponentHelpers.showToast?.( `Comparison complete: ${diffPercentage}% difference`, diffPercentage < 1 ? 'success' : diffPercentage < 10 ? 'warning' : 'error' ); } catch (error) { console.error('Failed to compare images:', error); ComponentHelpers.showToast?.(`Comparison failed: ${error.message}`, 'error'); const diffContent = this.querySelector('#diff-result-content'); if (diffContent) { diffContent.innerHTML = ComponentHelpers.renderError('Comparison failed', error); } } finally { this.isComparing = false; if (compareBtn) { compareBtn.disabled = false; compareBtn.textContent = '🔍 Compare'; } } } renderSelectors() { const beforeSelect = this.querySelector('#before-image-select'); const afterSelect = this.querySelector('#after-image-select'); if (!beforeSelect || !afterSelect) return; const options = this.screenshots.map(screenshot => { const timestamp = ComponentHelpers.formatTimestamp(new Date(screenshot.timestamp)); return ``; }).join(''); const emptyOption = ''; beforeSelect.innerHTML = emptyOption + options; afterSelect.innerHTML = emptyOption + options; } renderDiffResult() { const diffContent = this.querySelector('#diff-result-content'); if (!diffContent || !this.diffResult) return; const { diffPercentage, numDiffPixels, totalPixels } = this.diffResult; // Determine status badge let statusBadge; if (diffPercentage < 1) { statusBadge = ComponentHelpers.createBadge('Identical', 'success'); } else if (diffPercentage < 10) { statusBadge = ComponentHelpers.createBadge('Minor Changes', 'warning'); } else { statusBadge = ComponentHelpers.createBadge('Significant Changes', 'error'); } diffContent.innerHTML = `
Select two screenshots above and click "Compare" to see the visual differences.