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

Configure Feed

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

feat(sheets): rich clipboard paste from Excel/Google Sheets + copy with formatting

Paste: detects text/html from clipboard, parses HTML tables from
Excel (urn:schemas-microsoft-com:office:excel) and Google Sheets
(google-sheets-html-origin), extracts values + formulas + inline
styles (bg color, text color, bold, italic, underline, font-size,
alignment). Falls back to TSV for plain text paste.

Copy: puts both text/html (styled table) and text/plain (TSV) on
clipboard via ClipboardItem, so pasting into Excel/Google Sheets
preserves formatting.

82 new tests (55 paste, 27 copy).

+1027
+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 + }
+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 + });