715 lines
24 KiB
JavaScript
715 lines
24 KiB
JavaScript
/**
|
|
* 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 <table> ensures cell boundaries are preserved.
|
|
*/
|
|
function rowsToHtml(allRows) {
|
|
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const headerRow = allRows[0];
|
|
const dataRows = allRows.slice(1);
|
|
let html = '<table>';
|
|
html += '<tr>' + headerRow.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>';
|
|
for (const row of dataRows) {
|
|
html += '<tr>' + row.map(c => '<td>' + esc(c) + '</td>').join('') + '</tr>';
|
|
}
|
|
html += '</table>';
|
|
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
|
|
};
|
|
})();
|