Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: seamless import/export (clipboard, drag-drop, CSV/XLSX export)' (#54) from feat/seamless-import into main

scott 12620c59 92411bce

+2642 -76
+138
src/drop-overlay.ts
··· 1 + /** 2 + * Reusable drag-and-drop overlay for file import. 3 + * 4 + * Encapsulates the drag counter pattern, overlay display, file extension 5 + * validation, and cleanup. Reuses the `.drop-overlay` CSS class from app.css. 6 + */ 7 + 8 + /** 9 + * Get the lowercase file extension (without dot) from a filename. 10 + * @param {string} filename 11 + * @returns {string|null} 12 + */ 13 + export function getFileExtension(filename) { 14 + if (!filename || typeof filename !== 'string') return null; 15 + const dotIdx = filename.lastIndexOf('.'); 16 + if (dotIdx < 0 || dotIdx === filename.length - 1) return null; 17 + return filename.slice(dotIdx).toLowerCase(); 18 + } 19 + 20 + /** 21 + * Check whether a filename's extension is in the accepted list. 22 + * @param {string} filename 23 + * @param {string[]} acceptedExtensions - e.g. ['.docx', '.md', '.txt'] 24 + * @returns {boolean} 25 + */ 26 + export function isAcceptedFile(filename, acceptedExtensions) { 27 + const ext = getFileExtension(filename); 28 + if (!ext) return false; 29 + return acceptedExtensions.includes(ext); 30 + } 31 + 32 + /** 33 + * Build the overlay DOM element that is shown during a drag. 34 + * @param {string} label - Hint text for the overlay 35 + * @returns {HTMLElement} 36 + */ 37 + function buildOverlayElement(label) { 38 + const overlay = document.createElement('div'); 39 + overlay.className = 'drop-overlay'; 40 + overlay.innerHTML = ` 41 + <div class="drop-overlay-content"> 42 + <div class="drop-overlay-icon">\u2B07</div> 43 + <div class="drop-overlay-text">Drop file to import</div> 44 + <div class="drop-overlay-hint">${label}</div> 45 + </div> 46 + `; 47 + return overlay; 48 + } 49 + 50 + /** 51 + * Create drag-and-drop event listeners on a container element. 52 + * 53 + * Shows a `.drop-overlay` during drag, validates the dropped file's extension, 54 + * and calls `onDrop(file)` for valid files. 55 + * 56 + * Uses a drag enter/leave counter to avoid flicker when dragging over children. 57 + * 58 + * @param {HTMLElement} container - The element to listen on 59 + * @param {Object} opts 60 + * @param {string[]} opts.acceptedExtensions - Accepted file extensions (e.g. ['.docx', '.md']) 61 + * @param {function(File): void} opts.onDrop - Callback when a valid file is dropped 62 + * @param {string} [opts.label] - Descriptive hint text shown in the overlay 63 + * @param {function(string, number): void} [opts.onReject] - Called with a message when a file is rejected 64 + * @returns {{ destroy(): void }} Cleanup handle 65 + */ 66 + export function createDropOverlay(container, { acceptedExtensions, onDrop, label, onReject }) { 67 + let dragCounter = 0; 68 + let overlayEl = null; 69 + 70 + const defaultLabel = acceptedExtensions.map(e => e.replace('.', '')).join(', '); 71 + const displayLabel = label || `Supported: ${defaultLabel}`; 72 + 73 + function showOverlay() { 74 + if (overlayEl) return; 75 + overlayEl = buildOverlayElement(displayLabel); 76 + container.appendChild(overlayEl); 77 + } 78 + 79 + function hideOverlay() { 80 + if (overlayEl) { 81 + overlayEl.remove(); 82 + overlayEl = null; 83 + } 84 + } 85 + 86 + function handleDragEnter(e) { 87 + e.preventDefault(); 88 + dragCounter++; 89 + if (dragCounter === 1) showOverlay(); 90 + } 91 + 92 + function handleDragOver(e) { 93 + e.preventDefault(); 94 + e.dataTransfer.dropEffect = 'copy'; 95 + } 96 + 97 + function handleDragLeave(e) { 98 + e.preventDefault(); 99 + dragCounter--; 100 + if (dragCounter <= 0) { 101 + dragCounter = 0; 102 + hideOverlay(); 103 + } 104 + } 105 + 106 + function handleDrop(e) { 107 + e.preventDefault(); 108 + dragCounter = 0; 109 + hideOverlay(); 110 + 111 + const file = e.dataTransfer?.files?.[0]; 112 + if (!file) return; 113 + 114 + if (!isAcceptedFile(file.name, acceptedExtensions)) { 115 + const ext = getFileExtension(file.name) || 'unknown'; 116 + const msg = `Unsupported file type: ${ext}`; 117 + if (onReject) onReject(msg, 4000); 118 + return; 119 + } 120 + 121 + onDrop(file); 122 + } 123 + 124 + container.addEventListener('dragenter', handleDragEnter); 125 + container.addEventListener('dragover', handleDragOver); 126 + container.addEventListener('dragleave', handleDragLeave); 127 + container.addEventListener('drop', handleDrop); 128 + 129 + return { 130 + destroy() { 131 + container.removeEventListener('dragenter', handleDragEnter); 132 + container.removeEventListener('dragover', handleDragOver); 133 + container.removeEventListener('dragleave', handleDragLeave); 134 + container.removeEventListener('drop', handleDrop); 135 + hideOverlay(); 136 + }, 137 + }; 138 + }
+132
src/sheets/clipboard-copy.ts
··· 1 + /** 2 + * Clipboard Copy — Build rich clipboard data for copy operations. 3 + * 4 + * Pure functions for generating HTML tables with inline styles and 5 + * TSV text from selected cells for pasting into other spreadsheets. 6 + */ 7 + 8 + /** 9 + * Escape a string for safe inclusion in HTML. 10 + * 11 + * @param {string} str 12 + * @returns {string} 13 + */ 14 + function escapeHtml(str) { 15 + if (typeof str !== 'string') return String(str ?? ''); 16 + return str 17 + .replace(/&/g, '&amp;') 18 + .replace(/</g, '&lt;') 19 + .replace(/>/g, '&gt;') 20 + .replace(/"/g, '&quot;'); 21 + } 22 + 23 + /** 24 + * Convert a CellStyle object to an inline CSS style string. 25 + * 26 + * Maps our internal style properties to CSS: 27 + * bg -> background-color 28 + * color -> color 29 + * bold -> font-weight:bold 30 + * italic -> font-style:italic 31 + * underline -> text-decoration:underline 32 + * strikethrough -> text-decoration:line-through 33 + * fontSize -> font-size:Xpt 34 + * align -> text-align 35 + * 36 + * @param {Object} cellStyle - CellStyle object 37 + * @returns {string} CSS style string 38 + */ 39 + export function cellStyleToCss(cellStyle) { 40 + if (!cellStyle || typeof cellStyle !== 'object') return ''; 41 + 42 + const parts = []; 43 + 44 + if (cellStyle.bg) { 45 + parts.push('background-color:' + cellStyle.bg); 46 + } 47 + if (cellStyle.color) { 48 + parts.push('color:' + cellStyle.color); 49 + } 50 + if (cellStyle.bold) { 51 + parts.push('font-weight:bold'); 52 + } 53 + if (cellStyle.italic) { 54 + parts.push('font-style:italic'); 55 + } 56 + 57 + // Combine underline and strikethrough into a single text-decoration 58 + const decorations = []; 59 + if (cellStyle.underline) decorations.push('underline'); 60 + if (cellStyle.strikethrough) decorations.push('line-through'); 61 + if (decorations.length > 0) { 62 + parts.push('text-decoration:' + decorations.join(' ')); 63 + } 64 + 65 + if (cellStyle.fontSize) { 66 + parts.push('font-size:' + cellStyle.fontSize + 'pt'); 67 + } 68 + if (cellStyle.align) { 69 + parts.push('text-align:' + cellStyle.align); 70 + } 71 + 72 + return parts.join(';'); 73 + } 74 + 75 + /** 76 + * Build an HTML table string with inline styles from selected cells. 77 + * 78 + * @param {function} getCellData - Function (cellId) => { v, f, s } | null 79 + * @param {{ startCol: number, startRow: number, endCol: number, endRow: number }} selection - Normalized selection range 80 + * @param {function} cellId - Function (col, row) => string 81 + * @returns {string} HTML table string 82 + */ 83 + export function buildCopyHtml(getCellData, selection, cellId) { 84 + const { startCol, startRow, endCol, endRow } = selection; 85 + 86 + let html = '<table>'; 87 + for (let r = startRow; r <= endRow; r++) { 88 + html += '<tr>'; 89 + for (let c = startCol; c <= endCol; c++) { 90 + const id = cellId(c, r); 91 + const data = getCellData(id); 92 + const value = data?.f ? '=' + data.f : (data?.v ?? ''); 93 + const style = cellStyleToCss(data?.s); 94 + const styleAttr = style ? ' style="' + escapeHtml(style) + '"' : ''; 95 + html += '<td' + styleAttr + '>' + escapeHtml(String(value)) + '</td>'; 96 + } 97 + html += '</tr>'; 98 + } 99 + html += '</table>'; 100 + 101 + return html; 102 + } 103 + 104 + /** 105 + * Build a TSV (tab-separated values) string from selected cells. 106 + * 107 + * @param {function} getCellData - Function (cellId) => { v, f, s } | null 108 + * @param {{ startCol: number, startRow: number, endCol: number, endRow: number }} selection - Normalized selection range 109 + * @param {function} cellId - Function (col, row) => string 110 + * @returns {string} TSV string 111 + */ 112 + export function buildCopyTsv(getCellData, selection, cellId) { 113 + const { startCol, startRow, endCol, endRow } = selection; 114 + 115 + const rows = []; 116 + for (let r = startRow; r <= endRow; r++) { 117 + const cols = []; 118 + for (let c = startCol; c <= endCol; c++) { 119 + const id = cellId(c, r); 120 + const data = getCellData(id); 121 + let value = data?.f ? '=' + data.f : (data?.v ?? ''); 122 + // Escape tabs and newlines in cell values for TSV safety 123 + if (typeof value === 'string') { 124 + value = value.replace(/\t/g, ' ').replace(/\n/g, ' '); 125 + } 126 + cols.push(value); 127 + } 128 + rows.push(cols.join('\t')); 129 + } 130 + 131 + return rows.join('\n'); 132 + }
+267
src/sheets/clipboard-paste.ts
··· 1 + /** 2 + * Clipboard Paste — Parse rich clipboard data from Excel and Google Sheets. 3 + * 4 + * Pure functions for parsing HTML tables and TSV text from the system 5 + * clipboard into the cell data format used by the Yjs-backed spreadsheet. 6 + */ 7 + 8 + /** 9 + * Parse inline CSS style attribute into a CellStyle-compatible object. 10 + * 11 + * Maps: 12 + * background-color / background -> bg 13 + * color -> color 14 + * font-weight:bold / font-weight:700 -> bold:true 15 + * font-style:italic -> italic:true 16 + * text-decoration containing underline -> underline:true 17 + * text-decoration containing line-through -> strikethrough:true 18 + * font-size:Xpt -> fontSize:X 19 + * text-align -> align 20 + * 21 + * @param {string} styleAttr - CSS style attribute string 22 + * @returns {Object} CellStyle object 23 + */ 24 + export function parseInlineStyles(styleAttr) { 25 + if (!styleAttr) return {}; 26 + 27 + const style = {}; 28 + const props = {}; 29 + 30 + // Parse CSS properties from the style string 31 + const parts = styleAttr.split(';'); 32 + for (const part of parts) { 33 + const colonIdx = part.indexOf(':'); 34 + if (colonIdx === -1) continue; 35 + const prop = part.slice(0, colonIdx).trim().toLowerCase(); 36 + const val = part.slice(colonIdx + 1).trim(); 37 + if (prop && val) { 38 + props[prop] = val; 39 + } 40 + } 41 + 42 + // Background color 43 + const bg = props['background-color'] || props['background']; 44 + if (bg) { 45 + const normalized = normalizeColor(bg); 46 + if (normalized) style.bg = normalized; 47 + } 48 + 49 + // Text color 50 + if (props['color']) { 51 + const normalized = normalizeColor(props['color']); 52 + if (normalized) style.color = normalized; 53 + } 54 + 55 + // Bold 56 + if (props['font-weight']) { 57 + const fw = props['font-weight'].toLowerCase(); 58 + if (fw === 'bold' || fw === '700' || fw === '800' || fw === '900') { 59 + style.bold = true; 60 + } 61 + } 62 + 63 + // Italic 64 + if (props['font-style']) { 65 + if (props['font-style'].toLowerCase() === 'italic') { 66 + style.italic = true; 67 + } 68 + } 69 + 70 + // Text decoration (can contain multiple values like "underline line-through") 71 + if (props['text-decoration'] || props['text-decoration-line']) { 72 + const td = (props['text-decoration'] || props['text-decoration-line']).toLowerCase(); 73 + if (td.includes('underline')) { 74 + style.underline = true; 75 + } 76 + if (td.includes('line-through')) { 77 + style.strikethrough = true; 78 + } 79 + } 80 + 81 + // Font size (Xpt -> X) 82 + if (props['font-size']) { 83 + const match = props['font-size'].match(/^(\d+(?:\.\d+)?)\s*pt$/i); 84 + if (match) { 85 + style.fontSize = parseFloat(match[1]); 86 + } 87 + } 88 + 89 + // Text alignment 90 + if (props['text-align']) { 91 + const align = props['text-align'].toLowerCase(); 92 + if (['left', 'center', 'right'].includes(align)) { 93 + style.align = align; 94 + } 95 + } 96 + 97 + return style; 98 + } 99 + 100 + /** 101 + * Normalize a CSS color value to a hex string. 102 + * Handles rgb(), rgba(), and hex values. 103 + * 104 + * @param {string} color - CSS color string 105 + * @returns {string|null} Hex color or null 106 + */ 107 + function normalizeColor(color) { 108 + if (!color) return null; 109 + const trimmed = color.trim().toLowerCase(); 110 + 111 + // Already a hex color 112 + if (trimmed.startsWith('#')) { 113 + // Expand shorthand #abc -> #aabbcc 114 + if (/^#[0-9a-f]{3}$/i.test(trimmed)) { 115 + return '#' + trimmed[1] + trimmed[1] + trimmed[2] + trimmed[2] + trimmed[3] + trimmed[3]; 116 + } 117 + if (/^#[0-9a-f]{6}$/i.test(trimmed)) { 118 + return trimmed; 119 + } 120 + // 8-char hex (#rrggbbaa) — strip alpha 121 + if (/^#[0-9a-f]{8}$/i.test(trimmed)) { 122 + return trimmed.slice(0, 7); 123 + } 124 + return trimmed; 125 + } 126 + 127 + // rgb(r, g, b) or rgba(r, g, b, a) 128 + const rgbMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); 129 + if (rgbMatch) { 130 + const r = Math.min(255, parseInt(rgbMatch[1], 10)); 131 + const g = Math.min(255, parseInt(rgbMatch[2], 10)); 132 + const b = Math.min(255, parseInt(rgbMatch[3], 10)); 133 + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 134 + } 135 + 136 + return null; 137 + } 138 + 139 + /** 140 + * Detect the source application from an HTML string. 141 + * 142 + * @param {string} html - Clipboard HTML 143 + * @returns {string|null} 'google-sheets', 'excel', or null 144 + */ 145 + function detectSource(html) { 146 + if (html.includes('google-sheets-html-origin')) return 'google-sheets'; 147 + if (html.includes('xmlns:x="urn:schemas-microsoft-com:office:excel"')) return 'excel'; 148 + if (html.includes('urn:schemas-microsoft-com:office:excel')) return 'excel'; 149 + if (html.includes('xmlns:o="urn:schemas-microsoft-com:office:office"')) return 'excel'; 150 + return null; 151 + } 152 + 153 + /** 154 + * Parse an HTML table from clipboard data (from Excel or Google Sheets). 155 + * 156 + * Uses DOMParser to extract cell values, formulas, and styles from 157 + * the HTML representation of a copied spreadsheet range. 158 + * 159 + * @param {string} html - HTML string from clipboard 160 + * @param {DOMParser} [domParser] - Optional DOMParser instance (for testing) 161 + * @returns {{ rows: Array<Array<{value: string|number, formula: string, style: Object}>>, sourceApp: string|null }|null} 162 + */ 163 + export function parseClipboardHtml(html, domParser) { 164 + if (!html || typeof html !== 'string') return null; 165 + 166 + const parser = domParser || new DOMParser(); 167 + const doc = parser.parseFromString(html, 'text/html'); 168 + 169 + // Find the table element 170 + const table = doc.querySelector('table'); 171 + if (!table) return null; 172 + 173 + const sourceApp = detectSource(html); 174 + const rows = []; 175 + 176 + const trs = table.querySelectorAll('tr'); 177 + if (trs.length === 0) return null; 178 + 179 + for (const tr of trs) { 180 + const rowCells = []; 181 + const tds = tr.querySelectorAll('td, th'); 182 + for (const td of tds) { 183 + const rawValue = td.textContent || ''; 184 + const styleAttr = td.getAttribute('style') || ''; 185 + const style = parseInlineStyles(styleAttr); 186 + 187 + // Detect formulas (Google Sheets puts data-formula, Excel uses text starting with =) 188 + let formula = ''; 189 + const dataFormula = td.getAttribute('data-formula'); 190 + if (dataFormula) { 191 + formula = dataFormula.startsWith('=') ? dataFormula.slice(1) : dataFormula; 192 + } else if (rawValue.startsWith('=')) { 193 + formula = rawValue.slice(1); 194 + } 195 + 196 + // Parse numeric values 197 + let value = rawValue; 198 + if (!formula && rawValue !== '') { 199 + const num = Number(rawValue); 200 + if (!isNaN(num) && rawValue.trim() !== '') { 201 + value = num; 202 + } 203 + } 204 + 205 + // Handle colspan for merged cells 206 + const colspan = parseInt(td.getAttribute('colspan'), 10) || 1; 207 + const rowspan = parseInt(td.getAttribute('rowspan'), 10) || 1; 208 + 209 + rowCells.push({ value: formula ? rawValue : value, formula, style }); 210 + 211 + // Fill extra columns for colspan > 1 212 + for (let i = 1; i < colspan; i++) { 213 + rowCells.push({ value: '', formula: '', style: {} }); 214 + } 215 + } 216 + rows.push(rowCells); 217 + } 218 + 219 + if (rows.length === 0) return null; 220 + 221 + return { rows, sourceApp }; 222 + } 223 + 224 + /** 225 + * Parse tab-separated text from clipboard. 226 + * 227 + * @param {string} text - TSV string from clipboard 228 + * @returns {{ rows: Array<Array<{value: string|number, formula: string, style: Object}>>, sourceApp: null }|null} 229 + */ 230 + export function parseClipboardTsv(text) { 231 + if (!text || typeof text !== 'string') return null; 232 + 233 + // Split by lines, handling both \r\n and \n 234 + let lines = text.split(/\r?\n/); 235 + 236 + // Remove trailing empty line (common in clipboard paste) 237 + if (lines.length > 0 && lines[lines.length - 1] === '') { 238 + lines = lines.slice(0, -1); 239 + } 240 + 241 + if (lines.length === 0) return null; 242 + 243 + const rows = []; 244 + for (const line of lines) { 245 + const cols = line.split('\t'); 246 + const rowCells = []; 247 + for (const col of cols) { 248 + const trimmed = col.trim(); 249 + let value = trimmed; 250 + let formula = ''; 251 + 252 + if (trimmed.startsWith('=')) { 253 + formula = trimmed.slice(1); 254 + } else if (trimmed !== '') { 255 + const num = Number(trimmed); 256 + if (!isNaN(num)) { 257 + value = num; 258 + } 259 + } 260 + 261 + rowCells.push({ value, formula, style: {} }); 262 + } 263 + rows.push(rowCells); 264 + } 265 + 266 + return { rows, sourceApp: null }; 267 + }
+120
src/sheets/csv-export.ts
··· 1 + /** 2 + * CSV/TSV Export module for Tools Sheets. 3 + * 4 + * Pure logic — no DOM dependencies. Generates RFC 4180 compliant CSV 5 + * with UTF-8 BOM for Excel compatibility. 6 + */ 7 + 8 + /** 9 + * @typedef {Object} CsvExportOptions 10 + * @property {string} [delimiter=','] - Field delimiter (',' for CSV, '\t' for TSV) 11 + * @property {boolean} [includeHeaders=false] - Whether to include column letter headers as first row 12 + * @property {{ startRow: number, endRow: number, startCol: number, endCol: number }|null} [range=null] - Export subrange (1-based), or null for all data 13 + */ 14 + 15 + /** 16 + * Convert a 1-based column number to a spreadsheet letter (A, B, ..., Z, AA, ...). 17 + * @param {number} col - 1-based column index 18 + * @returns {string} 19 + */ 20 + function colToLetter(col) { 21 + let s = ''; 22 + let n = col; 23 + while (n > 0) { 24 + n--; 25 + s = String.fromCharCode(65 + (n % 26)) + s; 26 + n = Math.floor(n / 26); 27 + } 28 + return s; 29 + } 30 + 31 + /** 32 + * Escape a field value according to RFC 4180. 33 + * 34 + * A field must be quoted if it contains the delimiter, a double-quote, 35 + * or a newline (CR or LF). Inside quotes, double-quotes are escaped 36 + * by doubling them. 37 + * 38 + * @param {string} value - Raw field value 39 + * @param {string} delimiter - The field delimiter in use 40 + * @returns {string} Properly escaped field 41 + */ 42 + export function escapeField(value, delimiter) { 43 + if (value === '') return ''; 44 + if ( 45 + value.includes(delimiter) || 46 + value.includes('"') || 47 + value.includes('\n') || 48 + value.includes('\r') 49 + ) { 50 + return '"' + value.replace(/"/g, '""') + '"'; 51 + } 52 + return value; 53 + } 54 + 55 + /** 56 + * Generate a CSV (or TSV) string from spreadsheet data. 57 + * 58 + * @param {function(number, number): string} getCellValue - (row, col) => string value (1-based) 59 + * @param {number} rowCount - Total rows in the sheet 60 + * @param {number} colCount - Total columns in the sheet 61 + * @param {CsvExportOptions} [options={}] 62 + * @returns {string} CSV content with UTF-8 BOM prefix 63 + */ 64 + export function exportToCsv(getCellValue, rowCount, colCount, options = {}) { 65 + const delimiter = options.delimiter || ','; 66 + const includeHeaders = options.includeHeaders || false; 67 + const range = options.range || null; 68 + 69 + const startRow = range ? range.startRow : 1; 70 + const endRow = range ? range.endRow : rowCount; 71 + const startCol = range ? range.startCol : 1; 72 + const endCol = range ? range.endCol : colCount; 73 + 74 + const lines = []; 75 + 76 + // Optional header row with column letters 77 + if (includeHeaders) { 78 + const headers = []; 79 + for (let c = startCol; c <= endCol; c++) { 80 + headers.push(escapeField(colToLetter(c), delimiter)); 81 + } 82 + lines.push(headers.join(delimiter)); 83 + } 84 + 85 + for (let r = startRow; r <= endRow; r++) { 86 + const fields = []; 87 + for (let c = startCol; c <= endCol; c++) { 88 + const val = getCellValue(r, c); 89 + fields.push(escapeField(val, delimiter)); 90 + } 91 + lines.push(fields.join(delimiter)); 92 + } 93 + 94 + // UTF-8 BOM for Excel compatibility 95 + return '\uFEFF' + lines.join('\r\n'); 96 + } 97 + 98 + /** 99 + * Trigger a file download in the browser. 100 + * 101 + * @param {string} content - The file content string 102 + * @param {string} filename - Base filename (without extension) 103 + * @param {string} delimiter - The delimiter used (',' or '\t') to determine extension 104 + */ 105 + export function downloadCsv(content, filename, delimiter) { 106 + const ext = delimiter === '\t' ? '.tsv' : '.csv'; 107 + const mimeType = delimiter === '\t' 108 + ? 'text/tab-separated-values;charset=utf-8' 109 + : 'text/csv;charset=utf-8'; 110 + 111 + const blob = new Blob([content], { type: mimeType }); 112 + const url = URL.createObjectURL(blob); 113 + const a = document.createElement('a'); 114 + a.href = url; 115 + a.download = filename + ext; 116 + document.body.appendChild(a); 117 + a.click(); 118 + document.body.removeChild(a); 119 + URL.revokeObjectURL(url); 120 + }
+5 -2
src/sheets/index.html
··· 231 231 <button class="toolbar-dropdown-item" id="tb-export-csv" title="Export as CSV" role="menuitem"> 232 232 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="11" y2="10"/></svg></span><span class="item-label">Export CSV</span> 233 233 </button> 234 - <button class="toolbar-dropdown-item" id="tb-import" title="Import CSV/TSV" role="menuitem"> 234 + <button class="toolbar-dropdown-item" id="tb-export-xlsx" title="Export as XLSX" role="menuitem"> 235 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="11" y2="10"/></svg></span><span class="item-label">Export XLSX</span> 236 + </button> 237 + <button class="toolbar-dropdown-item" id="tb-import" title="Import CSV/TSV/XLSX" role="menuitem"> 235 238 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M8 10V2"/><path d="M5 5l3-3 3 3"/><path d="M2 10v3h12v-3"/></svg></span><span class="item-label">Import file</span> 236 239 </button> 237 240 </div> ··· 312 315 <div class="cell-note-tooltip" id="cell-note-tooltip" style="display:none"></div> 313 316 </div> 314 317 315 - <script type="module" src="./main.ts"></script> 318 + <script type="module" src="./main.js"></script> 316 319 <script> 317 320 (function() { 318 321 var toggle = document.getElementById('theme-toggle');
+212
src/sheets/xlsx-export.ts
··· 1 + /** 2 + * .xlsx Export module for Tools Sheets. 3 + * 4 + * Uses SheetJS (xlsx) to build workbooks from the internal cell data format. 5 + * Maps internal style properties back to Excel style objects. 6 + * 7 + * Pure logic (except downloadXlsx which touches the DOM). 8 + */ 9 + 10 + /** 11 + * Convert a 1-based column number to a spreadsheet letter. 12 + * @param {number} col 13 + * @returns {string} 14 + */ 15 + function colToLetter(col) { 16 + let s = ''; 17 + let n = col; 18 + while (n > 0) { 19 + n--; 20 + s = String.fromCharCode(65 + (n % 26)) + s; 21 + n = Math.floor(n / 26); 22 + } 23 + return s; 24 + } 25 + 26 + /** 27 + * Map our internal format identifier back to an Excel number format string. 28 + * @param {string|undefined} format 29 + * @returns {string|undefined} 30 + */ 31 + function mapFormatToExcel(format) { 32 + if (!format) return undefined; 33 + switch (format) { 34 + case 'currency': return '$#,##0.00'; 35 + case 'percent': return '0.00%'; 36 + case 'date': return 'mm/dd/yyyy'; 37 + case 'number': return '#,##0.00'; 38 + default: return undefined; 39 + } 40 + } 41 + 42 + /** 43 + * Map our internal style object to a SheetJS cell style object. 44 + * 45 + * @param {Object} s - Internal style object { bold, italic, fontSize, color, bg, align, format, ... } 46 + * @returns {Object} SheetJS style object 47 + */ 48 + export function mapStyleToXlsx(s) { 49 + if (!s) return {}; 50 + const style = {}; 51 + 52 + // Font properties 53 + const font = {}; 54 + let hasFont = false; 55 + if (s.bold) { font.bold = true; hasFont = true; } 56 + if (s.italic) { font.italic = true; hasFont = true; } 57 + if (s.underline) { font.underline = true; hasFont = true; } 58 + if (s.strikethrough) { font.strike = true; hasFont = true; } 59 + if (s.fontSize) { font.sz = s.fontSize; hasFont = true; } 60 + if (s.color) { 61 + // Strip leading # and ensure 6 hex digits 62 + const hex = s.color.replace(/^#/, ''); 63 + font.color = { rgb: hex.length === 6 ? hex : hex.padStart(6, '0') }; 64 + hasFont = true; 65 + } 66 + if (hasFont) style.font = font; 67 + 68 + // Fill (background color) 69 + if (s.bg) { 70 + const hex = s.bg.replace(/^#/, ''); 71 + style.fill = { 72 + patternType: 'solid', 73 + fgColor: { rgb: hex.length === 6 ? hex : hex.padStart(6, '0') }, 74 + }; 75 + } 76 + 77 + // Alignment 78 + const alignment = {}; 79 + let hasAlignment = false; 80 + if (s.align && ['left', 'center', 'right'].includes(s.align)) { 81 + alignment.horizontal = s.align; 82 + hasAlignment = true; 83 + } 84 + if (s.verticalAlign) { 85 + if (s.verticalAlign === 'middle') alignment.vertical = 'center'; 86 + else alignment.vertical = s.verticalAlign; 87 + hasAlignment = true; 88 + } 89 + if (s.wrap) { 90 + alignment.wrapText = true; 91 + hasAlignment = true; 92 + } 93 + if (hasAlignment) style.alignment = alignment; 94 + 95 + return style; 96 + } 97 + 98 + /** 99 + * Build an xlsx workbook ArrayBuffer from the spreadsheet data. 100 + * 101 + * @param {function(number, number): { v: any, f: string, s: Object }|null} getCellData 102 + * (row, col) => cell data object (1-based), or null for empty cell 103 + * @param {number} rowCount 104 + * @param {number} colCount 105 + * @param {function(number): number} colWidths - (col) => width in pixels 106 + * @param {string} sheetName - Name for the worksheet 107 + * @param {object} XLSX - The SheetJS library object 108 + * @returns {ArrayBuffer} 109 + */ 110 + export function exportToXlsx(getCellData, rowCount, colCount, colWidths, sheetName, XLSX) { 111 + const workbook = XLSX.utils.book_new(); 112 + const worksheet = {}; 113 + 114 + let maxRow = 0; 115 + let maxCol = 0; 116 + 117 + for (let r = 1; r <= rowCount; r++) { 118 + for (let c = 1; c <= colCount; c++) { 119 + const data = getCellData(r, c); 120 + if (!data) continue; 121 + 122 + // Skip entirely empty cells 123 + if ((data.v === '' || data.v === undefined || data.v === null) && !data.f && (!data.s || Object.keys(data.s).length === 0)) { 124 + continue; 125 + } 126 + 127 + const addr = colToLetter(c) + r; 128 + const cell = {}; 129 + 130 + // Value and type 131 + if (data.f) { 132 + cell.f = data.f; 133 + // SheetJS needs a type even for formula cells 134 + if (typeof data.v === 'number') { 135 + cell.t = 'n'; 136 + cell.v = data.v; 137 + } else { 138 + cell.t = 's'; 139 + cell.v = data.v !== undefined && data.v !== null && data.v !== '' ? data.v : ''; 140 + } 141 + } else if (typeof data.v === 'number') { 142 + cell.t = 'n'; 143 + cell.v = data.v; 144 + } else if (typeof data.v === 'boolean') { 145 + cell.t = 'b'; 146 + cell.v = data.v; 147 + } else { 148 + cell.t = 's'; 149 + cell.v = data.v !== undefined && data.v !== null ? String(data.v) : ''; 150 + } 151 + 152 + // Style mapping 153 + if (data.s && Object.keys(data.s).length > 0) { 154 + cell.s = mapStyleToXlsx(data.s); 155 + 156 + // Number format 157 + const excelFormat = mapFormatToExcel(data.s.format); 158 + if (excelFormat) { 159 + cell.z = excelFormat; 160 + } 161 + } 162 + 163 + worksheet[addr] = cell; 164 + 165 + if (r > maxRow) maxRow = r; 166 + if (c > maxCol) maxCol = c; 167 + } 168 + } 169 + 170 + // Set range 171 + if (maxRow > 0 && maxCol > 0) { 172 + worksheet['!ref'] = 'A1:' + colToLetter(maxCol) + maxRow; 173 + } else { 174 + worksheet['!ref'] = 'A1'; 175 + } 176 + 177 + // Column widths (SheetJS uses wch = width in characters, approximate from pixels) 178 + if (colWidths) { 179 + const cols = []; 180 + for (let c = 1; c <= Math.max(maxCol, 1); c++) { 181 + const px = colWidths(c); 182 + // Approximate: 1 character ~= 7px at default font size 183 + cols.push({ wch: Math.max(Math.round(px / 7), 8) }); 184 + } 185 + worksheet['!cols'] = cols; 186 + } 187 + 188 + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName || 'Sheet 1'); 189 + 190 + const buffer = XLSX.write(workbook, { type: 'array', bookType: 'xlsx', cellStyles: true }); 191 + return buffer; 192 + } 193 + 194 + /** 195 + * Trigger download of an xlsx ArrayBuffer. 196 + * 197 + * @param {ArrayBuffer} buffer 198 + * @param {string} filename - Filename including .xlsx extension 199 + */ 200 + export function downloadXlsx(buffer, filename) { 201 + const blob = new Blob([buffer], { 202 + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 203 + }); 204 + const url = URL.createObjectURL(blob); 205 + const a = document.createElement('a'); 206 + a.href = url; 207 + a.download = filename.endsWith('.xlsx') ? filename : filename + '.xlsx'; 208 + document.body.appendChild(a); 209 + a.click(); 210 + document.body.removeChild(a); 211 + URL.revokeObjectURL(url); 212 + }
+156 -74
src/sheets/xlsx-import.ts
··· 5 5 * cell data format used by the Yjs-backed spreadsheet. 6 6 */ 7 7 8 - import type { XlsxCellData, XlsxCellStyle, ParsedSheet, ImportXlsxOptions } from './types.js'; 8 + /** 9 + * @typedef {Object} CellData 10 + * @property {string|number} v - Cell value 11 + * @property {string} f - Formula (without leading =) 12 + * @property {Object} [s] - Style object 13 + * @property {boolean} [s.bold] - Bold formatting 14 + * @property {string} [s.format] - Number format string 15 + */ 16 + 17 + /** 18 + * @typedef {Object} ParsedSheet 19 + * @property {string} name - Sheet name 20 + * @property {Map<string, CellData>} cells - Map of cellId -> CellData 21 + * @property {number} rowCount - Number of rows with data 22 + * @property {number} colCount - Number of columns with data 23 + */ 9 24 10 25 /** 11 26 * Convert a 1-based column number to a spreadsheet letter (A, B, ..., Z, AA, ...). 27 + * @param {number} col - 1-based column index 28 + * @returns {string} 12 29 */ 13 - function colToLetter(col: number): string { 30 + function colToLetter(col) { 14 31 let s = ''; 15 32 let n = col; 16 33 while (n > 0) { ··· 23 40 24 41 /** 25 42 * Create a cell ID from column and row (1-based). 43 + * @param {number} col 44 + * @param {number} row 45 + * @returns {string} e.g. "A1", "B2" 26 46 */ 27 - function cellId(col: number, row: number): string { 47 + function cellId(col, row) { 28 48 return `${colToLetter(col)}${row}`; 29 49 } 30 50 31 - // SheetJS type stubs for the dynamic import 32 - interface XlsxCell { 33 - v?: string | number; 34 - f?: string; 35 - z?: string; 36 - s?: { 37 - font?: { 38 - bold?: boolean; 39 - italic?: boolean; 40 - underline?: boolean; 41 - strike?: boolean; 42 - sz?: number; 43 - color?: { rgb?: string }; 44 - }; 45 - fill?: { 46 - fgColor?: { rgb?: string }; 47 - }; 48 - alignment?: { 49 - horizontal?: string; 50 - vertical?: string; 51 - wrapText?: boolean; 52 - }; 53 - }; 54 - } 55 - 56 - interface XlsxUtils { 57 - decode_range(range: string): { s: { r: number; c: number }; e: { r: number; c: number } }; 58 - encode_cell(cell: { r: number; c: number }): string; 59 - } 60 - 61 - interface XlsxLib { 62 - read(data: ArrayBuffer, opts: { type: string; cellStyles: boolean; cellFormula: boolean }): { 63 - SheetNames: string[]; 64 - Sheets: Record<string, Record<string, XlsxCell>>; 65 - }; 66 - utils: XlsxUtils; 67 - } 68 - 69 51 /** 70 - * Parse an .xlsx ArrayBuffer using the provided XLSX library. 52 + * Parse a single worksheet from an xlsx workbook. 53 + * 54 + * @param {object} worksheet - SheetJS worksheet object 55 + * @param {string} sheetName - Name of the sheet 56 + * @param {object} XLSX - The SheetJS library object 57 + * @returns {ParsedSheet} 71 58 */ 72 - export function parseXlsxWithLib(arrayBuffer: ArrayBuffer, XLSX: XlsxLib): ParsedSheet { 73 - const workbook = XLSX.read(arrayBuffer, { type: 'array', cellStyles: true, cellFormula: true }); 59 + function parseWorksheet(worksheet, sheetName, XLSX) { 60 + const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1'); 74 61 75 - const sheetName = workbook.SheetNames[0]; 76 - if (!sheetName) { 77 - return { name: 'Sheet 1', cells: new Map(), rowCount: 0, colCount: 0 }; 78 - } 79 - 80 - const worksheet = workbook.Sheets[sheetName]; 81 - const range = XLSX.utils.decode_range(worksheet['!ref'] as string || 'A1'); 82 - 83 - const cells = new Map<string, XlsxCellData>(); 62 + const cells = new Map(); 84 63 let maxRow = 0; 85 64 let maxCol = 0; 86 65 ··· 94 73 const col = c + 1; 95 74 const id = cellId(col, row); 96 75 97 - const data: XlsxCellData = { v: '', f: '', s: {} }; 76 + const data = { v: '', f: '', s: {} }; 98 77 99 78 // Extract value 100 79 if (cell.v !== undefined && cell.v !== null) { ··· 132 111 if (cell.s.alignment && cell.s.alignment.horizontal) { 133 112 const align = cell.s.alignment.horizontal; 134 113 if (['left', 'center', 'right'].includes(align)) { 135 - data.s.align = align as 'left' | 'center' | 'right'; 114 + data.s.align = align; 136 115 } 137 116 } 138 117 if (cell.s.alignment && cell.s.alignment.vertical) { ··· 167 146 } 168 147 169 148 /** 149 + * Parse an .xlsx ArrayBuffer using the provided XLSX library. 150 + * Returns the first sheet only (backwards-compatible). 151 + * 152 + * @param {ArrayBuffer} arrayBuffer - The .xlsx file contents 153 + * @param {object} XLSX - The SheetJS library object 154 + * @returns {ParsedSheet} Parsed sheet data 155 + */ 156 + export function parseXlsxWithLib(arrayBuffer, XLSX) { 157 + const workbook = XLSX.read(arrayBuffer, { type: 'array', cellStyles: true, cellFormula: true }); 158 + 159 + const sheetName = workbook.SheetNames[0]; 160 + if (!sheetName) { 161 + return { name: 'Sheet 1', cells: new Map(), rowCount: 0, colCount: 0 }; 162 + } 163 + 164 + const worksheet = workbook.Sheets[sheetName]; 165 + return parseWorksheet(worksheet, sheetName, XLSX); 166 + } 167 + 168 + /** 169 + * Parse ALL sheets from an .xlsx ArrayBuffer. 170 + * 171 + * @param {ArrayBuffer} arrayBuffer - The .xlsx file contents 172 + * @param {object} XLSX - The SheetJS library object 173 + * @returns {{ sheets: ParsedSheet[] }} Object with array of parsed sheets 174 + */ 175 + export function parseXlsxWorkbook(arrayBuffer, XLSX) { 176 + const workbook = XLSX.read(arrayBuffer, { type: 'array', cellStyles: true, cellFormula: true }); 177 + 178 + if (!workbook.SheetNames || workbook.SheetNames.length === 0) { 179 + return { sheets: [] }; 180 + } 181 + 182 + const sheets = workbook.SheetNames.map((name) => { 183 + const worksheet = workbook.Sheets[name]; 184 + return parseWorksheet(worksheet, name, XLSX); 185 + }); 186 + 187 + return { sheets }; 188 + } 189 + 190 + /** 170 191 * Map Excel number format strings to our internal format identifiers. 192 + * 193 + * @param {string} excelFormat 194 + * @returns {string|undefined} 171 195 */ 172 - export function mapExcelFormat(excelFormat: string): string | undefined { 196 + export function mapExcelFormat(excelFormat) { 173 197 if (!excelFormat || excelFormat === 'General') return undefined; 174 198 175 199 // Currency patterns ··· 190 214 /** 191 215 * Validate that the given ArrayBuffer looks like a valid .xlsx file. 192 216 * An .xlsx is a ZIP file, so it starts with the PK signature (0x504B0304). 217 + * 218 + * @param {ArrayBuffer} arrayBuffer 219 + * @returns {boolean} 193 220 */ 194 - export function isValidXlsx(arrayBuffer: ArrayBuffer): boolean { 221 + export function isValidXlsx(arrayBuffer) { 195 222 if (!arrayBuffer || arrayBuffer.byteLength < 4) return false; 196 223 const view = new Uint8Array(arrayBuffer); 197 224 return view[0] === 0x50 && view[1] === 0x4B && view[2] === 0x03 && view[3] === 0x04; ··· 199 226 200 227 /** 201 228 * Import an .xlsx File into the spreadsheet via Yjs. 229 + * Now supports multi-sheet workbooks: each sheet in the workbook 230 + * creates a corresponding Yjs sheet tab. 231 + * 202 232 * DOM-coupled entry point — not unit-testable directly. 233 + * 234 + * @param {File} file - The .xlsx file 235 + * @param {object} opts 236 + * @param {object} opts.ydoc - Yjs document 237 + * @param {function} opts.getActiveSheet - Returns the active Yjs sheet map 238 + * @param {function} opts.ensureSheet - Function to create/get a sheet tab by index 239 + * @param {function} opts.setCellData - Function to set cell data by ID for a given sheet 240 + * @param {function} opts.setCellDataForSheet - Function to set cell data on a specific sheet (idx, id, data) 241 + * @param {function} opts.getCells - Function to get cells Yjs map 242 + * @param {function} opts.renderGrid - Function to re-render the grid 243 + * @param {function} opts.renderSheetTabs - Function to re-render the sheet tabs 244 + * @param {function} opts.showToast - Toast notification function 245 + * @param {object} opts.evalCache - Formula evaluation cache to clear 246 + * @param {number} opts.DEFAULT_ROWS - Default row count 247 + * @param {number} opts.DEFAULT_COLS - Default column count 203 248 */ 204 - export async function importXlsx(file: File, opts: ImportXlsxOptions): Promise<void> { 205 - const { ydoc, getActiveSheet, setCellData, renderGrid, showToast, evalCache, DEFAULT_ROWS, DEFAULT_COLS } = opts; 249 + export async function importXlsx(file, opts) { 250 + const { 251 + ydoc, getActiveSheet, ensureSheet, setCellData, setCellDataForSheet, 252 + renderGrid, renderSheetTabs, showToast, evalCache, 253 + DEFAULT_ROWS, DEFAULT_COLS, 254 + } = opts; 206 255 207 256 try { 208 257 const arrayBuffer = await file.arrayBuffer(); 209 258 210 259 if (!isValidXlsx(arrayBuffer)) { 211 - showToast('Invalid .xlsx file — the file appears to be corrupt', 5000); 260 + showToast('Invalid .xlsx file \u2014 the file appears to be corrupt', 5000); 212 261 return; 213 262 } 214 263 215 - const XLSX = await import('xlsx') as unknown as XlsxLib; 216 - const parsed = parseXlsxWithLib(arrayBuffer, XLSX); 264 + const XLSX = await import('xlsx'); 265 + const { sheets } = parseXlsxWorkbook(arrayBuffer, XLSX); 217 266 218 - if (parsed.cells.size === 0) { 267 + if (sheets.length === 0 || sheets.every(s => s.cells.size === 0)) { 219 268 showToast('The .xlsx file appears to be empty', 3000); 220 269 return; 221 270 } 222 271 223 - const sheet = getActiveSheet(); 272 + let totalCells = 0; 224 273 225 274 ydoc.transact(() => { 226 - for (const [id, data] of parsed.cells) { 227 - setCellData(id, data); 228 - } 275 + for (let idx = 0; idx < sheets.length; idx++) { 276 + const parsed = sheets[idx]; 277 + const sheet = ensureSheet(idx); 278 + 279 + // Set tab name from workbook 280 + sheet.set('name', parsed.name); 281 + 282 + // Get the cells map for this sheet 283 + const cells = sheet.get('cells'); 284 + 285 + // Import cell data 286 + for (const [id, data] of parsed.cells) { 287 + if (setCellDataForSheet) { 288 + setCellDataForSheet(idx, id, data); 289 + } else { 290 + // Fallback: write directly to the sheet's cells Y.Map 291 + // This matches how setCellData works in main.js 292 + let cell; 293 + if (cells.has(id)) { 294 + cell = cells.get(id); 295 + } else { 296 + const Y = ydoc.constructor ? ydoc : { Map: function() { return new Map(); } }; 297 + // We need Y.Map — import from yjs or use the cells parent 298 + cell = new cells.constructor(); 299 + cells.set(id, cell); 300 + } 301 + if (data.v !== undefined) cell.set('v', data.v); 302 + if (data.f !== undefined) cell.set('f', data.f); 303 + if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 304 + } 305 + totalCells++; 306 + } 229 307 230 - if (parsed.rowCount > ((sheet.get('rowCount') as number) || DEFAULT_ROWS)) { 231 - sheet.set('rowCount', parsed.rowCount); 232 - } 233 - if (parsed.colCount > ((sheet.get('colCount') as number) || DEFAULT_COLS)) { 234 - sheet.set('colCount', parsed.colCount); 308 + // Expand grid dimensions if needed 309 + if (parsed.rowCount > (sheet.get('rowCount') || DEFAULT_ROWS)) { 310 + sheet.set('rowCount', parsed.rowCount); 311 + } 312 + if (parsed.colCount > (sheet.get('colCount') || DEFAULT_COLS)) { 313 + sheet.set('colCount', parsed.colCount); 314 + } 235 315 } 236 316 }); 237 317 238 318 evalCache.clear(); 319 + if (renderSheetTabs) renderSheetTabs(); 239 320 renderGrid(); 240 321 241 - showToast(`Imported "${parsed.name}" — ${parsed.cells.size} cells`, 3000); 322 + const sheetWord = sheets.length === 1 ? 'sheet' : 'sheets'; 323 + showToast(`Imported ${sheets.length} ${sheetWord} \u2014 ${totalCells} cells`, 3000); 242 324 } catch (err) { 243 325 console.error('xlsx import error:', err); 244 - showToast('Failed to import .xlsx file — it may be corrupt or unsupported', 5000); 326 + showToast('Failed to import .xlsx file \u2014 it may be corrupt or unsupported', 5000); 245 327 } 246 328 }
+211
tests/clipboard-copy.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + buildCopyHtml, 4 + buildCopyTsv, 5 + cellStyleToCss, 6 + } from '../src/sheets/clipboard-copy.js'; 7 + 8 + // Helper: create a mock getCellData function from a map 9 + function mockGetCellData(map) { 10 + return (id) => map[id] || null; 11 + } 12 + 13 + // Helper: simple cellId function matching the codebase pattern 14 + function cellId(col, row) { 15 + let s = ''; 16 + let n = col; 17 + while (n > 0) { 18 + n--; 19 + s = String.fromCharCode(65 + (n % 26)) + s; 20 + n = Math.floor(n / 26); 21 + } 22 + return s + row; 23 + } 24 + 25 + // ============================================================ 26 + // cellStyleToCss — convert CellStyle to inline CSS 27 + // ============================================================ 28 + 29 + describe('cellStyleToCss', () => { 30 + it('returns empty string for null/undefined', () => { 31 + expect(cellStyleToCss(null)).toBe(''); 32 + expect(cellStyleToCss(undefined)).toBe(''); 33 + }); 34 + 35 + it('returns empty string for empty object', () => { 36 + expect(cellStyleToCss({})).toBe(''); 37 + }); 38 + 39 + it('converts bg to background-color', () => { 40 + expect(cellStyleToCss({ bg: '#ff0000' })).toBe('background-color:#ff0000'); 41 + }); 42 + 43 + it('converts color to color', () => { 44 + expect(cellStyleToCss({ color: '#333' })).toBe('color:#333'); 45 + }); 46 + 47 + it('converts bold to font-weight:bold', () => { 48 + expect(cellStyleToCss({ bold: true })).toBe('font-weight:bold'); 49 + }); 50 + 51 + it('does not include bold when false', () => { 52 + expect(cellStyleToCss({ bold: false })).toBe(''); 53 + }); 54 + 55 + it('converts italic to font-style:italic', () => { 56 + expect(cellStyleToCss({ italic: true })).toBe('font-style:italic'); 57 + }); 58 + 59 + it('converts underline to text-decoration:underline', () => { 60 + expect(cellStyleToCss({ underline: true })).toBe('text-decoration:underline'); 61 + }); 62 + 63 + it('converts strikethrough to text-decoration:line-through', () => { 64 + expect(cellStyleToCss({ strikethrough: true })).toBe('text-decoration:line-through'); 65 + }); 66 + 67 + it('combines underline and strikethrough', () => { 68 + const css = cellStyleToCss({ underline: true, strikethrough: true }); 69 + expect(css).toBe('text-decoration:underline line-through'); 70 + }); 71 + 72 + it('converts fontSize to font-size in pt', () => { 73 + expect(cellStyleToCss({ fontSize: 14 })).toBe('font-size:14pt'); 74 + }); 75 + 76 + it('converts align to text-align', () => { 77 + expect(cellStyleToCss({ align: 'center' })).toBe('text-align:center'); 78 + }); 79 + 80 + it('combines multiple properties', () => { 81 + const css = cellStyleToCss({ 82 + bg: '#eee', 83 + color: '#000', 84 + bold: true, 85 + italic: true, 86 + fontSize: 12, 87 + align: 'right', 88 + }); 89 + expect(css).toContain('background-color:#eee'); 90 + expect(css).toContain('color:#000'); 91 + expect(css).toContain('font-weight:bold'); 92 + expect(css).toContain('font-style:italic'); 93 + expect(css).toContain('font-size:12pt'); 94 + expect(css).toContain('text-align:right'); 95 + }); 96 + }); 97 + 98 + // ============================================================ 99 + // buildCopyHtml — generate HTML table from selection 100 + // ============================================================ 101 + 102 + describe('buildCopyHtml', () => { 103 + it('builds a simple 1x1 table', () => { 104 + const data = { A1: { v: 'hello', f: '', s: {} } }; 105 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 106 + expect(html).toContain('<table>'); 107 + expect(html).toContain('<tr>'); 108 + expect(html).toContain('<td>hello</td>'); 109 + expect(html).toContain('</table>'); 110 + }); 111 + 112 + it('builds a 2x2 table', () => { 113 + const data = { 114 + A1: { v: 1, f: '', s: {} }, 115 + B1: { v: 2, f: '', s: {} }, 116 + A2: { v: 3, f: '', s: {} }, 117 + B2: { v: 4, f: '', s: {} }, 118 + }; 119 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 2, endRow: 2 }, cellId); 120 + expect(html).toContain('<tr><td>1</td><td>2</td></tr>'); 121 + expect(html).toContain('<tr><td>3</td><td>4</td></tr>'); 122 + }); 123 + 124 + it('includes inline styles', () => { 125 + const data = { A1: { v: 'styled', f: '', s: { bold: true, bg: '#ff0' } } }; 126 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 127 + expect(html).toContain('style='); 128 + expect(html).toContain('font-weight:bold'); 129 + expect(html).toContain('background-color:#ff0'); 130 + }); 131 + 132 + it('uses formula as value when present', () => { 133 + const data = { A1: { v: 42, f: 'SUM(A1:A5)', s: {} } }; 134 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 135 + expect(html).toContain('=SUM(A1:A5)'); 136 + }); 137 + 138 + it('handles null cells gracefully', () => { 139 + const data = {}; 140 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 141 + expect(html).toContain('<td></td>'); 142 + }); 143 + 144 + it('escapes HTML entities in values', () => { 145 + const data = { A1: { v: '<script>alert(1)</script>', f: '', s: {} } }; 146 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 147 + expect(html).toContain('&lt;script&gt;'); 148 + expect(html).not.toContain('<script>'); 149 + }); 150 + 151 + it('does not add style attr when no styles', () => { 152 + const data = { A1: { v: 'plain', f: '', s: {} } }; 153 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 154 + expect(html).toContain('<td>plain</td>'); 155 + expect(html).not.toContain('style='); 156 + }); 157 + }); 158 + 159 + // ============================================================ 160 + // buildCopyTsv — generate TSV from selection 161 + // ============================================================ 162 + 163 + describe('buildCopyTsv', () => { 164 + it('builds a single cell TSV', () => { 165 + const data = { A1: { v: 'hello', f: '', s: {} } }; 166 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 167 + expect(tsv).toBe('hello'); 168 + }); 169 + 170 + it('builds a 2x2 TSV', () => { 171 + const data = { 172 + A1: { v: 1, f: '', s: {} }, 173 + B1: { v: 2, f: '', s: {} }, 174 + A2: { v: 3, f: '', s: {} }, 175 + B2: { v: 4, f: '', s: {} }, 176 + }; 177 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 2, endRow: 2 }, cellId); 178 + expect(tsv).toBe('1\t2\n3\t4'); 179 + }); 180 + 181 + it('uses formula prefixed with = when present', () => { 182 + const data = { A1: { v: 42, f: 'A2+B2', s: {} } }; 183 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 184 + expect(tsv).toBe('=A2+B2'); 185 + }); 186 + 187 + it('handles null cells as empty', () => { 188 + const data = {}; 189 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 2, endRow: 1 }, cellId); 190 + expect(tsv).toBe('\t'); 191 + }); 192 + 193 + it('escapes tabs in cell values', () => { 194 + const data = { A1: { v: 'has\ttab', f: '', s: {} } }; 195 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 196 + expect(tsv).toBe('has tab'); 197 + expect(tsv).not.toContain('\t'); 198 + }); 199 + 200 + it('escapes newlines in cell values', () => { 201 + const data = { A1: { v: 'line1\nline2', f: '', s: {} } }; 202 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 203 + expect(tsv).toBe('line1 line2'); 204 + }); 205 + 206 + it('handles numeric values correctly', () => { 207 + const data = { A1: { v: 3.14159, f: '', s: {} } }; 208 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 209 + expect(tsv).toBe('3.14159'); 210 + }); 211 + });
+417
tests/clipboard-paste.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect } from 'vitest'; 3 + import { 4 + parseClipboardHtml, 5 + parseClipboardTsv, 6 + parseInlineStyles, 7 + } from '../src/sheets/clipboard-paste.js'; 8 + 9 + // ============================================================ 10 + // parseInlineStyles — extract CSS into CellStyle 11 + // ============================================================ 12 + 13 + describe('parseInlineStyles', () => { 14 + it('returns empty object for empty string', () => { 15 + expect(parseInlineStyles('')).toEqual({}); 16 + }); 17 + 18 + it('returns empty object for null/undefined', () => { 19 + expect(parseInlineStyles(null)).toEqual({}); 20 + expect(parseInlineStyles(undefined)).toEqual({}); 21 + }); 22 + 23 + it('parses background-color hex', () => { 24 + const style = parseInlineStyles('background-color: #ff0000'); 25 + expect(style.bg).toBe('#ff0000'); 26 + }); 27 + 28 + it('parses background shorthand', () => { 29 + const style = parseInlineStyles('background: #00ff00'); 30 + expect(style.bg).toBe('#00ff00'); 31 + }); 32 + 33 + it('parses background-color rgb', () => { 34 + const style = parseInlineStyles('background-color: rgb(255, 0, 128)'); 35 + expect(style.bg).toBe('#ff0080'); 36 + }); 37 + 38 + it('parses text color', () => { 39 + const style = parseInlineStyles('color: #336699'); 40 + expect(style.color).toBe('#336699'); 41 + }); 42 + 43 + it('parses color as rgb()', () => { 44 + const style = parseInlineStyles('color: rgb(0, 0, 0)'); 45 + expect(style.color).toBe('#000000'); 46 + }); 47 + 48 + it('parses font-weight:bold', () => { 49 + const style = parseInlineStyles('font-weight: bold'); 50 + expect(style.bold).toBe(true); 51 + }); 52 + 53 + it('parses font-weight:700', () => { 54 + const style = parseInlineStyles('font-weight: 700'); 55 + expect(style.bold).toBe(true); 56 + }); 57 + 58 + it('does not set bold for normal weight', () => { 59 + const style = parseInlineStyles('font-weight: normal'); 60 + expect(style.bold).toBeUndefined(); 61 + }); 62 + 63 + it('parses font-style:italic', () => { 64 + const style = parseInlineStyles('font-style: italic'); 65 + expect(style.italic).toBe(true); 66 + }); 67 + 68 + it('does not set italic for normal', () => { 69 + const style = parseInlineStyles('font-style: normal'); 70 + expect(style.italic).toBeUndefined(); 71 + }); 72 + 73 + it('parses text-decoration:underline', () => { 74 + const style = parseInlineStyles('text-decoration: underline'); 75 + expect(style.underline).toBe(true); 76 + }); 77 + 78 + it('parses text-decoration:line-through', () => { 79 + const style = parseInlineStyles('text-decoration: line-through'); 80 + expect(style.strikethrough).toBe(true); 81 + }); 82 + 83 + it('parses combined text-decoration (underline + line-through)', () => { 84 + const style = parseInlineStyles('text-decoration: underline line-through'); 85 + expect(style.underline).toBe(true); 86 + expect(style.strikethrough).toBe(true); 87 + }); 88 + 89 + it('parses font-size in pt', () => { 90 + const style = parseInlineStyles('font-size: 12pt'); 91 + expect(style.fontSize).toBe(12); 92 + }); 93 + 94 + it('parses font-size with decimal pt', () => { 95 + const style = parseInlineStyles('font-size: 10.5pt'); 96 + expect(style.fontSize).toBe(10.5); 97 + }); 98 + 99 + it('ignores font-size in px (not pt)', () => { 100 + const style = parseInlineStyles('font-size: 16px'); 101 + expect(style.fontSize).toBeUndefined(); 102 + }); 103 + 104 + it('parses text-align', () => { 105 + expect(parseInlineStyles('text-align: center').align).toBe('center'); 106 + expect(parseInlineStyles('text-align: left').align).toBe('left'); 107 + expect(parseInlineStyles('text-align: right').align).toBe('right'); 108 + }); 109 + 110 + it('ignores unsupported text-align values', () => { 111 + const style = parseInlineStyles('text-align: justify'); 112 + expect(style.align).toBeUndefined(); 113 + }); 114 + 115 + it('parses multiple properties', () => { 116 + const style = parseInlineStyles( 117 + 'background-color: #eee; color: #333; font-weight: bold; font-style: italic; font-size: 14pt; text-align: right' 118 + ); 119 + expect(style.bg).toBe('#eeeeee'); 120 + expect(style.color).toBe('#333333'); 121 + expect(style.bold).toBe(true); 122 + expect(style.italic).toBe(true); 123 + expect(style.fontSize).toBe(14); 124 + expect(style.align).toBe('right'); 125 + }); 126 + 127 + it('normalizes 3-char hex to 6-char', () => { 128 + const style = parseInlineStyles('color: #abc'); 129 + expect(style.color).toBe('#aabbcc'); 130 + }); 131 + 132 + it('strips alpha from 8-char hex', () => { 133 + const style = parseInlineStyles('background-color: #ff000080'); 134 + expect(style.bg).toBe('#ff0000'); 135 + }); 136 + }); 137 + 138 + // ============================================================ 139 + // parseClipboardHtml — parse HTML tables 140 + // ============================================================ 141 + 142 + describe('parseClipboardHtml', () => { 143 + it('returns null for empty/null input', () => { 144 + expect(parseClipboardHtml(null)).toBeNull(); 145 + expect(parseClipboardHtml('')).toBeNull(); 146 + expect(parseClipboardHtml(undefined)).toBeNull(); 147 + }); 148 + 149 + it('returns null for HTML without a table', () => { 150 + expect(parseClipboardHtml('<p>just text</p>')).toBeNull(); 151 + }); 152 + 153 + it('parses a simple 2x2 table', () => { 154 + const html = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'; 155 + const result = parseClipboardHtml(html); 156 + expect(result).not.toBeNull(); 157 + expect(result.rows.length).toBe(2); 158 + expect(result.rows[0].length).toBe(2); 159 + expect(result.rows[0][0].value).toBe('A'); 160 + expect(result.rows[0][1].value).toBe('B'); 161 + expect(result.rows[1][0].value).toBe('C'); 162 + expect(result.rows[1][1].value).toBe('D'); 163 + }); 164 + 165 + it('parses numeric values as numbers', () => { 166 + const html = '<table><tr><td>42</td><td>3.14</td></tr></table>'; 167 + const result = parseClipboardHtml(html); 168 + expect(result.rows[0][0].value).toBe(42); 169 + expect(result.rows[0][1].value).toBe(3.14); 170 + }); 171 + 172 + it('keeps non-numeric strings as strings', () => { 173 + const html = '<table><tr><td>hello</td><td>world</td></tr></table>'; 174 + const result = parseClipboardHtml(html); 175 + expect(result.rows[0][0].value).toBe('hello'); 176 + expect(result.rows[0][1].value).toBe('world'); 177 + }); 178 + 179 + it('extracts inline styles from cells', () => { 180 + const html = '<table><tr><td style="background-color:#ff0;font-weight:bold;color:#000">A</td></tr></table>'; 181 + const result = parseClipboardHtml(html); 182 + expect(result.rows[0][0].style.bg).toBe('#ffff00'); 183 + expect(result.rows[0][0].style.bold).toBe(true); 184 + expect(result.rows[0][0].style.color).toBe('#000000'); 185 + }); 186 + 187 + it('detects Google Sheets as source', () => { 188 + const html = '<meta name="google-sheets-html-origin"><table><tr><td>1</td></tr></table>'; 189 + const result = parseClipboardHtml(html); 190 + expect(result.sourceApp).toBe('google-sheets'); 191 + }); 192 + 193 + it('detects Excel as source', () => { 194 + const html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><table><tr><td>1</td></tr></table></html>'; 195 + const result = parseClipboardHtml(html); 196 + expect(result.sourceApp).toBe('excel'); 197 + }); 198 + 199 + it('returns null sourceApp for unknown source', () => { 200 + const html = '<table><tr><td>1</td></tr></table>'; 201 + const result = parseClipboardHtml(html); 202 + expect(result.sourceApp).toBeNull(); 203 + }); 204 + 205 + it('handles single cell table', () => { 206 + const html = '<table><tr><td>only</td></tr></table>'; 207 + const result = parseClipboardHtml(html); 208 + expect(result.rows.length).toBe(1); 209 + expect(result.rows[0].length).toBe(1); 210 + expect(result.rows[0][0].value).toBe('only'); 211 + }); 212 + 213 + it('handles empty cells', () => { 214 + const html = '<table><tr><td></td><td>B</td></tr></table>'; 215 + const result = parseClipboardHtml(html); 216 + expect(result.rows[0][0].value).toBe(''); 217 + expect(result.rows[0][1].value).toBe('B'); 218 + }); 219 + 220 + it('handles colspan by filling extra cells', () => { 221 + const html = '<table><tr><td colspan="3">merged</td></tr></table>'; 222 + const result = parseClipboardHtml(html); 223 + expect(result.rows[0].length).toBe(3); 224 + expect(result.rows[0][0].value).toBe('merged'); 225 + expect(result.rows[0][1].value).toBe(''); 226 + expect(result.rows[0][2].value).toBe(''); 227 + }); 228 + 229 + it('handles th elements the same as td', () => { 230 + const html = '<table><tr><th>Header</th><td>Data</td></tr></table>'; 231 + const result = parseClipboardHtml(html); 232 + expect(result.rows[0][0].value).toBe('Header'); 233 + expect(result.rows[0][1].value).toBe('Data'); 234 + }); 235 + 236 + it('detects formula from data-formula attribute', () => { 237 + const html = '<table><tr><td data-formula="=A1+B1">3</td></tr></table>'; 238 + const result = parseClipboardHtml(html); 239 + expect(result.rows[0][0].formula).toBe('A1+B1'); 240 + expect(result.rows[0][0].value).toBe('3'); 241 + }); 242 + 243 + it('detects formula from value starting with =', () => { 244 + const html = '<table><tr><td>=SUM(A1:A5)</td></tr></table>'; 245 + const result = parseClipboardHtml(html); 246 + expect(result.rows[0][0].formula).toBe('SUM(A1:A5)'); 247 + }); 248 + 249 + it('parses Google Sheets HTML with rich formatting', () => { 250 + const html = ` 251 + <meta name="google-sheets-html-origin"> 252 + <table> 253 + <tr> 254 + <td style="background-color:#4a86e8;color:#ffffff;font-weight:bold;font-size:12pt;text-align:center">Revenue</td> 255 + <td style="font-style:italic;text-decoration:underline">Notes</td> 256 + </tr> 257 + <tr> 258 + <td>1000</td> 259 + <td style="text-decoration:line-through">old data</td> 260 + </tr> 261 + </table> 262 + `; 263 + const result = parseClipboardHtml(html); 264 + expect(result.sourceApp).toBe('google-sheets'); 265 + expect(result.rows.length).toBe(2); 266 + 267 + const header = result.rows[0][0]; 268 + expect(header.value).toBe('Revenue'); 269 + expect(header.style.bg).toBe('#4a86e8'); 270 + expect(header.style.color).toBe('#ffffff'); 271 + expect(header.style.bold).toBe(true); 272 + expect(header.style.fontSize).toBe(12); 273 + expect(header.style.align).toBe('center'); 274 + 275 + const notes = result.rows[0][1]; 276 + expect(notes.style.italic).toBe(true); 277 + expect(notes.style.underline).toBe(true); 278 + 279 + const oldData = result.rows[1][1]; 280 + expect(oldData.style.strikethrough).toBe(true); 281 + 282 + expect(result.rows[1][0].value).toBe(1000); 283 + }); 284 + 285 + it('parses Excel HTML with rich formatting', () => { 286 + const html = ` 287 + <html xmlns:x="urn:schemas-microsoft-com:office:excel"> 288 + <table> 289 + <tr> 290 + <td style="background-color:rgb(255,255,0);font-weight:700;text-align:right">Total</td> 291 + <td style="color:rgb(255,0,0)">-50</td> 292 + </tr> 293 + </table> 294 + </html> 295 + `; 296 + const result = parseClipboardHtml(html); 297 + expect(result.sourceApp).toBe('excel'); 298 + 299 + const total = result.rows[0][0]; 300 + expect(total.value).toBe('Total'); 301 + expect(total.style.bg).toBe('#ffff00'); 302 + expect(total.style.bold).toBe(true); 303 + expect(total.style.align).toBe('right'); 304 + 305 + const neg = result.rows[0][1]; 306 + expect(neg.value).toBe(-50); 307 + expect(neg.style.color).toBe('#ff0000'); 308 + }); 309 + 310 + it('returns null for table with no rows', () => { 311 + const html = '<table></table>'; 312 + const result = parseClipboardHtml(html); 313 + expect(result).toBeNull(); 314 + }); 315 + 316 + it('handles data-formula without leading =', () => { 317 + const html = '<table><tr><td data-formula="SUM(A1:A5)">15</td></tr></table>'; 318 + const result = parseClipboardHtml(html); 319 + expect(result.rows[0][0].formula).toBe('SUM(A1:A5)'); 320 + }); 321 + }); 322 + 323 + // ============================================================ 324 + // parseClipboardTsv — parse tab-separated values 325 + // ============================================================ 326 + 327 + describe('parseClipboardTsv', () => { 328 + it('returns null for empty/null input', () => { 329 + expect(parseClipboardTsv(null)).toBeNull(); 330 + expect(parseClipboardTsv('')).toBeNull(); 331 + expect(parseClipboardTsv(undefined)).toBeNull(); 332 + }); 333 + 334 + it('parses a single value', () => { 335 + const result = parseClipboardTsv('hello'); 336 + expect(result.rows.length).toBe(1); 337 + expect(result.rows[0].length).toBe(1); 338 + expect(result.rows[0][0].value).toBe('hello'); 339 + }); 340 + 341 + it('parses tab-separated columns', () => { 342 + const result = parseClipboardTsv('A\tB\tC'); 343 + expect(result.rows[0].length).toBe(3); 344 + expect(result.rows[0][0].value).toBe('A'); 345 + expect(result.rows[0][1].value).toBe('B'); 346 + expect(result.rows[0][2].value).toBe('C'); 347 + }); 348 + 349 + it('parses multiple rows', () => { 350 + const result = parseClipboardTsv('1\t2\n3\t4'); 351 + expect(result.rows.length).toBe(2); 352 + expect(result.rows[0][0].value).toBe(1); 353 + expect(result.rows[0][1].value).toBe(2); 354 + expect(result.rows[1][0].value).toBe(3); 355 + expect(result.rows[1][1].value).toBe(4); 356 + }); 357 + 358 + it('parses numbers', () => { 359 + const result = parseClipboardTsv('42\t3.14\t-7'); 360 + expect(result.rows[0][0].value).toBe(42); 361 + expect(result.rows[0][1].value).toBe(3.14); 362 + expect(result.rows[0][2].value).toBe(-7); 363 + }); 364 + 365 + it('keeps non-numeric strings', () => { 366 + const result = parseClipboardTsv('abc\t123abc'); 367 + expect(result.rows[0][0].value).toBe('abc'); 368 + expect(result.rows[0][1].value).toBe('123abc'); 369 + }); 370 + 371 + it('handles empty cells', () => { 372 + const result = parseClipboardTsv('\tB\t\n\t\t'); 373 + expect(result.rows[0][0].value).toBe(''); 374 + expect(result.rows[0][1].value).toBe('B'); 375 + expect(result.rows[0][2].value).toBe(''); 376 + expect(result.rows[1][0].value).toBe(''); 377 + }); 378 + 379 + it('detects formulas starting with =', () => { 380 + const result = parseClipboardTsv('=SUM(A1:A5)\t=B1+B2'); 381 + expect(result.rows[0][0].formula).toBe('SUM(A1:A5)'); 382 + expect(result.rows[0][1].formula).toBe('B1+B2'); 383 + }); 384 + 385 + it('handles Windows-style line endings (\\r\\n)', () => { 386 + const result = parseClipboardTsv('A\tB\r\nC\tD'); 387 + expect(result.rows.length).toBe(2); 388 + expect(result.rows[0][0].value).toBe('A'); 389 + expect(result.rows[1][0].value).toBe('C'); 390 + }); 391 + 392 + it('strips trailing empty line', () => { 393 + const result = parseClipboardTsv('A\tB\n'); 394 + expect(result.rows.length).toBe(1); 395 + expect(result.rows[0][0].value).toBe('A'); 396 + expect(result.rows[0][1].value).toBe('B'); 397 + }); 398 + 399 + it('returns null sourceApp', () => { 400 + const result = parseClipboardTsv('test'); 401 + expect(result.sourceApp).toBeNull(); 402 + }); 403 + 404 + it('styles are always empty objects', () => { 405 + const result = parseClipboardTsv('A\tB'); 406 + expect(result.rows[0][0].style).toEqual({}); 407 + expect(result.rows[0][1].style).toEqual({}); 408 + }); 409 + 410 + it('treats single newline as one row with one empty cell', () => { 411 + const result = parseClipboardTsv('\n'); 412 + // Trailing newline is stripped, leaving one line with empty value 413 + expect(result).not.toBeNull(); 414 + expect(result.rows.length).toBe(1); 415 + expect(result.rows[0][0].value).toBe(''); 416 + }); 417 + });
+252
tests/csv-export.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { exportToCsv, escapeField } from '../src/sheets/csv-export.js'; 3 + 4 + // UTF-8 BOM character 5 + const BOM = '\uFEFF'; 6 + 7 + /** 8 + * Helper: create a getCellValue function from a 2D array. 9 + * Array is 0-indexed; getCellValue is 1-based (row, col). 10 + */ 11 + function gridFromArray(data) { 12 + return (row, col) => { 13 + const r = row - 1; 14 + const c = col - 1; 15 + if (r < 0 || r >= data.length) return ''; 16 + if (c < 0 || c >= data[r].length) return ''; 17 + return String(data[r][c] ?? ''); 18 + }; 19 + } 20 + 21 + // ============================================================ 22 + // escapeField 23 + // ============================================================ 24 + describe('CSV Export — escapeField', () => { 25 + it('returns empty string for empty input', () => { 26 + expect(escapeField('', ',')).toBe(''); 27 + }); 28 + 29 + it('returns plain string unchanged when no special chars', () => { 30 + expect(escapeField('hello', ',')).toBe('hello'); 31 + }); 32 + 33 + it('quotes fields containing the comma delimiter', () => { 34 + expect(escapeField('a,b', ',')).toBe('"a,b"'); 35 + }); 36 + 37 + it('quotes fields containing double quotes and escapes them', () => { 38 + expect(escapeField('say "hi"', ',')).toBe('"say ""hi"""'); 39 + }); 40 + 41 + it('quotes fields containing newlines', () => { 42 + expect(escapeField('line1\nline2', ',')).toBe('"line1\nline2"'); 43 + }); 44 + 45 + it('quotes fields containing carriage returns', () => { 46 + expect(escapeField('line1\rline2', ',')).toBe('"line1\rline2"'); 47 + }); 48 + 49 + it('handles tab delimiter — does not quote commas when delimiter is tab', () => { 50 + expect(escapeField('a,b', '\t')).toBe('a,b'); 51 + }); 52 + 53 + it('quotes fields containing tab when delimiter is tab', () => { 54 + expect(escapeField('a\tb', '\t')).toBe('"a\tb"'); 55 + }); 56 + 57 + it('handles field with all special characters', () => { 58 + expect(escapeField('a,"b"\nc', ',')).toBe('"a,""b""\nc"'); 59 + }); 60 + }); 61 + 62 + // ============================================================ 63 + // exportToCsv — basic output 64 + // ============================================================ 65 + describe('CSV Export — exportToCsv basic', () => { 66 + it('generates CSV from a simple 2x2 grid', () => { 67 + const data = [['A', 'B'], ['1', '2']]; 68 + const result = exportToCsv(gridFromArray(data), 2, 2); 69 + expect(result).toBe(BOM + 'A,B\r\n1,2'); 70 + }); 71 + 72 + it('starts with UTF-8 BOM', () => { 73 + const data = [['x']]; 74 + const result = exportToCsv(gridFromArray(data), 1, 1); 75 + expect(result.charCodeAt(0)).toBe(0xFEFF); 76 + }); 77 + 78 + it('uses CRLF line endings (RFC 4180)', () => { 79 + const data = [['a'], ['b'], ['c']]; 80 + const result = exportToCsv(gridFromArray(data), 3, 1); 81 + expect(result).toBe(BOM + 'a\r\nb\r\nc'); 82 + }); 83 + 84 + it('handles empty cells', () => { 85 + const data = [['', 'B'], ['', '']]; 86 + const result = exportToCsv(gridFromArray(data), 2, 2); 87 + expect(result).toBe(BOM + ',B\r\n,'); 88 + }); 89 + 90 + it('handles single cell', () => { 91 + const data = [['only']]; 92 + const result = exportToCsv(gridFromArray(data), 1, 1); 93 + expect(result).toBe(BOM + 'only'); 94 + }); 95 + 96 + it('handles numeric values', () => { 97 + const data = [['42', '3.14']]; 98 + const result = exportToCsv(gridFromArray(data), 1, 2); 99 + expect(result).toBe(BOM + '42,3.14'); 100 + }); 101 + }); 102 + 103 + // ============================================================ 104 + // exportToCsv — RFC 4180 escaping 105 + // ============================================================ 106 + describe('CSV Export — RFC 4180 escaping', () => { 107 + it('quotes fields with commas', () => { 108 + const data = [['a,b', 'c']]; 109 + const result = exportToCsv(gridFromArray(data), 1, 2); 110 + expect(result).toBe(BOM + '"a,b",c'); 111 + }); 112 + 113 + it('escapes double quotes within fields', () => { 114 + const data = [['say "hello"']]; 115 + const result = exportToCsv(gridFromArray(data), 1, 1); 116 + expect(result).toBe(BOM + '"say ""hello"""'); 117 + }); 118 + 119 + it('quotes fields with embedded newlines', () => { 120 + const data = [['line1\nline2']]; 121 + const result = exportToCsv(gridFromArray(data), 1, 1); 122 + expect(result).toBe(BOM + '"line1\nline2"'); 123 + }); 124 + 125 + it('handles field with commas, quotes, and newlines together', () => { 126 + const data = [['a,"b"\nc']]; 127 + const result = exportToCsv(gridFromArray(data), 1, 1); 128 + expect(result).toBe(BOM + '"a,""b""\nc"'); 129 + }); 130 + }); 131 + 132 + // ============================================================ 133 + // exportToCsv — TSV (tab delimiter) 134 + // ============================================================ 135 + describe('CSV Export — TSV variant', () => { 136 + it('uses tab delimiter', () => { 137 + const data = [['A', 'B'], ['1', '2']]; 138 + const result = exportToCsv(gridFromArray(data), 2, 2, { delimiter: '\t' }); 139 + expect(result).toBe(BOM + 'A\tB\r\n1\t2'); 140 + }); 141 + 142 + it('does not quote fields with commas in TSV mode', () => { 143 + const data = [['a,b', 'c']]; 144 + const result = exportToCsv(gridFromArray(data), 1, 2, { delimiter: '\t' }); 145 + expect(result).toBe(BOM + 'a,b\tc'); 146 + }); 147 + 148 + it('quotes fields with tabs in TSV mode', () => { 149 + const data = [['a\tb', 'c']]; 150 + const result = exportToCsv(gridFromArray(data), 1, 2, { delimiter: '\t' }); 151 + expect(result).toBe(BOM + '"a\tb"\tc'); 152 + }); 153 + }); 154 + 155 + // ============================================================ 156 + // exportToCsv — includeHeaders option 157 + // ============================================================ 158 + describe('CSV Export — includeHeaders', () => { 159 + it('prepends column letter headers when includeHeaders is true', () => { 160 + const data = [['val1', 'val2']]; 161 + const result = exportToCsv(gridFromArray(data), 1, 2, { includeHeaders: true }); 162 + expect(result).toBe(BOM + 'A,B\r\nval1,val2'); 163 + }); 164 + 165 + it('generates correct headers for 27+ columns (AA, AB, ...)', () => { 166 + const row = []; 167 + for (let i = 0; i < 28; i++) row.push(String(i)); 168 + const result = exportToCsv(gridFromArray([row]), 1, 28, { includeHeaders: true }); 169 + const lines = result.slice(1).split('\r\n'); // skip BOM 170 + const headers = lines[0].split(','); 171 + expect(headers[0]).toBe('A'); 172 + expect(headers[25]).toBe('Z'); 173 + expect(headers[26]).toBe('AA'); 174 + expect(headers[27]).toBe('AB'); 175 + }); 176 + }); 177 + 178 + // ============================================================ 179 + // exportToCsv — range export 180 + // ============================================================ 181 + describe('CSV Export — range export', () => { 182 + it('exports only the specified range', () => { 183 + const data = [ 184 + ['A1', 'B1', 'C1'], 185 + ['A2', 'B2', 'C2'], 186 + ['A3', 'B3', 'C3'], 187 + ]; 188 + const result = exportToCsv(gridFromArray(data), 3, 3, { 189 + range: { startRow: 2, endRow: 3, startCol: 2, endCol: 3 }, 190 + }); 191 + expect(result).toBe(BOM + 'B2,C2\r\nB3,C3'); 192 + }); 193 + 194 + it('exports a single cell range', () => { 195 + const data = [['A1', 'B1'], ['A2', 'B2']]; 196 + const result = exportToCsv(gridFromArray(data), 2, 2, { 197 + range: { startRow: 1, endRow: 1, startCol: 2, endCol: 2 }, 198 + }); 199 + expect(result).toBe(BOM + 'B1'); 200 + }); 201 + 202 + it('exports full range when range is null', () => { 203 + const data = [['X', 'Y']]; 204 + const result = exportToCsv(gridFromArray(data), 1, 2, { range: null }); 205 + expect(result).toBe(BOM + 'X,Y'); 206 + }); 207 + 208 + it('combines range with includeHeaders', () => { 209 + const data = [['A1', 'B1'], ['A2', 'B2']]; 210 + const result = exportToCsv(gridFromArray(data), 2, 2, { 211 + range: { startRow: 1, endRow: 2, startCol: 2, endCol: 2 }, 212 + includeHeaders: true, 213 + }); 214 + expect(result).toBe(BOM + 'B\r\nB1\r\nB2'); 215 + }); 216 + }); 217 + 218 + // ============================================================ 219 + // exportToCsv — edge cases 220 + // ============================================================ 221 + describe('CSV Export — edge cases', () => { 222 + it('handles a completely empty grid', () => { 223 + const result = exportToCsv(() => '', 2, 2); 224 + // All cells empty => two rows of "," 225 + expect(result).toBe(BOM + ',\r\n,'); 226 + }); 227 + 228 + it('preserves leading/trailing whitespace in values', () => { 229 + const data = [[' spaced ']]; 230 + const result = exportToCsv(gridFromArray(data), 1, 1); 231 + expect(result).toBe(BOM + ' spaced '); 232 + }); 233 + 234 + it('handles unicode characters', () => { 235 + const data = [['\u00e9', '\u00fc']]; 236 + const result = exportToCsv(gridFromArray(data), 1, 2); 237 + expect(result).toBe(BOM + '\u00e9,\u00fc'); 238 + }); 239 + 240 + it('handles formulas as string values', () => { 241 + const data = [['=SUM(A1:A10)']]; 242 + const result = exportToCsv(gridFromArray(data), 1, 1); 243 + expect(result).toBe(BOM + '=SUM(A1:A10)'); 244 + }); 245 + 246 + it('handles very long string values', () => { 247 + const longStr = 'x'.repeat(10000); 248 + const data = [[longStr]]; 249 + const result = exportToCsv(gridFromArray(data), 1, 1); 250 + expect(result).toBe(BOM + longStr); 251 + }); 252 + });
+311
tests/drop-overlay.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 3 + import { getFileExtension, isAcceptedFile, createDropOverlay } from '../src/drop-overlay.js'; 4 + 5 + // --- getFileExtension --- 6 + 7 + describe('getFileExtension', () => { 8 + it('returns the extension with dot for normal filenames', () => { 9 + expect(getFileExtension('report.docx')).toBe('.docx'); 10 + expect(getFileExtension('data.csv')).toBe('.csv'); 11 + expect(getFileExtension('image.png')).toBe('.png'); 12 + }); 13 + 14 + it('is case-insensitive (returns lowercase)', () => { 15 + expect(getFileExtension('FILE.DOCX')).toBe('.docx'); 16 + expect(getFileExtension('Image.PNG')).toBe('.png'); 17 + expect(getFileExtension('Sheet.XLSX')).toBe('.xlsx'); 18 + }); 19 + 20 + it('handles filenames with multiple dots', () => { 21 + expect(getFileExtension('my.report.final.docx')).toBe('.docx'); 22 + expect(getFileExtension('q1.budget.2026.xlsx')).toBe('.xlsx'); 23 + }); 24 + 25 + it('returns null for filenames without extensions', () => { 26 + expect(getFileExtension('README')).toBeNull(); 27 + }); 28 + 29 + it('returns null for empty or invalid input', () => { 30 + expect(getFileExtension('')).toBeNull(); 31 + expect(getFileExtension(null)).toBeNull(); 32 + expect(getFileExtension(undefined)).toBeNull(); 33 + expect(getFileExtension(123)).toBeNull(); 34 + }); 35 + 36 + it('returns null for filenames ending with a dot', () => { 37 + expect(getFileExtension('file.')).toBeNull(); 38 + }); 39 + }); 40 + 41 + // --- isAcceptedFile --- 42 + 43 + describe('isAcceptedFile', () => { 44 + const docExtensions = ['.docx', '.md', '.txt']; 45 + const sheetExtensions = ['.xlsx', '.csv', '.tsv']; 46 + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']; 47 + 48 + it('returns true for accepted doc extensions', () => { 49 + expect(isAcceptedFile('report.docx', docExtensions)).toBe(true); 50 + expect(isAcceptedFile('notes.md', docExtensions)).toBe(true); 51 + expect(isAcceptedFile('readme.txt', docExtensions)).toBe(true); 52 + }); 53 + 54 + it('returns true for accepted sheet extensions', () => { 55 + expect(isAcceptedFile('budget.xlsx', sheetExtensions)).toBe(true); 56 + expect(isAcceptedFile('data.csv', sheetExtensions)).toBe(true); 57 + expect(isAcceptedFile('export.tsv', sheetExtensions)).toBe(true); 58 + }); 59 + 60 + it('returns true for accepted image extensions', () => { 61 + expect(isAcceptedFile('photo.png', imageExtensions)).toBe(true); 62 + expect(isAcceptedFile('photo.jpg', imageExtensions)).toBe(true); 63 + expect(isAcceptedFile('photo.jpeg', imageExtensions)).toBe(true); 64 + expect(isAcceptedFile('anim.gif', imageExtensions)).toBe(true); 65 + expect(isAcceptedFile('logo.webp', imageExtensions)).toBe(true); 66 + expect(isAcceptedFile('icon.svg', imageExtensions)).toBe(true); 67 + }); 68 + 69 + it('returns false for non-accepted extensions', () => { 70 + expect(isAcceptedFile('image.png', docExtensions)).toBe(false); 71 + expect(isAcceptedFile('report.docx', sheetExtensions)).toBe(false); 72 + expect(isAcceptedFile('archive.zip', docExtensions)).toBe(false); 73 + }); 74 + 75 + it('is case-insensitive', () => { 76 + expect(isAcceptedFile('FILE.DOCX', docExtensions)).toBe(true); 77 + expect(isAcceptedFile('DATA.CSV', sheetExtensions)).toBe(true); 78 + expect(isAcceptedFile('PHOTO.PNG', imageExtensions)).toBe(true); 79 + }); 80 + 81 + it('returns false for empty or invalid input', () => { 82 + expect(isAcceptedFile('', docExtensions)).toBe(false); 83 + expect(isAcceptedFile(null, docExtensions)).toBe(false); 84 + expect(isAcceptedFile(undefined, docExtensions)).toBe(false); 85 + }); 86 + 87 + it('returns false for filenames with no extension', () => { 88 + expect(isAcceptedFile('README', docExtensions)).toBe(false); 89 + }); 90 + }); 91 + 92 + // --- createDropOverlay --- 93 + 94 + describe('createDropOverlay', () => { 95 + let container; 96 + 97 + beforeEach(() => { 98 + container = document.createElement('div'); 99 + document.body.appendChild(container); 100 + }); 101 + 102 + afterEach(() => { 103 + container.remove(); 104 + }); 105 + 106 + function createDragEvent(type, files = []) { 107 + const event = new Event(type, { bubbles: true, cancelable: true }); 108 + event.dataTransfer = { 109 + files, 110 + dropEffect: '', 111 + }; 112 + event.preventDefault = vi.fn(); 113 + return event; 114 + } 115 + 116 + it('shows overlay on dragenter', () => { 117 + createDropOverlay(container, { 118 + acceptedExtensions: ['.docx'], 119 + onDrop: vi.fn(), 120 + }); 121 + 122 + container.dispatchEvent(createDragEvent('dragenter')); 123 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 124 + }); 125 + 126 + it('hides overlay on dragleave', () => { 127 + createDropOverlay(container, { 128 + acceptedExtensions: ['.docx'], 129 + onDrop: vi.fn(), 130 + }); 131 + 132 + container.dispatchEvent(createDragEvent('dragenter')); 133 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 134 + 135 + container.dispatchEvent(createDragEvent('dragleave')); 136 + expect(container.querySelector('.drop-overlay')).toBeNull(); 137 + }); 138 + 139 + it('handles nested drag enter/leave with counter pattern', () => { 140 + createDropOverlay(container, { 141 + acceptedExtensions: ['.docx'], 142 + onDrop: vi.fn(), 143 + }); 144 + 145 + // Enter parent 146 + container.dispatchEvent(createDragEvent('dragenter')); 147 + // Enter child (counter = 2) 148 + container.dispatchEvent(createDragEvent('dragenter')); 149 + // Leave child (counter = 1) — overlay still visible 150 + container.dispatchEvent(createDragEvent('dragleave')); 151 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 152 + 153 + // Leave parent (counter = 0) — overlay removed 154 + container.dispatchEvent(createDragEvent('dragleave')); 155 + expect(container.querySelector('.drop-overlay')).toBeNull(); 156 + }); 157 + 158 + it('calls onDrop with the file for accepted extensions', () => { 159 + const onDrop = vi.fn(); 160 + createDropOverlay(container, { 161 + acceptedExtensions: ['.docx', '.md'], 162 + onDrop, 163 + }); 164 + 165 + const file = new File(['content'], 'report.docx', { type: 'application/vnd.openxmlformats' }); 166 + container.dispatchEvent(createDragEvent('drop', [file])); 167 + 168 + expect(onDrop).toHaveBeenCalledWith(file); 169 + }); 170 + 171 + it('does not call onDrop for rejected extensions', () => { 172 + const onDrop = vi.fn(); 173 + createDropOverlay(container, { 174 + acceptedExtensions: ['.docx'], 175 + onDrop, 176 + }); 177 + 178 + const file = new File(['content'], 'image.png', { type: 'image/png' }); 179 + container.dispatchEvent(createDragEvent('drop', [file])); 180 + 181 + expect(onDrop).not.toHaveBeenCalled(); 182 + }); 183 + 184 + it('calls onReject with a message for rejected files', () => { 185 + const onReject = vi.fn(); 186 + createDropOverlay(container, { 187 + acceptedExtensions: ['.docx'], 188 + onDrop: vi.fn(), 189 + onReject, 190 + }); 191 + 192 + const file = new File(['content'], 'image.png', { type: 'image/png' }); 193 + container.dispatchEvent(createDragEvent('drop', [file])); 194 + 195 + expect(onReject).toHaveBeenCalledWith('Unsupported file type: .png', 4000); 196 + }); 197 + 198 + it('hides overlay after drop', () => { 199 + createDropOverlay(container, { 200 + acceptedExtensions: ['.docx'], 201 + onDrop: vi.fn(), 202 + }); 203 + 204 + container.dispatchEvent(createDragEvent('dragenter')); 205 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 206 + 207 + const file = new File(['content'], 'report.docx', { type: 'application/vnd.openxmlformats' }); 208 + container.dispatchEvent(createDragEvent('drop', [file])); 209 + expect(container.querySelector('.drop-overlay')).toBeNull(); 210 + }); 211 + 212 + it('sets dropEffect to copy on dragover', () => { 213 + createDropOverlay(container, { 214 + acceptedExtensions: ['.docx'], 215 + onDrop: vi.fn(), 216 + }); 217 + 218 + const event = createDragEvent('dragover'); 219 + container.dispatchEvent(event); 220 + expect(event.dataTransfer.dropEffect).toBe('copy'); 221 + }); 222 + 223 + it('prevents default on dragover and drop events', () => { 224 + createDropOverlay(container, { 225 + acceptedExtensions: ['.docx'], 226 + onDrop: vi.fn(), 227 + }); 228 + 229 + const dragoverEvent = createDragEvent('dragover'); 230 + container.dispatchEvent(dragoverEvent); 231 + expect(dragoverEvent.preventDefault).toHaveBeenCalled(); 232 + 233 + const file = new File(['x'], 'test.docx'); 234 + const dropEvent = createDragEvent('drop', [file]); 235 + container.dispatchEvent(dropEvent); 236 + expect(dropEvent.preventDefault).toHaveBeenCalled(); 237 + }); 238 + 239 + it('destroy() removes all listeners and hides overlay', () => { 240 + const onDrop = vi.fn(); 241 + const handle = createDropOverlay(container, { 242 + acceptedExtensions: ['.docx'], 243 + onDrop, 244 + }); 245 + 246 + // Show overlay first 247 + container.dispatchEvent(createDragEvent('dragenter')); 248 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 249 + 250 + // Destroy 251 + handle.destroy(); 252 + expect(container.querySelector('.drop-overlay')).toBeNull(); 253 + 254 + // After destroy, events should not trigger onDrop 255 + const file = new File(['content'], 'report.docx'); 256 + container.dispatchEvent(createDragEvent('drop', [file])); 257 + expect(onDrop).not.toHaveBeenCalled(); 258 + }); 259 + 260 + it('shows custom label in overlay', () => { 261 + createDropOverlay(container, { 262 + acceptedExtensions: ['.xlsx'], 263 + onDrop: vi.fn(), 264 + label: 'Drop spreadsheets here', 265 + }); 266 + 267 + container.dispatchEvent(createDragEvent('dragenter')); 268 + const hint = container.querySelector('.drop-overlay-hint'); 269 + expect(hint.textContent).toBe('Drop spreadsheets here'); 270 + }); 271 + 272 + it('shows default label listing extensions when no custom label', () => { 273 + createDropOverlay(container, { 274 + acceptedExtensions: ['.docx', '.md'], 275 + onDrop: vi.fn(), 276 + }); 277 + 278 + container.dispatchEvent(createDragEvent('dragenter')); 279 + const hint = container.querySelector('.drop-overlay-hint'); 280 + expect(hint.textContent).toContain('docx'); 281 + expect(hint.textContent).toContain('md'); 282 + }); 283 + 284 + it('does nothing when drop has no files', () => { 285 + const onDrop = vi.fn(); 286 + const onReject = vi.fn(); 287 + createDropOverlay(container, { 288 + acceptedExtensions: ['.docx'], 289 + onDrop, 290 + onReject, 291 + }); 292 + 293 + container.dispatchEvent(createDragEvent('drop', [])); 294 + expect(onDrop).not.toHaveBeenCalled(); 295 + expect(onReject).not.toHaveBeenCalled(); 296 + }); 297 + 298 + it('only creates one overlay even with multiple dragenter events', () => { 299 + createDropOverlay(container, { 300 + acceptedExtensions: ['.docx'], 301 + onDrop: vi.fn(), 302 + }); 303 + 304 + container.dispatchEvent(createDragEvent('dragenter')); 305 + container.dispatchEvent(createDragEvent('dragenter')); 306 + container.dispatchEvent(createDragEvent('dragenter')); 307 + 308 + const overlays = container.querySelectorAll('.drop-overlay'); 309 + expect(overlays.length).toBe(1); 310 + }); 311 + });
+254
tests/xlsx-export.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { exportToXlsx, mapStyleToXlsx } from '../src/sheets/xlsx-export.js'; 3 + import * as XLSX from 'xlsx'; 4 + 5 + /** 6 + * Helper: create a getCellData function from a 2D array of cell objects. 7 + * Each element can be null or { v, f, s }. 8 + */ 9 + function gridFromCells(cells) { 10 + return (row, col) => { 11 + const r = row - 1; 12 + const c = col - 1; 13 + if (r < 0 || r >= cells.length) return null; 14 + if (c < 0 || c >= cells[r].length) return null; 15 + return cells[r][c]; 16 + }; 17 + } 18 + 19 + /** 20 + * Helper: export a grid and read it back with SheetJS for round-trip testing. 21 + */ 22 + function exportAndRead(cells, rowCount, colCount, opts = {}) { 23 + const getCellData = gridFromCells(cells); 24 + const colWidths = opts.colWidths || ((c) => 96); 25 + const sheetName = opts.sheetName || 'Sheet 1'; 26 + const buffer = exportToXlsx(getCellData, rowCount, colCount, colWidths, sheetName, XLSX); 27 + const workbook = XLSX.read(buffer, { type: 'array', cellStyles: true, cellFormula: true }); 28 + return workbook; 29 + } 30 + 31 + // ============================================================ 32 + // mapStyleToXlsx 33 + // ============================================================ 34 + describe('XLSX Export — mapStyleToXlsx', () => { 35 + it('returns empty object for null/undefined style', () => { 36 + expect(mapStyleToXlsx(null)).toEqual({}); 37 + expect(mapStyleToXlsx(undefined)).toEqual({}); 38 + }); 39 + 40 + it('maps bold to font.bold', () => { 41 + const style = mapStyleToXlsx({ bold: true }); 42 + expect(style.font.bold).toBe(true); 43 + }); 44 + 45 + it('maps italic to font.italic', () => { 46 + const style = mapStyleToXlsx({ italic: true }); 47 + expect(style.font.italic).toBe(true); 48 + }); 49 + 50 + it('maps underline to font.underline', () => { 51 + const style = mapStyleToXlsx({ underline: true }); 52 + expect(style.font.underline).toBe(true); 53 + }); 54 + 55 + it('maps strikethrough to font.strike', () => { 56 + const style = mapStyleToXlsx({ strikethrough: true }); 57 + expect(style.font.strike).toBe(true); 58 + }); 59 + 60 + it('maps fontSize to font.sz', () => { 61 + const style = mapStyleToXlsx({ fontSize: 14 }); 62 + expect(style.font.sz).toBe(14); 63 + }); 64 + 65 + it('maps color to font.color.rgb (stripping #)', () => { 66 + const style = mapStyleToXlsx({ color: '#FF0000' }); 67 + expect(style.font.color.rgb).toBe('FF0000'); 68 + }); 69 + 70 + it('maps bg to fill.fgColor.rgb', () => { 71 + const style = mapStyleToXlsx({ bg: '#00FF00' }); 72 + expect(style.fill.fgColor.rgb).toBe('00FF00'); 73 + expect(style.fill.patternType).toBe('solid'); 74 + }); 75 + 76 + it('maps align to alignment.horizontal', () => { 77 + const style = mapStyleToXlsx({ align: 'center' }); 78 + expect(style.alignment.horizontal).toBe('center'); 79 + }); 80 + 81 + it('maps verticalAlign middle to alignment.vertical center', () => { 82 + const style = mapStyleToXlsx({ verticalAlign: 'middle' }); 83 + expect(style.alignment.vertical).toBe('center'); 84 + }); 85 + 86 + it('maps wrap to alignment.wrapText', () => { 87 + const style = mapStyleToXlsx({ wrap: true }); 88 + expect(style.alignment.wrapText).toBe(true); 89 + }); 90 + 91 + it('combines multiple style properties', () => { 92 + const style = mapStyleToXlsx({ bold: true, italic: true, color: '#0000FF', align: 'right' }); 93 + expect(style.font.bold).toBe(true); 94 + expect(style.font.italic).toBe(true); 95 + expect(style.font.color.rgb).toBe('0000FF'); 96 + expect(style.alignment.horizontal).toBe('right'); 97 + }); 98 + }); 99 + 100 + // ============================================================ 101 + // exportToXlsx — basic workbook structure 102 + // ============================================================ 103 + describe('XLSX Export — basic workbook', () => { 104 + it('creates a workbook with the correct sheet name', () => { 105 + const cells = [[{ v: 'test', f: '', s: {} }]]; 106 + const wb = exportAndRead(cells, 1, 1, { sheetName: 'MySheet' }); 107 + expect(wb.SheetNames[0]).toBe('MySheet'); 108 + }); 109 + 110 + it('exports string values', () => { 111 + const cells = [[{ v: 'hello', f: '', s: {} }]]; 112 + const wb = exportAndRead(cells, 1, 1); 113 + const ws = wb.Sheets[wb.SheetNames[0]]; 114 + expect(ws.A1.v).toBe('hello'); 115 + }); 116 + 117 + it('exports numeric values', () => { 118 + const cells = [[{ v: 42, f: '', s: {} }]]; 119 + const wb = exportAndRead(cells, 1, 1); 120 + const ws = wb.Sheets[wb.SheetNames[0]]; 121 + expect(ws.A1.v).toBe(42); 122 + expect(ws.A1.t).toBe('n'); 123 + }); 124 + 125 + it('exports multiple rows and columns', () => { 126 + const cells = [ 127 + [{ v: 'A1', f: '', s: {} }, { v: 'B1', f: '', s: {} }], 128 + [{ v: 'A2', f: '', s: {} }, { v: 'B2', f: '', s: {} }], 129 + ]; 130 + const wb = exportAndRead(cells, 2, 2); 131 + const ws = wb.Sheets[wb.SheetNames[0]]; 132 + expect(ws.A1.v).toBe('A1'); 133 + expect(ws.B1.v).toBe('B1'); 134 + expect(ws.A2.v).toBe('A2'); 135 + expect(ws.B2.v).toBe('B2'); 136 + }); 137 + 138 + it('skips null/empty cells', () => { 139 + const cells = [ 140 + [{ v: 'A1', f: '', s: {} }, null], 141 + [null, { v: 'B2', f: '', s: {} }], 142 + ]; 143 + const wb = exportAndRead(cells, 2, 2); 144 + const ws = wb.Sheets[wb.SheetNames[0]]; 145 + expect(ws.A1.v).toBe('A1'); 146 + expect(ws.B2.v).toBe('B2'); 147 + // B1 and A2 should not be present (or be undefined) 148 + expect(ws.B1).toBeUndefined(); 149 + expect(ws.A2).toBeUndefined(); 150 + }); 151 + 152 + it('returns an ArrayBuffer-like output', () => { 153 + const cells = [[{ v: 'x', f: '', s: {} }]]; 154 + const getCellData = gridFromCells(cells); 155 + const buffer = exportToXlsx(getCellData, 1, 1, () => 96, 'Test', XLSX); 156 + expect(buffer).toBeTruthy(); 157 + expect(buffer.byteLength || buffer.length).toBeGreaterThan(0); 158 + }); 159 + }); 160 + 161 + // ============================================================ 162 + // exportToXlsx — formulas 163 + // ============================================================ 164 + describe('XLSX Export — formula preservation', () => { 165 + it('preserves formulas in cells', () => { 166 + const cells = [[{ v: 10, f: 'SUM(A2:A5)', s: {} }]]; 167 + const getCellData = gridFromCells(cells); 168 + const buffer = exportToXlsx(getCellData, 1, 1, () => 96, 'Sheet 1', XLSX); 169 + const wb = XLSX.read(buffer, { type: 'array', cellFormula: true }); 170 + const ws = wb.Sheets[wb.SheetNames[0]]; 171 + expect(ws.A1.f).toBe('SUM(A2:A5)'); 172 + }); 173 + 174 + it('preserves formulas with string results', () => { 175 + const cells = [[{ v: 'hello world', f: 'CONCATENATE(A2,A3)', s: {} }]]; 176 + const getCellData = gridFromCells(cells); 177 + const buffer = exportToXlsx(getCellData, 1, 1, () => 96, 'Sheet 1', XLSX); 178 + const wb = XLSX.read(buffer, { type: 'array', cellFormula: true }); 179 + const ws = wb.Sheets[wb.SheetNames[0]]; 180 + expect(ws.A1.f).toBe('CONCATENATE(A2,A3)'); 181 + }); 182 + }); 183 + 184 + // ============================================================ 185 + // exportToXlsx — style mapping 186 + // ============================================================ 187 + describe('XLSX Export — style mapping in cells', () => { 188 + it('exports bold style', () => { 189 + const cells = [[{ v: 'bold', f: '', s: { bold: true } }]]; 190 + const getCellData = gridFromCells(cells); 191 + const buffer = exportToXlsx(getCellData, 1, 1, () => 96, 'Sheet 1', XLSX); 192 + // Read back and check the cell has a style object with bold 193 + const wb = XLSX.read(buffer, { type: 'array', cellStyles: true }); 194 + const ws = wb.Sheets[wb.SheetNames[0]]; 195 + // SheetJS may or may not round-trip styles depending on version 196 + // At minimum, the cell should exist with value 197 + expect(ws.A1.v).toBe('bold'); 198 + }); 199 + 200 + it('exports cells with background color style object', () => { 201 + const cells = [[{ v: 'colored', f: '', s: { bg: '#FF0000' } }]]; 202 + const getCellData = gridFromCells(cells); 203 + const buffer = exportToXlsx(getCellData, 1, 1, () => 96, 'Sheet 1', XLSX); 204 + // Verify buffer is valid xlsx 205 + const wb = XLSX.read(buffer, { type: 'array' }); 206 + expect(wb.SheetNames).toHaveLength(1); 207 + expect(wb.Sheets[wb.SheetNames[0]].A1.v).toBe('colored'); 208 + }); 209 + 210 + it('exports number format for currency', () => { 211 + const cells = [[{ v: 1234.56, f: '', s: { format: 'currency' } }]]; 212 + const getCellData = gridFromCells(cells); 213 + const buffer = exportToXlsx(getCellData, 1, 1, () => 96, 'Sheet 1', XLSX); 214 + const wb = XLSX.read(buffer, { type: 'array' }); 215 + const ws = wb.Sheets[wb.SheetNames[0]]; 216 + expect(ws.A1.v).toBe(1234.56); 217 + }); 218 + 219 + it('exports column widths (set on worksheet before write)', () => { 220 + // SheetJS community edition may not round-trip !cols through write/read. 221 + // We verify the export function sets !cols on the worksheet by checking 222 + // the buffer is valid and contains data. 223 + const cells = [[{ v: 'x', f: '', s: {} }, { v: 'y', f: '', s: {} }]]; 224 + const getCellData = gridFromCells(cells); 225 + const colWidths = (c) => c === 1 ? 200 : 96; 226 + const buffer = exportToXlsx(getCellData, 1, 2, colWidths, 'Sheet 1', XLSX); 227 + // Verify the buffer is a valid xlsx that can be parsed 228 + const wb = XLSX.read(buffer, { type: 'array' }); 229 + expect(wb.SheetNames).toHaveLength(1); 230 + expect(wb.Sheets[wb.SheetNames[0]].A1.v).toBe('x'); 231 + expect(wb.Sheets[wb.SheetNames[0]].B1.v).toBe('y'); 232 + }); 233 + }); 234 + 235 + // ============================================================ 236 + // mapStyleToXlsx — ignores invalid alignment values 237 + // ============================================================ 238 + describe('XLSX Export — style edge cases', () => { 239 + it('ignores unknown align values', () => { 240 + const style = mapStyleToXlsx({ align: 'justify' }); 241 + expect(style.alignment).toBeUndefined(); 242 + }); 243 + 244 + it('handles empty style object', () => { 245 + const style = mapStyleToXlsx({}); 246 + expect(style).toEqual({}); 247 + }); 248 + 249 + it('handles color without hash prefix', () => { 250 + const style = mapStyleToXlsx({ color: 'AABBCC' }); 251 + // Should still produce a valid color 252 + expect(style.font.color.rgb).toBe('AABBCC'); 253 + }); 254 + });
+167
tests/xlsx-multi-sheet.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseXlsxWorkbook, parseXlsxWithLib } from '../src/sheets/xlsx-import.js'; 3 + import * as XLSX from 'xlsx'; 4 + 5 + /** 6 + * Helper: create an .xlsx ArrayBuffer with multiple sheets. 7 + * @param {Array<{ name: string, data: any[][] }>} sheets 8 + * @returns {ArrayBuffer} 9 + */ 10 + function createMultiSheetXlsx(sheets) { 11 + const workbook = XLSX.utils.book_new(); 12 + for (const sheet of sheets) { 13 + const worksheet = XLSX.utils.aoa_to_sheet(sheet.data); 14 + XLSX.utils.book_append_sheet(workbook, worksheet, sheet.name); 15 + } 16 + return XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); 17 + } 18 + 19 + /** 20 + * Helper: create a single-sheet .xlsx ArrayBuffer. 21 + */ 22 + function createXlsx(data, sheetName = 'Sheet1') { 23 + return createMultiSheetXlsx([{ name: sheetName, data }]); 24 + } 25 + 26 + // ============================================================ 27 + // parseXlsxWorkbook — multi-sheet parsing 28 + // ============================================================ 29 + describe('Multi-Sheet XLSX Import — parseXlsxWorkbook', () => { 30 + it('parses a 2-sheet workbook and returns both sheets', () => { 31 + const buf = createMultiSheetXlsx([ 32 + { name: 'Sales', data: [['Product', 'Revenue'], ['Widget', 100]] }, 33 + { name: 'Costs', data: [['Item', 'Cost'], ['Material', 50]] }, 34 + ]); 35 + const result = parseXlsxWorkbook(buf, XLSX); 36 + expect(result.sheets).toHaveLength(2); 37 + }); 38 + 39 + it('preserves sheet names', () => { 40 + const buf = createMultiSheetXlsx([ 41 + { name: 'First Sheet', data: [['a']] }, 42 + { name: 'Second Sheet', data: [['b']] }, 43 + ]); 44 + const result = parseXlsxWorkbook(buf, XLSX); 45 + expect(result.sheets[0].name).toBe('First Sheet'); 46 + expect(result.sheets[1].name).toBe('Second Sheet'); 47 + }); 48 + 49 + it('parses data correctly in each sheet', () => { 50 + const buf = createMultiSheetXlsx([ 51 + { name: 'Sheet1', data: [['Hello', 'World']] }, 52 + { name: 'Sheet2', data: [['Foo'], ['Bar']] }, 53 + ]); 54 + const result = parseXlsxWorkbook(buf, XLSX); 55 + 56 + // Sheet 1 57 + expect(result.sheets[0].cells.get('A1').v).toBe('Hello'); 58 + expect(result.sheets[0].cells.get('B1').v).toBe('World'); 59 + expect(result.sheets[0].rowCount).toBe(1); 60 + expect(result.sheets[0].colCount).toBe(2); 61 + 62 + // Sheet 2 63 + expect(result.sheets[1].cells.get('A1').v).toBe('Foo'); 64 + expect(result.sheets[1].cells.get('A2').v).toBe('Bar'); 65 + expect(result.sheets[1].rowCount).toBe(2); 66 + expect(result.sheets[1].colCount).toBe(1); 67 + }); 68 + 69 + it('handles a workbook with 3 sheets', () => { 70 + const buf = createMultiSheetXlsx([ 71 + { name: 'A', data: [['a1']] }, 72 + { name: 'B', data: [['b1']] }, 73 + { name: 'C', data: [['c1']] }, 74 + ]); 75 + const result = parseXlsxWorkbook(buf, XLSX); 76 + expect(result.sheets).toHaveLength(3); 77 + expect(result.sheets[2].name).toBe('C'); 78 + expect(result.sheets[2].cells.get('A1').v).toBe('c1'); 79 + }); 80 + 81 + it('handles sheets with different dimensions', () => { 82 + const buf = createMultiSheetXlsx([ 83 + { name: 'Small', data: [['x']] }, 84 + { name: 'Big', data: [ 85 + ['a', 'b', 'c'], 86 + ['d', 'e', 'f'], 87 + ['g', 'h', 'i'], 88 + ]}, 89 + ]); 90 + const result = parseXlsxWorkbook(buf, XLSX); 91 + expect(result.sheets[0].rowCount).toBe(1); 92 + expect(result.sheets[0].colCount).toBe(1); 93 + expect(result.sheets[1].rowCount).toBe(3); 94 + expect(result.sheets[1].colCount).toBe(3); 95 + }); 96 + 97 + it('handles an empty workbook', () => { 98 + // Create a workbook with no sheets — SheetJS requires at least one, 99 + // so we use a mock for this edge case 100 + const mockXLSX = { 101 + read: () => ({ SheetNames: [], Sheets: {} }), 102 + utils: { 103 + decode_range: () => ({ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }), 104 + encode_cell: ({ r, c }) => { 105 + let s = ''; 106 + let n = c + 1; 107 + while (n > 0) { n--; s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26); } 108 + return s + (r + 1); 109 + }, 110 + }, 111 + }; 112 + const result = parseXlsxWorkbook(new ArrayBuffer(0), mockXLSX); 113 + expect(result.sheets).toHaveLength(0); 114 + }); 115 + 116 + it('handles a single-sheet workbook (backwards compat)', () => { 117 + const buf = createXlsx([['only']]); 118 + const result = parseXlsxWorkbook(buf, XLSX); 119 + expect(result.sheets).toHaveLength(1); 120 + expect(result.sheets[0].name).toBe('Sheet1'); 121 + expect(result.sheets[0].cells.get('A1').v).toBe('only'); 122 + }); 123 + 124 + it('preserves numeric values across sheets', () => { 125 + const buf = createMultiSheetXlsx([ 126 + { name: 'Numbers', data: [[42, 3.14]] }, 127 + { name: 'More', data: [[100]] }, 128 + ]); 129 + const result = parseXlsxWorkbook(buf, XLSX); 130 + expect(result.sheets[0].cells.get('A1').v).toBe(42); 131 + expect(result.sheets[0].cells.get('B1').v).toBe(3.14); 132 + expect(result.sheets[1].cells.get('A1').v).toBe(100); 133 + }); 134 + 135 + it('handles empty sheets within a multi-sheet workbook', () => { 136 + // One sheet has data, the other is empty 137 + const workbook = XLSX.utils.book_new(); 138 + const ws1 = XLSX.utils.aoa_to_sheet([['data']]); 139 + XLSX.utils.book_append_sheet(workbook, ws1, 'HasData'); 140 + const ws2 = XLSX.utils.aoa_to_sheet([]); 141 + XLSX.utils.book_append_sheet(workbook, ws2, 'Empty'); 142 + const buf = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); 143 + 144 + const result = parseXlsxWorkbook(buf, XLSX); 145 + expect(result.sheets).toHaveLength(2); 146 + expect(result.sheets[0].cells.size).toBeGreaterThan(0); 147 + // Empty sheet may have 0 cells or a minimal cell 148 + expect(result.sheets[1].name).toBe('Empty'); 149 + }); 150 + }); 151 + 152 + // ============================================================ 153 + // Backwards compatibility — parseXlsxWithLib still works 154 + // ============================================================ 155 + describe('Multi-Sheet XLSX Import — backwards compatibility', () => { 156 + it('parseXlsxWithLib still returns only the first sheet', () => { 157 + const buf = createMultiSheetXlsx([ 158 + { name: 'First', data: [['first']] }, 159 + { name: 'Second', data: [['second']] }, 160 + ]); 161 + const result = parseXlsxWithLib(buf, XLSX); 162 + expect(result.name).toBe('First'); 163 + expect(result.cells.get('A1').v).toBe('first'); 164 + // Should not include second sheet data 165 + expect(result.cells.size).toBe(1); 166 + }); 167 + });