/** * Selection Module * Handles Excel-style cell selection and copy/paste */ (function() { 'use strict'; let selectionStart = null; let selectionEnd = null; let isSelecting = false; let initialized = false; let autoScrollInterval = null; let lastMouseY = 0; let startMouseX = 0; let startMouseY = 0; let dragDistance = 0; /** * Initialize selection handlers */ function init() { // Only initialize once if (initialized) return; initialized = true; const table = window.app.dom.spreadsheet; // Make table focusable so clipboard events fire if (!table.hasAttribute('tabindex')) { table.setAttribute('tabindex', '-1'); table.style.outline = 'none'; } // Mouse down on cell - start selection table.addEventListener('mousedown', handleMouseDown); // Mouse move - extend selection document.addEventListener('mousemove', handleMouseMove); // Mouse up - end selection document.addEventListener('mouseup', handleMouseUp); // Selectstart handler - prevent only when dragging (multi-cell selection) document.addEventListener('selectstart', (e) => { if (isSelecting && dragDistance > 4) { e.preventDefault(); } }); // Copy/paste handlers document.addEventListener('copy', handleCopy); document.addEventListener('paste', handlePaste); document.addEventListener('cut', handleCut); } /** * Handle mouse down on cell */ function handleMouseDown(e) { const cell = e.target.closest('td'); if (!cell) return; // Ignore if clicking action buttons if (e.target.closest('.inline-actions')) return; // Ignore if cell is being edited (contenteditable) if (cell.isContentEditable || cell.classList.contains('editing')) return; // Don't start selection if double-clicking to edit if (e.detail === 2) return; const row = cell.closest('tr'); if (!row) return; const rowIndex = parseInt(row.dataset.index); const colIndex = Array.from(row.children).indexOf(cell); // Shift+Click: extend selection from existing start to clicked cell if (e.shiftKey && selectionStart) { selectionEnd = { row: rowIndex, col: colIndex }; updateSelection(); updateButtonStates(); e.preventDefault(); return; } // Clear previous selection clearSelection(); // Start new selection selectionStart = { row: rowIndex, col: colIndex }; selectionEnd = { row: rowIndex, col: colIndex }; isSelecting = true; dragDistance = 0; startMouseX = e.clientX; startMouseY = e.clientY; // Highlight cell updateSelection(); updateButtonStates(); // Focus the table so clipboard events (Ctrl+V) fire window.app.dom.spreadsheet.focus(); // Only prevent default for shift-click (extending selection) // Single click should allow text selection to work normally if (e.shiftKey) { e.preventDefault(); } } /** * Handle mouse move during selection */ function handleMouseMove(e) { if (!isSelecting) return; // Store mouse position for auto-scroll lastMouseY = e.clientY; // Track drag distance from mousedown const dx = e.clientX - startMouseX; const dy = e.clientY - startMouseY; dragDistance = Math.sqrt(dx*dx + dy*dy); const cell = e.target.closest('td'); if (!cell) return; const row = cell.closest('tr'); if (!row) return; const rowIndex = parseInt(row.dataset.index); const colIndex = Array.from(row.children).indexOf(cell); if (rowIndex === undefined || colIndex === -1) return; // Update selection end selectionEnd = { row: rowIndex, col: colIndex }; // Highlight selected cells updateSelection(); // Start auto-scroll if not already running if (!autoScrollInterval) { startAutoScroll(); } } /** * Start continuous auto-scroll */ function startAutoScroll() { const scrollThreshold = 50; // pixels from edge const scrollSpeed = 5; // pixels per frame const scroll = () => { if (!isSelecting) { autoScrollInterval = null; return; } const viewport = document.querySelector('.spreadsheet-pane'); if (!viewport) { autoScrollInterval = null; return; } const rect = viewport.getBoundingClientRect(); // Determine scroll direction based on last mouse position if (lastMouseY > rect.bottom - scrollThreshold) { viewport.scrollTop += scrollSpeed; // Scroll down } else if (lastMouseY < rect.top + scrollThreshold) { viewport.scrollTop -= scrollSpeed; // Scroll up } // Continue scrolling autoScrollInterval = requestAnimationFrame(scroll); }; autoScrollInterval = requestAnimationFrame(scroll); } /** * Handle mouse up - end selection */ function handleMouseUp(e) { isSelecting = false; dragDistance = 0; // Stop auto-scrolling if (autoScrollInterval) { cancelAnimationFrame(autoScrollInterval); autoScrollInterval = null; } updateButtonStates(); } /** * Update visual selection highlighting */ function updateSelection() { if (!selectionStart || !selectionEnd) return; // Clear all previous highlights document.querySelectorAll('.selected-cell').forEach(cell => { cell.classList.remove('selected-cell'); }); // Calculate selection bounds const minRow = Math.min(selectionStart.row, selectionEnd.row); const maxRow = Math.max(selectionStart.row, selectionEnd.row); const minCol = Math.min(selectionStart.col, selectionEnd.col); const maxCol = Math.max(selectionStart.col, selectionEnd.col); // Highlight selected cells const tbody = window.app.dom.spreadsheetBody; const rows = tbody.querySelectorAll('tr'); for (let r = minRow; r <= maxRow; r++) { const row = rows[r]; if (!row) continue; const cells = row.children; for (let c = minCol; c <= maxCol; c++) { const cell = cells[c]; if (cell) { cell.classList.add('selected-cell'); } } } // Emit rowfocused event for preview pane emitRowFocused(minRow); } /** * Emit row focused event for preview pane */ function emitRowFocused(rowIndex) { const files = window.app.modules.store.getDisplayFiles(); const file = files[rowIndex]; if (file) { const event = new CustomEvent('rowfocused', { detail: { rowIndex, file } }); document.dispatchEvent(event); } } /** * Clear selection */ function clearSelection() { selectionStart = null; selectionEnd = null; document.querySelectorAll('.selected-cell').forEach(cell => { cell.classList.remove('selected-cell'); }); updateButtonStates(); } /** * Check if there is an active selection */ function hasSelection() { return selectionStart !== null && selectionEnd !== null; } /** * Update Copy/Paste button enabled states */ function updateButtonStates() { const copyBtn = document.getElementById('copyBtn'); const pasteBtn = document.getElementById('pasteBtn'); const active = hasSelection(); if (copyBtn) copyBtn.disabled = !active; if (pasteBtn) pasteBtn.disabled = !active; } /** * Get column headers for selected columns */ function getColumnHeaders(minCol, maxCol) { const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th'); const headers = []; for (let c = minCol; c <= maxCol; c++) { const th = headerCells[c]; if (th) { // Get the text content, excluding filter inputs const text = th.childNodes[0]?.textContent?.trim() || th.textContent.trim(); headers.push(text); } else { headers.push(''); } } return headers; } /** * Check if first row looks like a header row */ function isHeaderRow(row) { const headerPatterns = ['#', 'Original', 'Ext', 'New', 'Tracking', 'Rev', 'Status', 'Title', 'SHA256']; return row.some(cell => headerPatterns.includes(cell.trim())); } /** * Convert 2D array of rows to an HTML table string. * Excel prefers text/html over text/plain, so providing a * proper ensures cell boundaries are preserved. */ function rowsToHtml(allRows) { const esc = s => s.replace(/&/g, '&').replace(//g, '>'); const headerRow = allRows[0]; const dataRows = allRows.slice(1); let html = '
'; html += '' + headerRow.map(c => '').join('') + ''; for (const row of dataRows) { html += '' + row.map(c => '').join('') + ''; } html += '
' + esc(c) + '
' + esc(c) + '
'; return html; } /** * Handle copy event */ function handleCopy(e) { if (!selectionStart || !selectionEnd) return; const minCol = Math.min(selectionStart.col, selectionEnd.col); const maxCol = Math.max(selectionStart.col, selectionEnd.col); // Get column headers for selected range const headers = getColumnHeaders(minCol, maxCol); const data = getSelectionData(); if (!data) return; // Prepend header row const allRows = [headers, ...data]; // Convert to TSV and HTML table const tsv = allRows.map(row => row.join('\t')).join('\n'); e.clipboardData.setData('text/plain', tsv); e.clipboardData.setData('text/html', rowsToHtml(allRows)); e.preventDefault(); } /** * Handle cut event */ function handleCut(e) { if (!selectionStart || !selectionEnd) return; const minCol = Math.min(selectionStart.col, selectionEnd.col); const maxCol = Math.max(selectionStart.col, selectionEnd.col); // Get column headers for selected range const headers = getColumnHeaders(minCol, maxCol); const data = getSelectionData(); if (!data) return; // Prepend header row const allRows = [headers, ...data]; // Convert to TSV const tsv = allRows.map(row => row.join('\t')).join('\n'); e.clipboardData.setData('text/plain', tsv); e.clipboardData.setData('text/html', rowsToHtml(allRows)); // Clear selected cells (only editable ones) clearSelectionData(); e.preventDefault(); } /** * Handle paste event */ function handlePaste(e) { // Don't intercept paste in input fields if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) { return; } if (!selectionStart) return; const tsv = e.clipboardData.getData('text/plain'); if (!tsv) return; e.preventDefault(); executePaste(tsv); } /** * Execute paste from TSV string into the selected range. * - If pasted cols > selected cols, right-align (user likely copied all but only pastes back editable cols). * - If pasted data includes the first two columns (#, Original), validate they match. * - If pasted row count != selected row count, abort with error. */ function executePaste(tsv) { if (!selectionStart || !selectionEnd) return; // Parse TSV let rows = tsv.split('\n').map(row => row.split('\t')); // Filter out empty trailing rows while (rows.length > 0 && rows[rows.length - 1].every(cell => !cell.trim())) { rows.pop(); } if (rows.length === 0) return; // Check if first row is a header row and skip it if (rows.length > 1 && isHeaderRow(rows[0])) { rows = rows.slice(1); } if (rows.length === 0) return; // Selection bounds const minRow = Math.min(selectionStart.row, selectionEnd.row); const maxRow = Math.max(selectionStart.row, selectionEnd.row); const minCol = Math.min(selectionStart.col, selectionEnd.col); const maxCol = Math.max(selectionStart.col, selectionEnd.col); const selectedRowCount = maxRow - minRow + 1; const selectedColCount = maxCol - minCol + 1; const pastedColCount = rows[0].length; // Row count validation: must match if (rows.length !== selectedRowCount) { alert(`Paste aborted: row count mismatch.\n` + `Selected ${selectedRowCount} row(s), but clipboard has ${rows.length} row(s).`); return; } // Determine column offset for right-alignment let colOffset = 0; if (pastedColCount > selectedColCount) { colOffset = pastedColCount - selectedColCount; } // Get column names from header const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th'); const columnNames = Array.from(headerCells).map(th => { const match = th.className.match(/col-(\w+)/); return match ? match[1] : ''; }); // If pasted data includes first two columns (row-num, original), validate they match if (colOffset === 0 && pastedColCount >= selectedColCount) { // Check if pasted range starts at col 0 or col 1 (row-num or original) const files = window.app.modules.store.getDisplayFiles(); const startsAtRowNum = (minCol === 0); const startsAtOriginal = (minCol === 1); if (startsAtRowNum || startsAtOriginal) { for (let r = 0; r < rows.length; r++) { const targetRowIndex = minRow + r; const file = files[targetRowIndex]; if (!file) continue; if (startsAtRowNum) { // Validate col 0 = row number, col 1 = original filename const expectedNum = String(targetRowIndex + 1); const pastedNum = rows[r][0]?.trim(); const pastedOriginal = rows[r][1]?.trim(); if (pastedNum && pastedNum !== expectedNum) { alert(`Paste aborted: row number mismatch at row ${targetRowIndex + 1}.\n` + `Expected "${expectedNum}", got "${pastedNum}".\n` + `Data may be shuffled.`); return; } if (pastedOriginal && pastedOriginal !== file.originalFilename) { alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` + `Expected "${file.originalFilename}", got "${pastedOriginal}".\n` + `Data may be shuffled.`); return; } } else if (startsAtOriginal) { // Validate col 0 of paste = original filename const pastedOriginal = rows[r][0]?.trim(); if (pastedOriginal && pastedOriginal !== file.originalFilename) { alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` + `Expected "${file.originalFilename}", got "${pastedOriginal}".\n` + `Data may be shuffled.`); return; } } } } } // Editable columns const editableColumns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title']; const files = window.app.modules.store.getDisplayFiles(); let updatedCount = 0; for (let r = 0; r < rows.length; r++) { const targetRowIndex = minRow + r; if (targetRowIndex >= files.length) continue; const file = files[targetRowIndex]; if (!file) continue; const rowData = rows[r]; for (let c = 0; c < selectedColCount; c++) { const pasteIdx = c + colOffset; // right-align: skip leading pasted cols if (pasteIdx >= rowData.length) continue; const targetColIndex = minCol + c; if (targetColIndex >= columnNames.length) continue; const columnName = columnNames[targetColIndex]; if (!editableColumns.includes(columnName)) continue; const value = rowData[pasteIdx]?.trim() || ''; if (columnName === 'newFilename') { if (value) { file.manualFilename = value; } else { delete file.manualFilename; } } else { file[columnName] = value; if (file.manualFilename) { delete file.manualFilename; } } file.isDirty = true; file.autoPopulated = false; updatedCount++; } } // Re-render and restore selection if (updatedCount > 0) { window.app.modules.spreadsheet.render(); // Restore selection highlight updateSelection(); showToast(`Pasted ${updatedCount} cell(s)`, 'success'); } } /** * Show a brief toast notification */ function showToast(message, type) { if (window.app.modules.excel && window.app.modules.excel.showToast) { window.app.modules.excel.showToast(message, type); return; } // Fallback: simple toast const toast = document.createElement('div'); toast.textContent = message; toast.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:8px 16px;' + 'background:' + (type === 'success' ? '#28a745' : '#dc3545') + ';color:#fff;' + 'border-radius:4px;z-index:9999;font-size:14px;'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); } /** * Get data from selected cells */ function getSelectionData() { if (!selectionStart || !selectionEnd) return null; const minRow = Math.min(selectionStart.row, selectionEnd.row); const maxRow = Math.max(selectionStart.row, selectionEnd.row); const minCol = Math.min(selectionStart.col, selectionEnd.col); const maxCol = Math.max(selectionStart.col, selectionEnd.col); const data = []; const tbody = window.app.dom.spreadsheetBody; const rows = tbody.querySelectorAll('tr'); for (let r = minRow; r <= maxRow; r++) { const row = rows[r]; if (!row) continue; const rowData = []; const cells = row.children; for (let c = minCol; c <= maxCol; c++) { const cell = cells[c]; if (cell) { rowData.push(cell.textContent.trim()); } else { rowData.push(''); } } data.push(rowData); } return data; } /** * Clear data from selected cells (only editable ones) */ function clearSelectionData() { if (!selectionStart || !selectionEnd) return; const minRow = Math.min(selectionStart.row, selectionEnd.row); const maxRow = Math.max(selectionStart.row, selectionEnd.row); const minCol = Math.min(selectionStart.col, selectionEnd.col); const maxCol = Math.max(selectionStart.col, selectionEnd.col); const tbody = window.app.dom.spreadsheetBody; const rows = tbody.querySelectorAll('tr'); // Get column names from header const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th'); const columnNames = Array.from(headerCells).map(th => { const className = th.className.replace('col-', ''); return className; }); for (let r = minRow; r <= maxRow; r++) { const row = rows[r]; if (!row) continue; const rowIndex = parseInt(row.dataset.index); const files = window.app.modules.store.getDisplayFiles(); const file = files[rowIndex]; if (!file) continue; const cells = row.children; for (let c = minCol; c <= maxCol; c++) { const cell = cells[c]; if (!cell || !cell.classList.contains('cell-editable')) continue; const columnName = columnNames[c]; // Clear the data if (columnName === 'newFilename') { delete file.manualFilename; } else { file[columnName] = ''; } file.isDirty = true; } } // Re-render window.app.modules.spreadsheet.render(); } /** * Copy selection to clipboard via button click */ function doCopy() { if (!selectionStart || !selectionEnd) return; const minCol = Math.min(selectionStart.col, selectionEnd.col); const maxCol = Math.max(selectionStart.col, selectionEnd.col); const headers = getColumnHeaders(minCol, maxCol); const data = getSelectionData(); if (!data) return; const allRows = [headers, ...data]; const tsv = allRows.map(row => row.join('\t')).join('\n'); const html = rowsToHtml(allRows); // Write both plain text and HTML to clipboard const htmlBlob = new Blob([html], { type: 'text/html' }); const textBlob = new Blob([tsv], { type: 'text/plain' }); const item = new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob }); navigator.clipboard.write([item]).then(() => { showToast(`Copied ${data.length} row(s)`, 'success'); }).catch(err => { // Fallback to plain text if ClipboardItem not supported navigator.clipboard.writeText(tsv).then(() => { showToast(`Copied ${data.length} row(s)`, 'success'); }).catch(err2 => { console.error('Copy failed:', err2); }); }); } /** * Paste from clipboard via button click */ function doPaste() { if (!selectionStart || !selectionEnd) return; navigator.clipboard.readText().then(tsv => { if (tsv) executePaste(tsv); }).catch(err => { console.error('Paste failed:', err); alert('Cannot read clipboard. Use Ctrl+V instead, or grant clipboard permission.'); }); } // Export module window.app.modules.selection = { init, clearSelection, hasSelection, doCopy, doPaste }; })();