From 25233c044c7f87ab4d21cfb834e589cf9eaf1f35 Mon Sep 17 00:00:00 2001 From: abdullahmujahidali Date: Mon, 22 Dec 2025 08:01:45 +0500 Subject: [PATCH 1/4] updated package-lock.json --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c76bfbe..ded01cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cellify", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cellify", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "fflate": "^0.8.0" From ff342392e007359ff0160db971f2d20943bd9560 Mon Sep 17 00:00:00 2001 From: abdullahmujahidali Date: Tue, 23 Dec 2025 18:45:41 +0500 Subject: [PATCH 2/4] feat: add paste and search options, implement copy/paste functionality with tests - Introduced PasteOptions and SearchOptions interfaces in range.types.ts for enhanced paste and search functionalities. - Implemented copyRange, pasteRange, cutRange, and duplicateRange methods in the Workbook class with corresponding tests. - Added comprehensive tests for copy/paste operations, including handling of values, styles, and transposition. - Developed data import/export helpers with tests for fromArray, fromObjects, toArray, and toObjects methods. - Implemented row and column operations with tests for insert, delete, move, and edge cases. - Created search functionality with tests for find, findAll, replace, and replaceAll methods. --- CHANGELOG.md | 46 +- demo/app.js | 461 +++++++++++++++++- demo/index.html | 49 ++ demo/styles.css | 112 +++++ src/core/Sheet.ts | 968 +++++++++++++++++++++++++++++++++++++ src/types/index.ts | 2 + src/types/range.types.ts | 30 ++ tests/copy-paste.test.ts | 186 +++++++ tests/data-helpers.test.ts | 259 ++++++++++ tests/row-column.test.ts | 302 ++++++++++++ tests/search.test.ts | 219 +++++++++ 11 files changed, 2613 insertions(+), 21 deletions(-) create mode 100644 tests/copy-paste.test.ts create mode 100644 tests/data-helpers.test.ts create mode 100644 tests/row-column.test.ts create mode 100644 tests/search.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f2202..196b9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.3.0] - 2025-12-21 +## [0.3.0] - 2025-12-22 + +### Added + +- **Search & Replace** + - `sheet.find(options)` to find the first cell matching search criteria + - `sheet.findAll(options)` to find all cells matching search criteria + - `sheet.replace(options)` to find and replace the first match + - `sheet.replaceAll(options)` to find and replace all matches + - Search by string, number, or regular expression + - Options for case-sensitive matching, whole cell matching + - Search in values, formulas, or both + - Search within specific range or entire sheet + +- **Copy/Paste Operations** + - `sheet.copyRange(range)` to copy cells to internal clipboard + - `sheet.cutRange(range)` to cut cells (copy and clear originals) + - `sheet.pasteRange(target, options)` to paste clipboard contents + - `sheet.duplicateRange(source, target)` to copy and paste in one operation + - `sheet.clearClipboard()` to clear the internal clipboard + - `sheet.hasClipboard` property to check if clipboard has content + - Paste options: `valuesOnly`, `stylesOnly`, `transpose` + - Preserves values, styles, and formulas when copying + +- **Row/Column Operations** + - `sheet.insertRow(index, count)` to insert rows at position + - `sheet.insertColumn(index, count)` to insert columns at position + - `sheet.deleteRow(index, count)` to delete rows + - `sheet.deleteColumn(index, count)` to delete columns + - `sheet.moveRow(from, to)` to move a row to new position + - `sheet.moveColumn(from, to)` to move a column to new position + - Preserves cell values, styles, formulas, and comments + - Shifts existing cells correctly when inserting/deleting + - Updates row/column configurations (height, width) when shifting + +- **Data Import/Export Helpers** + - `sheet.fromArray(data, options)` to populate sheet from 2D array + - `sheet.fromObjects(data, options)` to populate sheet from array of objects + - `sheet.toArray(options)` to export sheet data as 2D array + - `sheet.toObjects()` to export sheet data as typed array of objects + - `sheet.appendRow(values)` to append a single row at the end + - `sheet.appendRows(rows)` to append multiple rows + - Options for custom start position, header styling, column selection + - Handles empty arrays and sheets gracefully + - Round-trip preservation (fromArray -> toArray) ## [0.2.0] - 2025-12-21 diff --git a/demo/app.js b/demo/app.js index 994f67b..0fb8503 100644 --- a/demo/app.js +++ b/demo/app.js @@ -459,6 +459,7 @@ function displayImportResult(result) { const { workbook, stats, warnings } = result; document.getElementById('importStats').style.display = 'block'; + showSheetOperationsToolbar(); // Show the operations toolbar let statsHtml = ` @@ -757,9 +758,61 @@ let selectedRow = null; let selectedCol = null; let isEditing = false; +// Range selection support +let selectionStart = null; // { row, col } +let selectionEnd = null; // { row, col } +let isDragging = false; + let selectedColumn = null; // Column index when whole column is selected let selectedRowHeader = null; // Row index when whole row is selected +// Get normalized selection range (start <= end) +function getSelectionRange() { + if (!selectionStart || !selectionEnd) return null; + return { + startRow: Math.min(selectionStart.row, selectionEnd.row), + endRow: Math.max(selectionStart.row, selectionEnd.row), + startCol: Math.min(selectionStart.col, selectionEnd.col), + endCol: Math.max(selectionStart.col, selectionEnd.col) + }; +} + +// Check if a cell is in the current selection +function isCellSelected(row, col) { + const range = getSelectionRange(); + if (!range) return false; + return row >= range.startRow && row <= range.endRow && + col >= range.startCol && col <= range.endCol; +} + +// Highlight selected cells +function updateSelectionHighlight() { + // Clear old highlights + document.querySelectorAll('td.cell-selected').forEach(td => { + td.classList.remove('cell-selected'); + }); + + const range = getSelectionRange(); + if (!range) return; + + // Highlight all cells in range + for (let r = range.startRow; r <= range.endRow; r++) { + for (let c = range.startCol; c <= range.endCol; c++) { + const td = document.querySelector(`td[data-row="${r}"][data-col="${c}"]`); + if (td) td.classList.add('cell-selected'); + } + } +} + +// Get selection as A1 notation (e.g., "A1:C5") +function getSelectionA1() { + const range = getSelectionRange(); + if (!range) return null; + const start = `${columnLetter(range.startCol)}${range.startRow + 1}`; + const end = `${columnLetter(range.endCol)}${range.endRow + 1}`; + return start === end ? start : `${start}:${end}`; +} + const undoStack = []; const MAX_UNDO_STACK = 50; @@ -905,8 +958,19 @@ function copyCell() { if (selectedRow === null || selectedCol === null) return; const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; - const cell = sheet.getCell(selectedRow, selectedCol); + const range = getSelectionRange(); + + // If range selected, use sheet's copyRange + if (range && (range.endRow > range.startRow || range.endCol > range.startCol)) { + const rangeA1 = getSelectionA1(); + sheet.copyRange(rangeA1); + clipboard = { isRange: true, range: rangeA1, isCut: false }; + log(`Copied range ${rangeA1}`, 'info'); + return; + } + // Single cell copy + const cell = sheet.getCell(selectedRow, selectedCol); clipboard = { row: selectedRow, col: selectedCol, @@ -914,7 +978,8 @@ function copyCell() { formula: cell?.formula?.formula, style: cell?.style ? JSON.parse(JSON.stringify(cell.style)) : null, comment: cell?.comment ? JSON.parse(JSON.stringify(cell.comment)) : null, - isCut: false + isCut: false, + isRange: false }; log(`Copied cell ${columnLetter(selectedCol)}${selectedRow + 1}`, 'info'); @@ -925,8 +990,30 @@ function cutCell() { if (selectedRow === null || selectedCol === null) return; const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; - const cell = sheet.getCell(selectedRow, selectedCol); + const range = getSelectionRange(); + + // If range selected, use sheet's cutRange + if (range && (range.endRow > range.startRow || range.endCol > range.startCol)) { + const rangeA1 = getSelectionA1(); + sheet.cutRange(rangeA1); + clipboard = { isRange: true, range: rangeA1, isCut: true }; + + // Visual feedback for cut cells + for (let r = range.startRow; r <= range.endRow; r++) { + for (let c = range.startCol; c <= range.endCol; c++) { + const td = document.querySelector(`td[data-row="${r}"][data-col="${c}"]`); + if (td) { + td.style.opacity = '0.5'; + td.style.border = '2px dashed var(--primary)'; + } + } + } + log(`Cut range ${rangeA1}`, 'info'); + return; + } + // Single cell cut + const cell = sheet.getCell(selectedRow, selectedCol); clipboard = { row: selectedRow, col: selectedCol, @@ -934,10 +1021,10 @@ function cutCell() { formula: cell?.formula?.formula, style: cell?.style ? JSON.parse(JSON.stringify(cell.style)) : null, comment: cell?.comment ? JSON.parse(JSON.stringify(cell.comment)) : null, - isCut: true + isCut: true, + isRange: false }; - const td = document.querySelector(`td[data-row="${selectedRow}"][data-col="${selectedCol}"]`); if (td) { td.style.opacity = '0.5'; @@ -952,21 +1039,39 @@ function pasteCell() { if (!clipboard || selectedRow === null || selectedCol === null) return; const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + + // If clipboard has a range, use sheet's pasteRange + if (clipboard.isRange) { + const targetA1 = `${columnLetter(selectedCol)}${selectedRow + 1}`; + sheet.pasteRange(targetA1); + log(`Pasted range to ${targetA1}`, 'info'); + + // Clear cut visual feedback + if (clipboard.isCut) { + document.querySelectorAll('td').forEach(td => { + td.style.opacity = ''; + td.style.border = ''; + }); + clipboard = null; + } + + selectSheet(window.currentSheetIndex); + return; + } + + // Single cell paste const cell = sheet.cell(selectedRow, selectedCol); const oldValue = cell.value; - undoStack.push({ row: selectedRow, col: selectedCol, oldValue, newValue: clipboard.value }); if (undoStack.length > MAX_UNDO_STACK) undoStack.shift(); - if (clipboard.formula) { cell.setFormula(clipboard.formula); } else if (clipboard.value !== undefined && clipboard.value !== null) { cell.value = clipboard.value; } - if (clipboard.style) { cell.style = JSON.parse(JSON.stringify(clipboard.style)); } @@ -979,8 +1084,7 @@ function pasteCell() { } if (clipboard.isCut) { - const sourceSheet = window.currentWorkbook.sheets[window.currentSheetIndex]; - const sourceCell = sourceSheet.cell(clipboard.row, clipboard.col); + const sourceCell = sheet.cell(clipboard.row, clipboard.col); sourceCell.clear(); const sourceTd = document.querySelector(`td[data-row="${clipboard.row}"][data-col="${clipboard.col}"]`); @@ -1499,8 +1603,52 @@ function initCellEditHandlers() { } } - selectCell(row, col); + selectCell(row, col, e); + } + }; + + // Mouse down starts drag selection + const onMouseDown = (e) => { + const td = e.target.closest('td[data-row][data-col]'); + if (td && !isEditing && e.button === 0) { // Left mouse button + isDragging = true; + const row = parseInt(td.dataset.row); + const col = parseInt(td.dataset.col); + + if (!e.shiftKey) { + // Start new selection + clearAllSelections(); + selectionStart = { row, col }; + selectionEnd = { row, col }; + selectedRow = row; + selectedCol = col; + td.classList.add('selected', 'cell-selected'); + } + } + }; + + // Mouse move extends selection during drag + const onMouseMoveDrag = (e) => { + if (!isDragging || !selectionStart) return; + + const td = e.target.closest('td[data-row][data-col]'); + if (td) { + const row = parseInt(td.dataset.row); + const col = parseInt(td.dataset.col); + extendSelection(row, col); + } + }; + + // Mouse up ends drag selection + const onMouseUp = () => { + if (isDragging && selectionStart && selectionEnd) { + const range = getSelectionRange(); + const cellCount = (range.endRow - range.startRow + 1) * (range.endCol - range.startCol + 1); + if (cellCount > 1) { + log(`Selected ${getSelectionA1()} (${cellCount} cells)`, 'info'); + } } + isDragging = false; }; const onDblClick = (e) => { @@ -1508,7 +1656,7 @@ function initCellEditHandlers() { if (td) { const row = parseInt(td.dataset.row); const col = parseInt(td.dataset.col); - selectCell(row, col); + selectCell(row, col, e); startEditing(row, col); } }; @@ -1523,8 +1671,12 @@ function initCellEditHandlers() { }; const onMouseMove = (e) => { + // Handle drag selection + onMouseMoveDrag(e); + + // Handle comment tooltip const td = e.target.closest('td[data-row][data-col]'); - if (td && td.classList.contains('has-comment')) { + if (td && td.classList.contains('has-comment') && !isDragging) { const row = parseInt(td.dataset.row); const col = parseInt(td.dataset.col); const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; @@ -1556,30 +1708,31 @@ function initCellEditHandlers() { preview.addEventListener('contextmenu', onContextMenu); preview.addEventListener('mousemove', onMouseMove); preview.addEventListener('mouseleave', onMouseLeave); + preview.addEventListener('mousedown', onMouseDown); + document.addEventListener('mouseup', onMouseUp); document.addEventListener('keydown', onKeyDown); const filterBtns = preview.querySelectorAll('.filter-btn'); filterBtns.forEach(btn => btn.addEventListener('click', onFilterClick)); - window.cellEditListeners = { onClick, onDblClick, onContextMenu, onMouseMove, onMouseLeave, onKeyDown, onFilterClick }; + window.cellEditListeners = { onClick, onDblClick, onContextMenu, onMouseMove, onMouseLeave, onMouseDown, onMouseUp, onKeyDown, onFilterClick }; } function clearAllSelections() { - document.querySelectorAll('td.selected, td.col-selected, td.row-selected').forEach(el => { - el.classList.remove('selected', 'col-selected', 'row-selected'); + document.querySelectorAll('td.selected, td.col-selected, td.row-selected, td.cell-selected').forEach(el => { + el.classList.remove('selected', 'col-selected', 'row-selected', 'cell-selected'); }); document.querySelectorAll('th.col-selected, th.row-selected').forEach(el => { el.classList.remove('col-selected', 'row-selected'); }); selectedColumn = null; selectedRowHeader = null; + selectionStart = null; + selectionEnd = null; } -function selectCell(row, col) { - // Clear all selections - clearAllSelections(); - +function selectCell(row, col, event) { // If clicking same cell while editing, don't change selection if (isEditing && row === selectedRow && col === selectedCol) { return; @@ -1593,16 +1746,45 @@ function selectCell(row, col) { } } + // Shift+click extends selection + if (event && event.shiftKey && selectionStart) { + selectionEnd = { row, col }; + selectedRow = row; + selectedCol = col; + updateSelectionHighlight(); + + const range = getSelectionRange(); + const cellCount = (range.endRow - range.startRow + 1) * (range.endCol - range.startCol + 1); + log(`Selected ${getSelectionA1()} (${cellCount} cells)`, 'info'); + return; + } + + // Regular click - start new selection + clearAllSelections(); + selectedRow = row; selectedCol = col; + selectionStart = { row, col }; + selectionEnd = { row, col }; // Find and highlight the cell const td = document.querySelector(`td[data-row="${row}"][data-col="${col}"]`); if (td) { td.classList.add('selected'); + td.classList.add('cell-selected'); } } +// Extend selection during drag +function extendSelection(row, col) { + if (!selectionStart) return; + + selectionEnd = { row, col }; + selectedRow = row; + selectedCol = col; + updateSelectionHighlight(); +} + function selectColumn(colIndex) { // Clear all selections first clearAllSelections(); @@ -2141,3 +2323,242 @@ document.getElementById('btnComments').addEventListener('click', exportCommentsE document.getElementById('btnHyperlinks').addEventListener('click', exportHyperlinksExample); document.getElementById('btnReExportXlsx').addEventListener('click', reExportXlsx); document.getElementById('btnReExportCsv').addEventListener('click', reExportCsv); + +// ======================================== +// Sheet Operations Toolbar +// ======================================== + +let searchResults = []; +let searchIndex = -1; + +// Show toolbar when file is imported +function showSheetOperationsToolbar() { + document.getElementById('sheetOperationsSection').style.display = 'block'; +} + +// Clear search highlights +function clearSearchHighlights() { + document.querySelectorAll('td.search-highlight, td.search-current').forEach(td => { + td.classList.remove('search-highlight', 'search-current'); + }); +} + +// Search: Find +document.getElementById('btnFind').addEventListener('click', () => { + if (!window.currentWorkbook) return; + + const searchTerm = document.getElementById('searchInput').value; + if (!searchTerm) { + log('Enter a search term', 'warning'); + return; + } + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + clearSearchHighlights(); + + searchResults = sheet.findAll(searchTerm); + searchIndex = searchResults.length > 0 ? 0 : -1; + + log(`Found ${searchResults.length} cells containing "${searchTerm}"`, searchResults.length > 0 ? 'success' : 'info'); + + // Highlight all results + searchResults.forEach((cell, i) => { + const td = document.querySelector(`td[data-row="${cell.row}"][data-col="${cell.col}"]`); + if (td) { + td.classList.add(i === 0 ? 'search-current' : 'search-highlight'); + } + }); +}); + +// Search: Find Next +document.getElementById('btnFindNext').addEventListener('click', () => { + if (searchResults.length === 0) return; + + // Remove current highlight + const prevCell = searchResults[searchIndex]; + const prevTd = document.querySelector(`td[data-row="${prevCell.row}"][data-col="${prevCell.col}"]`); + if (prevTd) { + prevTd.classList.remove('search-current'); + prevTd.classList.add('search-highlight'); + } + + // Move to next + searchIndex = (searchIndex + 1) % searchResults.length; + const cell = searchResults[searchIndex]; + const td = document.querySelector(`td[data-row="${cell.row}"][data-col="${cell.col}"]`); + if (td) { + td.classList.remove('search-highlight'); + td.classList.add('search-current'); + td.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + log(`Result ${searchIndex + 1} of ${searchResults.length}`, 'info'); +}); + +// Search: Replace current +document.getElementById('btnReplace').addEventListener('click', () => { + if (!window.currentWorkbook || searchResults.length === 0 || searchIndex < 0) { + log('Find something first', 'warning'); + return; + } + + const replacement = document.getElementById('replaceInput').value; + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + const cell = searchResults[searchIndex]; + + const searchTerm = document.getElementById('searchInput').value; + const oldValue = cell.value; + + // Replace in this cell + if (typeof oldValue === 'string') { + cell.value = oldValue.replace(new RegExp(searchTerm, 'gi'), replacement); + } else { + cell.value = replacement; + } + + log(`Replaced in cell ${columnLetter(cell.col)}${cell.row + 1}`, 'success'); + + // Remove from results and refresh + searchResults.splice(searchIndex, 1); + if (searchIndex >= searchResults.length) searchIndex = 0; + + selectSheet(window.currentSheetIndex); + + // Re-highlight remaining results after render + setTimeout(() => { + searchResults.forEach((c, i) => { + const td = document.querySelector(`td[data-row="${c.row}"][data-col="${c.col}"]`); + if (td) td.classList.add(i === searchIndex ? 'search-current' : 'search-highlight'); + }); + }, 0); +}); + +// Search: Replace All +document.getElementById('btnReplaceAll').addEventListener('click', () => { + if (!window.currentWorkbook) return; + + const searchTerm = document.getElementById('searchInput').value; + const replacement = document.getElementById('replaceInput').value; + + if (!searchTerm) { + log('Enter a search term', 'warning'); + return; + } + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + const replacedCells = sheet.replaceAll(searchTerm, replacement); + + log(`Replaced ${replacedCells.length} occurrences of "${searchTerm}" → "${replacement}"`, 'success'); + + clearSearchHighlights(); + searchResults = []; + searchIndex = -1; + + selectSheet(window.currentSheetIndex); +}); + +// Clipboard: Copy (uses selected cell or range) +document.getElementById('btnToolbarCopy').addEventListener('click', () => { + if (!window.currentWorkbook || selectedRow === null) { + log('Select a cell first', 'warning'); + return; + } + copyCell(); +}); + +// Clipboard: Cut +document.getElementById('btnToolbarCut').addEventListener('click', () => { + if (!window.currentWorkbook || selectedRow === null) { + log('Select a cell first', 'warning'); + return; + } + cutCell(); +}); + +// Clipboard: Paste +document.getElementById('btnToolbarPaste').addEventListener('click', () => { + if (!window.currentWorkbook || selectedRow === null) { + log('Select a cell first', 'warning'); + return; + } + pasteCell(); +}); + +// Row: Insert +document.getElementById('btnInsertRow').addEventListener('click', () => { + if (!window.currentWorkbook) return; + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + const rowIndex = selectedRow !== null ? selectedRow : 0; + + console.log('Before insert - dimensions:', sheet.dimensions); + sheet.insertRow(rowIndex, 1); + console.log('After insert - dimensions:', sheet.dimensions); + + log(`Inserted row at index ${rowIndex}`, 'success'); + + // Force full re-render + selectSheet(window.currentSheetIndex); +}); + +// Row: Delete +document.getElementById('btnDeleteRow').addEventListener('click', () => { + if (!window.currentWorkbook || selectedRow === null) { + log('Select a row first (click row number)', 'warning'); + return; + } + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + sheet.deleteRow(selectedRow, 1); + log(`Deleted row ${selectedRow + 1}`, 'success'); + selectedRow = null; + selectSheet(window.currentSheetIndex); +}); + +// Column: Insert +document.getElementById('btnInsertCol').addEventListener('click', () => { + if (!window.currentWorkbook) return; + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + const colIndex = selectedCol !== null ? selectedCol : 0; + + sheet.insertColumn(colIndex, 1); + log(`Inserted column at ${columnLetter(colIndex)}`, 'success'); + selectSheet(window.currentSheetIndex); +}); + +// Column: Delete +document.getElementById('btnDeleteCol').addEventListener('click', () => { + if (!window.currentWorkbook || selectedCol === null) { + log('Select a column first (click column header)', 'warning'); + return; + } + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + sheet.deleteColumn(selectedCol, 1); + log(`Deleted column ${columnLetter(selectedCol)}`, 'success'); + selectedCol = null; + selectSheet(window.currentSheetIndex); +}); + +// Data: Export to Array +document.getElementById('btnExportArray').addEventListener('click', () => { + if (!window.currentWorkbook) return; + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + const data = sheet.toArray(); + + console.log('📊 sheet.toArray() result:', data); + log(`Exported ${data.length} rows as array → see browser console (F12)`, 'success'); +}); + +// Data: Export to Objects +document.getElementById('btnExportObjects').addEventListener('click', () => { + if (!window.currentWorkbook) return; + + const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; + const data = sheet.toObjects(); + + console.log('📋 sheet.toObjects() result:', data); + log(`Exported ${data.length} objects → see browser console (F12)`, 'success'); +}); diff --git a/demo/index.html b/demo/index.html index 3d5dfcf..7c9cdee 100644 --- a/demo/index.html +++ b/demo/index.html @@ -49,6 +49,55 @@

Export Excel Files

+ + +
diff --git a/demo/styles.css b/demo/styles.css index 7b67dec..37e5733 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -1041,3 +1041,115 @@ th.has-filter::after { background: var(--primary); border-radius: 50%; } + +/* ======================================== + Sheet Operations Toolbar + ======================================== */ +.toolbar-row { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; + padding: 12px; + background: var(--gray-100); + border-radius: 8px; +} + +.search-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + width: 100%; +} + +.toolbar-input { + padding: 8px 12px; + border: 1px solid var(--gray-300); + border-radius: 6px; + font-size: 14px; + width: 150px; + background: var(--white); +} + +.toolbar-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-light); +} + +.toolbar-btn { + padding: 8px 12px; + border: 1px solid var(--gray-300); + background: var(--white); + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.toolbar-btn:hover { + background: var(--gray-100); + border-color: var(--gray-400); +} + +.toolbar-btn:active { + background: var(--gray-200); +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--white); + border-radius: 6px; + border: 1px solid var(--gray-200); +} + +.toolbar-label { + font-size: 12px; + font-weight: 500; + color: var(--gray-500); + margin-right: 4px; +} + +.toolbar-hint { + font-size: 13px; + color: var(--gray-500); + margin: 0; + padding: 8px 12px; + background: var(--primary-light); + border-radius: 6px; +} + +.toolbar-hint strong { + color: var(--primary-dark); +} + +/* Search highlight */ +td.search-highlight { + outline: 2px solid var(--primary) !important; + background-color: var(--primary-light) !important; +} + +td.search-current { + outline: 3px solid var(--warning) !important; + background-color: var(--warning-bg) !important; +} + +/* Multi-cell selection */ +td.cell-selected { + background-color: rgba(59, 130, 246, 0.15) !important; +} + +td.cell-selected.selected { + background-color: rgba(59, 130, 246, 0.3) !important; + outline: 2px solid #3B82F6; +} + +/* Prevent text selection during drag */ +#preview.selecting { + user-select: none; +} diff --git a/src/core/Sheet.ts b/src/core/Sheet.ts index 8981b4d..97e5026 100644 --- a/src/core/Sheet.ts +++ b/src/core/Sheet.ts @@ -10,6 +10,8 @@ import type { ConditionalFormatRule, AutoFilter, FilterCriteria, + SearchOptions, + PasteOptions, } from '../types/range.types.js'; import { parseRangeReference, iterateRange, rangesOverlap } from '../types/range.types.js'; import type { @@ -1325,6 +1327,972 @@ export class Sheet { return true; } + // ============ Search ============ + + /** + * Find the first cell matching the search criteria + * + * @param query - String, number, RegExp, or search options + * @param options - Search options + * @returns The first matching cell, or undefined if not found + * + * @example + * ```typescript + * // Find by exact value + * const cell = sheet.find('Hello'); + * + * // Find by regex + * const cell = sheet.find(/error/i); + * + * // Find with options + * const cell = sheet.find('test', { matchCase: true, searchIn: 'values' }); + * ``` + */ + find( + query: string | number | RegExp | SearchOptions, + options: SearchOptions = {} + ): Cell | undefined { + const results = this.findAllInternal(query, options, 1); + return results[0]; + } + + /** + * Find all cells matching the search criteria + * + * @param query - String, number, RegExp, or search options + * @param options - Search options + * @returns Array of matching cells + * + * @example + * ```typescript + * // Find all cells containing 'error' + * const cells = sheet.findAll('error'); + * + * // Find all numbers greater than 100 using regex + * const cells = sheet.findAll(/^\d{3,}$/); + * + * // Search in formulas + * const cells = sheet.findAll('SUM', { searchIn: 'formulas' }); + * ``` + */ + findAll( + query: string | number | RegExp | SearchOptions, + options: SearchOptions = {} + ): Cell[] { + return this.findAllInternal(query, options); + } + + /** + * Replace the first occurrence of a value + * + * @param search - Value to search for + * @param replacement - Value to replace with + * @param options - Search options + * @returns The replaced cell, or undefined if not found + */ + replace( + search: string | number | RegExp, + replacement: string | number, + options: SearchOptions = {} + ): Cell | undefined { + const cell = this.find(search, options); + if (cell) { + this.replaceInCell(cell, search, replacement); + } + return cell; + } + + /** + * Replace all occurrences of a value + * + * @param search - Value to search for + * @param replacement - Value to replace with + * @param options - Search options + * @returns Array of replaced cells + */ + replaceAll( + search: string | number | RegExp, + replacement: string | number, + options: SearchOptions = {} + ): Cell[] { + const cells = this.findAll(search, options); + for (const cell of cells) { + this.replaceInCell(cell, search, replacement); + } + return cells; + } + + /** + * Internal: Find all matching cells with optional limit + */ + private findAllInternal( + query: string | number | RegExp | SearchOptions, + options: SearchOptions = {}, + limit?: number + ): Cell[] { + // Normalize query to options + let searchOptions: SearchOptions; + if (typeof query === 'string' || typeof query === 'number') { + searchOptions = { ...options, query }; + } else if (query instanceof RegExp) { + searchOptions = { ...options, regex: query }; + } else { + searchOptions = { ...query, ...options }; + } + + const { + query: searchQuery, + regex, + matchCase = false, + matchCell = false, + searchIn = 'values', + range, + } = searchOptions; + + const results: Cell[] = []; + + // Determine search range + let searchRange: RangeDefinition | null = null; + if (range) { + searchRange = typeof range === 'string' ? parseRangeReference(range) : range; + } + + for (const cell of this._cells.values()) { + // Check if cell is in range + if (searchRange) { + if ( + cell.row < searchRange.startRow || + cell.row > searchRange.endRow || + cell.col < searchRange.startCol || + cell.col > searchRange.endCol + ) { + continue; + } + } + + // Get value to search in + let searchValue: string | null = null; + + if (searchIn === 'values' || searchIn === 'both') { + const val = cell.value; + if (val !== null && val !== undefined) { + searchValue = String(val); + } + } + + if (searchIn === 'formulas' || searchIn === 'both') { + if (cell.formula) { + const formulaStr = cell.formula.formula; + searchValue = searchValue ? `${searchValue} ${formulaStr}` : formulaStr; + } + } + + if (searchValue === null) continue; + + // Perform search + let matches = false; + + if (regex) { + matches = regex.test(searchValue); + } else if (searchQuery !== undefined) { + const queryStr = String(searchQuery); + const targetStr = matchCase ? searchValue : searchValue.toLowerCase(); + const searchStr = matchCase ? queryStr : queryStr.toLowerCase(); + + if (matchCell) { + matches = targetStr === searchStr; + } else { + matches = targetStr.includes(searchStr); + } + } + + if (matches) { + results.push(cell); + if (limit && results.length >= limit) { + break; + } + } + } + + return results; + } + + /** + * Internal: Replace value in a cell + */ + private replaceInCell( + cell: Cell, + search: string | number | RegExp, + replacement: string | number + ): void { + const currentValue = cell.value; + if (currentValue === null || currentValue === undefined) return; + + if (typeof currentValue === 'string') { + if (search instanceof RegExp) { + cell.value = currentValue.replace(search, String(replacement)); + } else { + cell.value = currentValue.replace(String(search), String(replacement)); + } + } else if (typeof currentValue === 'number' && typeof search === 'number') { + if (currentValue === search) { + cell.value = typeof replacement === 'number' ? replacement : parseFloat(String(replacement)); + } + } + } + + // ============ Copy/Paste ============ + + // Internal clipboard for copy/paste operations + private _clipboard: { + cells: Map; + range: RangeDefinition; + } | null = null; + + /** + * Copy a range of cells to the internal clipboard + * + * @param range - Range to copy (e.g., 'A1:C3' or RangeDefinition) + * @returns this for chaining + * + * @example + * ```typescript + * sheet.copyRange('A1:C3'); + * sheet.pasteRange('E1'); // Paste at E1 + * ``` + */ + copyRange(range: string | RangeDefinition): this { + const rangeDef = typeof range === 'string' ? parseRangeReference(range) : range; + + const cells = new Map(); + + for (let row = rangeDef.startRow; row <= rangeDef.endRow; row++) { + for (let col = rangeDef.startCol; col <= rangeDef.endCol; col++) { + const cell = this.getCell(row, col); + if (cell) { + // Store relative position within the range + const relKey = `${row - rangeDef.startRow},${col - rangeDef.startCol}`; + cells.set(relKey, { + value: cell.value, + style: cell.style ? { ...cell.style } : undefined, + formula: cell.formula?.formula, + }); + } + } + } + + this._clipboard = { cells, range: rangeDef }; + return this; + } + + /** + * Cut a range of cells (copy and then clear) + * + * @param range - Range to cut + * @returns this for chaining + */ + cutRange(range: string | RangeDefinition): this { + this.copyRange(range); + this.clearRange(range); + return this; + } + + /** + * Paste the clipboard contents at the specified location + * + * @param target - Target cell address or position (top-left corner of paste area) + * @param options - Paste options + * @returns this for chaining + * + * @example + * ```typescript + * sheet.copyRange('A1:C3'); + * sheet.pasteRange('E1'); // Paste values and styles + * sheet.pasteRange('H1', { valuesOnly: true }); // Paste values only + * ``` + */ + pasteRange( + target: string | { row: number; col: number }, + options: PasteOptions = {} + ): this { + if (!this._clipboard) { + return this; + } + + const { + valuesOnly = false, + stylesOnly = false, + transpose = false, + } = options; + + let targetRow: number; + let targetCol: number; + + if (typeof target === 'string') { + const addr = a1ToAddress(target); + targetRow = addr.row; + targetCol = addr.col; + } else { + targetRow = target.row; + targetCol = target.col; + } + + for (const [relKey, cellData] of this._clipboard.cells) { + const [relRowStr, relColStr] = relKey.split(','); + let relRow = parseInt(relRowStr, 10); + let relCol = parseInt(relColStr, 10); + + // Handle transpose + if (transpose) { + [relRow, relCol] = [relCol, relRow]; + } + + const destRow = targetRow + relRow; + const destCol = targetCol + relCol; + const destCell = this.cell(destRow, destCol); + + if (!stylesOnly) { + if (cellData.formula) { + // Adjust formula references (simplified - just copy as-is for now) + destCell.setFormula('=' + cellData.formula); + } else { + destCell.value = cellData.value; + } + } + + if (!valuesOnly && cellData.style) { + destCell.style = { ...cellData.style }; + } + } + + return this; + } + + /** + * Check if there's content in the clipboard + */ + get hasClipboard(): boolean { + return this._clipboard !== null && this._clipboard.cells.size > 0; + } + + /** + * Clear the internal clipboard + */ + clearClipboard(): this { + this._clipboard = null; + return this; + } + + /** + * Duplicate a range to another location (copy + paste in one operation) + * + * @param source - Source range + * @param target - Target location (top-left corner) + * @param options - Paste options + */ + duplicateRange( + source: string | RangeDefinition, + target: string | { row: number; col: number }, + options: PasteOptions = {} + ): this { + this.copyRange(source); + this.pasteRange(target, options); + return this; + } + + // ============ Row/Column Insert/Delete ============ + + /** + * Insert one or more rows at the specified index + * + * @param rowIndex - Index where to insert (0-based) + * @param count - Number of rows to insert (default: 1) + * @returns this for chaining + * + * @example + * ```typescript + * // Insert a single row at index 2 (before row 3) + * sheet.insertRow(2); + * + * // Insert 3 rows at index 0 (at the top) + * sheet.insertRow(0, 3); + * ``` + */ + insertRow(rowIndex: number, count: number = 1): this { + if (count <= 0) return this; + + // Disable events during restructuring + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + + try { + // Collect cells that need to be shifted + const cellsToShift: { key: string; cell: Cell; newRow: number }[] = []; + + for (const [key, cell] of this._cells) { + if (cell.row >= rowIndex) { + cellsToShift.push({ key, cell: cell.clone(), newRow: cell.row + count }); + } + } + + // First, delete all old cells + for (const { key } of cellsToShift) { + this._cells.delete(key); + } + + // Then add cells at new positions + for (const { cell, newRow } of cellsToShift) { + const newCell = new Cell(newRow, cell.col, cell.value); + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); + newCell._onChange = this.handleCellChange.bind(this); + this._cells.set(cellKey(newRow, cell.col), newCell); + } + + // Shift row configurations + const rowConfigs = new Map(); + for (const [idx, config] of this._rows) { + if (idx >= rowIndex) { + rowConfigs.set(idx + count, config); + } else { + rowConfigs.set(idx, config); + } + } + this._rows = rowConfigs; + + this.recalculateDimensions(); + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + /** + * Insert one or more columns at the specified index + * + * @param colIndex - Column index where to insert (0-based) + * @param count - Number of columns to insert (default: 1) + * @returns this for chaining + */ + insertColumn(colIndex: number, count: number = 1): this { + if (count <= 0) return this; + + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + + try { + const cellsToShift: { key: string; cell: Cell; newCol: number }[] = []; + + for (const [key, cell] of this._cells) { + if (cell.col >= colIndex) { + cellsToShift.push({ key, cell: cell.clone(), newCol: cell.col + count }); + } + } + + // First, delete all old cells + for (const { key } of cellsToShift) { + this._cells.delete(key); + } + + // Then add cells at new positions + for (const { cell, newCol } of cellsToShift) { + const newCell = new Cell(cell.row, newCol, cell.value); + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); + newCell._onChange = this.handleCellChange.bind(this); + this._cells.set(cellKey(cell.row, newCol), newCell); + } + + // Shift column configurations + const colConfigs = new Map(); + for (const [idx, config] of this._cols) { + if (idx >= colIndex) { + colConfigs.set(idx + count, config); + } else { + colConfigs.set(idx, config); + } + } + this._cols = colConfigs; + + this.recalculateDimensions(); + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + /** + * Delete one or more rows at the specified index + * + * @param rowIndex - Index of first row to delete (0-based) + * @param count - Number of rows to delete (default: 1) + * @returns this for chaining + */ + deleteRow(rowIndex: number, count: number = 1): this { + if (count <= 0) return this; + + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + + try { + const cellsToDelete: string[] = []; + const cellsToShift: { key: string; cell: Cell; newRow: number }[] = []; + + for (const [key, cell] of this._cells) { + if (cell.row >= rowIndex && cell.row < rowIndex + count) { + // Cell is in deleted range + cellsToDelete.push(key); + } else if (cell.row >= rowIndex + count) { + // Cell needs to shift up + cellsToShift.push({ key, cell: cell.clone(), newRow: cell.row - count }); + } + } + + // Delete cells in range + for (const key of cellsToDelete) { + this._cells.delete(key); + } + + // Delete old shifted cells first + for (const { key } of cellsToShift) { + this._cells.delete(key); + } + + // Then add cells at new positions + for (const { cell, newRow } of cellsToShift) { + const newCell = new Cell(newRow, cell.col, cell.value); + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); + newCell._onChange = this.handleCellChange.bind(this); + this._cells.set(cellKey(newRow, cell.col), newCell); + } + + // Shift row configurations + const rowConfigs = new Map(); + for (const [idx, config] of this._rows) { + if (idx >= rowIndex && idx < rowIndex + count) { + // Skip deleted rows + continue; + } else if (idx >= rowIndex + count) { + rowConfigs.set(idx - count, config); + } else { + rowConfigs.set(idx, config); + } + } + this._rows = rowConfigs; + + this.recalculateDimensions(); + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + /** + * Delete one or more columns at the specified index + * + * @param colIndex - Index of first column to delete (0-based) + * @param count - Number of columns to delete (default: 1) + * @returns this for chaining + */ + deleteColumn(colIndex: number, count: number = 1): this { + if (count <= 0) return this; + + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + + try { + const cellsToDelete: string[] = []; + const cellsToShift: { key: string; cell: Cell; newCol: number }[] = []; + + for (const [key, cell] of this._cells) { + if (cell.col >= colIndex && cell.col < colIndex + count) { + cellsToDelete.push(key); + } else if (cell.col >= colIndex + count) { + cellsToShift.push({ key, cell: cell.clone(), newCol: cell.col - count }); + } + } + + // Delete cells in range + for (const key of cellsToDelete) { + this._cells.delete(key); + } + + // Delete old shifted cells first + for (const { key } of cellsToShift) { + this._cells.delete(key); + } + + // Then add cells at new positions + for (const { cell, newCol } of cellsToShift) { + const newCell = new Cell(cell.row, newCol, cell.value); + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); + newCell._onChange = this.handleCellChange.bind(this); + this._cells.set(cellKey(cell.row, newCol), newCell); + } + + // Shift column configurations + const colConfigs = new Map(); + for (const [idx, config] of this._cols) { + if (idx >= colIndex && idx < colIndex + count) { + continue; + } else if (idx >= colIndex + count) { + colConfigs.set(idx - count, config); + } else { + colConfigs.set(idx, config); + } + } + this._cols = colConfigs; + + this.recalculateDimensions(); + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + /** + * Move a row from one position to another + * + * @param fromIndex - Source row index + * @param toIndex - Target row index + */ + moveRow(fromIndex: number, toIndex: number): this { + if (fromIndex === toIndex) return this; + + // Copy the row data + const rowCells: Cell[] = []; + for (const cell of this._cells.values()) { + if (cell.row === fromIndex) { + rowCells.push(cell.clone()); + } + } + + // Delete the source row + this.deleteRow(fromIndex); + + // Adjust target if needed + const adjustedTo = fromIndex < toIndex ? toIndex - 1 : toIndex; + + // Insert at target + this.insertRow(adjustedTo); + + // Place the cells + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + try { + for (const cell of rowCells) { + const newCell = this.cell(adjustedTo, cell.col); + newCell.value = cell.value; + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + } + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + /** + * Move a column from one position to another + * + * @param fromIndex - Source column index + * @param toIndex - Target column index + */ + moveColumn(fromIndex: number, toIndex: number): this { + if (fromIndex === toIndex) return this; + + const colCells: Cell[] = []; + for (const cell of this._cells.values()) { + if (cell.col === fromIndex) { + colCells.push(cell.clone()); + } + } + + this.deleteColumn(fromIndex); + + const adjustedTo = fromIndex < toIndex ? toIndex - 1 : toIndex; + + this.insertColumn(adjustedTo); + + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + try { + for (const cell of colCells) { + const newCell = this.cell(cell.row, adjustedTo); + newCell.value = cell.value; + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + } + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + // ============ Data Import/Export Helpers ============ + + /** + * Populate sheet from a 2D array + * + * @param data - 2D array of values + * @param options - Import options + * @returns this for chaining + * + * @example + * ```typescript + * sheet.fromArray([ + * ['Name', 'Age', 'City'], + * ['Alice', 25, 'NYC'], + * ['Bob', 30, 'LA'], + * ]); + * ``` + */ + fromArray( + data: CellValue[][], + options: { + startRow?: number; + startCol?: number; + headers?: boolean; + headerStyle?: CellStyle; + } = {} + ): this { + const { + startRow = 0, + startCol = 0, + headers = false, + headerStyle, + } = options; + + for (let r = 0; r < data.length; r++) { + const row = data[r]; + for (let c = 0; c < row.length; c++) { + const cell = this.cell(startRow + r, startCol + c); + cell.value = row[c]; + + // Apply header style to first row if specified + if (headers && r === 0 && headerStyle) { + cell.style = headerStyle; + } + } + } + + return this; + } + + /** + * Populate sheet from an array of objects + * + * @param data - Array of objects + * @param options - Import options + * @returns this for chaining + * + * @example + * ```typescript + * sheet.fromObjects([ + * { name: 'Alice', age: 25, city: 'NYC' }, + * { name: 'Bob', age: 30, city: 'LA' }, + * ], { includeHeaders: true }); + * ``` + */ + fromObjects>( + data: T[], + options: { + startRow?: number; + startCol?: number; + includeHeaders?: boolean; + headerStyle?: CellStyle; + columns?: (keyof T)[]; + } = {} + ): this { + if (data.length === 0) return this; + + const { + startRow = 0, + startCol = 0, + includeHeaders = true, + headerStyle, + columns, + } = options; + + // Determine columns to use + const keys = columns ?? (Object.keys(data[0]) as (keyof T)[]); + + let currentRow = startRow; + + // Add headers + if (includeHeaders) { + for (let c = 0; c < keys.length; c++) { + const cell = this.cell(currentRow, startCol + c); + cell.value = String(keys[c]); + if (headerStyle) { + cell.style = headerStyle; + } + } + currentRow++; + } + + // Add data rows + for (const obj of data) { + for (let c = 0; c < keys.length; c++) { + const cell = this.cell(currentRow, startCol + c); + cell.value = obj[keys[c]] ?? null; + } + currentRow++; + } + + return this; + } + + /** + * Export sheet data as a 2D array + * + * @param options - Export options + * @returns 2D array of cell values + * + * @example + * ```typescript + * const data = sheet.toArray(); + * // [['Name', 'Age'], ['Alice', 25], ['Bob', 30]] + * ``` + */ + toArray(options: { + range?: string | RangeDefinition; + includeEmpty?: boolean; + } = {}): CellValue[][] { + const { range, includeEmpty = true } = options; + + let rangeToExport: RangeDefinition; + if (range) { + rangeToExport = typeof range === 'string' ? parseRangeReference(range) : range; + } else { + const dims = this.dimensions; + if (!dims) return []; + rangeToExport = dims; + } + + const result: CellValue[][] = []; + + for (let r = rangeToExport.startRow; r <= rangeToExport.endRow; r++) { + const row: CellValue[] = []; + for (let c = rangeToExport.startCol; c <= rangeToExport.endCol; c++) { + const cell = this.getCell(r, c); + row.push(cell?.value ?? null); + } + + // Skip empty rows if includeEmpty is false + if (!includeEmpty && row.every(v => v === null)) { + continue; + } + + result.push(row); + } + + return result; + } + + /** + * Export sheet data as an array of objects + * + * @param options - Export options + * @returns Array of objects with column headers as keys + * + * @example + * ```typescript + * const data = sheet.toObjects(); + * // [{ Name: 'Alice', Age: 25 }, { Name: 'Bob', Age: 30 }] + * ``` + */ + toObjects = Record>(options: { + range?: string | RangeDefinition; + headerRow?: number; + } = {}): T[] { + const { range, headerRow = 0 } = options; + + let rangeToExport: RangeDefinition; + if (range) { + rangeToExport = typeof range === 'string' ? parseRangeReference(range) : range; + } else { + const dims = this.dimensions; + if (!dims) return []; + rangeToExport = dims; + } + + // Get headers from the first row + const headers: string[] = []; + for (let c = rangeToExport.startCol; c <= rangeToExport.endCol; c++) { + const cell = this.getCell(headerRow, c); + headers.push(cell?.value !== null ? String(cell?.value) : `Column${c}`); + } + + // Build objects from remaining rows + const result: T[] = []; + for (let r = headerRow + 1; r <= rangeToExport.endRow; r++) { + const obj: Record = {}; + for (let c = rangeToExport.startCol; c <= rangeToExport.endCol; c++) { + const cell = this.getCell(r, c); + obj[headers[c - rangeToExport.startCol]] = cell?.value ?? null; + } + result.push(obj as T); + } + + return result; + } + + /** + * Append a row of data to the end of the sheet + * + * @param values - Array of values for the new row + * @param startCol - Starting column (default: 0) + * @returns The row index of the appended row + */ + appendRow(values: CellValue[], startCol: number = 0): number { + const dims = this.dimensions; + const newRow = dims ? dims.endRow + 1 : 0; + + for (let c = 0; c < values.length; c++) { + this.cell(newRow, startCol + c).value = values[c]; + } + + return newRow; + } + + /** + * Append multiple rows of data to the end of the sheet + * + * @param rows - 2D array of values + * @param startCol - Starting column (default: 0) + * @returns The starting row index of the appended rows + */ + appendRows(rows: CellValue[][], startCol: number = 0): number { + const dims = this.dimensions; + const startRow = dims ? dims.endRow + 1 : 0; + + for (let r = 0; r < rows.length; r++) { + const row = rows[r]; + for (let c = 0; c < row.length; c++) { + this.cell(startRow + r, startCol + c).value = row[c]; + } + } + + return startRow; + } + // ============ Utility Methods ============ /** diff --git a/src/types/index.ts b/src/types/index.ts index c68a1db..f040edd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,6 +67,8 @@ export type { ConditionalFormatRule, AutoFilter, AutoFilterColumn, + PasteOptions, + SearchOptions, FilterCriteria, } from './range.types.js'; diff --git a/src/types/range.types.ts b/src/types/range.types.ts index 06b0e00..a38b6fc 100644 --- a/src/types/range.types.ts +++ b/src/types/range.types.ts @@ -245,6 +245,36 @@ export interface AutoFilter { columns?: AutoFilterColumn[]; } +/** + * Paste options for sheet.pasteRange() method + */ +export interface PasteOptions { + /** Paste only values, ignore styles */ + valuesOnly?: boolean; + /** Paste only styles, ignore values */ + stylesOnly?: boolean; + /** Transpose rows and columns when pasting */ + transpose?: boolean; +} + +/** + * Search options for sheet.find() and sheet.findAll() methods + */ +export interface SearchOptions { + /** The search query (string or number) */ + query?: string | number; + /** Regular expression to match */ + regex?: RegExp; + /** Match case when searching (default: false) */ + matchCase?: boolean; + /** Match entire cell content (default: false, matches partial) */ + matchCell?: boolean; + /** Where to search: 'values', 'formulas', or 'both' (default: 'values') */ + searchIn?: 'values' | 'formulas' | 'both'; + /** Limit search to specific range */ + range?: string | RangeDefinition; +} + /** * Filter criteria for sheet.filter() method */ diff --git a/tests/copy-paste.test.ts b/tests/copy-paste.test.ts new file mode 100644 index 0000000..38370d2 --- /dev/null +++ b/tests/copy-paste.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; + +describe('Sheet Copy/Paste', () => { + describe('copyRange', () => { + it('should copy range to clipboard', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.cell('A2').value = 'World'; + + sheet.copyRange('A1:A2'); + + expect(sheet.hasClipboard).toBe(true); + }); + + it('should copy values and styles', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.cell('A1').style = { font: { bold: true } }; + + sheet.copyRange('A1'); + sheet.pasteRange('B1'); + + expect(sheet.cell('B1').value).toBe('Test'); + expect(sheet.cell('B1').style?.font?.bold).toBe(true); + }); + }); + + describe('pasteRange', () => { + it('should paste at specified location', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('A2').value = 'B'; + sheet.cell('B1').value = 1; + sheet.cell('B2').value = 2; + + sheet.copyRange('A1:B2'); + sheet.pasteRange('D1'); + + expect(sheet.cell('D1').value).toBe('A'); + expect(sheet.cell('D2').value).toBe('B'); + expect(sheet.cell('E1').value).toBe(1); + expect(sheet.cell('E2').value).toBe(2); + }); + + it('should paste values only when valuesOnly is true', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.cell('A1').style = { font: { bold: true } }; + + sheet.copyRange('A1'); + sheet.pasteRange('B1', { valuesOnly: true }); + + expect(sheet.cell('B1').value).toBe('Test'); + expect(sheet.cell('B1').style).toBeUndefined(); + }); + + it('should paste styles only when stylesOnly is true', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.cell('A1').style = { font: { bold: true } }; + sheet.cell('B1').value = 'Original'; + + sheet.copyRange('A1'); + sheet.pasteRange('B1', { stylesOnly: true }); + + expect(sheet.cell('B1').value).toBe('Original'); // Unchanged + expect(sheet.cell('B1').style?.font?.bold).toBe(true); + }); + + it('should transpose when transpose is true', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + // Create a row: A1=1, B1=2, C1=3 + sheet.cell('A1').value = 1; + sheet.cell('B1').value = 2; + sheet.cell('C1').value = 3; + + sheet.copyRange('A1:C1'); + sheet.pasteRange('E1', { transpose: true }); + + // Should become a column: E1=1, E2=2, E3=3 + expect(sheet.cell('E1').value).toBe(1); + expect(sheet.cell('E2').value).toBe(2); + expect(sheet.cell('E3').value).toBe(3); + }); + + it('should do nothing if clipboard is empty', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('B1').value = 'Original'; + + // No copy, just paste + sheet.pasteRange('B1'); + + expect(sheet.cell('B1').value).toBe('Original'); + }); + + it('should paste using row/col object', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.copyRange('A1'); + sheet.pasteRange({ row: 2, col: 3 }); // D3 + + expect(sheet.cell('D3').value).toBe('Test'); + }); + }); + + describe('cutRange', () => { + it('should cut and clear original cells', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Cut me'; + sheet.cell('A1').style = { font: { bold: true } }; + + sheet.cutRange('A1'); + sheet.pasteRange('B1'); + + expect(sheet.cell('A1').value).toBeNull(); // Cleared + expect(sheet.cell('B1').value).toBe('Cut me'); + expect(sheet.cell('B1').style?.font?.bold).toBe(true); + }); + }); + + describe('duplicateRange', () => { + it('should copy and paste in one operation', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.cell('A2').value = 'World'; + + sheet.duplicateRange('A1:A2', 'C1'); + + expect(sheet.cell('C1').value).toBe('Hello'); + expect(sheet.cell('C2').value).toBe('World'); + // Original still exists + expect(sheet.cell('A1').value).toBe('Hello'); + }); + }); + + describe('clearClipboard', () => { + it('should clear the clipboard', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.copyRange('A1'); + + expect(sheet.hasClipboard).toBe(true); + + sheet.clearClipboard(); + + expect(sheet.hasClipboard).toBe(false); + }); + }); + + describe('copy with formulas', () => { + it('should copy formulas', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').setFormula('=SUM(B1:B10)', 100); + + sheet.copyRange('A1'); + sheet.pasteRange('C1'); + + expect(sheet.cell('C1').formula?.formula).toBe('SUM(B1:B10)'); + }); + }); +}); diff --git a/tests/data-helpers.test.ts b/tests/data-helpers.test.ts new file mode 100644 index 0000000..9e19e28 --- /dev/null +++ b/tests/data-helpers.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; + +describe('Sheet Data Import/Export Helpers', () => { + describe('fromArray', () => { + it('should populate sheet from 2D array', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.fromArray([ + ['Name', 'Age'], + ['Alice', 25], + ['Bob', 30], + ]); + + expect(sheet.cell('A1').value).toBe('Name'); + expect(sheet.cell('B1').value).toBe('Age'); + expect(sheet.cell('A2').value).toBe('Alice'); + expect(sheet.cell('B2').value).toBe(25); + }); + + it('should support custom start position', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.fromArray([['Hello']], { startRow: 2, startCol: 3 }); + + expect(sheet.cell('D3').value).toBe('Hello'); + }); + + it('should apply header style', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.fromArray( + [['Header1', 'Header2'], ['Data1', 'Data2']], + { headers: true, headerStyle: { font: { bold: true } } } + ); + + expect(sheet.cell('A1').style?.font?.bold).toBe(true); + expect(sheet.cell('A2').style).toBeUndefined(); + }); + }); + + describe('fromObjects', () => { + it('should populate sheet from array of objects', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.fromObjects([ + { name: 'Alice', age: 25 }, + { name: 'Bob', age: 30 }, + ]); + + expect(sheet.cell('A1').value).toBe('name'); + expect(sheet.cell('B1').value).toBe('age'); + expect(sheet.cell('A2').value).toBe('Alice'); + expect(sheet.cell('B2').value).toBe(25); + }); + + it('should skip headers when includeHeaders is false', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.fromObjects( + [{ name: 'Alice', age: 25 }], + { includeHeaders: false } + ); + + expect(sheet.cell('A1').value).toBe('Alice'); + }); + + it('should use specified columns', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.fromObjects( + [{ name: 'Alice', age: 25, city: 'NYC' }], + { columns: ['name', 'city'] } + ); + + expect(sheet.cell('A1').value).toBe('name'); + expect(sheet.cell('B1').value).toBe('city'); + expect(sheet.cell('A2').value).toBe('Alice'); + expect(sheet.cell('B2').value).toBe('NYC'); + }); + + it('should handle empty array', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.fromObjects([]); + + expect(sheet.dimensions).toBeNull(); + }); + }); + + describe('toArray', () => { + it('should export sheet data as 2D array', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Name'; + sheet.cell('B1').value = 'Age'; + sheet.cell('A2').value = 'Alice'; + sheet.cell('B2').value = 25; + + const data = sheet.toArray(); + + expect(data).toEqual([ + ['Name', 'Age'], + ['Alice', 25], + ]); + }); + + it('should export specific range', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + sheet.cell('A2').value = 'C'; + sheet.cell('B2').value = 'D'; + + const data = sheet.toArray({ range: 'A1:A2' }); + + expect(data).toEqual([['A'], ['C']]); + }); + + it('should handle empty sheet', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + const data = sheet.toArray(); + + expect(data).toEqual([]); + }); + }); + + describe('toObjects', () => { + it('should export sheet data as array of objects', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Name'; + sheet.cell('B1').value = 'Age'; + sheet.cell('A2').value = 'Alice'; + sheet.cell('B2').value = 25; + sheet.cell('A3').value = 'Bob'; + sheet.cell('B3').value = 30; + + const data = sheet.toObjects(); + + expect(data).toEqual([ + { Name: 'Alice', Age: 25 }, + { Name: 'Bob', Age: 30 }, + ]); + }); + + it('should handle missing values', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Name'; + sheet.cell('B1').value = 'Age'; + sheet.cell('A2').value = 'Alice'; + // B2 is missing + + const data = sheet.toObjects(); + + expect(data[0]).toEqual({ Name: 'Alice', Age: null }); + }); + + it('should handle empty sheet', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + const data = sheet.toObjects(); + + expect(data).toEqual([]); + }); + }); + + describe('appendRow', () => { + it('should append row to end of sheet', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + + const rowIndex = sheet.appendRow(['Second', 'Data']); + + expect(rowIndex).toBe(1); + expect(sheet.cell('A2').value).toBe('Second'); + expect(sheet.cell('B2').value).toBe('Data'); + }); + + it('should append to empty sheet', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + const rowIndex = sheet.appendRow(['First']); + + expect(rowIndex).toBe(0); + expect(sheet.cell('A1').value).toBe('First'); + }); + }); + + describe('appendRows', () => { + it('should append multiple rows', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Header'; + + const startIndex = sheet.appendRows([ + ['Row 1', 'Data 1'], + ['Row 2', 'Data 2'], + ]); + + expect(startIndex).toBe(1); + expect(sheet.cell('A2').value).toBe('Row 1'); + expect(sheet.cell('A3').value).toBe('Row 2'); + }); + }); + + describe('round-trip', () => { + it('should preserve data through fromArray -> toArray', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + const original = [ + ['Name', 'Age', 'Active'], + ['Alice', 25, true], + ['Bob', 30, false], + ]; + + sheet.fromArray(original); + const exported = sheet.toArray(); + + expect(exported).toEqual(original); + }); + + it('should preserve data through fromObjects -> toObjects', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + const original = [ + { Name: 'Alice', Age: 25 }, + { Name: 'Bob', Age: 30 }, + ]; + + sheet.fromObjects(original, { includeHeaders: true }); + const exported = sheet.toObjects(); + + expect(exported).toEqual(original); + }); + }); +}); diff --git a/tests/row-column.test.ts b/tests/row-column.test.ts new file mode 100644 index 0000000..4424027 --- /dev/null +++ b/tests/row-column.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; + +describe('Sheet Row/Column Operations', () => { + describe('insertRow', () => { + it('should insert a single row', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Row 1'; + sheet.cell('A2').value = 'Row 2'; + sheet.cell('A3').value = 'Row 3'; + + sheet.insertRow(1); // Insert at row index 1 + + expect(sheet.cell('A1').value).toBe('Row 1'); + expect(sheet.cell('A2').value).toBeNull(); // New empty row + expect(sheet.cell('A3').value).toBe('Row 2'); + expect(sheet.cell('A4').value).toBe('Row 3'); + }); + + it('should insert multiple rows', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('A2').value = 'B'; + + sheet.insertRow(1, 3); // Insert 3 rows at index 1 + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('A5').value).toBe('B'); // Shifted by 3 + }); + + it('should insert at the beginning', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + + sheet.insertRow(0, 2); + + expect(sheet.cell('A3').value).toBe('First'); + }); + + it('should preserve cell styles', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Styled'; + sheet.cell('A1').style = { font: { bold: true } }; + + sheet.insertRow(0); + + expect(sheet.cell('A2').value).toBe('Styled'); + expect(sheet.cell('A2').style?.font?.bold).toBe(true); + }); + + it('should shift row configurations', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.setRowHeight(1, 30); + + sheet.insertRow(0); + + expect(sheet.getRow(2).height).toBe(30); + }); + }); + + describe('insertColumn', () => { + it('should insert a single column', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + sheet.cell('C1').value = 'C'; + + sheet.insertColumn(1); // Insert at column B + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('B1').value).toBeNull(); // New empty column + expect(sheet.cell('C1').value).toBe('B'); + expect(sheet.cell('D1').value).toBe('C'); + }); + + it('should insert multiple columns', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + + sheet.insertColumn(1, 2); + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('D1').value).toBe('B'); // Shifted by 2 + }); + }); + + describe('deleteRow', () => { + it('should delete a single row', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Row 1'; + sheet.cell('A2').value = 'Row 2'; + sheet.cell('A3').value = 'Row 3'; + + sheet.deleteRow(1); // Delete row at index 1 + + expect(sheet.cell('A1').value).toBe('Row 1'); + expect(sheet.cell('A2').value).toBe('Row 3'); // Shifted up + }); + + it('should delete multiple rows', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('A2').value = 'B'; + sheet.cell('A3').value = 'C'; + sheet.cell('A4').value = 'D'; + + sheet.deleteRow(1, 2); // Delete rows 1 and 2 + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('A2').value).toBe('D'); + }); + + it('should delete first row', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A2').value = 'Second'; + + sheet.deleteRow(0); + + expect(sheet.cell('A1').value).toBe('Second'); + }); + + it('should preserve data in other columns', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A1'; + sheet.cell('B1').value = 'B1'; + sheet.cell('A2').value = 'A2'; + sheet.cell('B2').value = 'B2'; + + sheet.deleteRow(0); + + expect(sheet.cell('A1').value).toBe('A2'); + expect(sheet.cell('B1').value).toBe('B2'); + }); + }); + + describe('deleteColumn', () => { + it('should delete a single column', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + sheet.cell('C1').value = 'C'; + + sheet.deleteColumn(1); // Delete column B + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('B1').value).toBe('C'); // Shifted left + }); + + it('should delete multiple columns', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + sheet.cell('C1').value = 'C'; + sheet.cell('D1').value = 'D'; + + sheet.deleteColumn(1, 2); // Delete columns B and C + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('B1').value).toBe('D'); + }); + }); + + describe('moveRow', () => { + it('should move row down', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A2').value = 'Second'; + sheet.cell('A3').value = 'Third'; + + sheet.moveRow(0, 2); // Move first row to position 2 + + expect(sheet.cell('A1').value).toBe('Second'); + expect(sheet.cell('A2').value).toBe('First'); + expect(sheet.cell('A3').value).toBe('Third'); + }); + + it('should move row up', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A2').value = 'Second'; + sheet.cell('A3').value = 'Third'; + + sheet.moveRow(2, 0); // Move third row to position 0 + + expect(sheet.cell('A1').value).toBe('Third'); + expect(sheet.cell('A2').value).toBe('First'); + expect(sheet.cell('A3').value).toBe('Second'); + }); + + it('should preserve styles when moving', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Styled'; + sheet.cell('A1').style = { font: { bold: true } }; + sheet.cell('A2').value = 'Plain'; + + sheet.moveRow(0, 2); + + expect(sheet.cell('A2').style?.font?.bold).toBe(true); + }); + }); + + describe('moveColumn', () => { + it('should move column right', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + sheet.cell('C1').value = 'C'; + + sheet.moveColumn(0, 2); // Move column A to position C + + expect(sheet.cell('A1').value).toBe('B'); + expect(sheet.cell('B1').value).toBe('A'); + expect(sheet.cell('C1').value).toBe('C'); + }); + + it('should move column left', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + sheet.cell('C1').value = 'C'; + + sheet.moveColumn(2, 0); // Move column C to position A + + expect(sheet.cell('A1').value).toBe('C'); + expect(sheet.cell('B1').value).toBe('A'); + expect(sheet.cell('C1').value).toBe('B'); + }); + }); + + describe('edge cases', () => { + it('should handle insert with count 0', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + + sheet.insertRow(0, 0); + + expect(sheet.cell('A1').value).toBe('Test'); + }); + + it('should handle delete with count 0', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + + sheet.deleteRow(0, 0); + + expect(sheet.cell('A1').value).toBe('Test'); + }); + + it('should handle move to same position', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + + sheet.moveRow(0, 0); + + expect(sheet.cell('A1').value).toBe('Test'); + }); + }); +}); diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 0000000..ed7fe2d --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; + +describe('Sheet Search', () => { + describe('find', () => { + it('should find cell by exact string match', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.cell('A2').value = 'World'; + sheet.cell('A3').value = 'Hello World'; + + const cell = sheet.find('Hello'); + expect(cell).toBeDefined(); + expect(cell?.value).toBe('Hello'); + }); + + it('should find cell by partial match', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello World'; + + const cell = sheet.find('World'); + expect(cell).toBeDefined(); + expect(cell?.value).toBe('Hello World'); + }); + + it('should find cell by number', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 100; + sheet.cell('A2').value = 200; + + const cell = sheet.find(100); + expect(cell).toBeDefined(); + expect(cell?.value).toBe(100); + }); + + it('should find cell by regex', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'test123'; + sheet.cell('A2').value = 'hello'; + + const cell = sheet.find(/\d+/); + expect(cell).toBeDefined(); + expect(cell?.value).toBe('test123'); + }); + + it('should return undefined when not found', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + + const cell = sheet.find('NotFound'); + expect(cell).toBeUndefined(); + }); + + it('should be case insensitive by default', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'HELLO'; + + const cell = sheet.find('hello'); + expect(cell).toBeDefined(); + }); + + it('should respect matchCase option', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'HELLO'; + sheet.cell('A2').value = 'hello'; + + const cell = sheet.find('hello', { matchCase: true }); + expect(cell?.value).toBe('hello'); + }); + + it('should respect matchCell option', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello World'; + sheet.cell('A2').value = 'Hello'; + + const cell = sheet.find('Hello', { matchCell: true }); + expect(cell?.value).toBe('Hello'); + }); + }); + + describe('findAll', () => { + it('should find all matching cells', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.cell('A2').value = 'World'; + sheet.cell('A3').value = 'Hello Again'; + + const cells = sheet.findAll('Hello'); + expect(cells.length).toBe(2); + }); + + it('should return empty array when no matches', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + + const cells = sheet.findAll('NotFound'); + expect(cells.length).toBe(0); + }); + + it('should search in formulas when specified', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 100; + sheet.cell('A2').setFormula('=SUM(A1:A1)', 100); + + const cells = sheet.findAll('SUM', { searchIn: 'formulas' }); + expect(cells.length).toBe(1); + expect(cells[0].address).toBe('A2'); + }); + + it('should search in both values and formulas', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'SUM'; + sheet.cell('A2').setFormula('=SUM(A1:A1)', 0); + + const cells = sheet.findAll('SUM', { searchIn: 'both' }); + expect(cells.length).toBe(2); + }); + + it('should respect range option', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'test'; + sheet.cell('A2').value = 'test'; + sheet.cell('A3').value = 'test'; + + const cells = sheet.findAll('test', { range: 'A1:A2' }); + expect(cells.length).toBe(2); + }); + }); + + describe('replace', () => { + it('should replace first occurrence', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello World'; + sheet.cell('A2').value = 'Hello Again'; + + const cell = sheet.replace('Hello', 'Hi'); + expect(cell?.value).toBe('Hi World'); + expect(sheet.cell('A2').value).toBe('Hello Again'); // Unchanged + }); + + it('should replace with regex', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'test123'; + + sheet.replace(/\d+/, '456'); + expect(sheet.cell('A1').value).toBe('test456'); + }); + + it('should return undefined if not found', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + + const cell = sheet.replace('NotFound', 'X'); + expect(cell).toBeUndefined(); + }); + }); + + describe('replaceAll', () => { + it('should replace all occurrences', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello World'; + sheet.cell('A2').value = 'Hello Again'; + + const cells = sheet.replaceAll('Hello', 'Hi'); + expect(cells.length).toBe(2); + expect(sheet.cell('A1').value).toBe('Hi World'); + expect(sheet.cell('A2').value).toBe('Hi Again'); + }); + + it('should replace numbers', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 100; + sheet.cell('A2').value = 100; + sheet.cell('A3').value = 200; + + const cells = sheet.replaceAll(100, 150); + expect(cells.length).toBe(2); + expect(sheet.cell('A1').value).toBe(150); + expect(sheet.cell('A2').value).toBe(150); + expect(sheet.cell('A3').value).toBe(200); + }); + }); +}); From 0f0a418dd41b0b71ab205c8b6c2057dbbc66e5b3 Mon Sep 17 00:00:00 2001 From: abdullahmujahidali Date: Tue, 23 Dec 2025 19:05:52 +0500 Subject: [PATCH 3/4] resolving comments --- src/core/Sheet.ts | 60 ++++++++-------------- tests/copy-paste.test.ts | 56 +++++--------------- tests/row-column.test.ts | 107 ++++++++++----------------------------- tests/undo-redo.test.ts | 65 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 164 deletions(-) diff --git a/src/core/Sheet.ts b/src/core/Sheet.ts index 97e5026..d5caa32 100644 --- a/src/core/Sheet.ts +++ b/src/core/Sheet.ts @@ -832,13 +832,7 @@ export class Sheet { const { cells } = rows[i]; for (const [col, cell] of cells) { - const newCell = new Cell(targetRow, col, cell.value); - if (cell.style) newCell.style = cell.style; - if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); - if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); - if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); - - newCell._onChange = this.handleCellChange.bind(this); + const newCell = this.createCellFromSource(targetRow, col, cell); this._cells.set(cellKey(targetRow, col), newCell); } } @@ -953,13 +947,7 @@ export class Sheet { const { cells } = rows[i]; for (const [col, cell] of cells) { - const newCell = new Cell(targetRow, col, cell.value); - if (cell.style) newCell.style = cell.style; - if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); - if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); - if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); - - newCell._onChange = this.handleCellChange.bind(this); + const newCell = this.createCellFromSource(targetRow, col, cell); this._cells.set(cellKey(targetRow, col), newCell); } } @@ -1742,12 +1730,7 @@ export class Sheet { // Then add cells at new positions for (const { cell, newRow } of cellsToShift) { - const newCell = new Cell(newRow, cell.col, cell.value); - if (cell.style) newCell.style = cell.style; - if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); - if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); - if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); - newCell._onChange = this.handleCellChange.bind(this); + const newCell = this.createCellFromSource(newRow, cell.col, cell); this._cells.set(cellKey(newRow, cell.col), newCell); } @@ -1799,12 +1782,7 @@ export class Sheet { // Then add cells at new positions for (const { cell, newCol } of cellsToShift) { - const newCell = new Cell(cell.row, newCol, cell.value); - if (cell.style) newCell.style = cell.style; - if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); - if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); - if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); - newCell._onChange = this.handleCellChange.bind(this); + const newCell = this.createCellFromSource(cell.row, newCol, cell); this._cells.set(cellKey(cell.row, newCol), newCell); } @@ -1866,12 +1844,7 @@ export class Sheet { // Then add cells at new positions for (const { cell, newRow } of cellsToShift) { - const newCell = new Cell(newRow, cell.col, cell.value); - if (cell.style) newCell.style = cell.style; - if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); - if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); - if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); - newCell._onChange = this.handleCellChange.bind(this); + const newCell = this.createCellFromSource(newRow, cell.col, cell); this._cells.set(cellKey(newRow, cell.col), newCell); } @@ -1879,7 +1852,6 @@ export class Sheet { const rowConfigs = new Map(); for (const [idx, config] of this._rows) { if (idx >= rowIndex && idx < rowIndex + count) { - // Skip deleted rows continue; } else if (idx >= rowIndex + count) { rowConfigs.set(idx - count, config); @@ -1934,12 +1906,7 @@ export class Sheet { // Then add cells at new positions for (const { cell, newCol } of cellsToShift) { - const newCell = new Cell(cell.row, newCol, cell.value); - if (cell.style) newCell.style = cell.style; - if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); - if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); - if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); - newCell._onChange = this.handleCellChange.bind(this); + const newCell = this.createCellFromSource(cell.row, newCol, cell); this._cells.set(cellKey(cell.row, newCol), newCell); } @@ -2293,6 +2260,21 @@ export class Sheet { return startRow; } + // ============ Internal Helpers ============ + + /** + * Internal: Create a new cell at (row, col) by copying all properties from a source cell + */ + private createCellFromSource(row: number, col: number, source: Cell): Cell { + const newCell = new Cell(row, col, source.value); + if (source.style) newCell.style = source.style; + if (source.formula) newCell.setFormula('=' + source.formula.formula, source.formula.result); + if (source.hyperlink) newCell.setHyperlink(source.hyperlink.target, source.hyperlink.tooltip); + if (source.comment) newCell.setComment(source.comment.text as string, source.comment.author); + newCell._onChange = this.handleCellChange.bind(this); + return newCell; + } + // ============ Utility Methods ============ /** diff --git a/tests/copy-paste.test.ts b/tests/copy-paste.test.ts index 38370d2..2cecb29 100644 --- a/tests/copy-paste.test.ts +++ b/tests/copy-paste.test.ts @@ -1,12 +1,17 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { Workbook } from '../src/core/Workbook.js'; +import type { Sheet } from '../src/core/Sheet.js'; describe('Sheet Copy/Paste', () => { + let sheet: Sheet; + + beforeEach(() => { + const workbook = new Workbook(); + sheet = workbook.addSheet('Test'); + }); + describe('copyRange', () => { it('should copy range to clipboard', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Hello'; sheet.cell('A2').value = 'World'; @@ -16,9 +21,6 @@ describe('Sheet Copy/Paste', () => { }); it('should copy values and styles', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.cell('A1').style = { font: { bold: true } }; @@ -32,9 +34,6 @@ describe('Sheet Copy/Paste', () => { describe('pasteRange', () => { it('should paste at specified location', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('A2').value = 'B'; sheet.cell('B1').value = 1; @@ -50,9 +49,6 @@ describe('Sheet Copy/Paste', () => { }); it('should paste values only when valuesOnly is true', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.cell('A1').style = { font: { bold: true } }; @@ -64,9 +60,6 @@ describe('Sheet Copy/Paste', () => { }); it('should paste styles only when stylesOnly is true', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.cell('A1').style = { font: { bold: true } }; sheet.cell('B1').value = 'Original'; @@ -74,15 +67,11 @@ describe('Sheet Copy/Paste', () => { sheet.copyRange('A1'); sheet.pasteRange('B1', { stylesOnly: true }); - expect(sheet.cell('B1').value).toBe('Original'); // Unchanged + expect(sheet.cell('B1').value).toBe('Original'); expect(sheet.cell('B1').style?.font?.bold).toBe(true); }); it('should transpose when transpose is true', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - - // Create a row: A1=1, B1=2, C1=3 sheet.cell('A1').value = 1; sheet.cell('B1').value = 2; sheet.cell('C1').value = 3; @@ -90,31 +79,23 @@ describe('Sheet Copy/Paste', () => { sheet.copyRange('A1:C1'); sheet.pasteRange('E1', { transpose: true }); - // Should become a column: E1=1, E2=2, E3=3 expect(sheet.cell('E1').value).toBe(1); expect(sheet.cell('E2').value).toBe(2); expect(sheet.cell('E3').value).toBe(3); }); it('should do nothing if clipboard is empty', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('B1').value = 'Original'; - // No copy, just paste sheet.pasteRange('B1'); expect(sheet.cell('B1').value).toBe('Original'); }); it('should paste using row/col object', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.copyRange('A1'); - sheet.pasteRange({ row: 2, col: 3 }); // D3 + sheet.pasteRange({ row: 2, col: 3 }); expect(sheet.cell('D3').value).toBe('Test'); }); @@ -122,16 +103,13 @@ describe('Sheet Copy/Paste', () => { describe('cutRange', () => { it('should cut and clear original cells', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Cut me'; sheet.cell('A1').style = { font: { bold: true } }; sheet.cutRange('A1'); sheet.pasteRange('B1'); - expect(sheet.cell('A1').value).toBeNull(); // Cleared + expect(sheet.cell('A1').value).toBeNull(); expect(sheet.cell('B1').value).toBe('Cut me'); expect(sheet.cell('B1').style?.font?.bold).toBe(true); }); @@ -139,9 +117,6 @@ describe('Sheet Copy/Paste', () => { describe('duplicateRange', () => { it('should copy and paste in one operation', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Hello'; sheet.cell('A2').value = 'World'; @@ -149,16 +124,12 @@ describe('Sheet Copy/Paste', () => { expect(sheet.cell('C1').value).toBe('Hello'); expect(sheet.cell('C2').value).toBe('World'); - // Original still exists expect(sheet.cell('A1').value).toBe('Hello'); }); }); describe('clearClipboard', () => { it('should clear the clipboard', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.copyRange('A1'); @@ -172,9 +143,6 @@ describe('Sheet Copy/Paste', () => { describe('copy with formulas', () => { it('should copy formulas', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').setFormula('=SUM(B1:B10)', 100); sheet.copyRange('A1'); diff --git a/tests/row-column.test.ts b/tests/row-column.test.ts index 4424027..7a33a56 100644 --- a/tests/row-column.test.ts +++ b/tests/row-column.test.ts @@ -1,41 +1,40 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { Workbook } from '../src/core/Workbook.js'; +import type { Sheet } from '../src/core/Sheet.js'; describe('Sheet Row/Column Operations', () => { + let sheet: Sheet; + + beforeEach(() => { + const workbook = new Workbook(); + sheet = workbook.addSheet('Test'); + }); + describe('insertRow', () => { it('should insert a single row', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Row 1'; sheet.cell('A2').value = 'Row 2'; sheet.cell('A3').value = 'Row 3'; - sheet.insertRow(1); // Insert at row index 1 + sheet.insertRow(1); expect(sheet.cell('A1').value).toBe('Row 1'); - expect(sheet.cell('A2').value).toBeNull(); // New empty row + expect(sheet.cell('A2').value).toBeNull(); expect(sheet.cell('A3').value).toBe('Row 2'); expect(sheet.cell('A4').value).toBe('Row 3'); }); it('should insert multiple rows', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('A2').value = 'B'; - sheet.insertRow(1, 3); // Insert 3 rows at index 1 + sheet.insertRow(1, 3); expect(sheet.cell('A1').value).toBe('A'); - expect(sheet.cell('A5').value).toBe('B'); // Shifted by 3 + expect(sheet.cell('A5').value).toBe('B'); }); it('should insert at the beginning', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'First'; sheet.insertRow(0, 2); @@ -44,9 +43,6 @@ describe('Sheet Row/Column Operations', () => { }); it('should preserve cell styles', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Styled'; sheet.cell('A1').style = { font: { bold: true } }; @@ -57,9 +53,6 @@ describe('Sheet Row/Column Operations', () => { }); it('should shift row configurations', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.setRowHeight(1, 30); sheet.insertRow(0); @@ -70,69 +63,54 @@ describe('Sheet Row/Column Operations', () => { describe('insertColumn', () => { it('should insert a single column', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('B1').value = 'B'; sheet.cell('C1').value = 'C'; - sheet.insertColumn(1); // Insert at column B + sheet.insertColumn(1); expect(sheet.cell('A1').value).toBe('A'); - expect(sheet.cell('B1').value).toBeNull(); // New empty column + expect(sheet.cell('B1').value).toBeNull(); expect(sheet.cell('C1').value).toBe('B'); expect(sheet.cell('D1').value).toBe('C'); }); it('should insert multiple columns', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('B1').value = 'B'; sheet.insertColumn(1, 2); expect(sheet.cell('A1').value).toBe('A'); - expect(sheet.cell('D1').value).toBe('B'); // Shifted by 2 + expect(sheet.cell('D1').value).toBe('B'); }); }); describe('deleteRow', () => { it('should delete a single row', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Row 1'; sheet.cell('A2').value = 'Row 2'; sheet.cell('A3').value = 'Row 3'; - sheet.deleteRow(1); // Delete row at index 1 + sheet.deleteRow(1); expect(sheet.cell('A1').value).toBe('Row 1'); - expect(sheet.cell('A2').value).toBe('Row 3'); // Shifted up + expect(sheet.cell('A2').value).toBe('Row 3'); }); it('should delete multiple rows', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('A2').value = 'B'; sheet.cell('A3').value = 'C'; sheet.cell('A4').value = 'D'; - sheet.deleteRow(1, 2); // Delete rows 1 and 2 + sheet.deleteRow(1, 2); expect(sheet.cell('A1').value).toBe('A'); expect(sheet.cell('A2').value).toBe('D'); }); it('should delete first row', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'First'; sheet.cell('A2').value = 'Second'; @@ -142,9 +120,6 @@ describe('Sheet Row/Column Operations', () => { }); it('should preserve data in other columns', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A1'; sheet.cell('B1').value = 'B1'; sheet.cell('A2').value = 'A2'; @@ -159,29 +134,23 @@ describe('Sheet Row/Column Operations', () => { describe('deleteColumn', () => { it('should delete a single column', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('B1').value = 'B'; sheet.cell('C1').value = 'C'; - sheet.deleteColumn(1); // Delete column B + sheet.deleteColumn(1); expect(sheet.cell('A1').value).toBe('A'); - expect(sheet.cell('B1').value).toBe('C'); // Shifted left + expect(sheet.cell('B1').value).toBe('C'); }); it('should delete multiple columns', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('B1').value = 'B'; sheet.cell('C1').value = 'C'; sheet.cell('D1').value = 'D'; - sheet.deleteColumn(1, 2); // Delete columns B and C + sheet.deleteColumn(1, 2); expect(sheet.cell('A1').value).toBe('A'); expect(sheet.cell('B1').value).toBe('D'); @@ -190,14 +159,11 @@ describe('Sheet Row/Column Operations', () => { describe('moveRow', () => { it('should move row down', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'First'; sheet.cell('A2').value = 'Second'; sheet.cell('A3').value = 'Third'; - sheet.moveRow(0, 2); // Move first row to position 2 + sheet.moveRow(0, 2); expect(sheet.cell('A1').value).toBe('Second'); expect(sheet.cell('A2').value).toBe('First'); @@ -205,14 +171,11 @@ describe('Sheet Row/Column Operations', () => { }); it('should move row up', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'First'; sheet.cell('A2').value = 'Second'; sheet.cell('A3').value = 'Third'; - sheet.moveRow(2, 0); // Move third row to position 0 + sheet.moveRow(2, 0); expect(sheet.cell('A1').value).toBe('Third'); expect(sheet.cell('A2').value).toBe('First'); @@ -220,9 +183,6 @@ describe('Sheet Row/Column Operations', () => { }); it('should preserve styles when moving', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Styled'; sheet.cell('A1').style = { font: { bold: true } }; sheet.cell('A2').value = 'Plain'; @@ -235,14 +195,11 @@ describe('Sheet Row/Column Operations', () => { describe('moveColumn', () => { it('should move column right', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('B1').value = 'B'; sheet.cell('C1').value = 'C'; - sheet.moveColumn(0, 2); // Move column A to position C + sheet.moveColumn(0, 2); expect(sheet.cell('A1').value).toBe('B'); expect(sheet.cell('B1').value).toBe('A'); @@ -250,14 +207,11 @@ describe('Sheet Row/Column Operations', () => { }); it('should move column left', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'A'; sheet.cell('B1').value = 'B'; sheet.cell('C1').value = 'C'; - sheet.moveColumn(2, 0); // Move column C to position A + sheet.moveColumn(2, 0); expect(sheet.cell('A1').value).toBe('C'); expect(sheet.cell('B1').value).toBe('A'); @@ -267,9 +221,6 @@ describe('Sheet Row/Column Operations', () => { describe('edge cases', () => { it('should handle insert with count 0', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.insertRow(0, 0); @@ -278,9 +229,6 @@ describe('Sheet Row/Column Operations', () => { }); it('should handle delete with count 0', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.deleteRow(0, 0); @@ -289,9 +237,6 @@ describe('Sheet Row/Column Operations', () => { }); it('should handle move to same position', () => { - const workbook = new Workbook(); - const sheet = workbook.addSheet('Test'); - sheet.cell('A1').value = 'Test'; sheet.moveRow(0, 0); diff --git a/tests/undo-redo.test.ts b/tests/undo-redo.test.ts index f8396c2..a16f86b 100644 --- a/tests/undo-redo.test.ts +++ b/tests/undo-redo.test.ts @@ -314,4 +314,69 @@ describe('Sheet Undo/Redo', () => { expect(sheet.canUndo).toBe(false); }); }); + + describe('redo style changes', () => { + it('should redo style changes correctly', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.cell('A1').style = { font: { bold: true, size: 14 } }; + + expect(sheet.cell('A1').style?.font?.bold).toBe(true); + expect(sheet.cell('A1').style?.font?.size).toBe(14); + + sheet.undo(); + expect(sheet.cell('A1').style).toBeUndefined(); + + sheet.redo(); + expect(sheet.cell('A1').style?.font?.bold).toBe(true); + expect(sheet.cell('A1').style?.font?.size).toBe(14); + }); + }); + + describe('setMaxUndoHistory trimming', () => { + it('should trim existing undo stack when setting lower max', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + // Add 5 changes + sheet.cell('A1').value = 'One'; + sheet.cell('A1').value = 'Two'; + sheet.cell('A1').value = 'Three'; + sheet.cell('A1').value = 'Four'; + sheet.cell('A1').value = 'Five'; + + expect(sheet.undoCount).toBe(5); + + // Set max to 2, should trim the stack + sheet.setMaxUndoHistory(2); + + expect(sheet.undoCount).toBe(2); + + // Can only undo to 'Four' now + sheet.undo(); + expect(sheet.cell('A1').value).toBe('Four'); + }); + + it('should trim style changes when exceeding max undo history', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.setMaxUndoHistory(3); + + // Make 5 style changes (more than max) + sheet.cell('A1').style = { font: { bold: true } }; + sheet.cell('A1').style = { font: { italic: true } }; + sheet.cell('A1').style = { font: { size: 12 } }; + sheet.cell('A1').style = { font: { size: 14 } }; + sheet.cell('A1').style = { font: { size: 16 } }; + + // Should only have 3 undo steps + expect(sheet.undoCount).toBe(3); + + sheet.undo(); + expect(sheet.cell('A1').style?.font?.size).toBe(14); + }); + }); }); From 6f8ba13425be0fb12d43de9b1bf30e87108636af Mon Sep 17 00:00:00 2001 From: abdullahmujahidali Date: Tue, 23 Dec 2025 19:50:40 +0500 Subject: [PATCH 4/4] resolving coderabbit comments --- demo/app.js | 10 +++++----- src/core/Sheet.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/demo/app.js b/demo/app.js index 0fb8503..be39a11 100644 --- a/demo/app.js +++ b/demo/app.js @@ -1554,6 +1554,8 @@ function initCellEditHandlers() { preview.removeEventListener('contextmenu', window.cellEditListeners.onContextMenu); preview.removeEventListener('mousemove', window.cellEditListeners.onMouseMove); preview.removeEventListener('mouseleave', window.cellEditListeners.onMouseLeave); + preview.removeEventListener('mousedown', window.cellEditListeners.onMouseDown); + document.removeEventListener('mouseup', window.cellEditListeners.onMouseUp); document.removeEventListener('keydown', window.cellEditListeners.onKeyDown); } @@ -2403,15 +2405,15 @@ document.getElementById('btnReplace').addEventListener('click', () => { } const replacement = document.getElementById('replaceInput').value; - const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; const cell = searchResults[searchIndex]; const searchTerm = document.getElementById('searchInput').value; const oldValue = cell.value; - // Replace in this cell + // Replace in this cell (escape special regex characters) if (typeof oldValue === 'string') { - cell.value = oldValue.replace(new RegExp(searchTerm, 'gi'), replacement); + const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + cell.value = oldValue.replace(new RegExp(escapedSearch, 'gi'), replacement); } else { cell.value = replacement; } @@ -2491,9 +2493,7 @@ document.getElementById('btnInsertRow').addEventListener('click', () => { const sheet = window.currentWorkbook.sheets[window.currentSheetIndex]; const rowIndex = selectedRow !== null ? selectedRow : 0; - console.log('Before insert - dimensions:', sheet.dimensions); sheet.insertRow(rowIndex, 1); - console.log('After insert - dimensions:', sheet.dimensions); log(`Inserted row at index ${rowIndex}`, 'success'); diff --git a/src/core/Sheet.ts b/src/core/Sheet.ts index d5caa32..d73dad1 100644 --- a/src/core/Sheet.ts +++ b/src/core/Sheet.ts @@ -1520,7 +1520,9 @@ export class Sheet { if (search instanceof RegExp) { cell.value = currentValue.replace(search, String(replacement)); } else { - cell.value = currentValue.replace(String(search), String(replacement)); + // Use global regex to replace all occurrences within the cell + const escapedSearch = String(search).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + cell.value = currentValue.replace(new RegExp(escapedSearch, 'g'), String(replacement)); } } else if (typeof currentValue === 'number' && typeof search === 'number') { if (currentValue === search) { @@ -1562,7 +1564,7 @@ export class Sheet { const relKey = `${row - rangeDef.startRow},${col - rangeDef.startCol}`; cells.set(relKey, { value: cell.value, - style: cell.style ? { ...cell.style } : undefined, + style: cell.style ? JSON.parse(JSON.stringify(cell.style)) : undefined, formula: cell.formula?.formula, }); } @@ -1649,7 +1651,7 @@ export class Sheet { } if (!valuesOnly && cellData.style) { - destCell.style = { ...cellData.style }; + destCell.style = JSON.parse(JSON.stringify(cellData.style)); } } @@ -1966,6 +1968,8 @@ export class Sheet { newCell.value = cell.value; if (cell.style) newCell.style = cell.style; if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); } } finally { this._eventsEnabled = wasEventsEnabled; @@ -2004,6 +2008,8 @@ export class Sheet { newCell.value = cell.value; if (cell.style) newCell.style = cell.style; if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); } } finally { this._eventsEnabled = wasEventsEnabled;