/**
* 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 => '| ' + esc(c) + ' | ').join('') + '
';
for (const row of dataRows) {
html += '' + row.map(c => '| ' + esc(c) + ' | ').join('') + '
';
}
html += '
';
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
};
})();