/** * 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) * - Inline cell editing with Tab navigation * - Add/remove rows and columns dynamically * - 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: 3, minCols: 2, 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'], 'Custom': null // Freeform text }; this.init(); } /** * Parse initial JSON data into spreadsheet format */ parseInitialData(jsonString) { if (!jsonString || jsonString.trim() === '' || jsonString === '[]') { return { properties: ['Size', 'Color'], values: [ ['S', 'Black'], ['M', 'White'], ['L', 'Red'] ] }; } 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), 1); // 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 defaults:', e); return this.parseInitialData(''); } } /** * Initialize Handsontable instance */ init() { if (!this.container) { console.error('Container element not found'); return; } this.hot = new Handsontable(this.container, { data: this.initialData.values, colHeaders: this.initialData.properties, rowHeaders: true, minRows: this.options.minRows, minSpareRows: 1, minCols: this.options.minCols, minSpareCols: 1, maxCols: this.options.maxCols, contextMenu: { items: { 'row_above': {}, 'row_below': {}, 'separator1': '---------', 'remove_row': {}, 'separator2': '---------', 'col_left': {}, 'col_right': {}, 'separator3': '---------', 'remove_col': {}, 'separator4': '---------', 'clear_column': {}, 'undo': {}, 'redo': {} } }, manualColumnResize: true, manualRowResize: true, undo: true, autoWrapRow: true, autoWrapCol: true, enterMoves: { row: 0, col: 1 }, // Tab behavior: move right tabMoves: { row: 0, col: 1 }, fillHandle: { autoInsertRow: true }, stretchH: 'all', licenseKey: 'non-commercial-and-evaluation', // Community Edition // Enable column header editing for property names afterGetColHeader: (col, TH) => { TH.classList.add('editable-header'); }, // Make headers clickable to change property type afterOnCellMouseDown: (event, coords) => { if (coords.row === -1 && coords.col >= 0) { this.showPropertyTypeMenu(coords.col, event); } } }); // Setup keyboard shortcuts this.setupKeyboardShortcuts(); // Setup touch gestures for mobile if (typeof Hammer !== 'undefined') { this.setupTouchGestures(); } } /** * Show property type selection menu */ showPropertyTypeMenu(colIndex, event) { const currentHeader = this.hot.getColHeader(colIndex); const menu = document.createElement('div'); menu.className = 'property-type-menu'; menu.style.position = 'absolute'; menu.style.left = event.clientX + 'px'; menu.style.top = event.clientY + 'px'; menu.style.zIndex = 10000; menu.style.background = 'white'; menu.style.border = '1px solid #ccc'; menu.style.borderRadius = '4px'; menu.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)'; menu.style.padding = '8px 0'; menu.style.minWidth = '150px'; const presetOptions = Object.keys(this.propertyPresets); presetOptions.push('Custom...'); presetOptions.forEach(preset => { const option = document.createElement('div'); option.className = 'property-type-option'; option.textContent = preset === 'Custom...' ? '✏️ ' + preset : preset; option.style.padding = '8px 16px'; option.style.cursor = 'pointer'; if (preset === currentHeader) { option.style.background = '#e3f2fd'; option.style.fontWeight = 'bold'; } option.addEventListener('mouseenter', () => { option.style.background = '#f5f5f5'; }); option.addEventListener('mouseleave', () => { if (preset !== currentHeader) { option.style.background = 'white'; } else { option.style.background = '#e3f2fd'; } }); option.addEventListener('click', () => { if (preset === 'Custom...') { const customName = prompt('Enter custom property name:', currentHeader); if (customName) { this.setPropertyType(colIndex, customName, null); } } else { this.setPropertyType(colIndex, preset, this.propertyPresets[preset]); } document.body.removeChild(menu); }); menu.appendChild(option); }); document.body.appendChild(menu); // Close menu when clicking outside const closeMenu = (e) => { if (!menu.contains(e.target)) { document.body.removeChild(menu); document.removeEventListener('click', closeMenu); } }; setTimeout(() => document.addEventListener('click', closeMenu), 10); } /** * Set property type for a column */ setPropertyType(colIndex, propertyName, presetValues) { // Update column header this.hot.updateSettings({ colHeaders: this.hot.getSettings().colHeaders.map((header, index) => { return index === colIndex ? propertyName : header; }) }); // If preset has values, populate the column if (presetValues && Array.isArray(presetValues)) { const data = this.hot.getData(); presetValues.forEach((value, rowIndex) => { if (rowIndex < data.length) { this.hot.setDataAtCell(rowIndex, colIndex, value); } }); } } /** * Setup keyboard shortcuts */ setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // 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 = []; // Process each column for (let col = 0; col < headers.length; col++) { const propertyName = headers[col]; if (!propertyName || propertyName.trim() === '') 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(cellValue); } } // Create property object const property = { name: propertyName, values: values.length > 0 ? values : null // null means freeform }; properties.push(property); } const jsonString = JSON.stringify(properties, null, 2); // Update hidden input or textarea const hiddenInput = document.getElementById('PropertiesJson'); if (hiddenInput) { hiddenInput.value = jsonString; } 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;