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

Configure Feed

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

v0.6.0: Sparklines, sheet tab management, paste special, print preview (#62)

scott 6202ecb0 20a9ba70

+3266 -88
+23
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.6.0] — 2026-03-19 11 + 12 + ### Added 13 + - **SPARKLINE() formula function**: inline line, bar, and win/loss charts rendered via Canvas 2D inside cells 14 + - **Sheet tab context menu**: right-click tabs for Rename, Duplicate, Delete, Move Left/Right, Tab Color 15 + - **Inline tab rename**: double-click sheet tab for contenteditable rename (replaces prompt() dialog) 16 + - **Duplicate sheet**: deep-clone all cell data, styles, merged cells, notes, CF/DV rules, hidden rows/cols 17 + - **Delete sheet**: with confirmation for non-empty sheets, prevents deleting last sheet 18 + - **Tab color picker**: 8 preset colors stored in Yjs, displayed as colored underline on tabs 19 + - **Paste Special dialog** (Cmd+Shift+V): Values Only, Formulas Only, Formatting Only, Transpose modes 20 + - **Rich clipboard**: copy preserves styled HTML for Excel/Google Sheets; paste parses HTML tables with styles 21 + - **Print preview**: styled print output with cell formatting, merged cells, landscape orientation, fit-to-width scaling 22 + 23 + ### Improved 24 + - **Print layout engine**: cell styles (bold, colors, borders, alignment), merged cell colspan/rowspan, orientation, scaling modes, hidden row/col exclusion 25 + 26 + ### Tests 27 + - 2966 unit tests across 97 test files (+137 from v0.5.0) 28 + - Sheet tab management: 47 tests (duplicate, delete, rename, color, move) 29 + - Sparkline: 34 tests (line/bar/winloss geometry, options parsing, edge cases) 30 + - Print layout: 37 tests (styles, gridlines, headers, orientation, scaling, merges) 31 + - Paste special: 19 tests (values-only, formatting-only, transpose, HTML parsing) 32 + 10 33 ## [0.5.0] — 2026-03-19 11 34 12 35 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.5.0", 3 + "version": "0.6.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+304
src/css/app.css
··· 1923 1923 } 1924 1924 .sheet-tab-add:hover { background: var(--color-hover); color: var(--color-text); } 1925 1925 1926 + /* Sheet tab color bar — colored underline indicator */ 1927 + .sheet-tab-color-bar { 1928 + position: absolute; 1929 + left: 0; 1930 + right: 0; 1931 + bottom: 0; 1932 + height: 3px; 1933 + border-radius: 0 0 var(--radius-sm) var(--radius-sm); 1934 + pointer-events: none; 1935 + } 1936 + 1937 + /* Sheet tab label (inside button, next to color bar) */ 1938 + .sheet-tab-label { 1939 + position: relative; 1940 + z-index: 1; 1941 + } 1942 + 1943 + /* Inline rename input */ 1944 + .sheet-tab-rename { 1945 + outline: none; 1946 + border: 1px solid var(--color-teal); 1947 + border-radius: 2px; 1948 + padding: 0 2px; 1949 + background: var(--color-bg); 1950 + color: var(--color-text); 1951 + font: inherit; 1952 + min-width: 2em; 1953 + cursor: text; 1954 + } 1955 + 1956 + /* Sheet tab color picker popup */ 1957 + .sheet-tab-color-picker { 1958 + display: flex; 1959 + flex-wrap: wrap; 1960 + gap: 4px; 1961 + padding: 6px; 1962 + background: var(--color-surface); 1963 + border: 1px solid var(--color-border); 1964 + border-radius: var(--radius-md); 1965 + box-shadow: var(--shadow-lg); 1966 + width: 148px; 1967 + } 1968 + 1969 + .sheet-tab-color-swatch { 1970 + width: 24px; 1971 + height: 24px; 1972 + border: 2px solid transparent; 1973 + border-radius: var(--radius-sm); 1974 + cursor: pointer; 1975 + padding: 0; 1976 + transition: border-color var(--transition-fast), transform var(--transition-fast); 1977 + } 1978 + .sheet-tab-color-swatch:hover { 1979 + transform: scale(1.15); 1980 + border-color: var(--color-text-muted); 1981 + } 1982 + .sheet-tab-color-swatch.selected { 1983 + border-color: var(--color-teal); 1984 + } 1985 + 1986 + /* "No color" swatch */ 1987 + .sheet-tab-color-none { 1988 + background: var(--color-bg); 1989 + color: var(--color-text-muted); 1990 + font-size: 0.7rem; 1991 + line-height: 20px; 1992 + text-align: center; 1993 + border-color: var(--color-border); 1994 + } 1995 + 1926 1996 /* --- Utility classes --- */ 1927 1997 .sr-only { 1928 1998 position: absolute; ··· 2997 3067 2998 3068 .sheet-dialog-actions button.btn-primary:hover { 2999 3069 background: var(--color-accent-hover); 3070 + } 3071 + 3072 + /* ======================================================== 3073 + Paste Special Dialog 3074 + ======================================================== */ 3075 + 3076 + .paste-special-dialog { 3077 + min-width: 280px; 3078 + max-width: 360px; 3079 + } 3080 + 3081 + .paste-special-options { 3082 + display: flex; 3083 + flex-direction: column; 3084 + gap: var(--space-xs); 3085 + margin-bottom: var(--space-md); 3086 + } 3087 + 3088 + .paste-special-option { 3089 + display: flex; 3090 + align-items: center; 3091 + gap: var(--space-sm); 3092 + padding: var(--space-xs) var(--space-sm); 3093 + border-radius: var(--radius-sm); 3094 + cursor: pointer; 3095 + font-size: 0.9rem; 3096 + color: var(--color-text); 3097 + transition: background var(--transition-fast); 3098 + margin-bottom: 0; 3099 + } 3100 + 3101 + .paste-special-option:hover { 3102 + background: var(--color-hover); 3103 + } 3104 + 3105 + .paste-special-option input[type="radio"] { 3106 + width: auto; 3107 + margin: 0; 3108 + accent-color: var(--color-accent); 3109 + } 3110 + 3111 + .paste-special-option span { 3112 + font-family: var(--font-body); 3000 3113 } 3001 3114 3002 3115 /* Sort level rows */ ··· 5585 5698 @media print { 5586 5699 .version-panel { 5587 5700 display: none !important; 5701 + } 5702 + } 5703 + 5704 + /* ======================================================== 5705 + Print Preview Dialog 5706 + ======================================================== */ 5707 + 5708 + .print-preview-backdrop { 5709 + position: fixed; 5710 + inset: 0; 5711 + background: var(--color-modal-backdrop); 5712 + z-index: 1100; 5713 + display: flex; 5714 + align-items: center; 5715 + justify-content: center; 5716 + } 5717 + 5718 + .print-preview-dialog { 5719 + background: var(--color-bg); 5720 + border: 1px solid var(--color-border); 5721 + border-radius: var(--radius-lg); 5722 + box-shadow: var(--shadow-lg); 5723 + display: flex; 5724 + width: 90vw; 5725 + max-width: 960px; 5726 + height: 80vh; 5727 + max-height: 720px; 5728 + overflow: hidden; 5729 + } 5730 + 5731 + .print-preview-sidebar { 5732 + width: 260px; 5733 + min-width: 260px; 5734 + padding: var(--space-lg); 5735 + border-right: 1px solid var(--color-border); 5736 + overflow-y: auto; 5737 + display: flex; 5738 + flex-direction: column; 5739 + gap: var(--space-sm); 5740 + } 5741 + 5742 + .print-preview-sidebar h3 { 5743 + margin: 0 0 var(--space-sm); 5744 + font-family: var(--font-display); 5745 + font-size: 1.1rem; 5746 + color: var(--color-text); 5747 + } 5748 + 5749 + .print-preview-sidebar label { 5750 + display: block; 5751 + margin-bottom: var(--space-xs); 5752 + font-size: 0.8rem; 5753 + color: var(--color-text-muted); 5754 + font-weight: 500; 5755 + } 5756 + 5757 + .print-preview-sidebar select { 5758 + width: 100%; 5759 + padding: var(--space-xs) var(--space-sm); 5760 + border: 1px solid var(--color-border); 5761 + border-radius: var(--radius-sm); 5762 + background: var(--color-bg); 5763 + color: var(--color-text); 5764 + font-family: var(--font-body); 5765 + font-size: 0.82rem; 5766 + margin-bottom: var(--space-xs); 5767 + } 5768 + 5769 + .print-preview-sidebar select:focus { 5770 + border-color: var(--color-teal); 5771 + outline: none; 5772 + box-shadow: 0 0 0 2px var(--color-focus); 5773 + } 5774 + 5775 + .print-preview-radio-group { 5776 + display: flex; 5777 + gap: var(--space-md); 5778 + margin-bottom: var(--space-xs); 5779 + } 5780 + 5781 + .print-preview-radio-group label { 5782 + display: inline-flex; 5783 + align-items: center; 5784 + gap: 0.3rem; 5785 + font-size: 0.82rem; 5786 + color: var(--color-text); 5787 + cursor: pointer; 5788 + margin-bottom: 0; 5789 + } 5790 + 5791 + .print-preview-radio-group input[type="radio"] { 5792 + margin: 0; 5793 + accent-color: var(--color-teal); 5794 + } 5795 + 5796 + .print-preview-checkbox { 5797 + display: flex; 5798 + align-items: center; 5799 + gap: 0.4rem; 5800 + margin-bottom: var(--space-xs); 5801 + } 5802 + 5803 + .print-preview-checkbox input[type="checkbox"] { 5804 + margin: 0; 5805 + accent-color: var(--color-teal); 5806 + } 5807 + 5808 + .print-preview-checkbox label { 5809 + font-size: 0.82rem; 5810 + color: var(--color-text); 5811 + margin-bottom: 0; 5812 + cursor: pointer; 5813 + } 5814 + 5815 + .print-preview-actions { 5816 + display: flex; 5817 + gap: var(--space-sm); 5818 + margin-top: auto; 5819 + padding-top: var(--space-md); 5820 + border-top: 1px solid var(--color-border); 5821 + } 5822 + 5823 + .print-preview-actions button { 5824 + flex: 1; 5825 + padding: var(--space-xs) var(--space-md); 5826 + border: 1px solid var(--color-border); 5827 + border-radius: var(--radius-sm); 5828 + cursor: pointer; 5829 + font-size: 0.85rem; 5830 + font-family: var(--font-body); 5831 + background: var(--color-surface); 5832 + color: var(--color-text); 5833 + transition: background var(--transition-fast); 5834 + } 5835 + 5836 + .print-preview-actions button:hover { 5837 + background: var(--color-hover); 5838 + } 5839 + 5840 + .print-preview-actions button.btn-primary { 5841 + background: var(--color-accent); 5842 + color: var(--color-btn-primary-text); 5843 + border-color: var(--color-accent); 5844 + } 5845 + 5846 + .print-preview-actions button.btn-primary:hover { 5847 + background: var(--color-accent-hover); 5848 + } 5849 + 5850 + .print-preview-content { 5851 + flex: 1; 5852 + overflow: auto; 5853 + background: var(--color-surface-alt); 5854 + padding: var(--space-lg); 5855 + display: flex; 5856 + align-items: flex-start; 5857 + justify-content: center; 5858 + } 5859 + 5860 + .print-preview-page { 5861 + background: #fff; 5862 + box-shadow: var(--shadow-md); 5863 + border-radius: 2px; 5864 + overflow: hidden; 5865 + transform-origin: top center; 5866 + } 5867 + 5868 + .print-preview-page iframe { 5869 + border: none; 5870 + width: 100%; 5871 + height: 100%; 5872 + display: block; 5873 + } 5874 + 5875 + @media (max-width: 640px) { 5876 + .print-preview-dialog { 5877 + flex-direction: column; 5878 + width: 100%; 5879 + max-width: 100%; 5880 + height: 100%; 5881 + max-height: 100%; 5882 + border-radius: 0; 5883 + } 5884 + 5885 + .print-preview-sidebar { 5886 + width: 100%; 5887 + min-width: 0; 5888 + border-right: none; 5889 + border-bottom: 1px solid var(--color-border); 5890 + max-height: 40vh; 5891 + overflow-y: auto; 5588 5892 } 5589 5893 } 5590 5894
+133 -12
src/lib/print-layout.ts
··· 20 20 left: string; 21 21 } 22 22 23 - type PageSizeKey = 'letter' | 'a4'; 23 + type PageSizeKey = 'letter' | 'a4' | 'legal'; 24 24 type MarginPresetKey = 'normal' | 'narrow' | 'wide'; 25 25 type ScalingMode = 'fit-to-width' | 'fit-to-page' | 'actual-size'; 26 + type Orientation = 'portrait' | 'landscape'; 26 27 27 28 /** Standard page sizes. */ 28 29 export const PAGE_SIZES: Readonly<Record<PageSizeKey, PageDimensions>> = Object.freeze({ 29 30 letter: { width: '8.5in', height: '11in' }, 30 31 a4: { width: '210mm', height: '297mm' }, 32 + legal: { width: '8.5in', height: '14in' }, 31 33 }); 32 34 33 35 /** Margin presets (all in inches for consistency). */ ··· 207 209 </html>`; 208 210 } 209 211 212 + /** Style properties for a print cell. */ 213 + export interface PrintCellStyle { 214 + bold?: boolean; 215 + italic?: boolean; 216 + underline?: boolean; 217 + strikethrough?: boolean; 218 + color?: string; 219 + bg?: string; 220 + align?: 'left' | 'center' | 'right'; 221 + verticalAlign?: 'top' | 'middle' | 'bottom'; 222 + fontSize?: number; 223 + fontFamily?: string; 224 + } 225 + 226 + /** A single cell in the print data. null means consumed by a merge. */ 227 + export interface PrintCell { 228 + value: string; 229 + style?: PrintCellStyle; 230 + colspan?: number; 231 + rowspan?: number; 232 + } 233 + 234 + /** A row that supports hidden flag. */ 235 + export interface PrintRow { 236 + cells: (PrintCell | null)[]; 237 + hidden?: boolean; 238 + } 239 + 240 + /** 241 + * Print data for sheets. Supports two formats for rows: 242 + * - Simple: (PrintCell | null)[][] — flat array of cell arrays 243 + * - Rich: PrintRow[] — rows with hidden flag 244 + */ 210 245 export interface SheetsPrintData { 211 246 headers: string[]; 212 - rows: string[][]; 247 + rows: (PrintCell | null)[][] | PrintRow[]; 248 + colWidths?: number[]; 213 249 } 214 250 215 251 export interface SheetsPrintOptions { 216 252 pageSize?: string; 253 + orientation?: Orientation; 254 + margins?: string; 217 255 gridLines?: boolean; 218 256 repeatHeaders?: boolean; 219 257 scaling?: ScalingMode; ··· 221 259 } 222 260 223 261 /** 262 + * Build inline style string from a PrintCellStyle. 263 + */ 264 + function buildCellInlineStyle(style: PrintCellStyle | undefined): string { 265 + if (!style) return ''; 266 + let s = ''; 267 + if (style.bold) s += 'font-weight:600;'; 268 + if (style.italic) s += 'font-style:italic;'; 269 + const decorations: string[] = []; 270 + if (style.underline) decorations.push('underline'); 271 + if (style.strikethrough) decorations.push('line-through'); 272 + if (decorations.length > 0) s += 'text-decoration:' + decorations.join(' ') + ';'; 273 + if (style.color) s += 'color:' + style.color + ';'; 274 + if (style.bg) s += 'background:' + style.bg + ';'; 275 + if (style.align) s += 'text-align:' + style.align + ';'; 276 + if (style.verticalAlign) s += 'vertical-align:' + style.verticalAlign + ';'; 277 + if (style.fontSize) s += 'font-size:' + style.fontSize + 'pt;'; 278 + if (style.fontFamily) { 279 + const families: Record<string, string> = { 280 + 'sans-serif': 'system-ui, sans-serif', 281 + 'serif': 'Charter, Georgia, serif', 282 + 'monospace': 'ui-monospace, "SF Mono", monospace', 283 + }; 284 + s += 'font-family:' + (families[style.fontFamily] || 'system-ui, sans-serif') + ';'; 285 + } 286 + return s; 287 + } 288 + 289 + /** 290 + * Normalize row data into a uniform format. 291 + * Handles both simple (PrintCell | null)[][] and rich PrintRow[] formats, 292 + * as well as legacy string[][] format for backward compatibility. 293 + */ 294 + function normalizeRows(rows: SheetsPrintData['rows']): { cells: (PrintCell | null)[]; hidden: boolean }[] { 295 + if (rows.length === 0) return []; 296 + 297 + const first = rows[0]; 298 + 299 + // Rich PrintRow format: has .cells property 300 + if (first && typeof first === 'object' && 'cells' in first) { 301 + return (rows as PrintRow[]).map(row => ({ 302 + cells: row.cells, 303 + hidden: row.hidden || false, 304 + })); 305 + } 306 + 307 + // Simple array format — could be (PrintCell | null)[][] or legacy string[][] 308 + return (rows as (PrintCell | null | string)[][]).map(row => ({ 309 + cells: row.map(cell => { 310 + if (cell === null) return null; 311 + if (typeof cell === 'string') return { value: cell }; 312 + return cell as PrintCell; 313 + }), 314 + hidden: false, 315 + })); 316 + } 317 + 318 + /** 224 319 * Build a full HTML document for printing Sheets content. 225 320 */ 226 321 export function buildSheetsPrintHtml(data: SheetsPrintData, opts: SheetsPrintOptions = {}): string { 227 322 const pageSize = opts.pageSize || 'letter'; 323 + const orientation = opts.orientation || 'portrait'; 324 + const margins = opts.margins || 'normal'; 228 325 const gridLines = opts.gridLines !== false; // default true 229 326 const repeatHeaders = opts.repeatHeaders || false; 230 327 const scaling = opts.scaling || 'actual-size'; 231 328 const title = opts.title || 'Spreadsheet'; 232 329 233 330 const dims = getPageDimensions(pageSize); 331 + const margin = (MARGIN_PRESETS as Record<string, MarginPreset | undefined>)[margins] || MARGIN_PRESETS.normal; 234 332 235 333 const borderStyle = gridLines 236 334 ? 'border: 1px solid #ccc;' ··· 242 340 ? 'max-width: 100%; width: auto;' 243 341 : ''; 244 342 343 + // Page size CSS — swap dimensions for landscape or use landscape keyword 344 + const pageSizeCSS = orientation === 'landscape' 345 + ? `size: ${dims.height} ${dims.width} landscape;` 346 + : `size: ${dims.width} ${dims.height};`; 347 + 348 + // Column width colgroup 349 + let colgroupHtml = ''; 350 + if (data.colWidths && data.colWidths.length > 0) { 351 + const cols = data.colWidths.map(w => `<col style="width:${w}px">`).join(''); 352 + colgroupHtml = `<colgroup>${cols}</colgroup>`; 353 + } 354 + 245 355 // Build header row 246 356 const headerCells = data.headers.map(h => 247 357 `<th style="${borderStyle} padding: 4px 8px; background: #f5f5f5; font-weight: 600; font-size: 0.85rem; text-align: left;">${escapeHtml(String(h))}</th>` ··· 252 362 : `<tr>${headerCells}</tr>`) 253 363 : ''; 254 364 255 - // Build data rows 256 - const bodyRows = data.rows.map(row => { 257 - const cells = row.map(cell => 258 - `<td style="${borderStyle} padding: 4px 8px; font-size: 0.85rem;">${escapeHtml(String(cell))}</td>` 259 - ).join(''); 260 - return `<tr>${cells}</tr>`; 365 + // Normalize and filter rows 366 + const normalizedRows = normalizeRows(data.rows); 367 + const visibleRows = normalizedRows.filter(r => !r.hidden); 368 + 369 + // Build data rows with cell styles and merge attributes 370 + const bodyRows = visibleRows.map(row => { 371 + const cellsHtml = row.cells.map(cell => { 372 + if (cell === null) return ''; // consumed by merge — skip 373 + const cellStyle = buildCellInlineStyle(cell.style); 374 + const baseStyle = `${borderStyle} padding: 4px 8px; font-size: 0.85rem;`; 375 + const fullStyle = cellStyle ? `${baseStyle} ${cellStyle}` : baseStyle; 376 + let spanAttrs = ''; 377 + if (cell.colspan && cell.colspan > 1) spanAttrs += ` colspan="${cell.colspan}"`; 378 + if (cell.rowspan && cell.rowspan > 1) spanAttrs += ` rowspan="${cell.rowspan}"`; 379 + return `<td style="${fullStyle}"${spanAttrs}>${escapeHtml(String(cell.value))}</td>`; 380 + }).join(''); 381 + return `<tr>${cellsHtml}</tr>`; 261 382 }).join('\n'); 262 383 263 384 const bodyContent = repeatHeaders ··· 265 386 : `<tbody>${headerRow}${bodyRows}</tbody>`; 266 387 267 388 const tableHtml = repeatHeaders 268 - ? `<table style="border-collapse: collapse; ${tableWidth} ${borderStyle}">${headerRow}${bodyContent}</table>` 269 - : `<table style="border-collapse: collapse; ${tableWidth} ${borderStyle}">${bodyContent}</table>`; 389 + ? `<table style="border-collapse: collapse; ${tableWidth} ${borderStyle}">${colgroupHtml}${headerRow}${bodyContent}</table>` 390 + : `<table style="border-collapse: collapse; ${tableWidth} ${borderStyle}">${colgroupHtml}${bodyContent}</table>`; 270 391 271 392 return `<!DOCTYPE html> 272 393 <html lang="en"> ··· 276 397 <title>${escapeHtml(title)} — Print</title> 277 398 <style> 278 399 @page { 279 - size: ${dims.width} ${dims.height}; 280 - margin: 0.5in; 400 + ${pageSizeCSS} 401 + margin: ${margin.top} ${margin.right} ${margin.bottom} ${margin.left}; 281 402 } 282 403 283 404 * { box-sizing: border-box; }
+3
src/sheets/formula-autocomplete.ts
··· 165 165 166 166 // Lookup (additional) 167 167 { name: 'CHOOSE', signature: 'CHOOSE(index_num, value1, [value2], ...)' }, 168 + 169 + // Visualization 170 + { name: 'SPARKLINE', signature: 'SPARKLINE(data, [options])' }, 168 171 ]; 169 172 170 173 /**
+9
src/sheets/formula-tooltip.ts
··· 813 813 { name: 'value2', desc: 'Additional values', required: false }, 814 814 ], 815 815 }, 816 + 817 + // --- Visualization --- 818 + SPARKLINE: { 819 + desc: 'Creates a miniature chart within a cell (line, bar, or win/loss)', 820 + params: [ 821 + { name: 'data', desc: 'Range of numeric values to chart', required: true }, 822 + { name: 'options', desc: 'Chart type string ("line", "bar", "winloss") or options object', required: false }, 823 + ], 824 + }, 816 825 }; 817 826 818 827 /**
+16
src/sheets/formulas.ts
··· 6 6 */ 7 7 8 8 import type { CellRef, CellValue, CrossSheetResolver, NamedRangesMap, RangeArray, FormatType } from './types.js'; 9 + import { parseSparklineArgs } from './sparkline.js'; 9 10 10 11 // --- Tokenizer --- 11 12 type TokenTypeValue = 'NUMBER' | 'STRING' | 'BOOLEAN' | 'CELL_REF' | 'CROSS_SHEET_REF' | 'RANGE' | 'FUNCTION' | 'IDENTIFIER' | 'OPERATOR' | 'LPAREN' | 'RPAREN' | 'COMMA' | 'COLON' | 'BANG' | 'EOF'; ··· 1262 1263 const chIdx = Math.floor(toNum(args[0])); 1263 1264 if (chIdx < 1 || chIdx >= args.length) return '#VALUE!'; 1264 1265 return args[chIdx]; 1266 + } 1267 + 1268 + // --- Sparkline --- 1269 + case 'SPARKLINE': { 1270 + // First arg is data (range array), second is optional type/options 1271 + const rawData = Array.isArray(args[0]) ? args[0] : flat(args.slice(0, 1)); 1272 + const numericData = rawData.flat(Infinity).filter((v: unknown) => 1273 + typeof v === 'number' && !isNaN(v as number) 1274 + ); 1275 + if (numericData.length === 0) return ''; 1276 + const sparkArgs: unknown[] = [numericData]; 1277 + if (args.length > 1) sparkArgs.push(args[1]); 1278 + const result = parseSparklineArgs(sparkArgs); 1279 + if (!result) return '#VALUE!'; 1280 + return result; 1265 1281 } 1266 1282 1267 1283 default: return `#NAME? (${name})`;
+575 -75
src/sheets/main.ts
··· 30 30 import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 31 31 import { createContextMenu, buildSheetsCellItems, buildSheetsColumnHeaderItems, buildSheetsRowHeaderItems, SEPARATOR } from '../lib/context-menu.js'; 32 32 import type { MenuItem, MenuItemConfig } from '../lib/context-menu.js'; 33 + import { duplicateSheet, deleteSheet, sheetHasData, countSheets, setTabColor, getTabColor, renameSheet, canMoveLeft, canMoveRight, TAB_COLORS } from './sheet-tab-management.js'; 34 + import { buildCopyHtml, buildCopyTsv } from './clipboard-copy.js'; 35 + import { parseClipboardHtml, parseClipboardTsv } from './clipboard-paste.js'; 36 + import { extractValuesOnly, extractFormulasOnly, extractFormattingOnly, transposeGrid, PASTE_MODES } from './paste-special.js'; 33 37 import { computeVisibleRows, computeVisibleCols, hiddenRowsSpacerAdjustment, getAdjacentHiddenRows, getAdjacentHiddenCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 34 38 import { createFindState, findInCells, nextMatch, prevMatch, replaceCurrentMatch, replaceAllMatches, getMatchInfo, isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 39 + import { isSparklineResult, drawSparkline } from './sparkline.js'; 40 + import { buildSheetsPrintHtml } from '../lib/print-layout.js'; 41 + import type { PrintCell, PrintRow, SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 35 42 36 43 // --- Constants --- 37 44 const DEFAULT_ROWS = 100; ··· 39 46 const DEFAULT_COL_WIDTH = 96; // px (was 6rem) 40 47 const MIN_COL_WIDTH = 40; // px minimum column width 41 48 const ROW_HEADER_WIDTH = 48; // px (3rem) 49 + 50 + // --- Clipboard buffer for paste-special operations --- 51 + // Stores the last copied grid data so paste-special can transform it 52 + let _clipboardBuffer: Array<Array<{ value: any; formula: string; style: Record<string, any> }>> | null = null; 42 53 43 54 // --- Resolve document ID and encryption key --- 44 55 // URL format: /sheets/{docId}#{base64urlKey} ··· 547 558 } 548 559 html += '<td data-col="' + c + '" data-row="' + r + '" data-id="' + id + '" class="' + tdCls.join(' ') + '"' + styleAttr + spanAttrs + '>'; 549 560 550 - // Conditional formatting 551 - const cfResult = evaluateRules(displayValue, cfRules); 552 - const cfStyleStr = buildCfStyle(cfResult); 561 + // Sparkline: render canvas instead of text 562 + if (isSparklineResult(displayValue)) { 563 + html += '<div class="cell-display" style="padding:0;overflow:hidden;' + getCellStyle(cellData, '') + '"><canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas></div>'; 564 + } else { 565 + // Conditional formatting 566 + const cfResult = evaluateRules(displayValue, cfRules); 567 + const cfStyleStr = buildCfStyle(cfResult); 553 568 554 - // Wrap text class 555 - const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; 569 + // Wrap text class 570 + const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; 556 571 557 - html += '<div class="cell-display' + wrapClass + '" style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 572 + html += '<div class="cell-display' + wrapClass + '" style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 573 + } 558 574 559 575 // Dropdown arrow for list validation 560 576 if (validation && validation.type === 'list') { ··· 593 609 updateSelectionVisuals(); 594 610 updateFreezeToolbarState(); 595 611 renderNoteIndicators(); 612 + renderSparklines(); 596 613 } 597 614 598 615 function getCellData(id) { ··· 627 644 if (!cellData) return ''; 628 645 if (cellData.f) { 629 646 const val = evaluateFormula(cellData.f); 647 + // Sparkline results pass through as objects for canvas rendering 648 + if (isSparklineResult(val)) return val; 630 649 return formatCell(val, cellData.s?.format); 631 650 } 632 651 return formatCell(cellData.v, cellData.s?.format); ··· 1060 1079 } 1061 1080 1062 1081 if ((e.metaKey || e.ctrlKey) && key === 'c') { e.preventDefault(); copySelection(); } 1082 + // Paste Special: Cmd+Shift+V / Ctrl+Shift+V 1083 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'v' || key === 'V')) { e.preventDefault(); showPasteSpecialDialog(); } 1063 1084 if ((e.metaKey || e.ctrlKey) && key === 'b') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('bold', !getCellData(id)?.s?.bold); } 1064 1085 if ((e.metaKey || e.ctrlKey) && key === 'i') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('italic', !getCellData(id)?.s?.italic); } 1065 1086 if ((e.metaKey || e.ctrlKey) && key === 'u') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('underline', !getCellData(id)?.s?.underline); updateUnderlineButtonState(); } ··· 1089 1110 document.addEventListener('paste', (e) => { 1090 1111 if (editingCell || document.activeElement === formulaInput) return; 1091 1112 e.preventDefault(); 1092 - pasteAtSelection(e.clipboardData.getData('text/plain')); 1113 + // Try HTML first (from Excel/Google Sheets), fall back to TSV/plain text 1114 + const htmlData = e.clipboardData.getData('text/html'); 1115 + const textData = e.clipboardData.getData('text/plain'); 1116 + let parsed = null; 1117 + if (htmlData) { 1118 + parsed = parseClipboardHtml(htmlData); 1119 + } 1120 + if (!parsed && textData) { 1121 + parsed = parseClipboardTsv(textData); 1122 + } 1123 + if (parsed) { 1124 + pasteRowsAtSelection(parsed.rows); 1125 + } 1093 1126 }); 1094 1127 1095 1128 function moveSelection(dCol, dRow) { ··· 1135 1168 1136 1169 function copySelection() { 1137 1170 if (!selectionRange) return; 1138 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 1139 - const rows = []; 1171 + const norm = normalizeRange(selectionRange); 1172 + const html = buildCopyHtml(getCellData, norm, cellId); 1173 + const tsv = buildCopyTsv(getCellData, norm, cellId); 1174 + 1175 + // Populate the internal clipboard buffer for paste-special 1176 + const { startCol, startRow, endCol, endRow } = norm; 1177 + const bufferRows = []; 1140 1178 for (let r = startRow; r <= endRow; r++) { 1141 - const cols = []; 1179 + const row = []; 1142 1180 for (let c = startCol; c <= endCol; c++) { 1143 1181 const data = getCellData(cellId(c, r)); 1144 - cols.push(data?.f ? '=' + data.f : (data?.v ?? '')); 1182 + row.push({ 1183 + value: data?.f ? '=' + data.f : (data?.v ?? ''), 1184 + formula: data?.f || '', 1185 + style: data?.s ? { ...data.s } : {}, 1186 + }); 1145 1187 } 1146 - rows.push(cols.join('\t')); 1188 + bufferRows.push(row); 1189 + } 1190 + _clipboardBuffer = bufferRows; 1191 + 1192 + // Write both HTML and plain text to the system clipboard 1193 + try { 1194 + const blob = new Blob([html], { type: 'text/html' }); 1195 + const textBlob = new Blob([tsv], { type: 'text/plain' }); 1196 + navigator.clipboard.write([ 1197 + new ClipboardItem({ 1198 + 'text/html': blob, 1199 + 'text/plain': textBlob, 1200 + }), 1201 + ]).catch(() => { 1202 + // Fallback: write plain text only 1203 + navigator.clipboard.writeText(tsv).catch(() => {}); 1204 + }); 1205 + } catch { 1206 + // ClipboardItem not supported -- fallback to plain text 1207 + navigator.clipboard.writeText(tsv).catch(() => {}); 1147 1208 } 1148 - navigator.clipboard.writeText(rows.join('\n')); 1149 1209 } 1150 1210 1151 - function pasteAtSelection(text) { 1152 - const lines = text.split('\n'); 1211 + /** 1212 + * Paste parsed clipboard rows at the current selection. 1213 + * Accepts the row format from parseClipboardHtml/parseClipboardTsv: 1214 + * Array<Array<{ value, formula, style }>> 1215 + */ 1216 + function pasteRowsAtSelection(rows) { 1217 + if (!rows || rows.length === 0) return; 1153 1218 const sc = selectedCell.col; 1154 1219 const sr = selectedCell.row; 1155 1220 ydoc.transact(() => { 1156 - for (let r = 0; r < parsedRows.length; r++) { 1157 - const cols = lines[r].split('\t'); 1158 - for (let c = 0; c < cols.length; c++) { 1159 - const val = cols[c].trim(); 1221 + for (let r = 0; r < rows.length; r++) { 1222 + const row = rows[r]; 1223 + for (let c = 0; c < row.length; c++) { 1224 + const cell = row[c]; 1160 1225 const id = cellId(sc + c, sr + r); 1161 - if (val.startsWith('=')) { setCellData(id, { v: '', f: val.slice(1) }); } 1162 - else { const n = Number(val); setCellData(id, { v: val === '' ? '' : (!isNaN(n) ? n : val), f: '' }); } 1226 + const val = cell.value; 1227 + const formula = cell.formula || ''; 1228 + const style = cell.style && Object.keys(cell.style).length > 0 ? cell.style : undefined; 1229 + if (formula) { 1230 + setCellData(id, { v: val ?? '', f: formula, ...(style ? { s: style } : {}) }); 1231 + } else { 1232 + const n = typeof val === 'number' ? val : Number(val); 1233 + const v = val === '' ? '' : (typeof val === 'number' || !isNaN(n) && String(val).trim() !== '' ? n : val); 1234 + setCellData(id, { v, f: '', ...(style ? { s: style } : {}) }); 1235 + } 1163 1236 } 1164 1237 } 1165 1238 }); ··· 1167 1240 refreshVisibleCells(); 1168 1241 } 1169 1242 1243 + /** 1244 + * Legacy plain-text paste (kept for context menu fallback). 1245 + */ 1246 + function pasteAtSelection(text) { 1247 + const parsed = parseClipboardTsv(text); 1248 + if (parsed) { 1249 + pasteRowsAtSelection(parsed.rows); 1250 + } 1251 + } 1252 + 1253 + // --- Paste Special Dialog --- 1254 + 1255 + function showPasteSpecialDialog() { 1256 + // Prevent duplicate dialogs 1257 + if (document.querySelector('.paste-special-overlay')) return; 1258 + 1259 + const overlay = document.createElement('div'); 1260 + overlay.className = 'sheet-dialog-overlay paste-special-overlay'; 1261 + 1262 + overlay.innerHTML = '<div class="sheet-dialog paste-special-dialog">' 1263 + + '<h3>Paste Special</h3>' 1264 + + '<div class="paste-special-options">' 1265 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="all" checked> <span>All (default)</span></label>' 1266 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="values_only"> <span>Values Only</span></label>' 1267 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="formulas_only"> <span>Formulas Only</span></label>' 1268 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="formatting_only"> <span>Formatting Only</span></label>' 1269 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="transpose"> <span>Transpose</span></label>' 1270 + + '</div>' 1271 + + '<div class="sheet-dialog-actions">' 1272 + + '<button class="paste-special-cancel">Cancel</button>' 1273 + + '<button class="paste-special-submit btn-primary">Paste</button>' 1274 + + '</div>' 1275 + + '</div>'; 1276 + 1277 + document.body.appendChild(overlay); 1278 + 1279 + const dialog = overlay.querySelector('.paste-special-dialog'); 1280 + const cancelBtn = overlay.querySelector('.paste-special-cancel'); 1281 + const submitBtn = overlay.querySelector('.paste-special-submit'); 1282 + 1283 + function close() { 1284 + overlay.remove(); 1285 + document.removeEventListener('keydown', escHandler); 1286 + } 1287 + 1288 + function escHandler(ev) { 1289 + if (ev.key === 'Escape') { close(); } 1290 + } 1291 + 1292 + document.addEventListener('keydown', escHandler); 1293 + 1294 + // Close on click outside dialog 1295 + overlay.addEventListener('click', (ev) => { 1296 + if (ev.target === overlay) close(); 1297 + }); 1298 + 1299 + cancelBtn.addEventListener('click', close); 1300 + 1301 + submitBtn.addEventListener('click', () => { 1302 + const selected = overlay.querySelector('input[name="paste-mode"]:checked'); 1303 + const mode = selected ? selected.value : 'all'; 1304 + close(); 1305 + executePasteSpecial(mode); 1306 + }); 1307 + 1308 + // Focus the first radio for keyboard navigation 1309 + const firstRadio = overlay.querySelector('input[type="radio"]'); 1310 + if (firstRadio) firstRadio.focus(); 1311 + } 1312 + 1313 + function executePasteSpecial(mode) { 1314 + // Get data from internal clipboard buffer if available 1315 + let rows = _clipboardBuffer; 1316 + 1317 + if (!rows || rows.length === 0) { 1318 + // Fallback: try to read from system clipboard (plain text only) 1319 + navigator.clipboard.readText().then(text => { 1320 + if (!text) return; 1321 + const parsed = parseClipboardTsv(text); 1322 + if (parsed) { 1323 + applyPasteSpecialMode(parsed.rows, mode); 1324 + } 1325 + }).catch(() => {}); 1326 + return; 1327 + } 1328 + 1329 + applyPasteSpecialMode(rows, mode); 1330 + } 1331 + 1332 + function applyPasteSpecialMode(rows, mode) { 1333 + if (mode === 'all') { 1334 + pasteRowsAtSelection(rows); 1335 + return; 1336 + } 1337 + 1338 + // Convert to PasteCellData grid for paste-special transforms 1339 + const grid = rows.map(row => 1340 + row.map(cell => ({ 1341 + v: cell.value ?? '', 1342 + f: cell.formula || '', 1343 + s: cell.style || {}, 1344 + })) 1345 + ); 1346 + 1347 + let transformed; 1348 + switch (mode) { 1349 + case PASTE_MODES.VALUES_ONLY: 1350 + transformed = extractValuesOnly(grid); 1351 + break; 1352 + case PASTE_MODES.FORMULAS_ONLY: 1353 + transformed = extractFormulasOnly(grid); 1354 + break; 1355 + case PASTE_MODES.FORMATTING_ONLY: 1356 + transformed = extractFormattingOnly(grid); 1357 + break; 1358 + case PASTE_MODES.TRANSPOSE: 1359 + transformed = transposeGrid(grid); 1360 + break; 1361 + default: 1362 + pasteRowsAtSelection(rows); 1363 + return; 1364 + } 1365 + 1366 + // Convert back to rows format for pasteRowsAtSelection 1367 + const outputRows = transformed.map(row => 1368 + row.map(cell => ({ 1369 + value: cell ? cell.v : '', 1370 + formula: cell ? cell.f : '', 1371 + style: cell ? cell.s : {}, 1372 + })) 1373 + ); 1374 + pasteRowsAtSelection(outputRows); 1375 + } 1376 + 1170 1377 // --- Visual updates (#18: improved range selection) --- 1171 1378 function updateSelectionVisuals() { 1172 1379 grid.querySelectorAll('.selected').forEach(el => el.classList.remove('selected')); ··· 1281 1488 1282 1489 function refreshVisibleCells() { 1283 1490 const cfRules = getCfRulesArray(); 1491 + let hasSparklines = false; 1284 1492 grid.querySelectorAll('td[data-id]').forEach(td => { 1285 1493 const id = td.dataset.id; 1286 1494 const cellData = getCellData(id); 1287 1495 const display = computeDisplayValue(id, cellData); 1288 1496 const displayDiv = td.querySelector('.cell-display'); 1289 1497 if (displayDiv) { 1290 - displayDiv.textContent = display; 1291 - const cfResult = evaluateRules(display, cfRules); 1292 - const cfStyleStr = buildCfStyle(cfResult); 1293 - displayDiv.style.cssText = getCellStyle(cellData, cfStyleStr); 1294 - // Update wrap class 1295 - if (cellData?.s?.wrap) displayDiv.classList.add('cell-wrap'); 1296 - else displayDiv.classList.remove('cell-wrap'); 1498 + if (isSparklineResult(display)) { 1499 + // Replace text content with sparkline canvas if needed 1500 + if (!displayDiv.querySelector('canvas.sparkline-canvas')) { 1501 + displayDiv.innerHTML = '<canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas>'; 1502 + displayDiv.style.cssText = 'padding:0;overflow:hidden;' + getCellStyle(cellData, ''); 1503 + } 1504 + hasSparklines = true; 1505 + } else { 1506 + // Remove sparkline canvas if value is no longer a sparkline 1507 + const existingCanvas = displayDiv.querySelector('canvas.sparkline-canvas'); 1508 + if (existingCanvas) existingCanvas.remove(); 1509 + displayDiv.textContent = display; 1510 + const cfResult = evaluateRules(display, cfRules); 1511 + const cfStyleStr = buildCfStyle(cfResult); 1512 + displayDiv.style.cssText = getCellStyle(cellData, cfStyleStr); 1513 + // Update wrap class 1514 + if (cellData?.s?.wrap) displayDiv.classList.add('cell-wrap'); 1515 + else displayDiv.classList.remove('cell-wrap'); 1516 + } 1297 1517 } 1298 1518 // Update validation state 1299 1519 const validation = getValidationForCell(id); ··· 1304 1524 td.classList.remove('validation-invalid'); 1305 1525 } 1306 1526 }); 1527 + if (hasSparklines) renderSparklines(); 1307 1528 } 1308 1529 1309 1530 // --- Formula bar editing --- ··· 1700 1921 // --- Sheet tabs --- 1701 1922 let dragSourceSheetIdx = -1; 1702 1923 1924 + /** Start inline rename on a sheet tab. */ 1925 + function beginInlineRename(tab: HTMLElement, sheet: any, sheetIdx: number) { 1926 + // Prevent re-entry if already editing 1927 + if (tab.querySelector('.sheet-tab-rename')) return; 1928 + 1929 + const currentName = sheet.get('name') || 'Sheet ' + (sheetIdx + 1); 1930 + 1931 + // Create a contenteditable span 1932 + const input = document.createElement('span'); 1933 + input.className = 'sheet-tab-rename'; 1934 + input.contentEditable = 'true'; 1935 + input.spellcheck = false; 1936 + input.textContent = currentName; 1937 + 1938 + // Replace tab content with the editable span (preserve color bar) 1939 + const colorBar = tab.querySelector('.sheet-tab-color-bar'); 1940 + tab.textContent = ''; 1941 + if (colorBar) tab.appendChild(colorBar); 1942 + tab.appendChild(input); 1943 + tab.draggable = false; // disable drag while editing 1944 + 1945 + // Select all text 1946 + const range = document.createRange(); 1947 + range.selectNodeContents(input); 1948 + const sel = window.getSelection(); 1949 + sel.removeAllRanges(); 1950 + sel.addRange(range); 1951 + input.focus(); 1952 + 1953 + let committed = false; 1954 + 1955 + function commit() { 1956 + if (committed) return; 1957 + committed = true; 1958 + const newName = (input.textContent || '').trim(); 1959 + if (newName && newName !== currentName) { 1960 + renameSheet(sheet, newName); 1961 + } 1962 + renderSheetTabs(); 1963 + } 1964 + 1965 + function cancel() { 1966 + if (committed) return; 1967 + committed = true; 1968 + renderSheetTabs(); 1969 + } 1970 + 1971 + input.addEventListener('keydown', (e) => { 1972 + if (e.key === 'Enter') { e.preventDefault(); commit(); } 1973 + else if (e.key === 'Escape') { e.preventDefault(); cancel(); } 1974 + }); 1975 + 1976 + input.addEventListener('blur', () => { 1977 + // Small delay to allow Escape keydown to fire before blur 1978 + setTimeout(() => { if (!committed) commit(); }, 0); 1979 + }); 1980 + } 1981 + 1982 + /** Show the tab color picker popup near a tab element. */ 1983 + function showTabColorPicker(tab: HTMLElement, sheet: any, sheetIdx: number) { 1984 + // Remove any existing color picker 1985 + document.querySelectorAll('.sheet-tab-color-picker').forEach(el => el.remove()); 1986 + 1987 + const picker = document.createElement('div'); 1988 + picker.className = 'sheet-tab-color-picker'; 1989 + 1990 + const currentColor = getTabColor(sheet); 1991 + 1992 + // "No color" option 1993 + const noneBtn = document.createElement('button'); 1994 + noneBtn.className = 'sheet-tab-color-swatch sheet-tab-color-none'; 1995 + noneBtn.title = 'No color'; 1996 + noneBtn.textContent = '\u2715'; 1997 + if (!currentColor) noneBtn.classList.add('selected'); 1998 + noneBtn.addEventListener('click', () => { 1999 + setTabColor(sheet, null); 2000 + renderSheetTabs(); 2001 + picker.remove(); 2002 + }); 2003 + picker.appendChild(noneBtn); 2004 + 2005 + // Preset colors 2006 + for (const color of TAB_COLORS) { 2007 + const swatch = document.createElement('button'); 2008 + swatch.className = 'sheet-tab-color-swatch'; 2009 + swatch.style.backgroundColor = color.value; 2010 + swatch.title = color.name; 2011 + if (currentColor === color.value) swatch.classList.add('selected'); 2012 + swatch.addEventListener('click', () => { 2013 + setTabColor(sheet, color.value); 2014 + renderSheetTabs(); 2015 + picker.remove(); 2016 + }); 2017 + picker.appendChild(swatch); 2018 + } 2019 + 2020 + // Position below the tab 2021 + const rect = tab.getBoundingClientRect(); 2022 + picker.style.position = 'fixed'; 2023 + picker.style.left = rect.left + 'px'; 2024 + picker.style.top = (rect.top - 40) + 'px'; // above the tab 2025 + picker.style.zIndex = '10000'; 2026 + 2027 + document.body.appendChild(picker); 2028 + 2029 + // Close on click outside 2030 + const closeHandler = (ev) => { 2031 + if (!picker.contains(ev.target)) { 2032 + picker.remove(); 2033 + document.removeEventListener('mousedown', closeHandler); 2034 + } 2035 + }; 2036 + setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 2037 + } 2038 + 2039 + /** Show confirmation dialog and delete a sheet. */ 2040 + function confirmAndDeleteSheet(sheetIdx: number) { 2041 + const total = countSheets(ySheets); 2042 + if (total <= 1) return; // cannot delete last sheet 2043 + 2044 + const sheet = ySheets.get('sheet_' + sheetIdx); 2045 + if (!sheet) return; 2046 + 2047 + const hasData = sheetHasData(sheet); 2048 + if (hasData) { 2049 + if (!confirm('This sheet contains data. Are you sure you want to delete it?')) return; 2050 + } 2051 + 2052 + const newActive = deleteSheet(ydoc, ySheets, sheetIdx, activeSheetIdx); 2053 + if (newActive >= 0) { 2054 + activeSheetIdx = newActive; 2055 + evalCache.clear(); 2056 + invalidateRecalcEngine(); 2057 + renderSheetTabs(); 2058 + renderGrid(); 2059 + } 2060 + } 2061 + 2062 + /** Duplicate a sheet and switch to the copy. */ 2063 + function doDuplicateSheet(sheetIdx: number) { 2064 + const total = countSheets(ySheets); 2065 + const targetIdx = total; // append at end 2066 + const newSheet = duplicateSheet(ydoc, ySheets, sheetIdx, targetIdx); 2067 + if (newSheet) { 2068 + activeSheetIdx = targetIdx; 2069 + evalCache.clear(); 2070 + invalidateRecalcEngine(); 2071 + renderSheetTabs(); 2072 + renderGrid(); 2073 + } 2074 + } 2075 + 2076 + /** Show the sheet tab context menu. */ 2077 + function showSheetTabContextMenu(e: MouseEvent, tab: HTMLElement, sheetIdx: number) { 2078 + e.preventDefault(); 2079 + hideActiveContextMenu(); 2080 + 2081 + const sheet = ensureSheet(sheetIdx); 2082 + const total = countSheets(ySheets); 2083 + 2084 + const items: MenuItem[] = [ 2085 + { label: 'Rename', icon: '\u270F', action: () => beginInlineRename(tab, sheet, sheetIdx) }, 2086 + { label: 'Duplicate', icon: '\u29C9', action: () => doDuplicateSheet(sheetIdx) }, 2087 + { label: 'Delete', icon: '\u2715', action: () => confirmAndDeleteSheet(sheetIdx), disabled: total <= 1 }, 2088 + SEPARATOR, 2089 + { label: 'Move Left', icon: '\u2190', action: () => { reorderSheets(sheetIdx, sheetIdx - 1); }, disabled: !canMoveLeft(sheetIdx) }, 2090 + { label: 'Move Right', icon: '\u2192', action: () => { reorderSheets(sheetIdx, sheetIdx + 1); }, disabled: !canMoveRight(sheetIdx, total) }, 2091 + SEPARATOR, 2092 + { label: 'Tab Color', icon: '\u25CF', action: () => showTabColorPicker(tab, sheet, sheetIdx) }, 2093 + ]; 2094 + 2095 + const ctxMenu = createContextMenu(items); 2096 + document.body.appendChild(ctxMenu.el); 2097 + ctxMenu.show(e.clientX, e.clientY); 2098 + _activeContextMenu = ctxMenu; 2099 + 2100 + // Close on click outside 2101 + const closeHandler = (ev) => { 2102 + if (!ctxMenu.el.contains(ev.target)) { 2103 + hideActiveContextMenu(); 2104 + document.removeEventListener('mousedown', closeHandler); 2105 + } 2106 + }; 2107 + setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 2108 + } 2109 + 1703 2110 function renderSheetTabs() { 1704 2111 sheetTabsContainer.querySelectorAll('.sheet-tab').forEach(t => t.remove()); 1705 2112 let sheetCount = 0; ··· 1712 2119 const tab = document.createElement('button'); 1713 2120 tab.className = 'sheet-tab' + (i === activeSheetIdx ? ' active' : ''); 1714 2121 tab.dataset.sheet = i; 1715 - tab.textContent = sheet.get('name') || 'Sheet ' + (i + 1); 2122 + 2123 + // Tab color indicator bar 2124 + const tabColor = getTabColor(sheet); 2125 + if (tabColor) { 2126 + const colorBar = document.createElement('span'); 2127 + colorBar.className = 'sheet-tab-color-bar'; 2128 + colorBar.style.backgroundColor = tabColor; 2129 + tab.appendChild(colorBar); 2130 + } 2131 + 2132 + // Tab label text 2133 + const label = document.createElement('span'); 2134 + label.className = 'sheet-tab-label'; 2135 + label.textContent = sheet.get('name') || 'Sheet ' + (i + 1); 2136 + tab.appendChild(label); 2137 + 1716 2138 tab.draggable = true; 1717 2139 tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); invalidateRecalcEngine(); renderGrid(); }); 1718 - tab.addEventListener('dblclick', () => { const name = prompt('Sheet name:', sheet.get('name')); if (name) { sheet.set('name', name); renderSheetTabs(); } }); 2140 + 2141 + // Double-click for inline rename 2142 + tab.addEventListener('dblclick', (e) => { 2143 + e.preventDefault(); 2144 + beginInlineRename(tab, sheet, i); 2145 + }); 2146 + 2147 + // Right-click context menu 2148 + tab.addEventListener('contextmenu', (e) => { 2149 + showSheetTabContextMenu(e, tab, i); 2150 + }); 1719 2151 1720 2152 // Drag reorder events 1721 2153 tab.addEventListener('dragstart', (e) => { ··· 1773 2205 const movedKey = sheetKeys.splice(fromIdx, 1)[0]; 1774 2206 sheetKeys.splice(toIdx, 0, movedKey); 1775 2207 1776 - // Store sheet data temporarily 1777 - const sheetData = []; 1778 - for (const key of sheetKeys) { 1779 - sheetData.push(ySheets.get(key)); 1780 - } 1781 - 1782 - // Re-assign sheets to their new indices 2208 + // Re-assign sheets to their new indices via adjacent swaps 1783 2209 ydoc.transact(() => { 1784 - // We need to swap the sheet data at the Yjs level. 1785 - // Since Y.Map entries can't be moved, we swap the names and data between the sheets. 1786 - // Simpler approach: just swap the 'name' and content between sheet maps at fromIdx and toIdx. 1787 - // But to do a full reorder, we need to copy all data. 1788 - 1789 - // Actually, the simplest correct approach for a 2-item swap: 1790 - // Copy the data we need to preserve from both sheets 1791 - const fromSheet = ySheets.get('sheet_' + fromIdx); 1792 - const toSheet = ySheets.get('sheet_' + toIdx); 1793 - if (!fromSheet || !toSheet) return; 1794 - 1795 - // Swap names 1796 - const fromName = fromSheet.get('name'); 1797 - const toName = toSheet.get('name'); 1798 - 1799 - // For a proper reorder, we need to shift all sheets between fromIdx and toIdx. 1800 - // Build array of names in current order 1801 - const names = []; 1802 - for (let i = 0; i < sheetCount; i++) { 1803 - const s = ySheets.get('sheet_' + i); 1804 - names.push(s ? s.get('name') : 'Sheet ' + (i + 1)); 1805 - } 1806 - 1807 - // Reorder the names array 1808 - const movedName = names.splice(fromIdx, 1)[0]; 1809 - names.splice(toIdx, 0, movedName); 1810 - 1811 - // Apply reordered names (cells stay with their sheet index, we just rename) 1812 - // This is a simplified approach - full data reorder would require deep-cloning Y.Maps 1813 - // For now, swap the sheet order by swapping all Y.Map contents 1814 - 1815 - // Actually, the cleanest approach: track sheet order separately 1816 - // But since sheets are keyed by index, we'll do adjacent swaps to move the sheet 1817 - 1818 - // Perform a series of adjacent swaps to move fromIdx to toIdx 1819 2210 const direction = fromIdx < toIdx ? 1 : -1; 1820 2211 let current = fromIdx; 1821 2212 while (current !== toIdx) { ··· 1901 2292 }); 1902 2293 1903 2294 // Swap other properties 1904 - const propsToSwap = ['colWidths', 'rowCount', 'colCount', 'freezeRows', 'freezeCols', 'stripedRows', 'merges', 'cfRules', 'validations', 'notes']; 2295 + const propsToSwap = ['colWidths', 'rowCount', 'colCount', 'freezeRows', 'freezeCols', 'stripedRows', 'merges', 'cfRules', 'validations', 'notes', 'tabColor']; 1905 2296 for (const prop of propsToSwap) { 1906 2297 const valA = sheetA.get(prop); 1907 2298 const valB = sheetB.get(prop); 1908 - // Only swap simple values (numbers, booleans). Y.Map/Y.Array refs can't be swapped directly. 2299 + // Only swap simple values (numbers, booleans, strings). Y.Map/Y.Array refs can't be swapped directly. 1909 2300 if (typeof valA !== 'object' && typeof valB !== 'object') { 1910 2301 sheetA.set(prop, valB !== undefined ? valB : null); 1911 2302 sheetB.set(prop, valA !== undefined ? valA : null); ··· 2190 2581 input.click(); 2191 2582 } 2192 2583 2193 - function printSheet() { window.print(); } 2584 + function printSheet() { 2585 + const sheet = getActiveSheet(); 2586 + const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 2587 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 2588 + const mergeMap = buildMergeMap(); 2589 + 2590 + // Find actual data extent to avoid printing huge empty grids 2591 + let maxRow = 0, maxCol = 0; 2592 + const cells = getCells(); 2593 + cells.forEach((_, id) => { 2594 + const ref = parseRef(id); 2595 + if (ref) { 2596 + if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 2597 + if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 2598 + } 2599 + }); 2600 + maxRow = Math.max(maxRow, 1); 2601 + maxCol = Math.max(maxCol, 1); 2602 + 2603 + // Column headers 2604 + const headers: string[] = []; 2605 + const colWidths: number[] = []; 2606 + for (let c = 0; c < maxCol; c++) { 2607 + if (isColHidden(c)) continue; 2608 + headers.push(colToLetter(c)); 2609 + colWidths.push(getColWidth(c)); 2610 + } 2611 + 2612 + // Build row data 2613 + const rows: PrintRow[] = []; 2614 + for (let r = 0; r < maxRow; r++) { 2615 + if (isRowHidden(r)) continue; 2616 + const rowCells: (PrintCell | null)[] = []; 2617 + for (let c = 0; c < maxCol; c++) { 2618 + if (isColHidden(c)) continue; 2619 + const id = cellId(c, r); 2620 + const mergeInfo = mergeMap.get(id); 2621 + if (mergeInfo?.hidden) { rowCells.push(null); continue; } 2622 + 2623 + const cellData = getCellData(id); 2624 + const displayValue = computeDisplayValue(id, cellData); 2625 + const displayStr = (typeof displayValue === 'object') ? '' : String(displayValue ?? ''); 2626 + const style = cellData?.s || {}; 2627 + 2628 + const printCell: PrintCell = { value: displayStr }; 2629 + const cellStyle: any = {}; 2630 + if (style.bold) cellStyle.bold = true; 2631 + if (style.italic) cellStyle.italic = true; 2632 + if (style.underline) cellStyle.underline = true; 2633 + if (style.strikethrough) cellStyle.strikethrough = true; 2634 + if (style.color) cellStyle.color = style.color; 2635 + if (style.bg) cellStyle.bg = style.bg; 2636 + if (style.align) cellStyle.align = style.align; 2637 + if (style.vAlign) cellStyle.verticalAlign = style.vAlign; 2638 + if (style.fontSize) cellStyle.fontSize = style.fontSize; 2639 + if (style.fontFamily) cellStyle.fontFamily = style.fontFamily; 2640 + if (Object.keys(cellStyle).length > 0) printCell.style = cellStyle; 2641 + 2642 + if (mergeInfo?.colspan > 1) printCell.colspan = mergeInfo.colspan; 2643 + if (mergeInfo?.rowspan > 1) printCell.rowspan = mergeInfo.rowspan; 2644 + 2645 + rowCells.push(printCell); 2646 + } 2647 + rows.push({ cells: rowCells }); 2648 + } 2649 + 2650 + const sheetName = sheet.get('name') || 'Sheet 1'; 2651 + const printData: SheetsPrintData = { headers, rows, colWidths }; 2652 + const printOpts: SheetsPrintOptions = { 2653 + title: sheetName, 2654 + gridLines: true, 2655 + repeatHeaders: true, 2656 + scaling: 'fit-to-width', 2657 + orientation: 'landscape', 2658 + }; 2659 + 2660 + const html = buildSheetsPrintHtml(printData, printOpts); 2661 + const printWindow = window.open('', '_blank'); 2662 + if (printWindow) { 2663 + printWindow.document.write(html); 2664 + printWindow.document.close(); 2665 + printWindow.addEventListener('load', () => { printWindow.print(); }); 2666 + } 2667 + } 2194 2668 2195 2669 // Toolbar button bindings for export/import/print 2196 2670 document.getElementById('tb-export-csv').addEventListener('click', () => { exportCSV(); closeAllDropdowns(); }); ··· 2372 2846 { keys: ['Delete'], label: 'Clear selected cells' }, 2373 2847 { keys: ['\u2318', 'C'], label: 'Copy' }, 2374 2848 { keys: ['\u2318', 'V'], label: 'Paste' }, 2849 + { keys: ['\u2318', '\u21e7', 'V'], label: 'Paste Special' }, 2375 2850 { keys: ['\u2318', 'Z'], label: 'Undo' }, 2376 2851 ]}, 2377 2852 { category: 'Document', shortcuts: [ ··· 3602 4077 } 3603 4078 } 3604 4079 4080 + function renderSparklines() { 4081 + grid.querySelectorAll('canvas.sparkline-canvas').forEach(canvas => { 4082 + const id = canvas.dataset.sparklineId; 4083 + if (!id) return; 4084 + const cellData = getCellData(id); 4085 + const val = computeDisplayValue(id, cellData); 4086 + if (!isSparklineResult(val)) return; 4087 + 4088 + // Size canvas to actual pixel dimensions of parent 4089 + const parent = canvas.parentElement; 4090 + if (parent) { 4091 + const rect = parent.getBoundingClientRect(); 4092 + const dpr = window.devicePixelRatio || 1; 4093 + canvas.width = rect.width * dpr; 4094 + canvas.height = rect.height * dpr; 4095 + canvas.style.width = rect.width + 'px'; 4096 + canvas.style.height = rect.height + 'px'; 4097 + const ctx = canvas.getContext('2d'); 4098 + if (ctx) ctx.scale(dpr, dpr); 4099 + } 4100 + drawSparkline(canvas, val); 4101 + }); 4102 + } 4103 + 3605 4104 // Show note tooltip on hover 3606 4105 grid.addEventListener('mouseover', (e) => { 3607 4106 const td = e.target.closest('td[data-id]'); ··· 3676 4175 { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => { copySelection(); deleteSelectedCells(); } }, 3677 4176 { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => copySelection() }, 3678 4177 { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => { navigator.clipboard.readText().then(text => pasteAtSelection(text)).catch(() => {}); } }, 4178 + { label: 'Paste Special...', shortcut: '\u2318\u21e7V', action: () => showPasteSpecialDialog() }, 3679 4179 SEPARATOR, 3680 4180 { label: 'Insert Row Above', action: () => doInsertRow(row) }, 3681 4181 { label: 'Insert Row Below', action: () => doInsertRow(row + 1) },
+361
src/sheets/sheet-tab-management.ts
··· 1 + /** 2 + * Sheet Tab Management — logic for duplicate, delete, move, color, and rename. 3 + * 4 + * Pure functions that operate on Yjs data structures for sheet management. 5 + * The UI layer in main.ts calls these functions and handles DOM updates. 6 + */ 7 + 8 + import * as Y from 'yjs'; 9 + 10 + /** Preset tab colors for the color picker. */ 11 + export const TAB_COLORS = [ 12 + { name: 'Red', value: '#ef4444' }, 13 + { name: 'Orange', value: '#f97316' }, 14 + { name: 'Yellow', value: '#eab308' }, 15 + { name: 'Green', value: '#22c55e' }, 16 + { name: 'Teal', value: '#14b8a6' }, 17 + { name: 'Blue', value: '#3b82f6' }, 18 + { name: 'Purple', value: '#a855f7' }, 19 + { name: 'Pink', value: '#ec4899' }, 20 + ] as const; 21 + 22 + /** 23 + * Count the number of sheets in a ySheets map. 24 + */ 25 + export function countSheets(ySheets: Y.Map<any>): number { 26 + let count = 0; 27 + ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) count++; }); 28 + return count; 29 + } 30 + 31 + /** 32 + * Check whether a sheet has any data (cells, merges, notes, cf rules, validations). 33 + * Returns true if the sheet has at least one non-empty data item. 34 + */ 35 + export function sheetHasData(sheet: Y.Map<any>): boolean { 36 + const cells = sheet.get('cells') as Y.Map<any> | undefined; 37 + if (cells && cells.size > 0) return true; 38 + 39 + const merges = sheet.get('merges') as Y.Map<any> | undefined; 40 + if (merges && merges.size > 0) return true; 41 + 42 + const notes = sheet.get('notes') as Y.Map<any> | undefined; 43 + if (notes && notes.size > 0) return true; 44 + 45 + const cfRules = sheet.get('cfRules') as Y.Array<any> | undefined; 46 + if (cfRules && cfRules.length > 0) return true; 47 + 48 + const validations = sheet.get('validations') as Y.Map<any> | undefined; 49 + if (validations && validations.size > 0) return true; 50 + 51 + const charts = sheet.get('charts') as Y.Map<any> | undefined; 52 + if (charts && charts.size > 0) return true; 53 + 54 + return false; 55 + } 56 + 57 + /** 58 + * Generate a name for a duplicated sheet. 59 + * Appends " (Copy)" to the source name, incrementing a counter if duplicates exist. 60 + */ 61 + export function generateCopyName(sourceName: string, existingNames: string[]): string { 62 + const baseCopyName = `${sourceName} (Copy)`; 63 + if (!existingNames.includes(baseCopyName)) return baseCopyName; 64 + 65 + let counter = 2; 66 + while (existingNames.includes(`${sourceName} (Copy ${counter})`)) { 67 + counter++; 68 + } 69 + return `${sourceName} (Copy ${counter})`; 70 + } 71 + 72 + /** 73 + * Collect all existing sheet names from ySheets. 74 + */ 75 + export function getSheetNames(ySheets: Y.Map<any>): string[] { 76 + const names: string[] = []; 77 + const count = countSheets(ySheets); 78 + for (let i = 0; i < count; i++) { 79 + const sheet = ySheets.get(`sheet_${i}`) as Y.Map<any> | undefined; 80 + if (sheet) names.push(sheet.get('name') || `Sheet ${i + 1}`); 81 + } 82 + return names; 83 + } 84 + 85 + /** 86 + * Deep-clone a Y.Map cell into a new Y.Map. 87 + */ 88 + function cloneCell(srcCell: Y.Map<any>): Y.Map<any> { 89 + const newCell = new Y.Map(); 90 + const v = srcCell.get('v'); 91 + const f = srcCell.get('f'); 92 + const s = srcCell.get('s'); 93 + if (v !== undefined) newCell.set('v', v); 94 + if (f !== undefined) newCell.set('f', f); 95 + if (s !== undefined) newCell.set('s', s); 96 + return newCell; 97 + } 98 + 99 + /** 100 + * Deep-clone a Y.Map<Y.Map> of cells into a new Y.Map<Y.Map>. 101 + */ 102 + function cloneCellsMap(srcCells: Y.Map<any>): Y.Map<any> { 103 + const newCells = new Y.Map(); 104 + srcCells.forEach((cell, key) => { 105 + newCells.set(key, cloneCell(cell)); 106 + }); 107 + return newCells; 108 + } 109 + 110 + /** 111 + * Clone a Y.Map of simple key-value pairs (strings, numbers, booleans). 112 + */ 113 + function cloneSimpleYMap(src: Y.Map<any>): Y.Map<any> { 114 + const dest = new Y.Map(); 115 + src.forEach((val, key) => { 116 + dest.set(key, val); 117 + }); 118 + return dest; 119 + } 120 + 121 + /** 122 + * Clone a Y.Array of JSON strings. 123 + */ 124 + function cloneYArray(src: Y.Array<any>): Y.Array<any> { 125 + const dest = new Y.Array(); 126 + const items: any[] = []; 127 + for (let i = 0; i < src.length; i++) { 128 + items.push(src.get(i)); 129 + } 130 + if (items.length > 0) dest.insert(0, items); 131 + return dest; 132 + } 133 + 134 + /** 135 + * Duplicate a sheet's data to a new sheet at the given target index. 136 + * 137 + * @param ydoc - the Y.Doc for transacting 138 + * @param ySheets - the shared sheets map 139 + * @param sourceIdx - index of the sheet to duplicate 140 + * @param targetIdx - index where the new sheet will be placed 141 + * @returns the new sheet's Y.Map, or null if source doesn't exist 142 + */ 143 + export function duplicateSheet( 144 + ydoc: Y.Doc, 145 + ySheets: Y.Map<any>, 146 + sourceIdx: number, 147 + targetIdx: number, 148 + ): Y.Map<any> | null { 149 + const sourceSheet = ySheets.get(`sheet_${sourceIdx}`) as Y.Map<any> | undefined; 150 + if (!sourceSheet) return null; 151 + 152 + const sourceName = sourceSheet.get('name') || `Sheet ${sourceIdx + 1}`; 153 + const existingNames = getSheetNames(ySheets); 154 + const newName = generateCopyName(sourceName, existingNames); 155 + 156 + let newSheet: Y.Map<any> | null = null; 157 + 158 + ydoc.transact(() => { 159 + newSheet = new Y.Map(); 160 + 161 + // Name 162 + newSheet!.set('name', newName); 163 + 164 + // Clone cells 165 + const srcCells = sourceSheet.get('cells') as Y.Map<any> | undefined; 166 + if (srcCells) { 167 + newSheet!.set('cells', cloneCellsMap(srcCells)); 168 + } else { 169 + newSheet!.set('cells', new Y.Map()); 170 + } 171 + 172 + // Clone column widths 173 + const srcColWidths = sourceSheet.get('colWidths') as Y.Map<any> | undefined; 174 + if (srcColWidths) { 175 + newSheet!.set('colWidths', cloneSimpleYMap(srcColWidths)); 176 + } 177 + 178 + // Clone row heights 179 + const srcRowHeights = sourceSheet.get('rowHeights') as Y.Map<any> | undefined; 180 + if (srcRowHeights) { 181 + newSheet!.set('rowHeights', cloneSimpleYMap(srcRowHeights)); 182 + } 183 + 184 + // Simple scalar properties 185 + const scalarProps = ['rowCount', 'colCount', 'freezeRows', 'freezeCols', 'stripedRows', 'tabColor']; 186 + for (const prop of scalarProps) { 187 + const val = sourceSheet.get(prop); 188 + if (val !== undefined) newSheet!.set(prop, val); 189 + } 190 + 191 + // Clone merges (Y.Map of JSON strings) 192 + const srcMerges = sourceSheet.get('merges') as Y.Map<any> | undefined; 193 + if (srcMerges) { 194 + newSheet!.set('merges', cloneSimpleYMap(srcMerges)); 195 + } 196 + 197 + // Clone conditional formatting rules (Y.Array of JSON strings) 198 + const srcCfRules = sourceSheet.get('cfRules') as Y.Array<any> | undefined; 199 + if (srcCfRules) { 200 + newSheet!.set('cfRules', cloneYArray(srcCfRules)); 201 + } 202 + 203 + // Clone data validations (Y.Map of JSON strings) 204 + const srcValidations = sourceSheet.get('validations') as Y.Map<any> | undefined; 205 + if (srcValidations) { 206 + newSheet!.set('validations', cloneSimpleYMap(srcValidations)); 207 + } 208 + 209 + // Clone notes (Y.Map of strings) 210 + const srcNotes = sourceSheet.get('notes') as Y.Map<any> | undefined; 211 + if (srcNotes) { 212 + newSheet!.set('notes', cloneSimpleYMap(srcNotes)); 213 + } 214 + 215 + // Clone hidden rows/cols 216 + const srcHiddenRows = sourceSheet.get('hiddenRows') as Y.Map<any> | undefined; 217 + if (srcHiddenRows) { 218 + newSheet!.set('hiddenRows', cloneSimpleYMap(srcHiddenRows)); 219 + } 220 + 221 + const srcHiddenCols = sourceSheet.get('hiddenCols') as Y.Map<any> | undefined; 222 + if (srcHiddenCols) { 223 + newSheet!.set('hiddenCols', cloneSimpleYMap(srcHiddenCols)); 224 + } 225 + 226 + // Clone charts (Y.Map of JSON strings) 227 + const srcCharts = sourceSheet.get('charts') as Y.Map<any> | undefined; 228 + if (srcCharts) { 229 + newSheet!.set('charts', cloneSimpleYMap(srcCharts)); 230 + } 231 + 232 + // Set the new sheet at target index 233 + ySheets.set(`sheet_${targetIdx}`, newSheet!); 234 + }); 235 + 236 + return newSheet; 237 + } 238 + 239 + /** 240 + * Deep-clone one sheet's entire content into another (already-integrated) sheet. 241 + * This is needed because Yjs Y.Maps that are already integrated into a doc 242 + * cannot be moved to a different key — we must copy data into a fresh map. 243 + */ 244 + function copySheetContent(src: Y.Map<any>, dest: Y.Map<any>): void { 245 + // Clear destination first 246 + const destKeys: string[] = []; 247 + dest.forEach((_, key) => destKeys.push(key)); 248 + for (const key of destKeys) dest.delete(key); 249 + 250 + // Copy name 251 + dest.set('name', src.get('name')); 252 + 253 + // Copy cells (deep clone each Y.Map cell) 254 + const srcCells = src.get('cells') as Y.Map<any> | undefined; 255 + if (srcCells) { 256 + dest.set('cells', cloneCellsMap(srcCells)); 257 + } else { 258 + dest.set('cells', new Y.Map()); 259 + } 260 + 261 + // Copy Y.Map properties (colWidths, rowHeights, merges, validations, notes, hiddenRows, hiddenCols, charts) 262 + const mapProps = ['colWidths', 'rowHeights', 'merges', 'validations', 'notes', 'hiddenRows', 'hiddenCols', 'charts']; 263 + for (const prop of mapProps) { 264 + const srcMap = src.get(prop) as Y.Map<any> | undefined; 265 + if (srcMap) dest.set(prop, cloneSimpleYMap(srcMap)); 266 + } 267 + 268 + // Copy Y.Array properties (cfRules) 269 + const srcCfRules = src.get('cfRules') as Y.Array<any> | undefined; 270 + if (srcCfRules) dest.set('cfRules', cloneYArray(srcCfRules)); 271 + 272 + // Copy scalar properties 273 + const scalarProps = ['rowCount', 'colCount', 'freezeRows', 'freezeCols', 'stripedRows', 'tabColor']; 274 + for (const prop of scalarProps) { 275 + const val = src.get(prop); 276 + if (val !== undefined) dest.set(prop, val); 277 + } 278 + } 279 + 280 + /** 281 + * Delete a sheet at the given index, shifting subsequent sheets down. 282 + * 283 + * Because Yjs Y.Map objects cannot be re-parented once integrated, we copy 284 + * each subsequent sheet's data into the previous slot rather than moving refs. 285 + * 286 + * @returns the recommended new activeSheetIdx, or -1 if deletion was refused (last sheet) 287 + */ 288 + export function deleteSheet( 289 + ydoc: Y.Doc, 290 + ySheets: Y.Map<any>, 291 + deleteIdx: number, 292 + currentActiveIdx: number, 293 + ): number { 294 + const sheetCount = countSheets(ySheets); 295 + if (sheetCount <= 1) return -1; // cannot delete last sheet 296 + 297 + ydoc.transact(() => { 298 + // Shift all sheets after deleteIdx down by one, copying content 299 + for (let i = deleteIdx; i < sheetCount - 1; i++) { 300 + const destSheet = ySheets.get(`sheet_${i}`) as Y.Map<any>; 301 + const srcSheet = ySheets.get(`sheet_${i + 1}`) as Y.Map<any>; 302 + if (destSheet && srcSheet) { 303 + copySheetContent(srcSheet, destSheet); 304 + } 305 + } 306 + // Remove the last (now-duplicate) entry 307 + ySheets.delete(`sheet_${sheetCount - 1}`); 308 + }); 309 + 310 + // Compute new active index 311 + const newCount = sheetCount - 1; 312 + if (currentActiveIdx === deleteIdx) { 313 + // Deleted the active sheet: go to previous, or 0 if deleting first 314 + return Math.min(Math.max(deleteIdx - 1, 0), newCount - 1); 315 + } else if (currentActiveIdx > deleteIdx) { 316 + return currentActiveIdx - 1; 317 + } 318 + return currentActiveIdx; 319 + } 320 + 321 + /** 322 + * Set the tab color for a sheet. 323 + */ 324 + export function setTabColor(sheet: Y.Map<any>, color: string | null): void { 325 + if (color) { 326 + sheet.set('tabColor', color); 327 + } else { 328 + if (sheet.has('tabColor')) sheet.delete('tabColor'); 329 + } 330 + } 331 + 332 + /** 333 + * Get the tab color for a sheet (or null if unset). 334 + */ 335 + export function getTabColor(sheet: Y.Map<any>): string | null { 336 + return sheet.get('tabColor') || null; 337 + } 338 + 339 + /** 340 + * Rename a sheet. 341 + */ 342 + export function renameSheet(sheet: Y.Map<any>, newName: string): boolean { 343 + const trimmed = newName.trim(); 344 + if (!trimmed) return false; 345 + sheet.set('name', trimmed); 346 + return true; 347 + } 348 + 349 + /** 350 + * Check if a sheet can be moved left (index > 0). 351 + */ 352 + export function canMoveLeft(sheetIdx: number): boolean { 353 + return sheetIdx > 0; 354 + } 355 + 356 + /** 357 + * Check if a sheet can be moved right (index < count - 1). 358 + */ 359 + export function canMoveRight(sheetIdx: number, sheetCount: number): boolean { 360 + return sheetIdx < sheetCount - 1; 361 + }
+388
src/sheets/sparkline.ts
··· 1 + /** 2 + * Sparkline formula support — pure-logic module for SPARKLINE(). 3 + * 4 + * Computes geometry for line, bar, and win/loss sparkline charts. 5 + * Rendering to canvas is handled by drawSparkline(). 6 + */ 7 + 8 + // --- Types --- 9 + 10 + export type SparklineChartType = 'line' | 'bar' | 'winloss'; 11 + 12 + export interface SparklineOptions { 13 + color?: string; 14 + negColor?: string; 15 + lineWidth?: number; 16 + minValue?: number; 17 + maxValue?: number; 18 + showAxis?: boolean; 19 + } 20 + 21 + export interface SparklineResult { 22 + __sparkline: true; 23 + chartType: SparklineChartType; 24 + data: number[]; 25 + options: SparklineOptions; 26 + } 27 + 28 + export interface LinePoint { 29 + x: number; 30 + y: number; 31 + } 32 + 33 + export interface BarRect { 34 + x: number; 35 + y: number; 36 + width: number; 37 + height: number; 38 + negative: boolean; 39 + } 40 + 41 + export interface WinLossRect { 42 + x: number; 43 + y: number; 44 + width: number; 45 + height: number; 46 + category: 'win' | 'loss' | 'zero'; 47 + } 48 + 49 + // --- Constants --- 50 + 51 + const VALID_CHART_TYPES: ReadonlySet<string> = new Set(['line', 'bar', 'winloss']); 52 + 53 + const DEFAULT_COLOR = '#3b82f6'; 54 + const DEFAULT_NEG_COLOR = '#ef4444'; 55 + const DEFAULT_LINE_WIDTH = 1.5; 56 + 57 + // --- Type guard --- 58 + 59 + export function isSparklineResult(value: unknown): value is SparklineResult { 60 + return ( 61 + value !== null && 62 + typeof value === 'object' && 63 + (value as Record<string, unknown>).__sparkline === true 64 + ); 65 + } 66 + 67 + // --- Argument parsing --- 68 + 69 + /** 70 + * Parse SPARKLINE() arguments into a SparklineResult marker object. 71 + * 72 + * @param args - [dataArray, optionsOrType?] 73 + * - dataArray: flat array of values (numbers, strings, nulls mixed) 74 + * - optionsOrType: string ("line"/"bar"/"winloss") or options object 75 + * @returns SparklineResult or null if invalid 76 + */ 77 + export function parseSparklineArgs(args: unknown[]): SparklineResult | null { 78 + if (args.length === 0) return null; 79 + 80 + // First arg: data array (may be nested from range resolution) 81 + const rawData = Array.isArray(args[0]) ? (args[0] as unknown[]).flat(Infinity) : [args[0]]; 82 + 83 + // Filter to numeric values only 84 + const data: number[] = []; 85 + for (const v of rawData) { 86 + if (typeof v === 'number' && !isNaN(v)) { 87 + data.push(v); 88 + } 89 + } 90 + 91 + if (data.length === 0) return null; 92 + 93 + // Second arg: chart type string or options object 94 + let chartType: SparklineChartType = 'line'; 95 + let options: SparklineOptions = {}; 96 + 97 + if (args.length > 1 && args[1] !== undefined && args[1] !== null) { 98 + const second = args[1]; 99 + 100 + if (typeof second === 'string') { 101 + const lower = second.toLowerCase(); 102 + if (!VALID_CHART_TYPES.has(lower)) return null; 103 + chartType = lower as SparklineChartType; 104 + } else if (typeof second === 'object' && !Array.isArray(second)) { 105 + const obj = second as Record<string, unknown>; 106 + 107 + if (obj.type !== undefined) { 108 + const t = String(obj.type).toLowerCase(); 109 + if (!VALID_CHART_TYPES.has(t)) return null; 110 + chartType = t as SparklineChartType; 111 + } 112 + 113 + if (typeof obj.color === 'string') options.color = obj.color; 114 + if (typeof obj.negColor === 'string') options.negColor = obj.negColor; 115 + if (typeof obj.lineWidth === 'number') options.lineWidth = obj.lineWidth; 116 + if (typeof obj.minValue === 'number') options.minValue = obj.minValue; 117 + if (typeof obj.maxValue === 'number') options.maxValue = obj.maxValue; 118 + if (typeof obj.showAxis === 'boolean') options.showAxis = obj.showAxis; 119 + } 120 + } 121 + 122 + return { 123 + __sparkline: true, 124 + chartType, 125 + data, 126 + options, 127 + }; 128 + } 129 + 130 + // --- Geometry computation --- 131 + 132 + /** 133 + * Compute (x, y) points for a line sparkline. 134 + * Y is inverted: min value maps to height (bottom), max to 0 (top). 135 + */ 136 + export function computeLinePoints( 137 + data: number[], 138 + width: number, 139 + height: number, 140 + options: SparklineOptions, 141 + ): LinePoint[] { 142 + if (data.length === 0) return []; 143 + 144 + const minVal = options.minValue ?? Math.min(...data); 145 + const maxVal = options.maxValue ?? Math.max(...data); 146 + const range = maxVal - minVal; 147 + 148 + if (data.length === 1) { 149 + // Single point: center it 150 + const y = range === 0 ? height / 2 : height - ((data[0] - minVal) / range) * height; 151 + return [{ x: width / 2, y }]; 152 + } 153 + 154 + return data.map((v, i) => { 155 + const x = (i / (data.length - 1)) * width; 156 + const y = range === 0 ? height / 2 : height - ((v - minVal) / range) * height; 157 + return { x, y }; 158 + }); 159 + } 160 + 161 + /** 162 + * Compute rectangles for a bar sparkline. 163 + * Positive bars extend upward from baseline, negative extend downward. 164 + */ 165 + export function computeBarRects( 166 + data: number[], 167 + width: number, 168 + height: number, 169 + options: SparklineOptions, 170 + ): BarRect[] { 171 + if (data.length === 0) return []; 172 + 173 + const minVal = options.minValue ?? Math.min(...data); 174 + const maxVal = options.maxValue ?? Math.max(...data); 175 + 176 + const barWidth = width / data.length; 177 + const hasNegative = minVal < 0; 178 + 179 + if (!hasNegative) { 180 + // All positive: baseline at bottom, bars grow upward 181 + const range = maxVal - minVal; 182 + return data.map((v, i) => { 183 + // When all values are the same (range=0), each bar fills full height 184 + const barHeight = range === 0 ? height : ((v - minVal) / range) * height; 185 + return { 186 + x: i * barWidth, 187 + y: height - barHeight, 188 + width: barWidth, 189 + height: barHeight, 190 + negative: false, 191 + }; 192 + }); 193 + } 194 + 195 + // Mixed positive/negative: baseline proportional to where 0 sits 196 + const range = maxVal - minVal || 1; 197 + const baselineY = (maxVal / range) * height; // Y position of zero line 198 + 199 + return data.map((v, i) => { 200 + if (v >= 0) { 201 + const barHeight = (v / range) * height; 202 + return { 203 + x: i * barWidth, 204 + y: baselineY - barHeight, 205 + width: barWidth, 206 + height: barHeight, 207 + negative: false, 208 + }; 209 + } else { 210 + const barHeight = (Math.abs(v) / range) * height; 211 + return { 212 + x: i * barWidth, 213 + y: baselineY, 214 + width: barWidth, 215 + height: barHeight, 216 + negative: true, 217 + }; 218 + } 219 + }); 220 + } 221 + 222 + /** 223 + * Compute rectangles for a win/loss sparkline. 224 + * Fixed-height blocks: positive in top half, negative in bottom half, zero as thin center line. 225 + */ 226 + export function computeWinLossRects( 227 + data: number[], 228 + width: number, 229 + height: number, 230 + _options: SparklineOptions, 231 + ): WinLossRect[] { 232 + if (data.length === 0) return []; 233 + 234 + const barWidth = width / data.length; 235 + const halfHeight = height / 2; 236 + const blockHeight = halfHeight - 2; // Small gap from center 237 + const zeroHeight = 2; 238 + 239 + return data.map((v, i) => { 240 + if (v > 0) { 241 + return { 242 + x: i * barWidth, 243 + y: halfHeight - blockHeight, 244 + width: barWidth, 245 + height: blockHeight, 246 + category: 'win' as const, 247 + }; 248 + } else if (v < 0) { 249 + return { 250 + x: i * barWidth, 251 + y: halfHeight, 252 + width: barWidth, 253 + height: blockHeight, 254 + category: 'loss' as const, 255 + }; 256 + } else { 257 + return { 258 + x: i * barWidth, 259 + y: halfHeight - zeroHeight / 2, 260 + width: barWidth, 261 + height: zeroHeight, 262 + category: 'zero' as const, 263 + }; 264 + } 265 + }); 266 + } 267 + 268 + // --- Canvas rendering --- 269 + 270 + /** 271 + * Draw a sparkline onto a canvas element using Canvas 2D API. 272 + */ 273 + export function drawSparkline( 274 + canvas: HTMLCanvasElement, 275 + result: SparklineResult, 276 + ): void { 277 + const ctx = canvas.getContext('2d'); 278 + if (!ctx) return; 279 + 280 + const w = canvas.width; 281 + const h = canvas.height; 282 + const opts = result.options; 283 + const color = opts.color || DEFAULT_COLOR; 284 + const negColor = opts.negColor || DEFAULT_NEG_COLOR; 285 + 286 + ctx.clearRect(0, 0, w, h); 287 + 288 + switch (result.chartType) { 289 + case 'line': { 290 + const points = computeLinePoints(result.data, w, h, opts); 291 + if (points.length === 0) return; 292 + 293 + // Optional zero axis 294 + if (opts.showAxis) { 295 + const minVal = opts.minValue ?? Math.min(...result.data); 296 + const maxVal = opts.maxValue ?? Math.max(...result.data); 297 + const range = maxVal - minVal; 298 + if (range > 0) { 299 + const axisY = h - ((0 - minVal) / range) * h; 300 + if (axisY >= 0 && axisY <= h) { 301 + ctx.strokeStyle = '#94a3b8'; 302 + ctx.lineWidth = 0.5; 303 + ctx.beginPath(); 304 + ctx.moveTo(0, axisY); 305 + ctx.lineTo(w, axisY); 306 + ctx.stroke(); 307 + } 308 + } 309 + } 310 + 311 + ctx.strokeStyle = color; 312 + ctx.lineWidth = opts.lineWidth ?? DEFAULT_LINE_WIDTH; 313 + ctx.lineJoin = 'round'; 314 + ctx.lineCap = 'round'; 315 + ctx.beginPath(); 316 + ctx.moveTo(points[0].x, points[0].y); 317 + for (let i = 1; i < points.length; i++) { 318 + ctx.lineTo(points[i].x, points[i].y); 319 + } 320 + ctx.stroke(); 321 + 322 + // Draw endpoint dot 323 + if (points.length > 1) { 324 + const last = points[points.length - 1]; 325 + ctx.fillStyle = color; 326 + ctx.beginPath(); 327 + ctx.arc(last.x, last.y, 2, 0, Math.PI * 2); 328 + ctx.fill(); 329 + } 330 + break; 331 + } 332 + 333 + case 'bar': { 334 + const rects = computeBarRects(result.data, w, h, opts); 335 + 336 + // Optional zero axis 337 + if (opts.showAxis) { 338 + const minVal = opts.minValue ?? Math.min(...result.data); 339 + const maxVal = opts.maxValue ?? Math.max(...result.data); 340 + const range = maxVal - minVal; 341 + if (range > 0 && minVal < 0) { 342 + const axisY = (maxVal / range) * h; 343 + ctx.strokeStyle = '#94a3b8'; 344 + ctx.lineWidth = 0.5; 345 + ctx.beginPath(); 346 + ctx.moveTo(0, axisY); 347 + ctx.lineTo(w, axisY); 348 + ctx.stroke(); 349 + } 350 + } 351 + 352 + for (const rect of rects) { 353 + ctx.fillStyle = rect.negative ? negColor : color; 354 + // Leave 1px gap between bars 355 + ctx.fillRect(rect.x + 0.5, rect.y, rect.width - 1, rect.height); 356 + } 357 + break; 358 + } 359 + 360 + case 'winloss': { 361 + const rects = computeWinLossRects(result.data, w, h, opts); 362 + 363 + // Draw center line 364 + ctx.strokeStyle = '#94a3b8'; 365 + ctx.lineWidth = 0.5; 366 + ctx.beginPath(); 367 + ctx.moveTo(0, h / 2); 368 + ctx.lineTo(w, h / 2); 369 + ctx.stroke(); 370 + 371 + for (const rect of rects) { 372 + switch (rect.category) { 373 + case 'win': 374 + ctx.fillStyle = color; 375 + break; 376 + case 'loss': 377 + ctx.fillStyle = negColor; 378 + break; 379 + case 'zero': 380 + ctx.fillStyle = '#94a3b8'; 381 + break; 382 + } 383 + ctx.fillRect(rect.x + 0.5, rect.y, rect.width - 1, rect.height); 384 + } 385 + break; 386 + } 387 + } 388 + }
+320
tests/paste-special-integration.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * Paste Special Integration Tests 4 + * 5 + * Tests the integration between clipboard-copy, clipboard-paste, and 6 + * paste-special modules -- verifying that data round-trips correctly 7 + * and that paste-special modes transform data as expected. 8 + */ 9 + import { describe, it, expect } from 'vitest'; 10 + 11 + import { 12 + extractValuesOnly, 13 + extractFormulasOnly, 14 + extractFormattingOnly, 15 + transposeGrid, 16 + PASTE_MODES, 17 + } from '../src/sheets/paste-special.js'; 18 + import { 19 + parseClipboardHtml, 20 + parseClipboardTsv, 21 + parseInlineStyles, 22 + } from '../src/sheets/clipboard-paste.js'; 23 + import { 24 + buildCopyHtml, 25 + buildCopyTsv, 26 + cellStyleToCss, 27 + } from '../src/sheets/clipboard-copy.js'; 28 + import type { PasteCellData } from '../src/sheets/types.js'; 29 + 30 + // --- Helpers --- 31 + 32 + function cellId(col: number, row: number): string { 33 + let s = ''; 34 + let n = col; 35 + while (n > 0) { 36 + n--; 37 + s = String.fromCharCode(65 + (n % 26)) + s; 38 + n = Math.floor(n / 26); 39 + } 40 + return s + row; 41 + } 42 + 43 + function mockGetCellData(map: Record<string, { v: any; f: string; s: Record<string, any> }>) { 44 + return (id: string) => map[id] || null; 45 + } 46 + 47 + /** 48 + * Convert clipboard-paste row format to PasteCellData grid format, 49 + * as would happen when wiring the paste path in main.ts. 50 + */ 51 + function clipboardRowsToPasteGrid( 52 + rows: Array<Array<{ value: any; formula: string; style: Record<string, any> }>> 53 + ): (PasteCellData | null)[][] { 54 + return rows.map(row => 55 + row.map(cell => ({ 56 + v: cell.value, 57 + f: cell.formula, 58 + s: cell.style || {}, 59 + })) 60 + ); 61 + } 62 + 63 + // ============================================================ 64 + // Round-trip: copy HTML -> parse HTML -> paste-special transform 65 + // ============================================================ 66 + 67 + describe('Rich clipboard round-trip', () => { 68 + it('copy HTML with styles -> parse HTML preserves styles', () => { 69 + const data = { 70 + A1: { v: 'Revenue', f: '', s: { bold: true, bg: '#4a86e8', color: '#ffffff' } }, 71 + B1: { v: 1000, f: '', s: { align: 'right' } }, 72 + }; 73 + const selection = { startCol: 1, startRow: 1, endCol: 2, endRow: 1 }; 74 + const html = buildCopyHtml(mockGetCellData(data), selection, cellId); 75 + const parsed = parseClipboardHtml(html); 76 + expect(parsed).not.toBeNull(); 77 + expect(parsed!.rows.length).toBe(1); 78 + expect(parsed!.rows[0].length).toBe(2); 79 + expect(parsed!.rows[0][0].value).toBe('Revenue'); 80 + expect(parsed!.rows[0][0].style.bold).toBe(true); 81 + expect(parsed!.rows[0][0].style.bg).toBe('#4a86e8'); 82 + expect(parsed!.rows[0][0].style.color).toBe('#ffffff'); 83 + expect(parsed!.rows[0][1].value).toBe(1000); 84 + expect(parsed!.rows[0][1].style.align).toBe('right'); 85 + }); 86 + 87 + it('copy HTML with formulas -> parse HTML detects formulas', () => { 88 + const data = { A1: { v: 42, f: 'SUM(B1:B10)', s: {} } }; 89 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 90 + const parsed = parseClipboardHtml(html); 91 + expect(parsed).not.toBeNull(); 92 + expect(parsed!.rows[0][0].formula).toBe('SUM(B1:B10)'); 93 + }); 94 + 95 + it('copy TSV -> parse TSV round-trips correctly', () => { 96 + const data = { 97 + A1: { v: 'hello', f: '', s: {} }, 98 + B1: { v: 42, f: '', s: {} }, 99 + A2: { v: '', f: 'A1&" world"', s: {} }, 100 + B2: { v: 100, f: '', s: {} }, 101 + }; 102 + const tsv = buildCopyTsv(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 2, endRow: 2 }, cellId); 103 + const parsed = parseClipboardTsv(tsv); 104 + expect(parsed).not.toBeNull(); 105 + expect(parsed!.rows.length).toBe(2); 106 + expect(parsed!.rows[0][0].value).toBe('hello'); 107 + expect(parsed!.rows[0][1].value).toBe(42); 108 + expect(parsed!.rows[1][0].formula).toBe('A1&" world"'); 109 + expect(parsed!.rows[1][1].value).toBe(100); 110 + }); 111 + }); 112 + 113 + // ============================================================ 114 + // Paste-special values-only strips formulas from parsed clipboard 115 + // ============================================================ 116 + 117 + describe('Paste special: values only strips formulas', () => { 118 + it('strips formulas from parsed HTML clipboard data', () => { 119 + const html = '<table><tr><td>=SUM(A1:A5)</td><td style="font-weight:bold">Revenue</td></tr></table>'; 120 + const parsed = parseClipboardHtml(html); 121 + const grid = clipboardRowsToPasteGrid(parsed!.rows); 122 + const result = extractValuesOnly(grid); 123 + expect(result[0][0]!.f).toBe(''); 124 + expect(result[0][0]!.v).toBe('=SUM(A1:A5)'); 125 + expect(result[0][1]!.v).toBe('Revenue'); 126 + expect(result[0][1]!.f).toBe(''); 127 + }); 128 + 129 + it('preserves numeric values with formulas stripped', () => { 130 + const grid: (PasteCellData | null)[][] = [[ 131 + { v: 42, f: 'A1+B1', s: { bold: true } }, 132 + { v: 100, f: '', s: {} }, 133 + ]]; 134 + const result = extractValuesOnly(grid); 135 + expect(result[0][0]!.v).toBe(42); 136 + expect(result[0][0]!.f).toBe(''); 137 + expect(result[0][0]!.s).toEqual({ bold: true }); 138 + expect(result[0][1]!.v).toBe(100); 139 + }); 140 + }); 141 + 142 + // ============================================================ 143 + // Paste-special formatting-only applies styles without values 144 + // ============================================================ 145 + 146 + describe('Paste special: formatting only applies styles', () => { 147 + it('strips values and formulas, keeps styles from HTML clipboard', () => { 148 + const html = '<table><tr><td style="background-color:#ff0;font-weight:bold;font-size:14pt">42</td><td style="color:#ff0000;font-style:italic;text-align:center">hello</td></tr></table>'; 149 + const parsed = parseClipboardHtml(html); 150 + const grid = clipboardRowsToPasteGrid(parsed!.rows); 151 + const result = extractFormattingOnly(grid); 152 + expect(result[0][0]!.v).toBe(''); 153 + expect(result[0][0]!.f).toBe(''); 154 + expect(result[0][1]!.v).toBe(''); 155 + expect(result[0][1]!.f).toBe(''); 156 + expect(result[0][0]!.s).toEqual(expect.objectContaining({ bg: '#ffff00', bold: true, fontSize: 14 })); 157 + expect(result[0][1]!.s).toEqual(expect.objectContaining({ color: '#ff0000', italic: true, align: 'center' })); 158 + }); 159 + 160 + it('produces empty cells with styles from grid with formulas', () => { 161 + const grid: (PasteCellData | null)[][] = [[ 162 + { v: 42, f: 'SUM(A1:A5)', s: { bold: true, bg: '#eee' } }, 163 + { v: 'text', f: '', s: { italic: true } }, 164 + ]]; 165 + const result = extractFormattingOnly(grid); 166 + expect(result[0][0]!.v).toBe(''); 167 + expect(result[0][0]!.f).toBe(''); 168 + expect(result[0][0]!.s).toEqual({ bold: true, bg: '#eee' }); 169 + expect(result[0][1]!.v).toBe(''); 170 + expect(result[0][1]!.s).toEqual({ italic: true }); 171 + }); 172 + }); 173 + 174 + // ============================================================ 175 + // Paste-special transpose swaps rows and columns 176 + // ============================================================ 177 + 178 + describe('Paste special: transpose swaps rows/cols', () => { 179 + it('transposes 2x3 parsed clipboard grid to 3x2', () => { 180 + const html = '<table><tr><td>1</td><td>2</td><td>3</td></tr><tr><td>4</td><td>5</td><td>6</td></tr></table>'; 181 + const parsed = parseClipboardHtml(html); 182 + const grid = clipboardRowsToPasteGrid(parsed!.rows); 183 + const result = transposeGrid(grid); 184 + expect(result.length).toBe(3); 185 + expect(result[0].length).toBe(2); 186 + expect(result[0][0]!.v).toBe(1); 187 + expect(result[0][1]!.v).toBe(4); 188 + expect(result[1][0]!.v).toBe(2); 189 + expect(result[1][1]!.v).toBe(5); 190 + expect(result[2][0]!.v).toBe(3); 191 + expect(result[2][1]!.v).toBe(6); 192 + }); 193 + 194 + it('transposes grid with styles preserved', () => { 195 + const grid: (PasteCellData | null)[][] = [[ 196 + { v: 'A', f: '', s: { bold: true } }, 197 + { v: 'B', f: '', s: { italic: true } }, 198 + ]]; 199 + const result = transposeGrid(grid); 200 + expect(result.length).toBe(2); 201 + expect(result[0][0]!.v).toBe('A'); 202 + expect(result[0][0]!.s).toEqual({ bold: true }); 203 + expect(result[1][0]!.v).toBe('B'); 204 + expect(result[1][0]!.s).toEqual({ italic: true }); 205 + }); 206 + 207 + it('transposes grid with formulas preserved', () => { 208 + const grid: (PasteCellData | null)[][] = [ 209 + [{ v: 10, f: 'A1+B1', s: {} }], 210 + [{ v: 20, f: 'A2+B2', s: {} }], 211 + ]; 212 + const result = transposeGrid(grid); 213 + expect(result.length).toBe(1); 214 + expect(result[0].length).toBe(2); 215 + expect(result[0][0]!.f).toBe('A1+B1'); 216 + expect(result[0][1]!.f).toBe('A2+B2'); 217 + }); 218 + }); 219 + 220 + // ============================================================ 221 + // clipboard-paste HTML parsing preserves styles end-to-end 222 + // ============================================================ 223 + 224 + describe('Clipboard paste HTML preserves styles', () => { 225 + it('parses Google Sheets HTML with multiple style properties', () => { 226 + const html = '<meta name="google-sheets-html-origin"><table><tr><td style="background-color:rgb(255,255,0);color:#000000;font-weight:bold;font-style:italic;text-decoration:underline;font-size:12pt;text-align:center">Header</td></tr></table>'; 227 + const result = parseClipboardHtml(html); 228 + expect(result).not.toBeNull(); 229 + const cell = result!.rows[0][0]; 230 + expect(cell.value).toBe('Header'); 231 + expect(cell.style.bg).toBe('#ffff00'); 232 + expect(cell.style.color).toBe('#000000'); 233 + expect(cell.style.bold).toBe(true); 234 + expect(cell.style.italic).toBe(true); 235 + expect(cell.style.underline).toBe(true); 236 + expect(cell.style.fontSize).toBe(12); 237 + expect(cell.style.align).toBe('center'); 238 + }); 239 + 240 + it('parses Excel HTML with bold via font-weight:700', () => { 241 + const html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><table><tr><td style="font-weight:700;background-color:#e2efda">Total</td></tr></table></html>'; 242 + const result = parseClipboardHtml(html); 243 + expect(result!.sourceApp).toBe('excel'); 244 + expect(result!.rows[0][0].style.bold).toBe(true); 245 + expect(result!.rows[0][0].style.bg).toBe('#e2efda'); 246 + }); 247 + 248 + it('parses strikethrough and underline combined', () => { 249 + const html = '<table><tr><td style="text-decoration:underline line-through">crossed</td></tr></table>'; 250 + const result = parseClipboardHtml(html); 251 + expect(result!.rows[0][0].style.underline).toBe(true); 252 + expect(result!.rows[0][0].style.strikethrough).toBe(true); 253 + }); 254 + }); 255 + 256 + // ============================================================ 257 + // clipboard-copy generates HTML with inline styles 258 + // ============================================================ 259 + 260 + describe('Clipboard copy generates styled HTML', () => { 261 + it('generates HTML with background-color for bg style', () => { 262 + const data = { A1: { v: 'test', f: '', s: { bg: '#ff0000' } } }; 263 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 264 + expect(html).toContain('background-color:#ff0000'); 265 + }); 266 + 267 + it('generates HTML with bold styling', () => { 268 + const data = { A1: { v: 'bold text', f: '', s: { bold: true } } }; 269 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 270 + expect(html).toContain('font-weight:bold'); 271 + }); 272 + 273 + it('generates HTML with multiple combined styles', () => { 274 + const data = { 275 + A1: { v: 'styled', f: '', s: { bold: true, italic: true, underline: true, bg: '#eee', color: '#333', fontSize: 14, align: 'center' } }, 276 + }; 277 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }, cellId); 278 + expect(html).toContain('font-weight:bold'); 279 + expect(html).toContain('font-style:italic'); 280 + expect(html).toContain('text-decoration:underline'); 281 + expect(html).toContain('background-color:#eee'); 282 + expect(html).toContain('color:#333'); 283 + expect(html).toContain('font-size:14pt'); 284 + expect(html).toContain('text-align:center'); 285 + }); 286 + 287 + it('generates multi-row HTML table', () => { 288 + const data = { 289 + A1: { v: 1, f: '', s: { bold: true } }, 290 + B1: { v: 2, f: '', s: {} }, 291 + A2: { v: 3, f: '', s: {} }, 292 + B2: { v: 4, f: '', s: { italic: true } }, 293 + }; 294 + const html = buildCopyHtml(mockGetCellData(data), { startCol: 1, startRow: 1, endCol: 2, endRow: 2 }, cellId); 295 + expect(html).toContain('<table>'); 296 + expect(html).toContain('</table>'); 297 + const trCount = (html.match(/<tr>/g) || []).length; 298 + expect(trCount).toBe(2); 299 + const tdCount = (html.match(/<td/g) || []).length; 300 + expect(tdCount).toBe(4); 301 + }); 302 + }); 303 + 304 + // ============================================================ 305 + // PASTE_MODES constant consistency 306 + // ============================================================ 307 + 308 + describe('PASTE_MODES', () => { 309 + it('all modes are distinct strings', () => { 310 + const values = Object.values(PASTE_MODES); 311 + expect(new Set(values).size).toBe(values.length); 312 + }); 313 + 314 + it('contains expected modes', () => { 315 + expect(PASTE_MODES).toHaveProperty('VALUES_ONLY'); 316 + expect(PASTE_MODES).toHaveProperty('FORMULAS_ONLY'); 317 + expect(PASTE_MODES).toHaveProperty('FORMATTING_ONLY'); 318 + expect(PASTE_MODES).toHaveProperty('TRANSPOSE'); 319 + }); 320 + });
+479
tests/sheet-tab-management.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import * as Y from 'yjs'; 3 + import { 4 + countSheets, 5 + sheetHasData, 6 + generateCopyName, 7 + getSheetNames, 8 + duplicateSheet, 9 + deleteSheet, 10 + setTabColor, 11 + getTabColor, 12 + renameSheet, 13 + canMoveLeft, 14 + canMoveRight, 15 + TAB_COLORS, 16 + } from '../src/sheets/sheet-tab-management.js'; 17 + 18 + // --- Helpers --- 19 + 20 + function createTestDoc(): { ydoc: Y.Doc; ySheets: Y.Map<any> } { 21 + const ydoc = new Y.Doc(); 22 + const ySheets = ydoc.getMap('sheets'); 23 + return { ydoc, ySheets }; 24 + } 25 + 26 + function addSheet(ySheets: Y.Map<any>, idx: number, name?: string): Y.Map<any> { 27 + const sheet = new Y.Map(); 28 + sheet.set('name', name || `Sheet ${idx + 1}`); 29 + sheet.set('cells', new Y.Map()); 30 + sheet.set('rowCount', 100); 31 + sheet.set('colCount', 26); 32 + ySheets.set(`sheet_${idx}`, sheet); 33 + return sheet; 34 + } 35 + 36 + function setCellValue(sheet: Y.Map<any>, cellId: string, value: any, formula?: string, style?: any): void { 37 + const cells = sheet.get('cells') as Y.Map<any>; 38 + const cell = new Y.Map(); 39 + if (value !== undefined) cell.set('v', value); 40 + if (formula) cell.set('f', formula); 41 + if (style) cell.set('s', style); 42 + cells.set(cellId, cell); 43 + } 44 + 45 + // --- TAB_COLORS --- 46 + 47 + describe('TAB_COLORS', () => { 48 + it('provides 8 preset colors', () => { 49 + expect(TAB_COLORS).toHaveLength(8); 50 + }); 51 + 52 + it('each color has a name and hex value', () => { 53 + for (const color of TAB_COLORS) { 54 + expect(color.name).toBeTruthy(); 55 + expect(color.value).toMatch(/^#[0-9a-f]{6}$/); 56 + } 57 + }); 58 + }); 59 + 60 + // --- countSheets --- 61 + 62 + describe('countSheets', () => { 63 + it('returns 0 for empty map', () => { 64 + const { ySheets } = createTestDoc(); 65 + expect(countSheets(ySheets)).toBe(0); 66 + }); 67 + 68 + it('counts sheet_ prefixed keys', () => { 69 + const { ySheets } = createTestDoc(); 70 + addSheet(ySheets, 0); 71 + addSheet(ySheets, 1); 72 + addSheet(ySheets, 2); 73 + expect(countSheets(ySheets)).toBe(3); 74 + }); 75 + 76 + it('ignores non-sheet keys', () => { 77 + const { ySheets } = createTestDoc(); 78 + addSheet(ySheets, 0); 79 + ySheets.set('meta', new Y.Map()); 80 + expect(countSheets(ySheets)).toBe(1); 81 + }); 82 + }); 83 + 84 + // --- sheetHasData --- 85 + 86 + describe('sheetHasData', () => { 87 + it('returns false for empty sheet', () => { 88 + const { ySheets } = createTestDoc(); 89 + const sheet = addSheet(ySheets, 0); 90 + expect(sheetHasData(sheet)).toBe(false); 91 + }); 92 + 93 + it('returns true when sheet has cells', () => { 94 + const { ySheets } = createTestDoc(); 95 + const sheet = addSheet(ySheets, 0); 96 + setCellValue(sheet, 'A1', 'hello'); 97 + expect(sheetHasData(sheet)).toBe(true); 98 + }); 99 + 100 + it('returns true when sheet has merges', () => { 101 + const { ySheets } = createTestDoc(); 102 + const sheet = addSheet(ySheets, 0); 103 + const merges = new Y.Map(); 104 + merges.set('A1', JSON.stringify({ startCol: 0, startRow: 0, endCol: 1, endRow: 0 })); 105 + sheet.set('merges', merges); 106 + expect(sheetHasData(sheet)).toBe(true); 107 + }); 108 + 109 + it('returns true when sheet has notes', () => { 110 + const { ySheets } = createTestDoc(); 111 + const sheet = addSheet(ySheets, 0); 112 + const notes = new Y.Map(); 113 + notes.set('A1', 'A note'); 114 + sheet.set('notes', notes); 115 + expect(sheetHasData(sheet)).toBe(true); 116 + }); 117 + 118 + it('returns true when sheet has CF rules', () => { 119 + const { ySheets } = createTestDoc(); 120 + const sheet = addSheet(ySheets, 0); 121 + const cfRules = new Y.Array(); 122 + cfRules.insert(0, [JSON.stringify({ type: 'greaterThan', value: 10 })]); 123 + sheet.set('cfRules', cfRules); 124 + expect(sheetHasData(sheet)).toBe(true); 125 + }); 126 + 127 + it('returns true when sheet has validations', () => { 128 + const { ySheets } = createTestDoc(); 129 + const sheet = addSheet(ySheets, 0); 130 + const validations = new Y.Map(); 131 + validations.set('A1', JSON.stringify({ type: 'number' })); 132 + sheet.set('validations', validations); 133 + expect(sheetHasData(sheet)).toBe(true); 134 + }); 135 + }); 136 + 137 + // --- generateCopyName --- 138 + 139 + describe('generateCopyName', () => { 140 + it('appends (Copy) to source name', () => { 141 + expect(generateCopyName('Sheet 1', ['Sheet 1'])).toBe('Sheet 1 (Copy)'); 142 + }); 143 + 144 + it('increments counter when (Copy) already exists', () => { 145 + expect(generateCopyName('Sheet 1', ['Sheet 1', 'Sheet 1 (Copy)'])).toBe('Sheet 1 (Copy 2)'); 146 + }); 147 + 148 + it('increments further when multiple copies exist', () => { 149 + expect(generateCopyName('Sheet 1', ['Sheet 1', 'Sheet 1 (Copy)', 'Sheet 1 (Copy 2)'])).toBe('Sheet 1 (Copy 3)'); 150 + }); 151 + 152 + it('works with custom sheet names', () => { 153 + expect(generateCopyName('Budget', ['Budget', 'Sales'])).toBe('Budget (Copy)'); 154 + }); 155 + }); 156 + 157 + // --- getSheetNames --- 158 + 159 + describe('getSheetNames', () => { 160 + it('returns empty array for no sheets', () => { 161 + const { ySheets } = createTestDoc(); 162 + expect(getSheetNames(ySheets)).toEqual([]); 163 + }); 164 + 165 + it('returns names in order', () => { 166 + const { ySheets } = createTestDoc(); 167 + addSheet(ySheets, 0, 'Alpha'); 168 + addSheet(ySheets, 1, 'Beta'); 169 + addSheet(ySheets, 2, 'Gamma'); 170 + expect(getSheetNames(ySheets)).toEqual(['Alpha', 'Beta', 'Gamma']); 171 + }); 172 + }); 173 + 174 + // --- duplicateSheet --- 175 + 176 + describe('duplicateSheet', () => { 177 + it('clones cell data to the new sheet', () => { 178 + const { ydoc, ySheets } = createTestDoc(); 179 + const sheet = addSheet(ySheets, 0, 'Data'); 180 + setCellValue(sheet, 'A1', 42, '=1+41'); 181 + setCellValue(sheet, 'B2', 'hello'); 182 + 183 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 184 + expect(newSheet).not.toBeNull(); 185 + 186 + const newCells = newSheet!.get('cells') as Y.Map<any>; 187 + expect(newCells.get('A1').get('v')).toBe(42); 188 + expect(newCells.get('A1').get('f')).toBe('=1+41'); 189 + expect(newCells.get('B2').get('v')).toBe('hello'); 190 + }); 191 + 192 + it('names the copy with (Copy) suffix', () => { 193 + const { ydoc, ySheets } = createTestDoc(); 194 + addSheet(ySheets, 0, 'Budget'); 195 + 196 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 197 + expect(newSheet!.get('name')).toBe('Budget (Copy)'); 198 + }); 199 + 200 + it('clones merged cells', () => { 201 + const { ydoc, ySheets } = createTestDoc(); 202 + const sheet = addSheet(ySheets, 0); 203 + const merges = new Y.Map(); 204 + merges.set('A1', JSON.stringify({ startCol: 0, startRow: 0, endCol: 2, endRow: 0 })); 205 + sheet.set('merges', merges); 206 + 207 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 208 + const newMerges = newSheet!.get('merges') as Y.Map<any>; 209 + expect(newMerges.get('A1')).toBe(JSON.stringify({ startCol: 0, startRow: 0, endCol: 2, endRow: 0 })); 210 + }); 211 + 212 + it('clones column widths', () => { 213 + const { ydoc, ySheets } = createTestDoc(); 214 + const sheet = addSheet(ySheets, 0); 215 + const colWidths = new Y.Map(); 216 + colWidths.set('0', 150); 217 + colWidths.set('2', 200); 218 + sheet.set('colWidths', colWidths); 219 + 220 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 221 + const newColWidths = newSheet!.get('colWidths') as Y.Map<any>; 222 + expect(newColWidths.get('0')).toBe(150); 223 + expect(newColWidths.get('2')).toBe(200); 224 + }); 225 + 226 + it('clones row heights', () => { 227 + const { ydoc, ySheets } = createTestDoc(); 228 + const sheet = addSheet(ySheets, 0); 229 + const rowHeights = new Y.Map(); 230 + rowHeights.set('5', 50); 231 + sheet.set('rowHeights', rowHeights); 232 + 233 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 234 + const newRowHeights = newSheet!.get('rowHeights') as Y.Map<any>; 235 + expect(newRowHeights.get('5')).toBe(50); 236 + }); 237 + 238 + it('clones conditional formatting rules', () => { 239 + const { ydoc, ySheets } = createTestDoc(); 240 + const sheet = addSheet(ySheets, 0); 241 + const cfRules = new Y.Array(); 242 + cfRules.insert(0, [JSON.stringify({ type: 'greaterThan', value: 10, color: '#ff0000' })]); 243 + sheet.set('cfRules', cfRules); 244 + 245 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 246 + const newCfRules = newSheet!.get('cfRules') as Y.Array<any>; 247 + expect(newCfRules.length).toBe(1); 248 + expect(JSON.parse(newCfRules.get(0))).toEqual({ type: 'greaterThan', value: 10, color: '#ff0000' }); 249 + }); 250 + 251 + it('clones data validation rules', () => { 252 + const { ydoc, ySheets } = createTestDoc(); 253 + const sheet = addSheet(ySheets, 0); 254 + const validations = new Y.Map(); 255 + validations.set('A1', JSON.stringify({ type: 'number', min: 0, max: 100 })); 256 + sheet.set('validations', validations); 257 + 258 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 259 + const newValidations = newSheet!.get('validations') as Y.Map<any>; 260 + expect(JSON.parse(newValidations.get('A1'))).toEqual({ type: 'number', min: 0, max: 100 }); 261 + }); 262 + 263 + it('clones cell notes', () => { 264 + const { ydoc, ySheets } = createTestDoc(); 265 + const sheet = addSheet(ySheets, 0); 266 + const notes = new Y.Map(); 267 + notes.set('C3', 'Important note'); 268 + sheet.set('notes', notes); 269 + 270 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 271 + const newNotes = newSheet!.get('notes') as Y.Map<any>; 272 + expect(newNotes.get('C3')).toBe('Important note'); 273 + }); 274 + 275 + it('clones hidden rows and cols', () => { 276 + const { ydoc, ySheets } = createTestDoc(); 277 + const sheet = addSheet(ySheets, 0); 278 + const hiddenRows = new Y.Map(); 279 + hiddenRows.set('3', true); 280 + sheet.set('hiddenRows', hiddenRows); 281 + const hiddenCols = new Y.Map(); 282 + hiddenCols.set('1', true); 283 + sheet.set('hiddenCols', hiddenCols); 284 + 285 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 286 + expect((newSheet!.get('hiddenRows') as Y.Map<any>).get('3')).toBe(true); 287 + expect((newSheet!.get('hiddenCols') as Y.Map<any>).get('1')).toBe(true); 288 + }); 289 + 290 + it('clones scalar properties (rowCount, colCount, freezeRows, freezeCols)', () => { 291 + const { ydoc, ySheets } = createTestDoc(); 292 + const sheet = addSheet(ySheets, 0); 293 + sheet.set('rowCount', 200); 294 + sheet.set('colCount', 52); 295 + sheet.set('freezeRows', 2); 296 + sheet.set('freezeCols', 1); 297 + 298 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 299 + expect(newSheet!.get('rowCount')).toBe(200); 300 + expect(newSheet!.get('colCount')).toBe(52); 301 + expect(newSheet!.get('freezeRows')).toBe(2); 302 + expect(newSheet!.get('freezeCols')).toBe(1); 303 + }); 304 + 305 + it('returns null if source sheet does not exist', () => { 306 + const { ydoc, ySheets } = createTestDoc(); 307 + expect(duplicateSheet(ydoc, ySheets, 5, 6)).toBeNull(); 308 + }); 309 + 310 + it('cloned cells are independent of source (not shared references)', () => { 311 + const { ydoc, ySheets } = createTestDoc(); 312 + const sheet = addSheet(ySheets, 0, 'Source'); 313 + setCellValue(sheet, 'A1', 'original'); 314 + 315 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 316 + 317 + // Modify the source cell 318 + const srcCells = sheet.get('cells') as Y.Map<any>; 319 + srcCells.get('A1').set('v', 'modified'); 320 + 321 + // Clone should be unaffected 322 + const newCells = newSheet!.get('cells') as Y.Map<any>; 323 + expect(newCells.get('A1').get('v')).toBe('original'); 324 + }); 325 + }); 326 + 327 + // --- deleteSheet --- 328 + 329 + describe('deleteSheet', () => { 330 + it('cannot delete the last remaining sheet', () => { 331 + const { ydoc, ySheets } = createTestDoc(); 332 + addSheet(ySheets, 0); 333 + expect(deleteSheet(ydoc, ySheets, 0, 0)).toBe(-1); 334 + expect(countSheets(ySheets)).toBe(1); 335 + }); 336 + 337 + it('deletes a sheet and shifts subsequent sheets down', () => { 338 + const { ydoc, ySheets } = createTestDoc(); 339 + addSheet(ySheets, 0, 'First'); 340 + addSheet(ySheets, 1, 'Second'); 341 + addSheet(ySheets, 2, 'Third'); 342 + 343 + deleteSheet(ydoc, ySheets, 1, 0); 344 + 345 + expect(countSheets(ySheets)).toBe(2); 346 + expect((ySheets.get('sheet_0') as Y.Map<any>).get('name')).toBe('First'); 347 + expect((ySheets.get('sheet_1') as Y.Map<any>).get('name')).toBe('Third'); 348 + }); 349 + 350 + it('returns previous index when deleting active sheet', () => { 351 + const { ydoc, ySheets } = createTestDoc(); 352 + addSheet(ySheets, 0); 353 + addSheet(ySheets, 1); 354 + addSheet(ySheets, 2); 355 + 356 + // Active is 2, deleting 2 -> should go to 1 357 + expect(deleteSheet(ydoc, ySheets, 2, 2)).toBe(1); 358 + }); 359 + 360 + it('returns 0 when deleting the first sheet (which was active)', () => { 361 + const { ydoc, ySheets } = createTestDoc(); 362 + addSheet(ySheets, 0); 363 + addSheet(ySheets, 1); 364 + 365 + // Active is 0, deleting 0 -> should go to 0 (the new first) 366 + expect(deleteSheet(ydoc, ySheets, 0, 0)).toBe(0); 367 + }); 368 + 369 + it('adjusts active index when deleting a sheet before the active one', () => { 370 + const { ydoc, ySheets } = createTestDoc(); 371 + addSheet(ySheets, 0); 372 + addSheet(ySheets, 1); 373 + addSheet(ySheets, 2); 374 + 375 + // Active is 2, deleting 0 -> active should become 1 376 + expect(deleteSheet(ydoc, ySheets, 0, 2)).toBe(1); 377 + }); 378 + 379 + it('keeps active index when deleting a sheet after the active one', () => { 380 + const { ydoc, ySheets } = createTestDoc(); 381 + addSheet(ySheets, 0); 382 + addSheet(ySheets, 1); 383 + addSheet(ySheets, 2); 384 + 385 + // Active is 0, deleting 2 -> active stays 0 386 + expect(deleteSheet(ydoc, ySheets, 2, 0)).toBe(0); 387 + }); 388 + }); 389 + 390 + // --- setTabColor / getTabColor --- 391 + 392 + describe('tab color', () => { 393 + it('stores and retrieves tab color', () => { 394 + const { ySheets } = createTestDoc(); 395 + const sheet = addSheet(ySheets, 0); 396 + setTabColor(sheet, '#ef4444'); 397 + expect(getTabColor(sheet)).toBe('#ef4444'); 398 + }); 399 + 400 + it('returns null when no tab color is set', () => { 401 + const { ySheets } = createTestDoc(); 402 + const sheet = addSheet(ySheets, 0); 403 + expect(getTabColor(sheet)).toBeNull(); 404 + }); 405 + 406 + it('clears tab color when set to null', () => { 407 + const { ySheets } = createTestDoc(); 408 + const sheet = addSheet(ySheets, 0); 409 + setTabColor(sheet, '#3b82f6'); 410 + expect(getTabColor(sheet)).toBe('#3b82f6'); 411 + setTabColor(sheet, null); 412 + expect(getTabColor(sheet)).toBeNull(); 413 + }); 414 + 415 + it('tab color is preserved through duplication', () => { 416 + const { ydoc, ySheets } = createTestDoc(); 417 + const sheet = addSheet(ySheets, 0); 418 + setTabColor(sheet, '#22c55e'); 419 + 420 + const newSheet = duplicateSheet(ydoc, ySheets, 0, 1); 421 + expect(getTabColor(newSheet!)).toBe('#22c55e'); 422 + }); 423 + }); 424 + 425 + // --- renameSheet --- 426 + 427 + describe('renameSheet', () => { 428 + it('renames a sheet', () => { 429 + const { ySheets } = createTestDoc(); 430 + const sheet = addSheet(ySheets, 0, 'Old Name'); 431 + expect(renameSheet(sheet, 'New Name')).toBe(true); 432 + expect(sheet.get('name')).toBe('New Name'); 433 + }); 434 + 435 + it('trims whitespace', () => { 436 + const { ySheets } = createTestDoc(); 437 + const sheet = addSheet(ySheets, 0); 438 + renameSheet(sheet, ' Trimmed '); 439 + expect(sheet.get('name')).toBe('Trimmed'); 440 + }); 441 + 442 + it('rejects empty name', () => { 443 + const { ySheets } = createTestDoc(); 444 + const sheet = addSheet(ySheets, 0, 'Original'); 445 + expect(renameSheet(sheet, '')).toBe(false); 446 + expect(sheet.get('name')).toBe('Original'); 447 + }); 448 + 449 + it('rejects whitespace-only name', () => { 450 + const { ySheets } = createTestDoc(); 451 + const sheet = addSheet(ySheets, 0, 'Original'); 452 + expect(renameSheet(sheet, ' ')).toBe(false); 453 + expect(sheet.get('name')).toBe('Original'); 454 + }); 455 + }); 456 + 457 + // --- canMoveLeft / canMoveRight --- 458 + 459 + describe('canMoveLeft', () => { 460 + it('returns false for index 0', () => { 461 + expect(canMoveLeft(0)).toBe(false); 462 + }); 463 + 464 + it('returns true for index > 0', () => { 465 + expect(canMoveLeft(1)).toBe(true); 466 + expect(canMoveLeft(5)).toBe(true); 467 + }); 468 + }); 469 + 470 + describe('canMoveRight', () => { 471 + it('returns false for last sheet', () => { 472 + expect(canMoveRight(2, 3)).toBe(false); 473 + }); 474 + 475 + it('returns true when not last', () => { 476 + expect(canMoveRight(0, 3)).toBe(true); 477 + expect(canMoveRight(1, 3)).toBe(true); 478 + }); 479 + });
+392
tests/sheets-print.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + PAGE_SIZES, 4 + MARGIN_PRESETS, 5 + buildSheetsPrintHtml, 6 + } from '../src/lib/print-layout.js'; 7 + import type { SheetsPrintData, SheetsPrintOptions } from '../src/lib/print-layout.js'; 8 + 9 + // ---- HTML structure ---- 10 + 11 + describe('buildSheetsPrintHtml generates valid HTML structure', () => { 12 + const data: SheetsPrintData = { 13 + headers: ['Name', 'Age', 'City'], 14 + rows: [ 15 + [{ value: 'Alice' }, { value: '30' }, { value: 'Portland' }], 16 + [{ value: 'Bob' }, { value: '25' }, { value: 'Seattle' }], 17 + ], 18 + }; 19 + 20 + it('produces a complete HTML document', () => { 21 + const html = buildSheetsPrintHtml(data); 22 + expect(html).toContain('<!DOCTYPE html>'); 23 + expect(html).toContain('<html'); 24 + expect(html).toContain('<head>'); 25 + expect(html).toContain('<body>'); 26 + expect(html).toContain('</html>'); 27 + }); 28 + 29 + it('contains a table element', () => { 30 + const html = buildSheetsPrintHtml(data); 31 + expect(html).toContain('<table'); 32 + expect(html).toContain('</table>'); 33 + }); 34 + 35 + it('renders all header cells', () => { 36 + const html = buildSheetsPrintHtml(data); 37 + expect(html).toContain('Name'); 38 + expect(html).toContain('Age'); 39 + expect(html).toContain('City'); 40 + }); 41 + 42 + it('renders all data cells', () => { 43 + const html = buildSheetsPrintHtml(data); 44 + expect(html).toContain('Alice'); 45 + expect(html).toContain('Bob'); 46 + expect(html).toContain('Portland'); 47 + expect(html).toContain('Seattle'); 48 + }); 49 + }); 50 + 51 + // ---- Grid lines toggle ---- 52 + 53 + describe('grid lines toggle affects output', () => { 54 + const data: SheetsPrintData = { 55 + headers: ['A'], 56 + rows: [[{ value: '1' }]], 57 + }; 58 + 59 + it('includes borders when gridLines is true (default)', () => { 60 + const html = buildSheetsPrintHtml(data, { gridLines: true }); 61 + expect(html).toContain('border:'); 62 + expect(html).not.toContain('border: none'); 63 + expect(html).toMatch(/border:\s*1px\s+solid/); 64 + }); 65 + 66 + it('removes borders when gridLines is false', () => { 67 + const html = buildSheetsPrintHtml(data, { gridLines: false }); 68 + expect(html).toContain('border: none'); 69 + }); 70 + 71 + it('defaults gridLines to true', () => { 72 + const html = buildSheetsPrintHtml(data); 73 + expect(html).toMatch(/border:\s*1px\s+solid/); 74 + }); 75 + }); 76 + 77 + // ---- Repeat headers adds thead ---- 78 + 79 + describe('repeat headers adds thead to each page', () => { 80 + const data: SheetsPrintData = { 81 + headers: ['Name', 'Score'], 82 + rows: [ 83 + [{ value: 'Alice' }, { value: '100' }], 84 + [{ value: 'Bob' }, { value: '200' }], 85 + ], 86 + }; 87 + 88 + it('wraps header row in <thead> when repeatHeaders is true', () => { 89 + const html = buildSheetsPrintHtml(data, { repeatHeaders: true }); 90 + expect(html).toContain('<thead>'); 91 + expect(html).toContain('</thead>'); 92 + }); 93 + 94 + it('includes table-header-group CSS for print repeat when repeatHeaders is true', () => { 95 + const html = buildSheetsPrintHtml(data, { repeatHeaders: true }); 96 + expect(html).toContain('table-header-group'); 97 + }); 98 + 99 + it('does not use <thead> when repeatHeaders is false', () => { 100 + const html = buildSheetsPrintHtml(data, { repeatHeaders: false }); 101 + expect(html).toContain('Name'); 102 + expect(html).not.toContain('<thead>'); 103 + }); 104 + }); 105 + 106 + // ---- Cell styles are included in output ---- 107 + 108 + describe('cell styles are included in output', () => { 109 + it('renders bold text with font-weight', () => { 110 + const data: SheetsPrintData = { 111 + headers: ['Header'], 112 + rows: [[{ value: 'Bold text', style: { bold: true } }]], 113 + }; 114 + const html = buildSheetsPrintHtml(data); 115 + expect(html).toContain('font-weight'); 116 + }); 117 + 118 + it('renders italic text', () => { 119 + const data: SheetsPrintData = { 120 + headers: ['Header'], 121 + rows: [[{ value: 'Italic text', style: { italic: true } }]], 122 + }; 123 + const html = buildSheetsPrintHtml(data); 124 + expect(html).toContain('font-style:italic'); 125 + }); 126 + 127 + it('renders text color', () => { 128 + const data: SheetsPrintData = { 129 + headers: ['Header'], 130 + rows: [[{ value: 'Red text', style: { color: '#ff0000' } }]], 131 + }; 132 + const html = buildSheetsPrintHtml(data); 133 + expect(html).toContain('#ff0000'); 134 + }); 135 + 136 + it('renders background color', () => { 137 + const data: SheetsPrintData = { 138 + headers: ['Header'], 139 + rows: [[{ value: 'Highlighted', style: { bg: '#ffff00' } }]], 140 + }; 141 + const html = buildSheetsPrintHtml(data); 142 + expect(html).toContain('#ffff00'); 143 + }); 144 + 145 + it('renders underline decoration', () => { 146 + const data: SheetsPrintData = { 147 + headers: ['Header'], 148 + rows: [[{ value: 'Underlined', style: { underline: true } }]], 149 + }; 150 + const html = buildSheetsPrintHtml(data); 151 + expect(html).toContain('underline'); 152 + }); 153 + 154 + it('renders text alignment', () => { 155 + const data: SheetsPrintData = { 156 + headers: ['Header'], 157 + rows: [[{ value: 'Right aligned', style: { align: 'right' } }]], 158 + }; 159 + const html = buildSheetsPrintHtml(data); 160 + expect(html).toContain('text-align:right'); 161 + }); 162 + }); 163 + 164 + // ---- Hidden rows are excluded ---- 165 + 166 + describe('hidden rows are excluded', () => { 167 + it('does not render rows marked as hidden in the data', () => { 168 + const data: SheetsPrintData = { 169 + headers: ['Name'], 170 + rows: [ 171 + { cells: [{ value: 'Visible' }], hidden: false }, 172 + { cells: [{ value: 'Hidden' }], hidden: true }, 173 + { cells: [{ value: 'Also Visible' }], hidden: false }, 174 + ], 175 + }; 176 + const html = buildSheetsPrintHtml(data); 177 + expect(html).toContain('Visible'); 178 + expect(html).toContain('Also Visible'); 179 + expect(html).not.toContain('Hidden'); 180 + }); 181 + }); 182 + 183 + // ---- Merged cells generate correct colspan/rowspan ---- 184 + 185 + describe('merged cells generate correct colspan/rowspan', () => { 186 + it('generates colspan attribute for horizontally merged cells', () => { 187 + const data: SheetsPrintData = { 188 + headers: ['A', 'B', 'C'], 189 + rows: [ 190 + [ 191 + { value: 'Merged', colspan: 2 }, 192 + null, 193 + { value: 'Normal' }, 194 + ], 195 + ], 196 + }; 197 + const html = buildSheetsPrintHtml(data); 198 + expect(html).toContain('colspan="2"'); 199 + expect(html).toContain('Merged'); 200 + expect(html).toContain('Normal'); 201 + }); 202 + 203 + it('generates rowspan attribute for vertically merged cells', () => { 204 + const data: SheetsPrintData = { 205 + headers: ['A', 'B'], 206 + rows: [ 207 + [{ value: 'Tall', rowspan: 2 }, { value: 'Row 1' }], 208 + [null, { value: 'Row 2' }], 209 + ], 210 + }; 211 + const html = buildSheetsPrintHtml(data); 212 + expect(html).toContain('rowspan="2"'); 213 + expect(html).toContain('Tall'); 214 + }); 215 + 216 + it('skips null cells (consumed by merge)', () => { 217 + const data: SheetsPrintData = { 218 + headers: ['A', 'B'], 219 + rows: [ 220 + [{ value: 'Span', colspan: 2 }, null], 221 + ], 222 + }; 223 + const html = buildSheetsPrintHtml(data); 224 + const tdCount = (html.match(/<td /g) || []).length; 225 + expect(tdCount).toBe(1); 226 + }); 227 + }); 228 + 229 + // ---- Landscape orientation adds correct CSS ---- 230 + 231 + describe('landscape orientation adds correct CSS', () => { 232 + it('includes landscape in @page size when orientation is landscape', () => { 233 + const data: SheetsPrintData = { 234 + headers: ['A'], 235 + rows: [[{ value: '1' }]], 236 + }; 237 + const html = buildSheetsPrintHtml(data, { orientation: 'landscape' }); 238 + expect(html).toContain('landscape'); 239 + }); 240 + 241 + it('uses portrait dimensions by default', () => { 242 + const data: SheetsPrintData = { 243 + headers: ['A'], 244 + rows: [[{ value: '1' }]], 245 + }; 246 + const html = buildSheetsPrintHtml(data, { orientation: 'portrait' }); 247 + expect(html).not.toContain('landscape'); 248 + }); 249 + 250 + it('swaps width/height in @page for landscape', () => { 251 + const data: SheetsPrintData = { 252 + headers: ['A'], 253 + rows: [[{ value: '1' }]], 254 + }; 255 + const html = buildSheetsPrintHtml(data, { 256 + pageSize: 'letter', 257 + orientation: 'landscape', 258 + }); 259 + expect(html).toMatch(/@page\s*\{[^}]*landscape/); 260 + }); 261 + }); 262 + 263 + // ---- Different scaling modes set correct styles ---- 264 + 265 + describe('different scaling modes set correct styles', () => { 266 + const data: SheetsPrintData = { 267 + headers: ['A', 'B'], 268 + rows: [[{ value: '1' }, { value: '2' }]], 269 + }; 270 + 271 + it('fit-to-width sets table width to 100%', () => { 272 + const html = buildSheetsPrintHtml(data, { scaling: 'fit-to-width' }); 273 + expect(html).toContain('width: 100%'); 274 + }); 275 + 276 + it('fit-to-page sets max-width constraints', () => { 277 + const html = buildSheetsPrintHtml(data, { scaling: 'fit-to-page' }); 278 + expect(html).toContain('max-width'); 279 + }); 280 + 281 + it('actual-size does not force width', () => { 282 + const html = buildSheetsPrintHtml(data, { scaling: 'actual-size' }); 283 + expect(html).not.toContain('width: 100%'); 284 + }); 285 + }); 286 + 287 + // ---- Margin presets ---- 288 + 289 + describe('margin presets in output', () => { 290 + const data: SheetsPrintData = { 291 + headers: ['A'], 292 + rows: [[{ value: '1' }]], 293 + }; 294 + 295 + it('applies normal margins', () => { 296 + const html = buildSheetsPrintHtml(data, { margins: 'normal' }); 297 + expect(html).toContain(MARGIN_PRESETS.normal.top); 298 + }); 299 + 300 + it('applies narrow margins', () => { 301 + const html = buildSheetsPrintHtml(data, { margins: 'narrow' }); 302 + expect(html).toContain(MARGIN_PRESETS.narrow.top); 303 + }); 304 + 305 + it('applies wide margins', () => { 306 + const html = buildSheetsPrintHtml(data, { margins: 'wide' }); 307 + expect(html).toContain(MARGIN_PRESETS.wide.top); 308 + }); 309 + }); 310 + 311 + // ---- Page sizes including Legal ---- 312 + 313 + describe('page sizes', () => { 314 + const data: SheetsPrintData = { 315 + headers: ['A'], 316 + rows: [[{ value: '1' }]], 317 + }; 318 + 319 + it('supports letter page size', () => { 320 + const html = buildSheetsPrintHtml(data, { pageSize: 'letter' }); 321 + expect(html).toContain('8.5in'); 322 + expect(html).toContain('11in'); 323 + }); 324 + 325 + it('supports A4 page size', () => { 326 + const html = buildSheetsPrintHtml(data, { pageSize: 'a4' }); 327 + expect(html).toContain('210mm'); 328 + expect(html).toContain('297mm'); 329 + }); 330 + 331 + it('supports legal page size', () => { 332 + const html = buildSheetsPrintHtml(data, { pageSize: 'legal' }); 333 + expect(html).toContain('8.5in'); 334 + expect(html).toContain('14in'); 335 + }); 336 + }); 337 + 338 + // ---- Column widths ---- 339 + 340 + describe('column widths in output', () => { 341 + it('applies custom column widths when provided', () => { 342 + const data: SheetsPrintData = { 343 + headers: ['Name', 'Value'], 344 + rows: [[{ value: 'A' }, { value: '1' }]], 345 + colWidths: [200, 100], 346 + }; 347 + const html = buildSheetsPrintHtml(data); 348 + expect(html).toMatch(/width/); 349 + }); 350 + }); 351 + 352 + // ---- Title in output ---- 353 + 354 + describe('title in print output', () => { 355 + const data: SheetsPrintData = { 356 + headers: ['A'], 357 + rows: [[{ value: '1' }]], 358 + }; 359 + 360 + it('includes title in the HTML document title', () => { 361 + const html = buildSheetsPrintHtml(data, { title: 'My Spreadsheet' }); 362 + expect(html).toContain('<title>My Spreadsheet'); 363 + }); 364 + 365 + it('defaults to "Spreadsheet" title', () => { 366 + const html = buildSheetsPrintHtml(data); 367 + expect(html).toContain('<title>Spreadsheet'); 368 + }); 369 + }); 370 + 371 + // ---- HTML escaping ---- 372 + 373 + describe('HTML escaping in print output', () => { 374 + it('escapes special characters in cell values', () => { 375 + const data: SheetsPrintData = { 376 + headers: ['Data'], 377 + rows: [[{ value: '<script>alert("xss")</script>' }]], 378 + }; 379 + const html = buildSheetsPrintHtml(data); 380 + expect(html).not.toContain('<script>'); 381 + expect(html).toContain('&lt;script&gt;'); 382 + }); 383 + 384 + it('escapes special characters in headers', () => { 385 + const data: SheetsPrintData = { 386 + headers: ['A & B'], 387 + rows: [[{ value: '1' }]], 388 + }; 389 + const html = buildSheetsPrintHtml(data); 390 + expect(html).toContain('A &amp; B'); 391 + }); 392 + });
+262
tests/sparkline.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseSparklineArgs, 4 + computeLinePoints, 5 + computeBarRects, 6 + computeWinLossRects, 7 + isSparklineResult, 8 + type SparklineResult, 9 + type SparklineOptions, 10 + } from '../src/sheets/sparkline.js'; 11 + import { evaluate } from '../src/sheets/formulas.js'; 12 + 13 + // Helper: evaluate with a simple cell map 14 + function evalWith(formula: string, cells: Record<string, unknown> = {}): unknown { 15 + return evaluate(formula, (ref) => (cells[ref] as string | number | '') ?? ''); 16 + } 17 + 18 + describe('parseSparklineArgs', () => { 19 + it('parses data array with default options (line)', () => { 20 + const result = parseSparklineArgs([[1, 2, 3]]); 21 + expect(result).not.toBeNull(); 22 + expect(result!.data).toEqual([1, 2, 3]); 23 + expect(result!.chartType).toBe('line'); 24 + }); 25 + 26 + it('parses data array with string type argument', () => { 27 + const result = parseSparklineArgs([[1, 2, 3], 'bar']); 28 + expect(result).not.toBeNull(); 29 + expect(result!.chartType).toBe('bar'); 30 + }); 31 + 32 + it('parses data array with winloss type', () => { 33 + const result = parseSparklineArgs([[1, -1, 0], 'winloss']); 34 + expect(result).not.toBeNull(); 35 + expect(result!.chartType).toBe('winloss'); 36 + }); 37 + 38 + it('returns null for empty data after filtering', () => { 39 + const result = parseSparklineArgs([[]]); 40 + expect(result).toBeNull(); 41 + }); 42 + 43 + it('filters out non-numeric values', () => { 44 + const result = parseSparklineArgs([['hello', 1, '', 2, null, 3, undefined, true]]); 45 + expect(result).not.toBeNull(); 46 + expect(result!.data).toEqual([1, 2, 3]); 47 + }); 48 + 49 + it('returns null for invalid chart type', () => { 50 + const result = parseSparklineArgs([[1, 2, 3], 'invalid']); 51 + expect(result).toBeNull(); 52 + }); 53 + 54 + it('parses options object with color and lineWidth', () => { 55 + const result = parseSparklineArgs([[1, 2, 3], { type: 'line', color: '#ff0000', lineWidth: 3 }]); 56 + expect(result).not.toBeNull(); 57 + expect(result!.options.color).toBe('#ff0000'); 58 + expect(result!.options.lineWidth).toBe(3); 59 + }); 60 + 61 + it('parses options object with negColor', () => { 62 + const result = parseSparklineArgs([[1, -2, 3], { type: 'bar', negColor: '#cc0000' }]); 63 + expect(result).not.toBeNull(); 64 + expect(result!.chartType).toBe('bar'); 65 + expect(result!.options.negColor).toBe('#cc0000'); 66 + }); 67 + 68 + it('parses options with minValue and maxValue overrides', () => { 69 + const result = parseSparklineArgs([[1, 2, 3], { minValue: 0, maxValue: 10 }]); 70 + expect(result).not.toBeNull(); 71 + expect(result!.options.minValue).toBe(0); 72 + expect(result!.options.maxValue).toBe(10); 73 + }); 74 + 75 + it('parses options with showAxis', () => { 76 + const result = parseSparklineArgs([[1, -2, 3], { showAxis: true }]); 77 + expect(result).not.toBeNull(); 78 + expect(result!.options.showAxis).toBe(true); 79 + }); 80 + }); 81 + 82 + describe('isSparklineResult', () => { 83 + it('returns true for valid sparkline result', () => { 84 + const result = parseSparklineArgs([[1, 2, 3]]); 85 + expect(isSparklineResult(result)).toBe(true); 86 + }); 87 + 88 + it('returns false for non-sparkline values', () => { 89 + expect(isSparklineResult(null)).toBe(false); 90 + expect(isSparklineResult(42)).toBe(false); 91 + expect(isSparklineResult('hello')).toBe(false); 92 + expect(isSparklineResult({})).toBe(false); 93 + expect(isSparklineResult({ type: 'not-sparkline' })).toBe(false); 94 + }); 95 + }); 96 + 97 + describe('computeLinePoints', () => { 98 + it('scales y values correctly (min at bottom, max at top)', () => { 99 + const points = computeLinePoints([0, 50, 100], 100, 50, {}); 100 + expect(points).toHaveLength(3); 101 + expect(points[0].x).toBeCloseTo(0, 0); 102 + expect(points[2].x).toBeCloseTo(100, 0); 103 + // min value should be at max y (bottom), max value at min y (top) 104 + expect(points[0].y).toBeGreaterThan(points[2].y); 105 + }); 106 + 107 + it('handles single data point (centered)', () => { 108 + const points = computeLinePoints([42], 100, 50, {}); 109 + expect(points).toHaveLength(1); 110 + expect(points[0].x).toBeCloseTo(50, 0); 111 + expect(points[0].y).toBeCloseTo(25, 0); 112 + }); 113 + 114 + it('handles all same values (flat line in middle)', () => { 115 + const points = computeLinePoints([5, 5, 5], 100, 50, {}); 116 + expect(points).toHaveLength(3); 117 + expect(points[0].y).toBeCloseTo(points[1].y); 118 + expect(points[1].y).toBeCloseTo(points[2].y); 119 + expect(points[0].y).toBeCloseTo(25, 0); 120 + }); 121 + 122 + it('respects minValue/maxValue overrides', () => { 123 + const points = computeLinePoints([50], 100, 100, { minValue: 0, maxValue: 100 }); 124 + expect(points).toHaveLength(1); 125 + expect(points[0].y).toBeCloseTo(50, 0); 126 + }); 127 + 128 + it('handles negative values', () => { 129 + const points = computeLinePoints([-10, 0, 10], 90, 100, {}); 130 + expect(points).toHaveLength(3); 131 + expect(points[0].y).toBeGreaterThan(points[2].y); 132 + expect(points[1].y).toBeCloseTo(50, 0); 133 + }); 134 + }); 135 + 136 + describe('computeBarRects', () => { 137 + it('creates correct number of bars', () => { 138 + const rects = computeBarRects([1, 2, 3, 4], 100, 50, {}); 139 + expect(rects).toHaveLength(4); 140 + }); 141 + 142 + it('bar widths fill the cell width', () => { 143 + const rects = computeBarRects([1, 2, 3, 4], 100, 50, {}); 144 + for (const rect of rects) { 145 + expect(rect.width).toBeCloseTo(25, 0); 146 + } 147 + }); 148 + 149 + it('handles negative values with bars below baseline', () => { 150 + const rects = computeBarRects([10, -5], 100, 100, {}); 151 + expect(rects).toHaveLength(2); 152 + const posRect = rects[0]; 153 + const negRect = rects[1]; 154 + expect(posRect.negative).toBe(false); 155 + expect(negRect.negative).toBe(true); 156 + }); 157 + 158 + it('handles all positive values (baseline at bottom)', () => { 159 + const rects = computeBarRects([1, 2, 3], 90, 60, {}); 160 + expect(rects).toHaveLength(3); 161 + const tallest = rects[2]; 162 + expect(tallest.height).toBeCloseTo(60, 0); 163 + }); 164 + 165 + it('handles single value', () => { 166 + const rects = computeBarRects([5], 100, 50, {}); 167 + expect(rects).toHaveLength(1); 168 + expect(rects[0].width).toBeCloseTo(100, 0); 169 + expect(rects[0].height).toBeCloseTo(50, 0); 170 + }); 171 + }); 172 + 173 + describe('computeWinLossRects', () => { 174 + it('categorizes positive values in top half', () => { 175 + const rects = computeWinLossRects([1, 5, 100], 90, 60, {}); 176 + expect(rects).toHaveLength(3); 177 + for (const rect of rects) { 178 + expect(rect.category).toBe('win'); 179 + expect(rect.y).toBeLessThan(30); 180 + } 181 + }); 182 + 183 + it('categorizes negative values in bottom half', () => { 184 + const rects = computeWinLossRects([-1, -5, -100], 90, 60, {}); 185 + expect(rects).toHaveLength(3); 186 + for (const rect of rects) { 187 + expect(rect.category).toBe('loss'); 188 + expect(rect.y).toBeGreaterThanOrEqual(30); 189 + } 190 + }); 191 + 192 + it('categorizes zero as zero (thin line at center)', () => { 193 + const rects = computeWinLossRects([0], 100, 60, {}); 194 + expect(rects).toHaveLength(1); 195 + expect(rects[0].category).toBe('zero'); 196 + expect(rects[0].height).toBeLessThanOrEqual(4); 197 + }); 198 + 199 + it('handles mixed values', () => { 200 + const rects = computeWinLossRects([1, -1, 0], 90, 60, {}); 201 + expect(rects).toHaveLength(3); 202 + expect(rects[0].category).toBe('win'); 203 + expect(rects[1].category).toBe('loss'); 204 + expect(rects[2].category).toBe('zero'); 205 + }); 206 + 207 + it('all rects have equal width', () => { 208 + const rects = computeWinLossRects([1, -1, 0, 5], 100, 60, {}); 209 + const width = rects[0].width; 210 + for (const rect of rects) { 211 + expect(rect.width).toBeCloseTo(width, 0); 212 + } 213 + }); 214 + }); 215 + 216 + describe('SPARKLINE formula integration', () => { 217 + it('returns sparkline marker for line type', () => { 218 + const result = evalWith('SPARKLINE(A1:A3)', { A1: 1, A2: 2, A3: 3 }); 219 + expect(isSparklineResult(result)).toBe(true); 220 + const sr = result as SparklineResult; 221 + expect(sr.chartType).toBe('line'); 222 + expect(sr.data).toEqual([1, 2, 3]); 223 + }); 224 + 225 + it('returns sparkline marker for bar type', () => { 226 + const result = evalWith('SPARKLINE(A1:A3, "bar")', { A1: 10, A2: 20, A3: 30 }); 227 + expect(isSparklineResult(result)).toBe(true); 228 + const sr = result as SparklineResult; 229 + expect(sr.chartType).toBe('bar'); 230 + }); 231 + 232 + it('returns sparkline marker for winloss type', () => { 233 + const result = evalWith('SPARKLINE(A1:A3, "winloss")', { A1: 1, A2: -1, A3: 0 }); 234 + expect(isSparklineResult(result)).toBe(true); 235 + const sr = result as SparklineResult; 236 + expect(sr.chartType).toBe('winloss'); 237 + }); 238 + 239 + it('returns empty string for empty range', () => { 240 + const result = evalWith('SPARKLINE(A1:A3)', {}); 241 + expect(result).toBe(''); 242 + }); 243 + 244 + it('returns #VALUE! for invalid type', () => { 245 + const result = evalWith('SPARKLINE(A1:A3, "pie")', { A1: 1, A2: 2, A3: 3 }); 246 + expect(result).toBe('#VALUE!'); 247 + }); 248 + 249 + it('filters non-numeric values from range', () => { 250 + const result = evalWith('SPARKLINE(A1:A4)', { A1: 1, A2: 'hello', A3: 3, A4: '' }); 251 + expect(isSparklineResult(result)).toBe(true); 252 + const sr = result as SparklineResult; 253 + expect(sr.data).toEqual([1, 3]); 254 + }); 255 + 256 + it('handles single data point', () => { 257 + const result = evalWith('SPARKLINE(A1:A1)', { A1: 42 }); 258 + expect(isSparklineResult(result)).toBe(true); 259 + const sr = result as SparklineResult; 260 + expect(sr.data).toEqual([42]); 261 + }); 262 + });