/** * Variant Collections Spreadsheet Editor * * Provides a professional spreadsheet-style interface for editing variant collection properties * using Handsontable. Replaces the JSON textarea with an intuitive grid editor. * * Features: * - Property presets (Size, Color, Material, Storage, Custom) * - Preset shortcut buttons for quick column addition * - Inline cell editing with Tab navigation * - Add/remove rows and columns via context menu * - Keyboard shortcuts (Ctrl+D delete, Ctrl+Enter save) * - Mobile-friendly touch gestures * - Automatic JSON serialization */ class VariantEditor { constructor(containerId, initialData, options = {}) { this.container = document.getElementById(containerId); this.hot = null; this.initialData = this.parseInitialData(initialData); this.options = { minRows: 5, minCols: 1, maxCols: 10, ...options }; // Property type presets this.propertyPresets = { 'Size': ['XS', 'S', 'M', 'L', 'XL', 'XXL'], 'Color': ['Black', 'White', 'Red', 'Blue', 'Green', 'Yellow', 'Pink', 'Purple', 'Gray', 'Brown'], 'Material': ['Cotton', 'Polyester', 'Silk', 'Wool', 'Leather', 'Denim', 'Linen'], 'Storage': ['16GB', '32GB', '64GB', '128GB', '256GB', '512GB', '1TB'] }; this.init(); } /** * Parse initial JSON data into spreadsheet format */ parseInitialData(jsonString) { // If no data, start with empty grid if (!jsonString || jsonString.trim() === '' || jsonString === '[]') { return { properties: ['Property 1'], values: [ [null], [null], [null], [null], [null] ] }; } try { const properties = JSON.parse(jsonString); if (!Array.isArray(properties) || properties.length === 0) { throw new Error('Invalid format'); } // Convert from JSON format to grid format const propertyNames = properties.map(p => p.name); const propertyValues = properties.map(p => p.values); // Determine max number of rows needed const maxRows = Math.max(...propertyValues.map(v => Array.isArray(v) ? v.length : 0), this.options.minRows); // Build grid data const gridData = []; for (let i = 0; i < maxRows; i++) { const row = propertyNames.map((name, colIndex) => { const values = propertyValues[colIndex]; if (values === null) { return null; // Freeform } return Array.isArray(values) && values[i] ? values[i] : null; }); gridData.push(row); } return { properties: propertyNames, values: gridData }; } catch (e) { console.warn('Failed to parse initial data, using empty grid:', e); return this.parseInitialData(''); } } /** * Initialize Handsontable instance */ init() { if (!this.container) { console.error('Container element not found'); return; } // Create preset buttons toolbar this.createPresetToolbar(); // Create spreadsheet container const spreadsheetDiv = document.createElement('div'); spreadsheetDiv.id = this.container.id + '-grid'; spreadsheetDiv.style.marginTop = '10px'; this.container.appendChild(spreadsheetDiv); this.hot = new Handsontable(spreadsheetDiv, { data: this.initialData.values, colHeaders: this.initialData.properties, rowHeaders: true, minRows: this.options.minRows, minSpareRows: 1, minCols: this.options.minCols, minSpareCols: 0, maxCols: this.options.maxCols, contextMenu: { items: { 'row_above': { name: 'Insert row above' }, 'row_below': { name: 'Insert row below' }, 'separator1': '---------', 'remove_row': { name: 'Delete row' }, 'separator2': '---------', 'col_left': { name: 'Insert column left' }, 'col_right': { name: 'Insert column right' }, 'separator3': '---------', 'remove_col': { name: 'Delete column' }, 'separator4': '---------', 'rename_column': { name: 'Rename column...', callback: (key, selection) => { const col = selection[0].start.col; this.showRenameDialog(col); } }, 'separator5': '---------', 'clear_column': { name: 'Clear column values' }, 'undo': {}, 'redo': {} } }, manualColumnResize: true, manualRowResize: true, undo: true, autoWrapRow: true, autoWrapCol: true, enterMoves: { row: 1, col: 0 }, // Enter moves down tabMoves: { row: 0, col: 1 }, // Tab moves right fillHandle: { autoInsertRow: true }, stretchH: 'all', licenseKey: 'non-commercial-and-evaluation', // Community Edition // Double-click header to rename afterOnCellMouseDown: (event, coords) => { if (coords.row === -1 && coords.col >= 0 && event.detail === 2) { this.showRenameDialog(coords.col); } } }); // Setup keyboard shortcuts this.setupKeyboardShortcuts(); // Setup touch gestures for mobile if (typeof Hammer !== 'undefined') { this.setupTouchGestures(); } } /** * Create preset shortcut buttons toolbar */ createPresetToolbar() { const toolbar = document.createElement('div'); toolbar.className = 'variant-preset-toolbar'; toolbar.style.marginBottom = '10px'; toolbar.style.display = 'flex'; toolbar.style.gap = '8px'; toolbar.style.flexWrap = 'wrap'; const label = document.createElement('strong'); label.textContent = 'Quick Add: '; label.style.marginRight = '8px'; label.style.alignSelf = 'center'; toolbar.appendChild(label); // Add preset buttons Object.keys(this.propertyPresets).forEach(presetName => { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-sm btn-outline-primary'; button.innerHTML = ` ${presetName}`; button.style.fontSize = '12px'; button.addEventListener('click', () => { this.addPresetColumn(presetName); }); toolbar.appendChild(button); }); // Add custom column button const customButton = document.createElement('button'); customButton.type = 'button'; customButton.className = 'btn btn-sm btn-outline-secondary'; customButton.innerHTML = ` Custom`; customButton.style.fontSize = '12px'; customButton.addEventListener('click', () => { this.addCustomColumn(); }); toolbar.appendChild(customButton); this.container.appendChild(toolbar); } /** * Add a preset column to the spreadsheet */ addPresetColumn(presetName) { const presetValues = this.propertyPresets[presetName]; const currentColCount = this.hot.countCols(); // Insert new column this.hot.alter('insert_col_end', currentColCount, 1); // Update column header with new array reference const currentHeaders = this.hot.getColHeaders(); const newHeaders = Array.isArray(currentHeaders) ? [...currentHeaders] : Array.from({ length: this.hot.countCols() }, (_, i) => this.hot.getColHeader(i)); newHeaders[currentColCount] = presetName; this.hot.updateSettings({ colHeaders: newHeaders }); // Populate with preset values if (presetValues && Array.isArray(presetValues)) { presetValues.forEach((value, rowIndex) => { if (rowIndex < this.hot.countRows()) { this.hot.setDataAtCell(rowIndex, currentColCount, value); } }); } console.log('Added preset column:', { presetName, columnIndex: currentColCount, valuesCount: presetValues ? presetValues.length : 0 }); } /** * Add a custom column to the spreadsheet */ addCustomColumn() { const customName = prompt('Enter column name:', 'Custom Property'); if (!customName || customName.trim() === '') { return; } const currentColCount = this.hot.countCols(); // Insert new column this.hot.alter('insert_col_end', currentColCount, 1); // Update column header with new array reference const currentHeaders = this.hot.getColHeaders(); const newHeaders = Array.isArray(currentHeaders) ? [...currentHeaders] : Array.from({ length: this.hot.countCols() }, (_, i) => this.hot.getColHeader(i)); newHeaders[currentColCount] = customName.trim(); this.hot.updateSettings({ colHeaders: newHeaders }); console.log('Added custom column:', { columnName: customName.trim(), columnIndex: currentColCount }); } /** * Show rename dialog for column */ showRenameDialog(colIndex) { const currentHeader = this.hot.getColHeader(colIndex); const newName = prompt('Rename column:', currentHeader); if (newName && newName.trim() !== '') { // Get current headers and create a new array (force immutability) const currentHeaders = this.hot.getColHeaders(); const newHeaders = Array.isArray(currentHeaders) ? [...currentHeaders] : Array.from({ length: this.hot.countCols() }, (_, i) => this.hot.getColHeader(i)); // Update the specific column header newHeaders[colIndex] = newName.trim(); // Force update with new array reference this.hot.updateSettings({ colHeaders: newHeaders }); // Render to ensure visual update this.hot.render(); console.log('Column renamed:', { colIndex, oldName: currentHeader, newName: newName.trim() }); } } /** * Setup keyboard shortcuts */ setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // Only process if the spreadsheet is focused if (!this.container.contains(document.activeElement)) { return; } // Ctrl+D or Cmd+D: Delete selected rows/columns if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); const selected = this.hot.getSelected(); if (selected) { const [[startRow, startCol, endRow, endCol]] = selected; if (startRow !== endRow) { // Delete rows this.hot.alter('remove_row', startRow, endRow - startRow + 1); } else if (startCol !== endCol) { // Delete columns this.hot.alter('remove_col', startCol, endCol - startCol + 1); } } } // Ctrl+Enter or Cmd+Enter: Trigger form save if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); const form = this.container.closest('form'); if (form) { this.serializeToJSON(); form.submit(); } } }); } /** * Setup touch gestures for mobile */ setupTouchGestures() { const hammertime = new Hammer(this.container); // Swipe left/right to delete row hammertime.on('swipeleft swiperight', (ev) => { const coords = this.hot.getSelectedLast(); if (coords) { const [row] = coords; if (confirm('Delete this row?')) { this.hot.alter('remove_row', row); } } }); } /** * Serialize spreadsheet data to JSON format */ serializeToJSON() { const data = this.hot.getData(); const headers = this.hot.getColHeaders(); const properties = []; console.log('Serializing spreadsheet:', { totalColumns: headers.length, totalRows: data.length }); // Process each column for (let col = 0; col < headers.length; col++) { const propertyName = headers[col]; // Skip empty column names or default column names if (!propertyName || propertyName.trim() === '' || propertyName.startsWith('Property ')) { console.log('Skipping column:', { colIndex: col, name: propertyName, reason: 'empty or default' }); continue; } // Collect non-null values from this column const values = []; for (let row = 0; row < data.length; row++) { const cellValue = data[row][col]; if (cellValue !== null && cellValue !== undefined && cellValue !== '') { values.push(String(cellValue).trim()); } } // Create property object const property = { name: propertyName.trim(), values: values.length > 0 ? values : null // null means freeform }; properties.push(property); console.log('Added property:', { name: property.name, valueCount: values.length }); } const jsonString = JSON.stringify(properties, null, 2); console.log('Final JSON:', jsonString); // Update hidden input const hiddenInput = document.getElementById('PropertiesJson'); if (hiddenInput) { hiddenInput.value = jsonString; console.log('Updated hidden input PropertiesJson'); } else { console.error('Hidden input PropertiesJson not found!'); } return jsonString; } /** * Get current data as JSON */ toJSON() { return this.serializeToJSON(); } /** * Destroy the editor instance */ destroy() { if (this.hot) { this.hot.destroy(); this.hot = null; } } } // Export for use in other scripts window.VariantEditor = VariantEditor;