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

Configure Feed

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

refactor(sheets): phase 3 — extract 9 UI modules from main.ts (#282)

scott e664a981 594179dd

+1799 -1409
+5
CHANGELOG.md
··· 12 12 - Markdown links `[text](url)` and task lists `- [ ]` now recognized as strong paste signals (#454) 13 13 14 14 ### Fixed 15 + - Sheets: @ts-nocheck in main.ts disables all type checking - high risk of runtime type errors in 3000+ line file (#409) 15 16 - Prefer markdown conversion when clipboard HTML contains unrendered `[text](url)` syntax (#455) 16 17 - Emit TipTap-compatible HTML for task list checkbox paste (`data-type="taskItem"`) (#453) 17 18 - Sheet cells and row headers now have opaque backgrounds, fixing dark mode scroll-through (#452) ··· 269 270 - Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305) 270 271 271 272 ### Changed 273 + - Aggressively decompose sheets/main.ts - extract all major UI blocks (#459) 274 + - Decompose sheets/main.ts monolith into focused modules (#458) 275 + - Polish task list checkbox alignment and spacing (#457) 276 + - Bump version to 0.24.0 and update CHANGELOG (#456) 272 277 - Consolidate z-index values into documented CSS custom properties (#450) 273 278 - QA batch 22: tests for cross-sheet, custom-format, permissions, named-ranges (#444) 274 279 - QA: batch 21 edge case tests for untested modules (#442)
+197
src/sheets/cell-notes-ui.ts
··· 1 + /** 2 + * Cell Notes UI — note CRUD, tooltip, indicators, error tooltip. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { getNote, getAllNotes, hasNote } from './cell-notes.js'; 9 + 10 + // ── Types ─────────────────────────────────────────────────── 11 + 12 + export interface CellNotesDeps { 13 + getActiveSheet: () => any; 14 + grid: HTMLElement; 15 + } 16 + 17 + // ── Notes Map Access ──────────────────────────────────────── 18 + 19 + export function getNotesMap(deps: CellNotesDeps): any { 20 + const sheet = deps.getActiveSheet(); 21 + if (!sheet.has('notes')) sheet.set('notes', new Y.Map()); 22 + return sheet.get('notes'); 23 + } 24 + 25 + export function getNotesObject(deps: CellNotesDeps): Record<string, string> { 26 + const yNotes = getNotesMap(deps); 27 + const obj: Record<string, string> = {}; 28 + yNotes.forEach((val: string, key: string) => { obj[key] = val; }); 29 + return obj; 30 + } 31 + 32 + export function setNoteInYjs(deps: CellNotesDeps, cellIdStr: string, text: string | null): void { 33 + const yNotes = getNotesMap(deps); 34 + if (text) { 35 + yNotes.set(cellIdStr, text); 36 + } else { 37 + if (yNotes.has(cellIdStr)) yNotes.delete(cellIdStr); 38 + } 39 + } 40 + 41 + // ── Note Tooltip ──────────────────────────────────────────── 42 + 43 + export function showNoteTooltip(deps: CellNotesDeps, cellIdStr: string, td: HTMLElement): void { 44 + const noteTooltip = document.getElementById('cell-note-tooltip'); 45 + if (!noteTooltip) return; 46 + const notes = getNotesObject(deps); 47 + const text = getNote(notes, cellIdStr); 48 + if (!text) return; 49 + 50 + noteTooltip.textContent = text; 51 + noteTooltip.style.display = ''; 52 + 53 + const rect = td.getBoundingClientRect(); 54 + noteTooltip.style.left = (rect.right + 4) + 'px'; 55 + noteTooltip.style.top = rect.top + 'px'; 56 + 57 + requestAnimationFrame(() => { 58 + const ttRect = noteTooltip.getBoundingClientRect(); 59 + if (ttRect.right > window.innerWidth) { 60 + noteTooltip.style.left = (rect.left - ttRect.width - 4) + 'px'; 61 + } 62 + if (ttRect.bottom > window.innerHeight) { 63 + noteTooltip.style.top = (rect.bottom - ttRect.height) + 'px'; 64 + } 65 + }); 66 + } 67 + 68 + export function hideNoteTooltip(): void { 69 + const noteTooltip = document.getElementById('cell-note-tooltip'); 70 + if (noteTooltip) noteTooltip.style.display = 'none'; 71 + } 72 + 73 + // ── Note Dialog ───────────────────────────────────────────── 74 + 75 + export function showNoteDialog(deps: CellNotesDeps, cellIdStr: string, renderNoteIndicators: () => void): void { 76 + const notes = getNotesObject(deps); 77 + const existing = getNote(notes, cellIdStr); 78 + 79 + const overlay = document.createElement('div'); 80 + overlay.className = 'sheet-dialog-overlay'; 81 + overlay.innerHTML = '<div class="sheet-dialog">' 82 + + '<h3>' + (existing ? 'Edit' : 'Add') + ' Note for ' + cellIdStr + '</h3>' 83 + + '<textarea id="note-text" rows="4" style="width:100%;resize:vertical;font-family:var(--font-body);font-size:0.85rem;padding:6px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);">' + (existing || '') + '</textarea>' 84 + + '<div class="sheet-dialog-actions">' 85 + + (existing ? '<button id="note-delete" class="btn-danger" style="margin-right:auto;">Delete</button>' : '') 86 + + '<button id="note-cancel">Cancel</button>' 87 + + '<button id="note-save" class="btn-primary">Save</button>' 88 + + '</div></div>'; 89 + 90 + document.body.appendChild(overlay); 91 + 92 + const textarea = overlay.querySelector('#note-text') as HTMLTextAreaElement; 93 + setTimeout(() => textarea.focus(), 50); 94 + 95 + overlay.querySelector('#note-cancel')!.addEventListener('click', () => overlay.remove()); 96 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 97 + 98 + overlay.querySelector('#note-save')!.addEventListener('click', () => { 99 + const text = textarea.value.trim(); 100 + setNoteInYjs(deps, cellIdStr, text || null); 101 + overlay.remove(); 102 + renderNoteIndicators(); 103 + }); 104 + 105 + if (existing) { 106 + overlay.querySelector('#note-delete')!.addEventListener('click', () => { 107 + setNoteInYjs(deps, cellIdStr, null); 108 + overlay.remove(); 109 + renderNoteIndicators(); 110 + }); 111 + } 112 + } 113 + 114 + // ── Note Indicators ───────────────────────────────────────── 115 + 116 + export function renderNoteIndicators(deps: CellNotesDeps): void { 117 + deps.grid.querySelectorAll('.cell-note-indicator').forEach(el => el.remove()); 118 + deps.grid.querySelectorAll('.has-note').forEach(el => el.classList.remove('has-note')); 119 + 120 + const notes = getNotesObject(deps); 121 + const cellIds = getAllNotes(notes); 122 + for (const cid of cellIds) { 123 + const td = deps.grid.querySelector('td[data-id="' + cid + '"]') as HTMLElement; 124 + if (td) { 125 + td.classList.add('has-note'); 126 + const indicator = document.createElement('div'); 127 + indicator.className = 'cell-note-indicator'; 128 + td.appendChild(indicator); 129 + } 130 + } 131 + } 132 + 133 + // ── Note Hover Wiring ─────────────────────────────────────── 134 + 135 + export function wireNoteHover(deps: CellNotesDeps): void { 136 + deps.grid.addEventListener('mouseover', (e) => { 137 + const td = (e.target as HTMLElement).closest('td[data-id]') as HTMLElement; 138 + if (!td) return; 139 + const cid = td.dataset.id!; 140 + const notes = getNotesObject(deps); 141 + if (hasNote(notes, cid)) { 142 + showNoteTooltip(deps, cid, td); 143 + } 144 + }); 145 + 146 + deps.grid.addEventListener('mouseout', (e) => { 147 + const td = (e.target as HTMLElement).closest('td[data-id]'); 148 + if (td) hideNoteTooltip(); 149 + }); 150 + } 151 + 152 + // ── Error Tooltip ─────────────────────────────────────────── 153 + 154 + export function wireErrorTooltip(grid: HTMLElement): void { 155 + const errorTooltipEl = document.getElementById('error-tooltip'); 156 + if (!errorTooltipEl) return; 157 + const errorTitleEl = errorTooltipEl.querySelector('.error-tooltip-title'); 158 + const errorDescEl = errorTooltipEl.querySelector('.error-tooltip-desc'); 159 + const errorHintEl = errorTooltipEl.querySelector('.error-tooltip-hint'); 160 + 161 + function showErrorTooltip(cellDiv: HTMLElement, td: HTMLElement) { 162 + const title = cellDiv.dataset.errorTitle; 163 + if (!title) return; 164 + errorTitleEl!.textContent = title; 165 + errorDescEl!.textContent = cellDiv.dataset.errorDesc || ''; 166 + errorHintEl!.textContent = cellDiv.dataset.errorHint ? 'Hint: ' + cellDiv.dataset.errorHint : ''; 167 + errorTooltipEl!.style.display = ''; 168 + const rect = td.getBoundingClientRect(); 169 + errorTooltipEl!.style.left = (rect.left) + 'px'; 170 + errorTooltipEl!.style.top = (rect.bottom + 4) + 'px'; 171 + requestAnimationFrame(() => { 172 + const ttRect = errorTooltipEl!.getBoundingClientRect(); 173 + if (ttRect.right > window.innerWidth) { 174 + errorTooltipEl!.style.left = (window.innerWidth - ttRect.width - 8) + 'px'; 175 + } 176 + if (ttRect.bottom > window.innerHeight) { 177 + errorTooltipEl!.style.top = (rect.top - ttRect.height - 4) + 'px'; 178 + } 179 + }); 180 + } 181 + 182 + function hideErrorTooltip() { 183 + errorTooltipEl!.style.display = 'none'; 184 + } 185 + 186 + grid.addEventListener('mouseover', (e) => { 187 + const cellDiv = (e.target as HTMLElement).closest('.cell-error[data-error-title]') as HTMLElement; 188 + if (!cellDiv) return; 189 + const td = cellDiv.closest('td[data-id]') as HTMLElement; 190 + if (td) showErrorTooltip(cellDiv, td); 191 + }); 192 + 193 + grid.addEventListener('mouseout', (e) => { 194 + const cellDiv = (e.target as HTMLElement).closest('.cell-error[data-error-title]'); 195 + if (cellDiv) hideErrorTooltip(); 196 + }); 197 + }
+156
src/sheets/charts-ui.ts
··· 1 + /** 2 + * Charts UI — dialog, rendering, Chart.js integration. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js'; 9 + import { cellId, colToLetter } from './formulas.js'; 10 + import { normalizeRange } from './selection-utils.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface ChartsUIDeps { 15 + getActiveSheet: () => any; 16 + getCellData: (id: string) => any; 17 + evaluateFormula: (formula: string) => any; 18 + getCharts: () => any; 19 + selectedCell: { col: number; row: number }; 20 + selectionRange: any; 21 + ydoc: { transact: (fn: () => void) => void }; 22 + chartsSection: HTMLElement; 23 + } 24 + 25 + // ── Chart.js Lazy Loading ─────────────────────────────────── 26 + 27 + let ChartJS: any = null; 28 + 29 + async function ensureChartJS() { 30 + if (ChartJS) return ChartJS; 31 + const mod = await import('chart.js'); 32 + mod.Chart.register( 33 + mod.CategoryScale, mod.LinearScale, mod.PointElement, 34 + mod.LineElement, mod.BarElement, mod.ArcElement, 35 + mod.Title, mod.Tooltip, mod.Legend 36 + ); 37 + ChartJS = mod.Chart; 38 + return ChartJS; 39 + } 40 + 41 + // ── Chart State ───────────────────────────────────────────── 42 + 43 + const chartInstances = new Map<string, any>(); 44 + 45 + // ── Chart Dialog ──────────────────────────────────────────── 46 + 47 + export function showChartDialog(deps: ChartsUIDeps, existingId?: string | null, existingConfig?: any): void { 48 + const overlay = document.createElement('div'); 49 + overlay.className = 'sheet-dialog-overlay'; 50 + 51 + const isEdit = !!existingId; 52 + const cfg = existingConfig || { type: 'bar', range: '', title: '', xAxisLabel: '', yAxisLabel: '' }; 53 + 54 + if (!isEdit && deps.selectionRange) { 55 + const { startCol, startRow, endCol, endRow } = normalizeRange(deps.selectionRange); 56 + if (startCol !== endCol || startRow !== endRow) { 57 + cfg.range = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 58 + } 59 + } 60 + 61 + overlay.innerHTML = ` 62 + <div class="sheet-dialog"> 63 + <h3>${isEdit ? 'Edit' : 'Insert'} Chart</h3> 64 + <label>Chart Type</label> 65 + <select id="chart-type"> 66 + ${CHART_TYPES.map(t => `<option value="${t}" ${t === cfg.type ? 'selected' : ''}>${t.charAt(0).toUpperCase() + t.slice(1)}</option>`).join('')} 67 + </select> 68 + <label>Data Range (e.g. A1:D10)</label> 69 + <input id="chart-range" value="${cfg.range}" placeholder="A1:D10"> 70 + <label>Title</label> 71 + <input id="chart-title" value="${cfg.title || ''}" placeholder="Chart title"> 72 + <label>X Axis Label</label> 73 + <input id="chart-x-label" value="${cfg.xAxisLabel || ''}"> 74 + <label>Y Axis Label</label> 75 + <input id="chart-y-label" value="${cfg.yAxisLabel || ''}"> 76 + <div class="sheet-dialog-actions"> 77 + <button id="chart-cancel">Cancel</button> 78 + <button id="chart-ok" class="btn-primary">${isEdit ? 'Update' : 'Insert'}</button> 79 + </div> 80 + </div> 81 + `; 82 + document.body.appendChild(overlay); 83 + 84 + overlay.querySelector('#chart-cancel')!.addEventListener('click', () => overlay.remove()); 85 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 86 + 87 + overlay.querySelector('#chart-ok')!.addEventListener('click', () => { 88 + const config = { 89 + type: (overlay.querySelector('#chart-type') as HTMLSelectElement).value, 90 + range: (overlay.querySelector('#chart-range') as HTMLInputElement).value.trim(), 91 + title: (overlay.querySelector('#chart-title') as HTMLInputElement).value.trim(), 92 + xAxisLabel: (overlay.querySelector('#chart-x-label') as HTMLInputElement).value.trim(), 93 + yAxisLabel: (overlay.querySelector('#chart-y-label') as HTMLInputElement).value.trim(), 94 + }; 95 + const validation = validateChartConfig(config); 96 + if (!validation.valid) { 97 + alert(validation.errors.join('\n')); 98 + return; 99 + } 100 + const charts = deps.getCharts(); 101 + const id = existingId || 'chart_' + Date.now(); 102 + charts.set(id, JSON.stringify(config)); 103 + overlay.remove(); 104 + renderCharts(deps); 105 + }); 106 + 107 + setTimeout(() => (overlay.querySelector('#chart-range') as HTMLInputElement)?.focus(), 50); 108 + } 109 + 110 + // ── Render Charts ─────────────────────────────────────────── 111 + 112 + function getCellValueForChart(deps: ChartsUIDeps, id: string): any { 113 + const data = deps.getCellData(id); 114 + if (!data) return ''; 115 + if (data.f) return deps.evaluateFormula(data.f); 116 + return data.v ?? ''; 117 + } 118 + 119 + export async function renderCharts(deps: ChartsUIDeps): Promise<void> { 120 + const Chart = await ensureChartJS(); 121 + const charts = deps.getCharts(); 122 + deps.chartsSection.innerHTML = ''; 123 + 124 + for (const [, inst] of chartInstances) inst.destroy(); 125 + chartInstances.clear(); 126 + 127 + charts.forEach((cfgStr: string, id: string) => { 128 + const config = typeof cfgStr === 'string' ? JSON.parse(cfgStr) : cfgStr; 129 + const rawData = extractChartData(config.range, (cid: string) => getCellValueForChart(deps, cid)); 130 + const transformed = transformChartData(rawData, config); 131 + const chartJsConfig = buildChartJsConfig(config, transformed); 132 + 133 + const container = document.createElement('div'); 134 + container.className = 'chart-container'; 135 + container.dataset.chartId = id; 136 + 137 + const actions = document.createElement('div'); 138 + actions.className = 'chart-actions'; 139 + actions.innerHTML = '<button class="chart-edit">Edit</button><button class="chart-delete">Delete</button>'; 140 + container.appendChild(actions); 141 + 142 + const canvas = document.createElement('canvas'); 143 + container.appendChild(canvas); 144 + deps.chartsSection.appendChild(container); 145 + 146 + const inst = new Chart(canvas, chartJsConfig); 147 + chartInstances.set(id, inst); 148 + 149 + actions.querySelector('.chart-edit')!.addEventListener('click', () => showChartDialog(deps, id, config)); 150 + actions.querySelector('.chart-delete')!.addEventListener('click', () => { 151 + const charts = deps.getCharts(); 152 + deps.ydoc.transact(() => charts.delete(id)); 153 + renderCharts(deps); 154 + }); 155 + }); 156 + }
+70
src/sheets/collaboration-ui.ts
··· 1 + /** 2 + * Collaboration UI — connection status, avatars, Tailscale identity. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + // ── Types ─────────────────────────────────────────────────── 8 + 9 + export interface CollabUIDeps { 10 + provider: any; 11 + ydoc: any; 12 + } 13 + 14 + // ── Connection Status ─────────────────────────────────────── 15 + 16 + export function wireConnectionStatus(deps: CollabUIDeps): void { 17 + const statusDot = document.getElementById('status-dot'); 18 + const statusText = document.getElementById('status-text'); 19 + if (!statusDot || !statusText) return; 20 + 21 + deps.provider.on('status', ({ connected }: { connected: boolean }) => { 22 + statusDot.classList.toggle('connected', connected); 23 + statusText.textContent = connected ? 'Connected' : 'Reconnecting...'; 24 + }); 25 + } 26 + 27 + // ── Collaboration Avatars ─────────────────────────────────── 28 + 29 + const COLORS = ['#e06c5e', '#d4893b', '#5ea3e0', '#5ec48a', '#9b7ec4', '#c45e8a', '#5eb8b0', '#8a7e5e', '#7e8ac4', '#c4a65e']; 30 + 31 + export function setupCollabAvatars(deps: CollabUIDeps): { userName: string } { 32 + const avatarContainer = document.getElementById('collab-avatars'); 33 + if (!avatarContainer) return { userName: '' }; 34 + 35 + // Try localStorage, fall back to random 36 + let userName = localStorage.getItem('tools-username') || (() => { 37 + const a = new Uint16Array(1); 38 + crypto.getRandomValues(a); 39 + return 'User ' + (a[0] % 1000); 40 + })(); 41 + const userColor = COLORS[(() => { const a = new Uint8Array(1); crypto.getRandomValues(a); return a[0] % COLORS.length; })()]; 42 + deps.provider.setAwareness({ name: userName, color: userColor }); 43 + 44 + // Upgrade to Tailscale identity if available 45 + fetch('/api/me').then(r => r.json()).then(data => { 46 + if (data.login) { 47 + userName = data.name; 48 + localStorage.setItem('tools-username', data.name); 49 + deps.provider.setAwareness({ name: userName, color: userColor }); 50 + } 51 + }).catch(() => { /* anonymous access */ }); 52 + 53 + deps.provider.awareness.on('change', () => { 54 + const states = deps.provider.awareness.getStates(); 55 + avatarContainer.innerHTML = ''; 56 + states.forEach((state: any, clientId: number) => { 57 + if (clientId === deps.ydoc.clientID) return; 58 + const user = state.user; 59 + if (!user) return; 60 + const avatar = document.createElement('div'); 61 + avatar.className = 'collab-avatar'; 62 + avatar.style.background = user.color; 63 + avatar.textContent = user.name.charAt(0).toUpperCase(); 64 + avatar.title = user.name; 65 + avatarContainer.appendChild(avatar); 66 + }); 67 + }); 68 + 69 + return { userName }; 70 + }
+183
src/sheets/context-menu-handler.ts
··· 1 + /** 2 + * Context Menu Handler — right-click menus for cells, row/col headers. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId, colToLetter } from './formulas.js'; 8 + import { normalizeRange } from './selection-utils.js'; 9 + import { createContextMenu, SEPARATOR } from '../lib/context-menu.js'; 10 + import { hasNote } from './cell-notes.js'; 11 + import { showToast } from './import-export.js'; 12 + 13 + // ── Types ─────────────────────────────────────────────────── 14 + 15 + export interface ContextMenuDeps { 16 + grid: HTMLElement; 17 + getActiveSheet: () => any; 18 + getCellData: (id: string) => any; 19 + getSelectedCell: () => { col: number; row: number }; 20 + setSelectedCell: (cell: { col: number; row: number }) => void; 21 + getSelectionRange: () => any; 22 + setSelectionRange: (range: any) => void; 23 + copySelection: () => void; 24 + deleteSelectedCells: () => void; 25 + pasteAtSelection: (text: string) => void; 26 + showPasteSpecialDialog: () => void; 27 + doInsertRow: (row: number) => void; 28 + doInsertColumn: (col: number) => void; 29 + doDeleteRow: (row: number) => void; 30 + doDeleteColumn: (col: number) => void; 31 + sortColumn: (ascending: boolean) => void; 32 + hideSelectedRows: () => void; 33 + hideSelectedCols: () => void; 34 + unhideAdjacentRows: (row: number) => void; 35 + unhideAdjacentCols: (col: number) => void; 36 + getFreezeRows: () => number; 37 + getFreezeCols: () => number; 38 + setFreezeRows: (n: number) => void; 39 + setFreezeCols: (n: number) => void; 40 + getColWidth: (col: number) => number; 41 + setColWidth: (col: number, w: number) => void; 42 + getRowHeight: (row: number) => number; 43 + setRowHeight: (row: number, h: number) => void; 44 + isAtHiddenRowBoundary: (row: number, rowCount: number, hiddenSet: Set<number>) => boolean; 45 + isAtHiddenColBoundary: (col: number, colCount: number, hiddenSet: Set<number>) => boolean; 46 + buildHiddenRowSet: () => Set<number>; 47 + buildHiddenColSet: () => Set<number>; 48 + renderGrid: () => void; 49 + showNoteDialog: (cellId: string) => void; 50 + setNoteInYjs: (cellId: string, text: string | null) => void; 51 + renderNoteIndicators: () => void; 52 + getNotesObject: () => Record<string, string>; 53 + DEFAULT_ROWS: number; 54 + DEFAULT_COLS: number; 55 + } 56 + 57 + // ── State ─────────────────────────────────────────────────── 58 + 59 + let _activeContextMenu: any = null; 60 + 61 + export function hideActiveContextMenu(): void { 62 + if (_activeContextMenu) { 63 + _activeContextMenu.destroy(); 64 + _activeContextMenu = null; 65 + } 66 + } 67 + 68 + export function getActiveContextMenu(): any { return _activeContextMenu; } 69 + export function setActiveContextMenu(menu: any): void { _activeContextMenu = menu; } 70 + 71 + // ── Wire Context Menu ─────────────────────────────────────── 72 + 73 + export function wireContextMenu(deps: ContextMenuDeps): void { 74 + deps.grid.addEventListener('contextmenu', (e) => { 75 + e.preventDefault(); 76 + hideActiveContextMenu(); 77 + 78 + const colHeader = (e.target as HTMLElement).closest('thead th[data-col]') as HTMLElement; 79 + const rowHeader = (e.target as HTMLElement).closest('th.row-header[data-row]') as HTMLElement; 80 + const td = (e.target as HTMLElement).closest('td[data-id]') as HTMLElement; 81 + 82 + const sheet = deps.getActiveSheet(); 83 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 84 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 85 + 86 + let items: any[]; 87 + if (colHeader) { 88 + const col = parseInt(colHeader.dataset.col!); 89 + const hasAdjacentHiddenCol = deps.isAtHiddenColBoundary(col, colCount, deps.buildHiddenColSet()); 90 + items = [ 91 + { label: 'Sort A \u2192 Z', icon: '\u2191', action: () => { deps.setSelectedCell({ col, row: 1 }); deps.setSelectionRange({ startCol: col, startRow: 1, endCol: col, endRow: rowCount }); deps.sortColumn(true); } }, 92 + { label: 'Sort Z \u2192 A', icon: '\u2193', action: () => { deps.setSelectedCell({ col, row: 1 }); deps.setSelectionRange({ startCol: col, startRow: 1, endCol: col, endRow: rowCount }); deps.sortColumn(false); } }, 93 + SEPARATOR, 94 + { label: 'Insert Column Left', action: () => deps.doInsertColumn(col) }, 95 + { label: 'Insert Column Right', action: () => deps.doInsertColumn(col + 1) }, 96 + { label: 'Delete Column', action: () => deps.doDeleteColumn(col) }, 97 + SEPARATOR, 98 + { label: 'Hide Column', shortcut: '\u2318+0', action: () => { deps.setSelectedCell({ col, row: 1 }); deps.setSelectionRange({ startCol: col, startRow: 1, endCol: col, endRow: rowCount }); deps.hideSelectedCols(); } }, 99 + ...(hasAdjacentHiddenCol ? [{ label: 'Unhide Columns', shortcut: '\u2318\u21e7+0', action: () => deps.unhideAdjacentCols(col) }] : []), 100 + SEPARATOR, 101 + ...(deps.getFreezeCols() !== col ? [{ label: 'Freeze up to column ' + colToLetter(col), action: () => { deps.setFreezeCols(col); deps.renderGrid(); } }] : []), 102 + ...(deps.getFreezeCols() > 0 ? [{ label: 'Unfreeze Columns', action: () => { deps.setFreezeCols(0); deps.renderGrid(); } }] : []), 103 + { label: 'Resize Column\u2026', action: () => { const w = prompt('Column width (px):', String(deps.getColWidth(col))); if (w && !isNaN(Number(w))) { deps.setColWidth(col, Number(w)); deps.renderGrid(); } } }, 104 + ]; 105 + } else if (rowHeader) { 106 + const row = parseInt(rowHeader.dataset.row!); 107 + const hasAdjacentHiddenRow = deps.isAtHiddenRowBoundary(row, rowCount, deps.buildHiddenRowSet()); 108 + items = [ 109 + { label: 'Insert Row Above', action: () => deps.doInsertRow(row) }, 110 + { label: 'Insert Row Below', action: () => deps.doInsertRow(row + 1) }, 111 + { label: 'Delete Row', action: () => deps.doDeleteRow(row) }, 112 + SEPARATOR, 113 + { label: 'Hide Row', shortcut: '\u2318+9', action: () => { deps.setSelectedCell({ col: 1, row }); deps.setSelectionRange({ startCol: 1, startRow: row, endCol: colCount, endRow: row }); deps.hideSelectedRows(); } }, 114 + ...(hasAdjacentHiddenRow ? [{ label: 'Unhide Rows', shortcut: '\u2318\u21e7+9', action: () => deps.unhideAdjacentRows(row) }] : []), 115 + SEPARATOR, 116 + ...(deps.getFreezeRows() !== row ? [{ label: 'Freeze at row ' + row, action: () => { deps.setFreezeRows(row); deps.renderGrid(); } }] : []), 117 + ...(deps.getFreezeRows() > 0 ? [{ label: 'Unfreeze Rows', action: () => { deps.setFreezeRows(0); deps.renderGrid(); } }] : []), 118 + { label: 'Resize Row\u2026', action: () => { const h = prompt('Row height (px):', String(deps.getRowHeight(row))); if (h && !isNaN(Number(h))) { deps.setRowHeight(row, Number(h)); deps.renderGrid(); } } }, 119 + ]; 120 + } else if (td) { 121 + const col = parseInt(td.dataset.col!); 122 + const row = parseInt(td.dataset.row!); 123 + const cid = td.dataset.id!; 124 + const notes = deps.getNotesObject(); 125 + const noteExists = hasNote(notes, cid); 126 + const selectionRange = deps.getSelectionRange(); 127 + items = [ 128 + { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => { deps.copySelection(); deps.deleteSelectedCells(); } }, 129 + { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => deps.copySelection() }, 130 + { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => { navigator.clipboard.readText().then(text => deps.pasteAtSelection(text)).catch(() => {}); } }, 131 + { label: 'Paste Special...', shortcut: '\u2318\u21e7V', action: () => deps.showPasteSpecialDialog() }, 132 + SEPARATOR, 133 + ...(selectionRange && (() => { 134 + const nr = normalizeRange(selectionRange); 135 + const isMulti = nr.startCol !== nr.endCol || nr.startRow !== nr.endRow; 136 + if (!isMulti) return []; 137 + const rangeRef = cellId(nr.startCol, nr.startRow) + ':' + cellId(nr.endCol, nr.endRow); 138 + return [ 139 + { label: 'Copy as reference', icon: 'f\u2099', action: () => { navigator.clipboard.writeText(rangeRef); showToast('Copied ' + rangeRef); } }, 140 + { label: 'Copy as =SUM()', action: () => { navigator.clipboard.writeText('=SUM(' + rangeRef + ')'); showToast('Copied =SUM(' + rangeRef + ')'); } }, 141 + { label: 'Copy as =AVERAGE()', action: () => { navigator.clipboard.writeText('=AVERAGE(' + rangeRef + ')'); showToast('Copied =AVERAGE(' + rangeRef + ')'); } }, 142 + { label: 'Copy as =COUNT()', action: () => { navigator.clipboard.writeText('=COUNT(' + rangeRef + ')'); showToast('Copied =COUNT(' + rangeRef + ')'); } }, 143 + SEPARATOR, 144 + ]; 145 + })() || []), 146 + { label: 'Insert Row Above', action: () => deps.doInsertRow(row) }, 147 + { label: 'Insert Row Below', action: () => deps.doInsertRow(row + 1) }, 148 + { label: 'Insert Column Left', action: () => deps.doInsertColumn(col) }, 149 + { label: 'Insert Column Right', action: () => deps.doInsertColumn(col + 1) }, 150 + SEPARATOR, 151 + { label: 'Delete Row', action: () => deps.doDeleteRow(row) }, 152 + { label: 'Delete Column', action: () => deps.doDeleteColumn(col) }, 153 + SEPARATOR, 154 + { label: 'Clear Cells', action: () => deps.deleteSelectedCells() }, 155 + SEPARATOR, 156 + { label: 'Hide Column ' + colToLetter(col), action: () => { deps.setSelectedCell({ col, row }); deps.setSelectionRange({ startCol: col, startRow: 1, endCol: col, endRow: rowCount }); deps.hideSelectedCols(); } }, 157 + { label: 'Hide Row ' + row, action: () => { deps.setSelectedCell({ col: 1, row }); deps.setSelectionRange({ startCol: 1, startRow: row, endCol: colCount, endRow: row }); deps.hideSelectedRows(); } }, 158 + ...(deps.getFreezeCols() !== col ? [{ label: 'Freeze up to column ' + colToLetter(col), action: () => { deps.setFreezeCols(col); deps.renderGrid(); } }] : []), 159 + ...(deps.getFreezeCols() > 0 ? [{ label: 'Unfreeze Columns', action: () => { deps.setFreezeCols(0); deps.renderGrid(); } }] : []), 160 + ...(deps.getFreezeRows() !== row ? [{ label: 'Freeze at row ' + row, action: () => { deps.setFreezeRows(row); deps.renderGrid(); } }] : []), 161 + ...(deps.getFreezeRows() > 0 ? [{ label: 'Unfreeze Rows', action: () => { deps.setFreezeRows(0); deps.renderGrid(); } }] : []), 162 + SEPARATOR, 163 + { label: noteExists ? 'Edit Note' : 'Add Note', icon: '\uD83D\uDCDD', action: () => deps.showNoteDialog(cid) }, 164 + ...(noteExists ? [{ label: 'Delete Note', action: () => { deps.setNoteInYjs(cid, null); deps.renderNoteIndicators(); } }] : []), 165 + ]; 166 + } else { 167 + return; 168 + } 169 + 170 + const ctxMenu = createContextMenu(items); 171 + document.body.appendChild(ctxMenu.el); 172 + ctxMenu.show(e.clientX, e.clientY); 173 + _activeContextMenu = ctxMenu; 174 + 175 + const closeHandler = (ev: MouseEvent) => { 176 + if (!ctxMenu.el.contains(ev.target as Node)) { 177 + hideActiveContextMenu(); 178 + document.removeEventListener('mousedown', closeHandler); 179 + } 180 + }; 181 + setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 182 + }); 183 + }
+259
src/sheets/database-views-ui.ts
··· 1 + /** 2 + * Database Views UI — Kanban, Gallery, Calendar views. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId, colToLetter } from './formulas.js'; 8 + import { buildKanbanColumns, buildGalleryCards, buildCalendarEvents, groupEventsByMonth, eventsForDate, createViewConfig, getViewTypes } from './database-views.js'; 9 + import type { ViewConfig, ViewType } from './database-views.js'; 10 + 11 + // ── Types ─────────────────────────────────────────────────── 12 + 13 + export interface DbViewsDeps { 14 + getActiveSheet: () => any; 15 + getCellData: (id: string) => any; 16 + evaluateFormula: (formula: string) => any; 17 + DEFAULT_ROWS: number; 18 + DEFAULT_COLS: number; 19 + dbViewSection: HTMLElement; 20 + } 21 + 22 + // ── State ─────────────────────────────────────────────────── 23 + 24 + let activeDbView: ViewConfig | null = null; 25 + 26 + // ── Helpers ───────────────────────────────────────────────── 27 + 28 + function getCellValueForView(deps: DbViewsDeps, row: number, col: number): string { 29 + const data = deps.getCellData(cellId(col, row)); 30 + if (!data) return ''; 31 + if (data.f) { 32 + const result = deps.evaluateFormula(data.f); 33 + return result == null ? '' : String(result); 34 + } 35 + return data.v == null ? '' : String(data.v); 36 + } 37 + 38 + function getDataRowIndices(deps: DbViewsDeps): number[] { 39 + const sheet = deps.getActiveSheet(); 40 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 41 + const indices: number[] = []; 42 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 43 + for (let r = 2; r <= rowCount; r++) { 44 + let hasData = false; 45 + for (let c = 1; c <= colCount; c++) { 46 + const data = deps.getCellData(cellId(c, r)); 47 + if (data?.v !== '' && data?.v != null) { hasData = true; break; } 48 + } 49 + if (hasData) indices.push(r); 50 + } 51 + return indices; 52 + } 53 + 54 + // ── Dialog ────────────────────────────────────────────────── 55 + 56 + export function showDbViewDialog(deps: DbViewsDeps): void { 57 + if (document.querySelector('.dbview-dialog-overlay')) return; 58 + const overlay = document.createElement('div'); 59 + overlay.className = 'sheet-dialog-overlay dbview-dialog-overlay'; 60 + 61 + const sheet = deps.getActiveSheet(); 62 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 63 + const colOptions: string[] = []; 64 + for (let c = 1; c <= colCount; c++) { 65 + const data = deps.getCellData(cellId(c, 1)); 66 + const label = data?.v ? String(data.v) : colToLetter(c); 67 + colOptions.push(`<option value="${c}">${colToLetter(c)}: ${label}</option>`); 68 + } 69 + 70 + overlay.innerHTML = ` 71 + <div class="sheet-dialog"> 72 + <h3>Database View</h3> 73 + <label>View Type</label> 74 + <select id="dbview-type"> 75 + <option value="kanban">Kanban</option> 76 + <option value="gallery">Gallery</option> 77 + <option value="calendar">Calendar</option> 78 + </select> 79 + <label>Group By / Date Column</label> 80 + <select id="dbview-group">${colOptions.join('')}</select> 81 + <label>Title Column</label> 82 + <select id="dbview-title">${colOptions.join('')}</select> 83 + <label>Display Columns</label> 84 + <select id="dbview-display" multiple size="4">${colOptions.join('')}</select> 85 + <div class="sheet-dialog-actions"> 86 + <button id="dbview-cancel">Cancel</button> 87 + <button id="dbview-ok" class="btn-primary">Open View</button> 88 + </div> 89 + </div> 90 + `; 91 + document.body.appendChild(overlay); 92 + 93 + overlay.querySelector('#dbview-cancel')!.addEventListener('click', () => overlay.remove()); 94 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 95 + 96 + overlay.querySelector('#dbview-ok')!.addEventListener('click', () => { 97 + const viewType = (overlay.querySelector('#dbview-type') as HTMLSelectElement).value as ViewType; 98 + const groupByColumn = Number((overlay.querySelector('#dbview-group') as HTMLSelectElement).value); 99 + const titleColumn = Number((overlay.querySelector('#dbview-title') as HTMLSelectElement).value); 100 + const displayColumns = Array.from((overlay.querySelector('#dbview-display') as HTMLSelectElement).selectedOptions, o => Number(o.value)); 101 + 102 + activeDbView = createViewConfig(viewType, groupByColumn, titleColumn); 103 + activeDbView.displayColumns = displayColumns; 104 + overlay.remove(); 105 + renderDbView(deps); 106 + }); 107 + } 108 + 109 + // ── Render ────────────────────────────────────────────────── 110 + 111 + export function renderDbView(deps: DbViewsDeps): void { 112 + if (!activeDbView) { 113 + deps.dbViewSection.style.display = 'none'; 114 + return; 115 + } 116 + 117 + deps.dbViewSection.style.display = ''; 118 + deps.dbViewSection.innerHTML = ''; 119 + 120 + const toolbar = document.createElement('div'); 121 + toolbar.className = 'db-view-toolbar'; 122 + toolbar.innerHTML = ` 123 + <strong>${activeDbView.type.charAt(0).toUpperCase() + activeDbView.type.slice(1)} View</strong> 124 + <button class="db-view-close" title="Close view">\u2715 Close</button> 125 + `; 126 + deps.dbViewSection.appendChild(toolbar); 127 + toolbar.querySelector('.db-view-close')!.addEventListener('click', () => { 128 + activeDbView = null; 129 + renderDbView(deps); 130 + }); 131 + 132 + const rowIndices = getDataRowIndices(deps); 133 + 134 + if (activeDbView.type === 'kanban') { 135 + renderKanbanView(deps, rowIndices); 136 + } else if (activeDbView.type === 'gallery') { 137 + renderGalleryView(deps, rowIndices); 138 + } else if (activeDbView.type === 'calendar') { 139 + renderCalendarView(deps, rowIndices); 140 + } 141 + } 142 + 143 + function renderKanbanView(deps: DbViewsDeps, rowIndices: number[]): void { 144 + const columns = buildKanbanColumns(rowIndices, (r, c) => getCellValueForView(deps, r, c), activeDbView!); 145 + const board = document.createElement('div'); 146 + board.className = 'kanban-board'; 147 + 148 + for (const col of columns) { 149 + const column = document.createElement('div'); 150 + column.className = 'kanban-column'; 151 + column.innerHTML = ` 152 + <div class="kanban-column-header"> 153 + <span>${col.groupValue}</span> 154 + <span class="kanban-column-count">${col.cards.length}</span> 155 + </div> 156 + `; 157 + 158 + for (const card of col.cards) { 159 + const cardEl = document.createElement('div'); 160 + cardEl.className = 'kanban-card'; 161 + cardEl.dataset.row = String(card.rowIndex); 162 + let fieldsHtml = ''; 163 + for (const f of card.fields) { 164 + const hdr = deps.getCellData(cellId(f.columnIndex, 1)); 165 + const label = hdr?.v ? String(hdr.v) : colToLetter(f.columnIndex); 166 + fieldsHtml += `<div class="kanban-card-field"><span class="kanban-card-field-label">${label}:</span> ${f.value}</div>`; 167 + } 168 + cardEl.innerHTML = `<div class="kanban-card-title">${card.title || '(untitled)'}</div>${fieldsHtml}`; 169 + column.appendChild(cardEl); 170 + } 171 + 172 + board.appendChild(column); 173 + } 174 + 175 + deps.dbViewSection.appendChild(board); 176 + } 177 + 178 + function renderGalleryView(deps: DbViewsDeps, rowIndices: number[]): void { 179 + const cards = buildGalleryCards(rowIndices, (r, c) => getCellValueForView(deps, r, c), activeDbView!); 180 + const gridEl = document.createElement('div'); 181 + gridEl.className = 'gallery-grid'; 182 + 183 + for (const card of cards) { 184 + const cardEl = document.createElement('div'); 185 + cardEl.className = 'gallery-card'; 186 + cardEl.dataset.row = String(card.rowIndex); 187 + let fieldsHtml = ''; 188 + for (const f of card.fields) { 189 + const hdr = deps.getCellData(cellId(f.columnIndex, 1)); 190 + const label = hdr?.v ? String(hdr.v) : colToLetter(f.columnIndex); 191 + fieldsHtml += `<div class="gallery-card-field"><span class="gallery-card-field-label">${label}:</span> ${f.value}</div>`; 192 + } 193 + cardEl.innerHTML = `<div class="gallery-card-title">${card.title || '(untitled)'}</div>${fieldsHtml}`; 194 + gridEl.appendChild(cardEl); 195 + } 196 + 197 + deps.dbViewSection.appendChild(gridEl); 198 + } 199 + 200 + function renderCalendarView(deps: DbViewsDeps, rowIndices: number[]): void { 201 + const events = buildCalendarEvents(rowIndices, (r, c) => getCellValueForView(deps, r, c), activeDbView!); 202 + const months = groupEventsByMonth(events); 203 + 204 + if (months.length === 0) { 205 + deps.dbViewSection.innerHTML += '<p style="color:var(--color-text-secondary);padding:var(--space-md)">No dates found in the selected column.</p>'; 206 + return; 207 + } 208 + 209 + for (const month of months) { 210 + const monthEl = document.createElement('div'); 211 + monthEl.className = 'calendar-view'; 212 + 213 + const monthName = new Date(month.year, month.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); 214 + monthEl.innerHTML = `<div class="calendar-month-header">${monthName}</div>`; 215 + 216 + const gridEl = document.createElement('div'); 217 + gridEl.className = 'calendar-grid'; 218 + 219 + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 220 + for (const d of dayNames) { 221 + const dh = document.createElement('div'); 222 + dh.className = 'calendar-day-header'; 223 + dh.textContent = d; 224 + gridEl.appendChild(dh); 225 + } 226 + 227 + const firstDay = new Date(month.year, month.month, 1); 228 + const lastDay = new Date(month.year, month.month + 1, 0); 229 + const startOffset = firstDay.getDay(); 230 + 231 + for (let i = 0; i < startOffset; i++) { 232 + const filler = document.createElement('div'); 233 + filler.className = 'calendar-day calendar-day-other'; 234 + gridEl.appendChild(filler); 235 + } 236 + 237 + for (let d = 1; d <= lastDay.getDate(); d++) { 238 + const dateStr = `${month.year}-${String(month.month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; 239 + const dayEvents = eventsForDate(events, dateStr); 240 + 241 + const dayEl = document.createElement('div'); 242 + dayEl.className = 'calendar-day'; 243 + dayEl.innerHTML = `<div class="calendar-day-number">${d}</div>`; 244 + 245 + for (const evt of dayEvents) { 246 + const evtEl = document.createElement('div'); 247 + evtEl.className = 'calendar-event'; 248 + evtEl.textContent = evt.title || '(untitled)'; 249 + evtEl.title = evt.title; 250 + dayEl.appendChild(evtEl); 251 + } 252 + 253 + gridEl.appendChild(dayEl); 254 + } 255 + 256 + monthEl.appendChild(gridEl); 257 + deps.dbViewSection.appendChild(monthEl); 258 + } 259 + }
+187
src/sheets/filter-ui.ts
··· 1 + /** 2 + * Multi-column filter UI — toggle, dropdowns, grid observer. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId } from './formulas.js'; 8 + import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js'; 9 + 10 + // ── Types ─────────────────────────────────────────────────── 11 + 12 + export interface FilterUIDeps { 13 + getActiveSheet: () => any; 14 + getCellData: (id: string) => any; 15 + grid: HTMLElement; 16 + DEFAULT_ROWS: number; 17 + DEFAULT_COLS: number; 18 + } 19 + 20 + // ── State ─────────────────────────────────────────────────── 21 + 22 + let filterMode = false; 23 + let filterState: Record<number, Record<string, boolean>> = {}; 24 + 25 + export function isFilterMode(): boolean { return filterMode; } 26 + export function getFilterState(): Record<number, Record<string, boolean>> { return filterState; } 27 + 28 + // ── Toggle ────────────────────────────────────────────────── 29 + 30 + export function toggleFilterMode(deps: FilterUIDeps): void { 31 + filterMode = !filterMode; 32 + const btn = document.getElementById('tb-filter'); 33 + if (btn) btn.classList.toggle('active', filterMode); 34 + 35 + if (!filterMode) { 36 + filterState = {}; 37 + const sheet = deps.getActiveSheet(); 38 + if (sheet.has('filterState')) sheet.delete('filterState'); 39 + } 40 + } 41 + 42 + // ── Build Row Objects ─────────────────────────────────────── 43 + 44 + function buildRowObjects(deps: FilterUIDeps): any[] { 45 + const sheet = deps.getActiveSheet(); 46 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 47 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 48 + const rows: any[] = []; 49 + for (let r = 1; r <= rowCount; r++) { 50 + const obj: any = { _row: r }; 51 + for (let c = 1; c <= colCount; c++) { 52 + const data = deps.getCellData(cellId(c, r)); 53 + obj[c] = data?.v ?? ''; 54 + } 55 + rows.push(obj); 56 + } 57 + return rows; 58 + } 59 + 60 + // ── Get Hidden Rows ───────────────────────────────────────── 61 + 62 + export function getFilterHiddenRows(deps: FilterUIDeps): Set<number> { 63 + if (!filterMode || Object.keys(filterState).length === 0) return new Set(); 64 + const rows = buildRowObjects(deps); 65 + const visible = applyFilters(rows, filterState); 66 + const visibleSet = new Set(visible.map((r: any) => r._row)); 67 + const hidden = new Set<number>(); 68 + for (const row of rows) { 69 + if (!visibleSet.has(row._row)) hidden.add(row._row); 70 + } 71 + return hidden; 72 + } 73 + 74 + // ── Filter Dropdown ───────────────────────────────────────── 75 + 76 + export function showFilterDropdown(deps: FilterUIDeps, col: number, th: HTMLElement): void { 77 + document.querySelectorAll('.filter-dropdown').forEach(d => d.remove()); 78 + 79 + const rows = buildRowObjects(deps); 80 + const values = getUniqueColumnValues(rows, col); 81 + const currentState = filterState[col] || buildFilterState(rows, col); 82 + 83 + const dropdown = document.createElement('div'); 84 + dropdown.className = 'filter-dropdown'; 85 + 86 + let html = ''; 87 + for (const val of values) { 88 + const checked = currentState[val] !== false ? 'checked' : ''; 89 + const displayVal = val === '' ? '(Empty)' : val; 90 + html += `<label><input type="checkbox" data-val="${String(val).replace(/"/g, '&quot;')}" ${checked}> ${displayVal}</label>`; 91 + } 92 + html += `<div class="filter-actions"><button class="filter-clear">Clear</button><button class="filter-all">Select All</button></div>`; 93 + dropdown.innerHTML = html; 94 + 95 + th.style.position = 'relative'; 96 + th.appendChild(dropdown); 97 + 98 + dropdown.addEventListener('change', (e) => { 99 + const cb = e.target as HTMLInputElement; 100 + if (!cb.dataset.val && cb.dataset.val !== '') return; 101 + if (!filterState[col]) filterState[col] = { ...currentState }; 102 + filterState[col][cb.dataset.val] = cb.checked; 103 + applyFilterToGrid(deps); 104 + }); 105 + 106 + dropdown.querySelector('.filter-clear')!.addEventListener('click', () => { 107 + filterState = clearColumnFilter(filterState, col); 108 + dropdown.remove(); 109 + applyFilterToGrid(deps); 110 + }); 111 + 112 + dropdown.querySelector('.filter-all')!.addEventListener('click', () => { 113 + filterState[col] = buildFilterState(rows, col); 114 + dropdown.querySelectorAll('input[type="checkbox"]').forEach((cb: Element) => { (cb as HTMLInputElement).checked = true; }); 115 + applyFilterToGrid(deps); 116 + }); 117 + 118 + const closeHandler = (e: MouseEvent) => { 119 + if (!dropdown.contains(e.target as Node) && e.target !== th) { 120 + dropdown.remove(); 121 + document.removeEventListener('click', closeHandler); 122 + } 123 + }; 124 + setTimeout(() => document.addEventListener('click', closeHandler), 0); 125 + } 126 + 127 + // ── Apply Filter to Grid ──────────────────────────────────── 128 + 129 + export function applyFilterToGrid(deps: FilterUIDeps): void { 130 + const hidden = getFilterHiddenRows(deps); 131 + deps.grid.querySelectorAll('tbody tr').forEach((tr: Element) => { 132 + const rowHeader = tr.querySelector('th.row-header') as HTMLElement; 133 + if (!rowHeader) return; 134 + const row = parseInt(rowHeader.dataset.row!); 135 + tr.classList.toggle('filter-hidden', hidden.has(row)); 136 + }); 137 + 138 + const sheet = deps.getActiveSheet(); 139 + if (Object.keys(filterState).length > 0) { 140 + sheet.set('filterState', JSON.stringify(filterState)); 141 + } else if (sheet.has('filterState')) { 142 + sheet.delete('filterState'); 143 + } 144 + } 145 + 146 + // ── Grid Observer for Filter Arrows ───────────────────────── 147 + 148 + export function setupFilterGridObserver(deps: FilterUIDeps): MutationObserver { 149 + const gridObserver = new MutationObserver(() => { 150 + if (filterMode) { 151 + deps.grid.querySelectorAll('thead th[data-col]').forEach((th: Element) => { 152 + if (th.querySelector('.filter-arrow')) return; 153 + const col = parseInt((th as HTMLElement).dataset.col!); 154 + const arrow = document.createElement('span'); 155 + arrow.className = 'filter-arrow'; 156 + arrow.textContent = '\u25BC'; 157 + arrow.addEventListener('click', (e) => { 158 + e.stopPropagation(); 159 + showFilterDropdown(deps, col, th as HTMLElement); 160 + }); 161 + th.appendChild(arrow); 162 + 163 + if (filterState[col]) { 164 + const hasFiltered = Object.values(filterState[col]).some(v => v === false); 165 + if (hasFiltered) th.classList.add('filter-active'); 166 + } 167 + }); 168 + applyFilterToGrid(deps); 169 + } 170 + }); 171 + gridObserver.observe(deps.grid, { childList: true, subtree: true }); 172 + return gridObserver; 173 + } 174 + 175 + // ── Load Filter State from Yjs ────────────────────────────── 176 + 177 + export function loadFilterStateFromYjs(deps: FilterUIDeps): void { 178 + const sheet = deps.getActiveSheet(); 179 + if (sheet.has('filterState')) { 180 + try { 181 + filterState = JSON.parse(sheet.get('filterState')); 182 + filterMode = Object.keys(filterState).length > 0; 183 + const btn = document.getElementById('tb-filter'); 184 + if (btn) btn.classList.toggle('active', filterMode); 185 + } catch {} 186 + } 187 + }
+190
src/sheets/find-replace-bar.ts
··· 1 + /** 2 + * Find & Replace Bar — creation, search, navigation, replace. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId } from './formulas.js'; 8 + import { createFindState, findInCells, getMatchInfo, nextMatch, prevMatch, replaceCurrentMatch, replaceAllMatches } from './sheets-find-replace.js'; 9 + import { showToast } from './import-export.js'; 10 + 11 + // ── Types ─────────────────────────────────────────────────── 12 + 13 + export interface FindReplaceDeps { 14 + getActiveSheet: () => any; 15 + getCellData: (id: string) => any; 16 + setCellData: (id: string, data: any) => void; 17 + computeDisplayValue: (id: string, data: any) => any; 18 + evalCache: { clear: () => void }; 19 + clearSpillMaps: () => void; 20 + invalidateRecalcEngine: () => void; 21 + renderGrid: () => void; 22 + scrollCellIntoView: (col: number, row: number) => void; 23 + getSelectedCell: () => { col: number; row: number }; 24 + setSelectedCell: (cell: { col: number; row: number }) => void; 25 + setSelectionRange: (range: any) => void; 26 + getFindReplaceBarVisible: () => boolean; 27 + setFindReplaceBarVisible: (v: boolean) => void; 28 + ydoc: { transact: (fn: () => void) => void }; 29 + sheetContainer: HTMLElement; 30 + DEFAULT_ROWS: number; 31 + DEFAULT_COLS: number; 32 + } 33 + 34 + // ── State ─────────────────────────────────────────────────── 35 + 36 + let sheetsFindState = createFindState(); 37 + 38 + export function getSheetsFindState() { return sheetsFindState; } 39 + 40 + // ── Create Bar ────────────────────────────────────────────── 41 + 42 + export function createFindReplaceBar(): HTMLElement { 43 + const bar = document.createElement('div'); 44 + bar.className = 'sheets-find-bar find-bar'; 45 + bar.style.display = 'none'; 46 + bar.innerHTML = '<div class="find-bar-row">' 47 + + '<input class="find-bar-input" id="sheets-find-input" placeholder="Find in sheet" aria-label="Find in sheet">' 48 + + '<span class="find-bar-count" id="sheets-find-count"></span>' 49 + + '<button class="tb-btn find-bar-btn" id="sheets-find-prev" title="Previous (Shift+Enter)" aria-label="Previous match">\u25B2</button>' 50 + + '<button class="tb-btn find-bar-btn" id="sheets-find-next" title="Next (Enter)" aria-label="Next match">\u25BC</button>' 51 + + '<label class="find-bar-btn" title="Case sensitive" style="display:flex;align-items:center;gap:2px;cursor:pointer;font-size:0.75rem">' 52 + + '<input type="checkbox" id="sheets-find-case"> Aa</label>' 53 + + '<button class="tb-btn find-bar-btn" id="sheets-find-replace-toggle" title="Show replace" aria-label="Toggle replace">\u2026</button>' 54 + + '<button class="tb-btn find-bar-btn" id="sheets-find-close" title="Close (Escape)" aria-label="Close find bar">\u2715</button>' 55 + + '</div>' 56 + + '<div class="find-bar-row find-bar-replace" id="sheets-replace-row" style="display:none">' 57 + + '<input class="find-bar-input" id="sheets-replace-input" placeholder="Replace with" aria-label="Replace with">' 58 + + '<button class="tb-btn find-bar-btn" id="sheets-replace-one" title="Replace">Replace</button>' 59 + + '<button class="tb-btn find-bar-btn" id="sheets-replace-all" title="Replace all">All</button>' 60 + + '</div>'; 61 + return bar; 62 + } 63 + 64 + // ── Show / Hide ───────────────────────────────────────────── 65 + 66 + export function showFindReplaceBar(deps: FindReplaceDeps, findBar: HTMLElement, showReplace: boolean): void { 67 + findBar.style.display = ''; 68 + deps.setFindReplaceBarVisible(true); 69 + const replaceRow = findBar.querySelector('#sheets-replace-row') as HTMLElement; 70 + if (showReplace) replaceRow.style.display = ''; 71 + const input = findBar.querySelector('#sheets-find-input') as HTMLInputElement; 72 + input.focus(); 73 + input.select(); 74 + } 75 + 76 + export function hideFindReplaceBar(deps: FindReplaceDeps, findBar: HTMLElement): void { 77 + findBar.style.display = 'none'; 78 + deps.setFindReplaceBarVisible(false); 79 + sheetsFindState = createFindState(); 80 + deps.renderGrid(); 81 + } 82 + 83 + // ── Search ────────────────────────────────────────────────── 84 + 85 + function runSheetsFind(deps: FindReplaceDeps, findBar: HTMLElement): void { 86 + const input = findBar.querySelector('#sheets-find-input') as HTMLInputElement; 87 + const caseCb = findBar.querySelector('#sheets-find-case') as HTMLInputElement; 88 + const query = input.value; 89 + const caseSensitive = caseCb.checked; 90 + 91 + const sheet = deps.getActiveSheet(); 92 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 93 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 94 + 95 + sheetsFindState.query = query; 96 + sheetsFindState.caseSensitive = caseSensitive; 97 + sheetsFindState.matches = findInCells( 98 + (id: string) => { const d = deps.getCellData(id); return deps.computeDisplayValue(id, d); }, 99 + rowCount, 100 + colCount, 101 + cellId, 102 + query, 103 + caseSensitive, 104 + ); 105 + sheetsFindState.currentIndex = sheetsFindState.matches.length > 0 ? 0 : -1; 106 + 107 + updateFindBarCount(findBar); 108 + navigateToCurrentMatch(deps); 109 + deps.renderGrid(); 110 + } 111 + 112 + function updateFindBarCount(findBar: HTMLElement): void { 113 + const countEl = findBar.querySelector('#sheets-find-count')!; 114 + const info = getMatchInfo(sheetsFindState); 115 + countEl.textContent = info.total > 0 ? info.current + ' of ' + info.total : 'No results'; 116 + } 117 + 118 + function navigateToCurrentMatch(deps: FindReplaceDeps): void { 119 + if (sheetsFindState.currentIndex < 0) return; 120 + const match = sheetsFindState.matches[sheetsFindState.currentIndex]; 121 + if (!match) return; 122 + deps.setSelectedCell({ col: match.col, row: match.row }); 123 + deps.setSelectionRange({ startCol: match.col, startRow: match.row, endCol: match.col, endRow: match.row }); 124 + deps.scrollCellIntoView(match.col, match.row); 125 + } 126 + 127 + // ── Wire Events ───────────────────────────────────────────── 128 + 129 + export function wireFindReplaceBar(deps: FindReplaceDeps, findBar: HTMLElement): void { 130 + findBar.querySelector('#sheets-find-input')!.addEventListener('input', () => runSheetsFind(deps, findBar)); 131 + findBar.querySelector('#sheets-find-case')!.addEventListener('change', () => runSheetsFind(deps, findBar)); 132 + 133 + findBar.querySelector('#sheets-find-next')!.addEventListener('click', () => { 134 + nextMatch(sheetsFindState); 135 + updateFindBarCount(findBar); 136 + navigateToCurrentMatch(deps); 137 + deps.renderGrid(); 138 + }); 139 + 140 + findBar.querySelector('#sheets-find-prev')!.addEventListener('click', () => { 141 + prevMatch(sheetsFindState); 142 + updateFindBarCount(findBar); 143 + navigateToCurrentMatch(deps); 144 + deps.renderGrid(); 145 + }); 146 + 147 + findBar.querySelector('#sheets-find-close')!.addEventListener('click', () => hideFindReplaceBar(deps, findBar)); 148 + 149 + findBar.querySelector('#sheets-find-replace-toggle')!.addEventListener('click', () => { 150 + const replaceRow = findBar.querySelector('#sheets-replace-row') as HTMLElement; 151 + replaceRow.style.display = replaceRow.style.display === 'none' ? '' : 'none'; 152 + }); 153 + 154 + findBar.querySelector('#sheets-replace-one')!.addEventListener('click', () => { 155 + const replaceInput = findBar.querySelector('#sheets-replace-input') as HTMLInputElement; 156 + const result = replaceCurrentMatch(sheetsFindState, replaceInput.value); 157 + if (result) { 158 + const numVal = Number(result.newValue); 159 + const value = result.newValue === '' ? '' : (!isNaN(numVal) && result.newValue !== '' ? numVal : result.newValue); 160 + deps.setCellData(result.cellId, { v: value, f: '' }); 161 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 162 + runSheetsFind(deps, findBar); 163 + } 164 + }); 165 + 166 + findBar.querySelector('#sheets-replace-all')!.addEventListener('click', () => { 167 + const replaceInput = findBar.querySelector('#sheets-replace-input') as HTMLInputElement; 168 + const results = replaceAllMatches(sheetsFindState, replaceInput.value); 169 + if (results.length > 0) { 170 + deps.ydoc.transact(() => { 171 + for (const r of results) { 172 + const numVal = Number(r.newValue); 173 + const value = r.newValue === '' ? '' : (!isNaN(numVal) && r.newValue !== '' ? numVal : r.newValue); 174 + deps.setCellData(r.cellId, { v: value, f: '' }); 175 + } 176 + }); 177 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 178 + showToast('Replaced ' + results.length + ' match' + (results.length > 1 ? 'es' : '')); 179 + runSheetsFind(deps, findBar); 180 + } 181 + }); 182 + 183 + // Keyboard in find bar 184 + findBar.addEventListener('keydown', (e) => { 185 + if (e.key === 'Escape') { e.preventDefault(); hideFindReplaceBar(deps, findBar); } 186 + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); nextMatch(sheetsFindState); updateFindBarCount(findBar); navigateToCurrentMatch(deps); deps.renderGrid(); } 187 + if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); prevMatch(sheetsFindState); updateFindBarCount(findBar); navigateToCurrentMatch(deps); deps.renderGrid(); } 188 + e.stopPropagation(); 189 + }); 190 + }
+329
src/sheets/import-export.ts
··· 1 + /** 2 + * Import/Export/Print — CSV, TSV, XLSX export, file import, print/PDF. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { cellId, colToLetter, parseRef } from './formulas.js'; 9 + import { parseCSVLine, detectHeaders } from './csv-utils.js'; 10 + import { importXlsx, isValidXlsx } from './xlsx-import.js'; 11 + import { exportToXlsx, downloadXlsx } from './xlsx-export.js'; 12 + import { buildSheetsPrintHtml } from '../lib/print-layout.js'; 13 + import type { PrintCell, PrintRow, SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 14 + 15 + // ── Types ─────────────────────────────────────────────────── 16 + 17 + export interface ImportExportDeps { 18 + getActiveSheet: () => any; 19 + getCellData: (id: string) => any; 20 + setCellData: (id: string, data: any) => void; 21 + getCells: () => any; 22 + computeDisplayValue: (id: string, data: any) => any; 23 + getColWidth: (col: number) => number; 24 + isRowHidden: (row: number) => boolean; 25 + isColHidden: (col: number) => boolean; 26 + buildMergeMap: () => Map<string, any>; 27 + ydoc: any; 28 + provider: any; 29 + ensureSheet: (idx: number) => any; 30 + evalCache: { clear: () => void }; 31 + clearSpillMaps: () => void; 32 + invalidateRecalcEngine: () => void; 33 + renderGrid: () => void; 34 + renderSheetTabs: () => void; 35 + refreshVisibleCells: () => void; 36 + DEFAULT_ROWS: number; 37 + DEFAULT_COLS: number; 38 + sheetContainer: HTMLElement; 39 + } 40 + 41 + // ── Utility ───────────────────────────────────────────────── 42 + 43 + export function downloadFile(content: string | ArrayBuffer, filename: string, mimeType: string): void { 44 + const blob = new Blob([content], { type: mimeType }); 45 + const url = URL.createObjectURL(blob); 46 + const a = document.createElement('a'); 47 + a.href = url; a.download = filename; 48 + document.body.appendChild(a); a.click(); document.body.removeChild(a); 49 + URL.revokeObjectURL(url); 50 + } 51 + 52 + export function showToast(message: string, duration = 3000): void { 53 + const existing = document.querySelector('.toast-notification'); 54 + if (existing) existing.remove(); 55 + const toast = document.createElement('div'); 56 + toast.className = 'toast-notification'; 57 + toast.textContent = message; 58 + document.body.appendChild(toast); 59 + toast.offsetHeight; 60 + toast.classList.add('toast-visible'); 61 + setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); 62 + } 63 + 64 + // ── Export ─────────────────────────────────────────────────── 65 + 66 + export function sheetToDelimited(deps: ImportExportDeps, delimiter: string): string { 67 + const sheet = deps.getActiveSheet(); 68 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 69 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 70 + let maxRow = 0, maxCol = 0; 71 + for (let r = 1; r <= rowCount; r++) { 72 + for (let c = 1; c <= colCount; c++) { 73 + const data = deps.getCellData(cellId(c, r)); 74 + if (data && (data.v !== '' || data.f !== '')) { maxRow = Math.max(maxRow, r); maxCol = Math.max(maxCol, c); } 75 + } 76 + } 77 + maxRow = Math.max(maxRow, 1); maxCol = Math.max(maxCol, 1); 78 + const lines: string[] = []; 79 + for (let r = 1; r <= maxRow; r++) { 80 + const cols: string[] = []; 81 + for (let c = 1; c <= maxCol; c++) { 82 + const data = deps.getCellData(cellId(c, r)); 83 + let val = ''; 84 + if (data) { val = data.f ? '=' + data.f : String(data.v ?? ''); } 85 + if (delimiter === ',' && (val.includes(',') || val.includes('"') || val.includes('\n'))) { val = '"' + val.replace(/"/g, '""') + '"'; } 86 + cols.push(val); 87 + } 88 + lines.push(cols.join(delimiter)); 89 + } 90 + return lines.join('\n'); 91 + } 92 + 93 + export function exportCSV(deps: ImportExportDeps): void { 94 + const name = deps.getActiveSheet().get('name') || 'sheet'; 95 + downloadFile(sheetToDelimited(deps, ','), name + '.csv', 'text/csv;charset=utf-8'); 96 + } 97 + 98 + export function exportTSV(deps: ImportExportDeps): void { 99 + const name = deps.getActiveSheet().get('name') || 'sheet'; 100 + downloadFile(sheetToDelimited(deps, '\t'), name + '.tsv', 'text/tab-separated-values;charset=utf-8'); 101 + } 102 + 103 + // ── Import ────────────────────────────────────────────────── 104 + 105 + export function importFileContent(deps: ImportExportDeps, text: string, filename?: string): void { 106 + const isTSV = filename?.endsWith('.tsv') || (text.split('\t').length > text.split(',').length); 107 + const delimiter = isTSV ? '\t' : null; 108 + const lines = text.split(/\r?\n/).filter(l => l.length > 0); 109 + if (lines.length === 0) return; 110 + 111 + const parsedRows = lines.map(l => delimiter ? l.split(delimiter) : parseCSVLine(l)); 112 + const hasHeaders = detectHeaders(parsedRows); 113 + const sheet = deps.getActiveSheet(); 114 + deps.ydoc.transact(() => { 115 + for (let r = 0; r < lines.length; r++) { 116 + const cols = parsedRows[r]; 117 + for (let c = 0; c < cols.length; c++) { 118 + const val = cols[c].trim(); const id = cellId(c + 1, r + 1); 119 + if (val.startsWith('=')) { deps.setCellData(id, { v: '', f: val.slice(1) }); } 120 + else { const n = Number(val); deps.setCellData(id, { v: val === '' ? '' : (!isNaN(n) && val !== '' ? n : val), f: '' }); } 121 + } 122 + } 123 + const neededRows = parsedRows.length; 124 + const neededCols = Math.max(...parsedRows.map(row => row.length)); 125 + if (neededRows > (sheet.get('rowCount') || deps.DEFAULT_ROWS)) sheet.set('rowCount', neededRows); 126 + if (neededCols > (sheet.get('colCount') || deps.DEFAULT_COLS)) sheet.set('colCount', neededCols); 127 + }); 128 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); deps.renderGrid(); 129 + 130 + if (hasHeaders) { 131 + deps.ydoc.transact(() => { 132 + const hdrCols = parsedRows[0]; 133 + for (let c = 0; c < hdrCols.length; c++) { 134 + const val = hdrCols[c].trim(); 135 + if (val !== '') { 136 + const id = cellId(c + 1, 1); 137 + const existing = deps.getCellData(id); 138 + const s = existing?.s || {}; 139 + s.bold = true; 140 + deps.setCellData(id, { s }); 141 + } 142 + } 143 + }); 144 + deps.refreshVisibleCells(); 145 + showToast('Headers detected \u2014 first row formatted as header'); 146 + } 147 + } 148 + 149 + export async function handleImportFile(deps: ImportExportDeps, file: File): Promise<void> { 150 + if (!file) return; 151 + const ext = file.name.split('.').pop()!.toLowerCase(); 152 + 153 + if (ext === 'xlsx' || ext === 'xls') { 154 + await importXlsx(file, { 155 + ydoc: deps.ydoc, 156 + getActiveSheet: deps.getActiveSheet, 157 + ensureSheet: deps.ensureSheet, 158 + setCellData: deps.setCellData, 159 + setCellDataForSheet: (sheetIdx: number, id: string, data: { v?: unknown; f?: string; s?: Record<string, unknown> }) => { 160 + const sheet = deps.ensureSheet(sheetIdx); 161 + const cells = sheet.get('cells'); 162 + if (!cells) return; 163 + let yCell = cells.get(id); 164 + if (!yCell) { yCell = new Y.Map(); cells.set(id, yCell); } 165 + let v = data.v; 166 + if (v instanceof Date) v = v.getTime(); 167 + if (v !== undefined) yCell.set('v', v); 168 + if (data.f !== undefined) yCell.set('f', data.f); 169 + if (data.s && Object.keys(data.s).length > 0) yCell.set('s', JSON.stringify(data.s)); 170 + }, 171 + getCells: deps.getCells, 172 + renderGrid: deps.renderGrid, 173 + renderSheetTabs: deps.renderSheetTabs, 174 + showToast, 175 + evalCache: deps.evalCache, 176 + DEFAULT_ROWS: deps.DEFAULT_ROWS, 177 + DEFAULT_COLS: deps.DEFAULT_COLS, 178 + }); 179 + await deps.provider._saveSnapshot(); 180 + return; 181 + } 182 + 183 + const reader = new FileReader(); 184 + reader.onload = () => { 185 + importFileContent(deps, reader.result as string, file.name); 186 + deps.provider._saveSnapshot(); 187 + }; 188 + reader.readAsText(file); 189 + } 190 + 191 + export function importCSV(deps: ImportExportDeps): void { 192 + const input = document.createElement('input'); input.type = 'file'; input.accept = '.csv,.tsv,.txt,.xlsx,.xls'; 193 + input.addEventListener('change', () => { if (input.files![0]) handleImportFile(deps, input.files![0]); }); 194 + input.click(); 195 + } 196 + 197 + // ── Print/PDF ─────────────────────────────────────────────── 198 + 199 + export function buildPrintData(deps: ImportExportDeps): SheetsPrintData { 200 + const sheet = deps.getActiveSheet(); 201 + const mergeMap = deps.buildMergeMap(); 202 + 203 + let maxRow = 0, maxCol = 0; 204 + const cells = deps.getCells(); 205 + cells.forEach((_: any, id: string) => { 206 + const ref = parseRef(id); 207 + if (ref) { 208 + if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 209 + if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 210 + } 211 + }); 212 + maxRow = Math.max(maxRow, 2); 213 + maxCol = Math.max(maxCol, 2); 214 + 215 + const headers: string[] = []; 216 + const colWidths: number[] = []; 217 + for (let c = 1; c <= maxCol; c++) { 218 + if (deps.isColHidden(c)) continue; 219 + headers.push(colToLetter(c)); 220 + colWidths.push(deps.getColWidth(c)); 221 + } 222 + 223 + const rows: PrintRow[] = []; 224 + for (let r = 1; r <= maxRow; r++) { 225 + if (deps.isRowHidden(r)) continue; 226 + const rowCells: (PrintCell | null)[] = []; 227 + for (let c = 1; c <= maxCol; c++) { 228 + if (deps.isColHidden(c)) continue; 229 + const id = cellId(c, r); 230 + const mergeInfo = mergeMap.get(id); 231 + if (mergeInfo?.hidden) { rowCells.push(null); continue; } 232 + 233 + const cellData = deps.getCellData(id); 234 + const displayValue = deps.computeDisplayValue(id, cellData); 235 + const displayStr = (typeof displayValue === 'object') ? '' : String(displayValue ?? ''); 236 + const style = cellData?.s || {}; 237 + 238 + const printCell: PrintCell = { value: displayStr }; 239 + const cellStyle: any = {}; 240 + if (style.bold) cellStyle.bold = true; 241 + if (style.italic) cellStyle.italic = true; 242 + if (style.underline) cellStyle.underline = true; 243 + if (style.strikethrough) cellStyle.strikethrough = true; 244 + if (style.color) cellStyle.color = style.color; 245 + if (style.bg) cellStyle.bg = style.bg; 246 + if (style.align) cellStyle.align = style.align; 247 + if (style.vAlign) cellStyle.verticalAlign = style.vAlign; 248 + if (style.fontSize) cellStyle.fontSize = style.fontSize; 249 + if (style.fontFamily) cellStyle.fontFamily = style.fontFamily; 250 + if (Object.keys(cellStyle).length > 0) printCell.style = cellStyle; 251 + 252 + if (mergeInfo?.colspan > 1) printCell.colspan = mergeInfo.colspan; 253 + if (mergeInfo?.rowspan > 1) printCell.rowspan = mergeInfo.rowspan; 254 + 255 + rowCells.push(printCell); 256 + } 257 + rows.push({ cells: rowCells }); 258 + } 259 + 260 + return { headers, rows, colWidths }; 261 + } 262 + 263 + export function buildPrintOptions(deps: ImportExportDeps): SheetsPrintOptions { 264 + const sheetName = deps.getActiveSheet().get('name') || 'Sheet 1'; 265 + return { title: sheetName, gridLines: true, repeatHeaders: true, scaling: 'fit-to-width', orientation: 'landscape' }; 266 + } 267 + 268 + export function printSheet(deps: ImportExportDeps): void { 269 + const html = buildSheetsPrintHtml(buildPrintData(deps), buildPrintOptions(deps)); 270 + const printWindow = window.open('', '_blank'); 271 + if (printWindow) { 272 + printWindow.document.write(html); 273 + printWindow.document.close(); 274 + printWindow.addEventListener('load', () => { printWindow.print(); }); 275 + } 276 + } 277 + 278 + export async function exportSheetPdf(deps: ImportExportDeps): Promise<void> { 279 + const html = buildSheetsPrintHtml(buildPrintData(deps), buildPrintOptions(deps)); 280 + const html2pdf = (await import('html2pdf.js')).default; 281 + const container = document.createElement('div'); 282 + container.innerHTML = html; 283 + container.style.cssText = 'position:fixed;left:-9999px;top:0;width:11in;background:#fff;color:#1a1815;'; 284 + document.body.appendChild(container); 285 + try { 286 + const name = (deps.getActiveSheet().get('name') || 'Sheet 1').replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); 287 + await html2pdf().set({ 288 + margin: [0.5, 0.5, 0.5, 0.5], 289 + filename: `${name}.pdf`, 290 + image: { type: 'jpeg', quality: 0.95 }, 291 + html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff' }, 292 + jsPDF: { unit: 'in', format: 'letter', orientation: 'landscape' }, 293 + }).from(container).save(); 294 + } finally { 295 + document.body.removeChild(container); 296 + } 297 + } 298 + 299 + // ── Toolbar + Drag-and-Drop Wiring ────────────────────────── 300 + 301 + export function wireImportExportToolbar(deps: ImportExportDeps, closeAllDropdowns: () => void): void { 302 + document.getElementById('tb-export-csv')!.addEventListener('click', () => { exportCSV(deps); closeAllDropdowns(); }); 303 + document.getElementById('tb-export-xlsx')?.addEventListener('click', async () => { 304 + closeAllDropdowns(); 305 + const sheet = deps.getActiveSheet(); 306 + const rc = sheet.get('rowCount') || deps.DEFAULT_ROWS; 307 + const cc = sheet.get('colCount') || deps.DEFAULT_COLS; 308 + const name = sheet.get('name') || 'sheet'; 309 + const buf = await exportToXlsx( 310 + (r: number, c: number) => deps.getCellData(cellId(c, r)), 311 + rc, cc, 312 + (c: number) => deps.getColWidth(c), 313 + name 314 + ); 315 + downloadXlsx(buf, name + '.xlsx'); 316 + }); 317 + document.getElementById('tb-export-pdf')?.addEventListener('click', () => { exportSheetPdf(deps); closeAllDropdowns(); }); 318 + document.getElementById('tb-import')!.addEventListener('click', () => { importCSV(deps); closeAllDropdowns(); }); 319 + document.getElementById('tb-print')!.addEventListener('click', () => { printSheet(deps); closeAllDropdowns(); }); 320 + 321 + // Drag-and-drop import 322 + deps.sheetContainer.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer!.dropEffect = 'copy'; deps.sheetContainer.classList.add('drag-over'); }); 323 + deps.sheetContainer.addEventListener('dragleave', () => { deps.sheetContainer.classList.remove('drag-over'); }); 324 + deps.sheetContainer.addEventListener('drop', (e) => { 325 + e.preventDefault(); deps.sheetContainer.classList.remove('drag-over'); 326 + const file = e.dataTransfer!.files[0]; if (!file) return; 327 + handleImportFile(deps, file); 328 + }); 329 + }
+115 -1409
src/sheets/main.ts
··· 14 14 import { createVersionPanel } from '../version-panel.js'; 15 15 import { evaluate, extractRefs, formatCell, parseRef, colToLetter, letterToCol, cellId } from './formulas.js'; 16 16 import { RecalcEngine } from './recalc.js'; 17 - import { importXlsx, isValidXlsx } from './xlsx-import.js'; 18 - import { exportToXlsx, downloadXlsx } from './xlsx-export.js'; 19 - import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js'; 20 - import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js'; 17 + // xlsx-import, xlsx-export, charts, filter — now used via extracted UI modules 21 18 import { multiColumnSort } from './sort.js'; 22 19 import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 23 20 import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; ··· 30 27 import { buildMergeMap, findCellMerge } from './merge-utils.js'; 31 28 import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget, getSpillTargetValue } from './spill-tracking.js'; 32 29 import { formatSaveTimestamp, getSaveDisplayText } from './save-indicator.js'; 33 - import { parseCSVLine, detectHeaders } from './csv-utils.js'; 30 + // csv-utils — now used via import-export.ts 34 31 import type { SpillState } from './spill-tracking.js'; 35 - import { computeSelectionStats, formatStatValue } from './status-bar.js'; 32 + // status-bar — now used via status-bar-ui.ts 36 33 import { showSortDialog as _showSortDialog, showCfModal as _showCfModal, showValidationModal as _showValidationModal } from './sheet-dialogs.js'; 37 34 import { getSheetContextText as _getSheetContextText, sendChatMessage as _sendChatMessage } from './ai-chat-panel.js'; 38 35 import { renderSheetTabs as _renderSheetTabs, reorderSheets as _reorderSheets, swapSheetData as _swapSheetData, beginInlineRename as _beginInlineRename, showSheetTabContextMenu as _showSheetTabContextMenu, showTabColorPicker as _showTabColorPicker, confirmAndDeleteSheet as _confirmAndDeleteSheet, doDuplicateSheet as _doDuplicateSheet } from './sheet-tabs-ui.js'; 39 36 import { showPivotDialog as _showPivotDialog, renderPivots as _renderPivots } from './pivot-ui.js'; 40 37 import { FORMULA_FUNCTIONS, filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js'; 41 - import { createNote, updateNote, deleteNote, getNote, hasNote, getAllNotes } from './cell-notes.js'; 38 + // cell-notes — now used via cell-notes-ui.ts 42 39 import { tokenizeForHighlighting, renderHighlightedFormula } from './formula-highlighter.js'; 43 40 import { extractFormulaRanges, assignRangeColors, renderGridHighlights, clearGridHighlights } from './range-highlight.js'; 44 41 import { detectCurrentFunction, renderTooltip, hideTooltip } from './formula-tooltip.js'; 45 42 import { extractFormat, applyFormat } from './format-painter.js'; 46 43 import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 47 - import { createContextMenu, buildSheetsCellItems, buildSheetsColumnHeaderItems, buildSheetsRowHeaderItems, SEPARATOR } from '../lib/context-menu.js'; 48 - import type { MenuItem, MenuItemConfig } from '../lib/context-menu.js'; 44 + // context-menu — now used via context-menu-handler.ts 49 45 // Sheet tab management functions used via sheet-tabs-ui.ts (no longer directly imported here) 50 46 import { buildCopyHtml, buildCopyTsv } from './clipboard-copy.js'; 51 47 import { parseClipboardHtml, parseClipboardTsv } from './clipboard-paste.js'; 52 48 import { extractValuesOnly, extractFormulasOnly, extractFormattingOnly, transposeGrid, PASTE_MODES } from './paste-special.js'; 53 49 import { computeVisibleRows, computeVisibleCols, hiddenRowsSpacerAdjustment, getAdjacentHiddenRows, getAdjacentHiddenCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 54 - import { createFindState, findInCells, nextMatch, prevMatch, replaceCurrentMatch, replaceAllMatches, getMatchInfo, isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 50 + import { isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 55 51 import { isSparklineResult, drawSparkline } from './sparkline.js'; 56 52 import { detectPattern, generateFillValues, adjustFormulaRef, PATTERN_TYPES } from './drag-fill.js'; 57 - import { buildSheetsPrintHtml } from '../lib/print-layout.js'; 58 - import type { PrintCell, PrintRow, SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 53 + // print-layout — now used via import-export.ts 59 54 import { 60 55 createChatSidebar, createChatState, loadConfig, initChatWiring, 61 56 } from '../lib/ai-chat.js'; ··· 63 58 // splitResponse/isSheetAction/executeSheetAction used via ai-chat-panel.ts 64 59 // computePivot/formatAggregateValue used via pivot-ui.ts 65 60 import type { PivotConfig, AggregateFunction } from './pivot-table.js'; 66 - import { buildKanbanColumns, buildGalleryCards, buildCalendarEvents, groupEventsByMonth, eventsForDate, createViewConfig, getViewTypes } from './database-views.js'; 67 - import type { ViewConfig, ViewType } from './database-views.js'; 61 + // database-views — now used via database-views-ui.ts 68 62 import { uploadBlob, downloadBlob, readFileAsBuffer, blobToObjectUrl } from '../lib/blob-upload.js'; 69 63 import { createImageCellState, setCellImage, getCellImage, hasImage, imageCellIds } from './image-cells.js'; 70 64 import type { ImageCellState } from './image-cells.js'; 65 + // ── Extracted UI modules ──────────────────────────────────── 66 + import { showToast, handleImportFile as _handleImportFile, printSheet as _printSheet, wireImportExportToolbar as _wireImportExportToolbar } from './import-export.js'; 67 + import { showChartDialog as _showChartDialogUI, renderCharts as _renderChartsUI } from './charts-ui.js'; 68 + import { showDbViewDialog as _showDbViewDialogUI, renderDbView as _renderDbViewUI } from './database-views-ui.js'; 69 + import { isFilterMode, getFilterState, toggleFilterMode as _toggleFilterMode, getFilterHiddenRows as _getFilterHiddenRows, showFilterDropdown as _showFilterDropdown, applyFilterToGrid as _applyFilterToGrid, setupFilterGridObserver as _setupFilterGridObserver, loadFilterStateFromYjs as _loadFilterStateFromYjs } from './filter-ui.js'; 70 + import { createFindReplaceBar, showFindReplaceBar as _showFindReplaceBarUI, hideFindReplaceBar as _hideFindReplaceBarUI, wireFindReplaceBar as _wireFindReplaceBar, getSheetsFindState } from './find-replace-bar.js'; 71 + import { getNotesMap as _getNotesMap, getNotesObject as _getNotesObject, setNoteInYjs as _setNoteInYjs, showNoteDialog as _showNoteDialogUI, renderNoteIndicators as _renderNoteIndicatorsUI, wireNoteHover as _wireNoteHover, wireErrorTooltip as _wireErrorTooltip, hideNoteTooltip } from './cell-notes-ui.js'; 72 + import { wireConnectionStatus as _wireConnectionStatus, setupCollabAvatars as _setupCollabAvatars } from './collaboration-ui.js'; 73 + import { updateStatusBar as _updateStatusBarUI, wireStatusBarFreezeClick as _wireStatusBarFreezeClick } from './status-bar-ui.js'; 74 + import { hideActiveContextMenu, wireContextMenu as _wireContextMenu, setActiveContextMenu } from './context-menu-handler.js'; 71 75 72 76 // --- Constants --- 73 77 const DEFAULT_ROWS = 100; ··· 342 346 let isFillDragging = false; 343 347 let fillPreviewRange = null; 344 348 345 - // --- Find & Replace state --- 346 - let sheetsFindState = createFindState(); 349 + // --- Find & Replace state (managed by find-replace-bar.ts) --- 350 + const sheetsFindState = getSheetsFindState(); 347 351 let findReplaceBarVisible = false; 348 352 349 353 // --- Merge helpers (#11) --- ··· 2717 2721 setActiveSheetIdx: (idx: number) => { activeSheetIdx = idx; }, 2718 2722 ensureSheet, evalCache, clearSpillMaps: _clearSpillMaps, 2719 2723 invalidateRecalcEngine, renderGrid, hideActiveContextMenu, 2720 - setActiveContextMenu: (menu: any) => { _activeContextMenu = menu; }, 2724 + setActiveContextMenu, 2721 2725 sheetTabsContainer, 2722 2726 }; 2723 2727 } ··· 2761 2765 }, 500); 2762 2766 }); 2763 2767 2764 - // --- Connection status --- 2765 - const statusDot = document.getElementById('status-dot'); 2766 - const statusText = document.getElementById('status-text'); 2767 - provider.on('status', ({ connected }) => { statusDot.classList.toggle('connected', connected); statusText.textContent = connected ? 'Connected' : 'Reconnecting...'; }); 2768 + // --- Connection status (extracted to collaboration-ui.ts) --- 2769 + _wireConnectionStatus({ provider, ydoc }); 2768 2770 provider.on('sync', () => { 2769 - statusText.textContent = 'Synced'; 2771 + const _st = document.getElementById('status-text'); if (_st) _st.textContent = 'Synced'; 2770 2772 // Re-attach ALL observers after sync — the snapshot may have replaced the Y.Map/Y.Array 2771 2773 // objects that were observed during initial setup (before data loaded from peers) 2772 2774 getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); ··· 2838 2840 } 2839 2841 }); 2840 2842 2841 - // --- Collaboration avatars --- 2842 - const avatarContainer = document.getElementById('collab-avatars'); 2843 - const COLORS = ['#e06c5e', '#d4893b', '#5ea3e0', '#5ec48a', '#9b7ec4', '#c45e8a', '#5eb8b0', '#8a7e5e', '#7e8ac4', '#c4a65e']; 2844 - // Try Tailscale identity, fall back to localStorage, then random 2845 - let userName = localStorage.getItem('tools-username') || (() => { const a = new Uint16Array(1); crypto.getRandomValues(a); return 'User ' + (a[0] % 1000); })(); 2846 - const userColor = COLORS[(() => { const a = new Uint8Array(1); crypto.getRandomValues(a); return a[0] % COLORS.length; })()]; 2847 - provider.setAwareness({ name: userName, color: userColor }); 2848 - 2849 - // Upgrade to Tailscale identity if available (async, updates awareness after fetch) 2850 - fetch('/api/me').then(r => r.json()).then(data => { 2851 - if (data.login) { 2852 - userName = data.name; 2853 - localStorage.setItem('tools-username', data.name); 2854 - provider.setAwareness({ name: userName, color: userColor }); 2855 - } 2856 - }).catch(() => { /* anonymous access */ }); 2857 - 2858 - provider.awareness.on('change', () => { 2859 - const states = provider.awareness.getStates(); 2860 - avatarContainer.innerHTML = ''; 2861 - states.forEach((state, clientId) => { 2862 - if (clientId === ydoc.clientID) return; 2863 - const user = state.user; 2864 - if (!user) return; 2865 - const avatar = document.createElement('div'); 2866 - avatar.className = 'collab-avatar'; 2867 - avatar.style.background = user.color; 2868 - avatar.textContent = user.name.charAt(0).toUpperCase(); 2869 - avatar.title = user.name; 2870 - avatarContainer.appendChild(avatar); 2871 - }); 2872 - }); 2873 - 2874 - // --- Export/Import/Print --- 2875 - function downloadFile(content, filename, mimeType) { 2876 - const blob = new Blob([content], { type: mimeType }); 2877 - const url = URL.createObjectURL(blob); 2878 - const a = document.createElement('a'); 2879 - a.href = url; a.download = filename; 2880 - document.body.appendChild(a); a.click(); document.body.removeChild(a); 2881 - URL.revokeObjectURL(url); 2882 - } 2883 - 2884 - function sheetToDelimited(delimiter) { 2885 - const sheet = getActiveSheet(); 2886 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 2887 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 2888 - let maxRow = 0, maxCol = 0; 2889 - for (let r = 1; r <= rowCount; r++) { 2890 - for (let c = 1; c <= colCount; c++) { 2891 - const data = getCellData(cellId(c, r)); 2892 - if (data && (data.v !== '' || data.f !== '')) { maxRow = Math.max(maxRow, r); maxCol = Math.max(maxCol, c); } 2893 - } 2894 - } 2895 - maxRow = Math.max(maxRow, 1); maxCol = Math.max(maxCol, 1); 2896 - const lines = []; 2897 - for (let r = 1; r <= maxRow; r++) { 2898 - const cols = []; 2899 - for (let c = 1; c <= maxCol; c++) { 2900 - const data = getCellData(cellId(c, r)); 2901 - let val = ''; 2902 - if (data) { val = data.f ? '=' + data.f : String(data.v ?? ''); } 2903 - if (delimiter === ',' && (val.includes(',') || val.includes('"') || val.includes('\n'))) { val = '"' + val.replace(/"/g, '""') + '"'; } 2904 - cols.push(val); 2905 - } 2906 - lines.push(cols.join(delimiter)); 2907 - } 2908 - return lines.join('\n'); 2909 - } 2910 - 2911 - function exportCSV() { const name = getActiveSheet().get('name') || 'sheet'; downloadFile(sheetToDelimited(','), name + '.csv', 'text/csv;charset=utf-8'); } 2912 - function exportTSV() { const name = getActiveSheet().get('name') || 'sheet'; downloadFile(sheetToDelimited('\t'), name + '.tsv', 'text/tab-separated-values;charset=utf-8'); } 2913 - 2914 - // parseCSVLine and detectHeaders extracted to csv-utils.ts 2915 - 2916 - function showToast(message, duration = 3000) { 2917 - const existing = document.querySelector('.toast-notification'); 2918 - if (existing) existing.remove(); 2919 - const toast = document.createElement('div'); 2920 - toast.className = 'toast-notification'; 2921 - toast.textContent = message; 2922 - document.body.appendChild(toast); 2923 - toast.offsetHeight; 2924 - toast.classList.add('toast-visible'); 2925 - setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); 2926 - } 2927 - 2928 - function importFileContent(text, filename) { 2929 - const isTSV = filename?.endsWith('.tsv') || (text.split('\t').length > text.split(',').length); 2930 - const delimiter = isTSV ? '\t' : null; 2931 - const lines = text.split(/\r?\n/).filter(l => l.length > 0); 2932 - if (lines.length === 0) return; 2933 - 2934 - const parsedRows = lines.map(l => delimiter ? l.split(delimiter) : parseCSVLine(l)); 2935 - const hasHeaders = detectHeaders(parsedRows); 2936 - const sheet = getActiveSheet(); 2937 - ydoc.transact(() => { 2938 - for (let r = 0; r < lines.length; r++) { 2939 - const cols = parsedRows[r]; 2940 - for (let c = 0; c < cols.length; c++) { 2941 - const val = cols[c].trim(); const id = cellId(c + 1, r + 1); 2942 - if (val.startsWith('=')) { setCellData(id, { v: '', f: val.slice(1) }); } 2943 - else { const n = Number(val); setCellData(id, { v: val === '' ? '' : (!isNaN(n) && val !== '' ? n : val), f: '' }); } 2944 - } 2945 - } 2946 - const neededRows = parsedRows.length; 2947 - const neededCols = Math.max(...parsedRows.map(row => row.length)); 2948 - if (neededRows > (sheet.get('rowCount') || DEFAULT_ROWS)) sheet.set('rowCount', neededRows); 2949 - if (neededCols > (sheet.get('colCount') || DEFAULT_COLS)) sheet.set('colCount', neededCols); 2950 - }); 2951 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 2843 + // --- Collaboration avatars (extracted to collaboration-ui.ts) --- 2844 + _setupCollabAvatars({ provider, ydoc }); 2952 2845 2953 - if (hasHeaders) { 2954 - ydoc.transact(() => { 2955 - const hdrCols = parsedRows[0]; 2956 - for (let c = 0; c < hdrCols.length; c++) { 2957 - const val = hdrCols[c].trim(); 2958 - if (val !== '') { 2959 - const id = cellId(c + 1, 1); 2960 - const existing = getCellData(id); 2961 - const s = existing?.s || {}; 2962 - s.bold = true; 2963 - setCellData(id, { s }); 2964 - } 2965 - } 2966 - }); 2967 - refreshVisibleCells(); 2968 - showToast('Headers detected \u2014 first row formatted as header'); 2969 - } 2970 - } 2971 - 2972 - async function handleImportFile(file) { 2973 - if (!file) return; 2974 - const ext = file.name.split('.').pop().toLowerCase(); 2975 - 2976 - // Handle .xlsx files via ExcelJS 2977 - if (ext === 'xlsx' || ext === 'xls') { 2978 - await importXlsx(file, { 2979 - ydoc, 2980 - getActiveSheet, 2981 - ensureSheet, 2982 - setCellData, 2983 - setCellDataForSheet: (sheetIdx: number, id: string, data: { v?: unknown; f?: string; s?: Record<string, unknown> }) => { 2984 - const sheet = ensureSheet(sheetIdx); 2985 - const cells = sheet.get('cells'); 2986 - if (!cells) return; 2987 - let yCell = cells.get(id); 2988 - if (!yCell) { yCell = new Y.Map(); cells.set(id, yCell); } 2989 - // Date objects don't survive Yjs serialization — store as timestamp 2990 - let v = data.v; 2991 - if (v instanceof Date) v = v.getTime(); 2992 - if (v !== undefined) yCell.set('v', v); 2993 - if (data.f !== undefined) yCell.set('f', data.f); 2994 - if (data.s && Object.keys(data.s).length > 0) yCell.set('s', JSON.stringify(data.s)); 2995 - }, 2996 - getCells, 2997 - renderGrid, 2998 - renderSheetTabs, 2999 - showToast, 3000 - evalCache, 3001 - DEFAULT_ROWS, 3002 - DEFAULT_COLS, 3003 - }); 3004 - // Force immediate save so imported data survives a refresh — the debounced 3005 - // save won't fire for 500ms, and the emergency sendBeacon path may send 3006 - // stale pre-import data if the user refreshes before the debounce fires. 3007 - await provider._saveSnapshot(); 3008 - return; 3009 - } 3010 - 3011 - // Handle text-based files (CSV, TSV, TXT) 3012 - const reader = new FileReader(); 3013 - reader.onload = () => { 3014 - importFileContent(reader.result, file.name); 3015 - // Force immediate save — same reason as xlsx import above 3016 - provider._saveSnapshot(); 2846 + // --- Export/Import/Print (extracted to import-export.ts) --- 2847 + function _importExportDeps() { 2848 + return { 2849 + getActiveSheet, getCellData, setCellData, getCells, computeDisplayValue, 2850 + getColWidth, isRowHidden, isColHidden, buildMergeMap: _buildMergeMap, 2851 + ydoc, provider, ensureSheet, evalCache, clearSpillMaps: _clearSpillMaps, 2852 + invalidateRecalcEngine, renderGrid, renderSheetTabs, refreshVisibleCells, 2853 + DEFAULT_ROWS, DEFAULT_COLS, sheetContainer, 3017 2854 }; 3018 - reader.readAsText(file); 3019 2855 } 3020 - 3021 - function importCSV() { 3022 - const input = document.createElement('input'); input.type = 'file'; input.accept = '.csv,.tsv,.txt,.xlsx,.xls'; 3023 - input.addEventListener('change', () => { if (input.files[0]) handleImportFile(input.files[0]); }); 3024 - input.click(); 3025 - } 3026 - 3027 - function buildPrintData(): SheetsPrintData { 3028 - const sheet = getActiveSheet(); 3029 - const mergeMap = _buildMergeMap(); 3030 - 3031 - // Find actual data extent to avoid printing huge empty grids 3032 - let maxRow = 0, maxCol = 0; 3033 - const cells = getCells(); 3034 - cells.forEach((_, id) => { 3035 - const ref = parseRef(id); 3036 - if (ref) { 3037 - if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 3038 - if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 3039 - } 3040 - }); 3041 - maxRow = Math.max(maxRow, 2); 3042 - maxCol = Math.max(maxCol, 2); 3043 - 3044 - const headers: string[] = []; 3045 - const colWidths: number[] = []; 3046 - for (let c = 1; c <= maxCol; c++) { 3047 - if (isColHidden(c)) continue; 3048 - headers.push(colToLetter(c)); 3049 - colWidths.push(getColWidth(c)); 3050 - } 3051 - 3052 - const rows: PrintRow[] = []; 3053 - for (let r = 1; r <= maxRow; r++) { 3054 - if (isRowHidden(r)) continue; 3055 - const rowCells: (PrintCell | null)[] = []; 3056 - for (let c = 1; c <= maxCol; c++) { 3057 - if (isColHidden(c)) continue; 3058 - const id = cellId(c, r); 3059 - const mergeInfo = mergeMap.get(id); 3060 - if (mergeInfo?.hidden) { rowCells.push(null); continue; } 3061 - 3062 - const cellData = getCellData(id); 3063 - const displayValue = computeDisplayValue(id, cellData); 3064 - const displayStr = (typeof displayValue === 'object') ? '' : String(displayValue ?? ''); 3065 - const style = cellData?.s || {}; 3066 - 3067 - const printCell: PrintCell = { value: displayStr }; 3068 - const cellStyle: any = {}; 3069 - if (style.bold) cellStyle.bold = true; 3070 - if (style.italic) cellStyle.italic = true; 3071 - if (style.underline) cellStyle.underline = true; 3072 - if (style.strikethrough) cellStyle.strikethrough = true; 3073 - if (style.color) cellStyle.color = style.color; 3074 - if (style.bg) cellStyle.bg = style.bg; 3075 - if (style.align) cellStyle.align = style.align; 3076 - if (style.vAlign) cellStyle.verticalAlign = style.vAlign; 3077 - if (style.fontSize) cellStyle.fontSize = style.fontSize; 3078 - if (style.fontFamily) cellStyle.fontFamily = style.fontFamily; 3079 - if (Object.keys(cellStyle).length > 0) printCell.style = cellStyle; 3080 - 3081 - if (mergeInfo?.colspan > 1) printCell.colspan = mergeInfo.colspan; 3082 - if (mergeInfo?.rowspan > 1) printCell.rowspan = mergeInfo.rowspan; 3083 - 3084 - rowCells.push(printCell); 3085 - } 3086 - rows.push({ cells: rowCells }); 3087 - } 3088 - 3089 - return { headers, rows, colWidths }; 3090 - } 3091 - 3092 - function buildPrintOptions(): SheetsPrintOptions { 3093 - const sheetName = getActiveSheet().get('name') || 'Sheet 1'; 3094 - return { title: sheetName, gridLines: true, repeatHeaders: true, scaling: 'fit-to-width', orientation: 'landscape' }; 3095 - } 3096 - 3097 - function printSheet() { 3098 - const html = buildSheetsPrintHtml(buildPrintData(), buildPrintOptions()); 3099 - const printWindow = window.open('', '_blank'); 3100 - if (printWindow) { 3101 - printWindow.document.write(html); 3102 - printWindow.document.close(); 3103 - printWindow.addEventListener('load', () => { printWindow.print(); }); 3104 - } 3105 - } 3106 - 3107 - async function exportSheetPdf() { 3108 - const html = buildSheetsPrintHtml(buildPrintData(), buildPrintOptions()); 3109 - const html2pdf = (await import('html2pdf.js')).default; 3110 - const container = document.createElement('div'); 3111 - container.innerHTML = html; 3112 - container.style.cssText = 'position:fixed;left:-9999px;top:0;width:11in;background:#fff;color:#1a1815;'; 3113 - document.body.appendChild(container); 3114 - try { 3115 - const name = (getActiveSheet().get('name') || 'Sheet 1').replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); 3116 - await html2pdf().set({ 3117 - margin: [0.5, 0.5, 0.5, 0.5], 3118 - filename: `${name}.pdf`, 3119 - image: { type: 'jpeg', quality: 0.95 }, 3120 - html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff' }, 3121 - jsPDF: { unit: 'in', format: 'letter', orientation: 'landscape' }, 3122 - }).from(container).save(); 3123 - } finally { 3124 - document.body.removeChild(container); 3125 - } 3126 - } 3127 - 3128 - // Toolbar button bindings for export/import/print 3129 - document.getElementById('tb-export-csv').addEventListener('click', () => { exportCSV(); closeAllDropdowns(); }); 3130 - document.getElementById('tb-export-xlsx')?.addEventListener('click', async () => { 3131 - closeAllDropdowns(); 3132 - const sheet = getActiveSheet(); 3133 - const rc = sheet.get('rowCount') || DEFAULT_ROWS; 3134 - const cc = sheet.get('colCount') || DEFAULT_COLS; 3135 - const name = sheet.get('name') || 'sheet'; 3136 - const buf = await exportToXlsx( 3137 - (r, c) => getCellData(cellId(c, r)), 3138 - rc, cc, 3139 - (c) => getColWidth(c), 3140 - name 3141 - ); 3142 - downloadXlsx(buf, name + '.xlsx'); 3143 - }); 3144 - document.getElementById('tb-export-pdf')?.addEventListener('click', () => { exportSheetPdf(); closeAllDropdowns(); }); 3145 - document.getElementById('tb-import').addEventListener('click', () => { importCSV(); closeAllDropdowns(); }); 3146 - document.getElementById('tb-print').addEventListener('click', () => { printSheet(); closeAllDropdowns(); }); 3147 - 3148 - // --- Drag-and-drop import --- 3149 - sheetContainer.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; sheetContainer.classList.add('drag-over'); }); 3150 - sheetContainer.addEventListener('dragleave', () => { sheetContainer.classList.remove('drag-over'); }); 3151 - sheetContainer.addEventListener('drop', (e) => { 3152 - e.preventDefault(); sheetContainer.classList.remove('drag-over'); 3153 - const file = e.dataTransfer.files[0]; if (!file) return; 3154 - handleImportFile(file); 3155 - }); 2856 + function handleImportFile(file) { return _handleImportFile(_importExportDeps(), file); } 2857 + function printSheet() { _printSheet(_importExportDeps()); } 2858 + _wireImportExportToolbar(_importExportDeps(), closeAllDropdowns); 3156 2859 3157 2860 // --- React to Yjs changes --- 3158 2861 getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); ··· 3369 3072 3370 3073 // (Responsive toolbar collapse removed -- flat single-row toolbar with overflow menu) 3371 3074 3372 - // ======================================================== 3373 - // Charts Feature 3374 - // ======================================================== 3375 - 3376 - // Lazy-load Chart.js 3377 - let ChartJS = null; 3378 - async function ensureChartJS() { 3379 - if (ChartJS) return ChartJS; 3380 - const mod = await import('chart.js'); 3381 - // Register all components 3382 - mod.Chart.register( 3383 - mod.CategoryScale, mod.LinearScale, mod.PointElement, 3384 - mod.LineElement, mod.BarElement, mod.ArcElement, 3385 - mod.Title, mod.Tooltip, mod.Legend 3386 - ); 3387 - ChartJS = mod.Chart; 3388 - return ChartJS; 3389 - } 3390 - 3075 + // ── Charts Feature (extracted to charts-ui.ts) ── 3391 3076 const chartsSection = document.getElementById('charts-section'); 3392 - const chartInstances = new Map(); // chartId -> Chart instance 3393 3077 3394 3078 function getCharts() { 3395 3079 const sheet = getActiveSheet(); ··· 3397 3081 return sheet.get('charts'); 3398 3082 } 3399 3083 3400 - function showChartDialog(existingId, existingConfig) { 3401 - const overlay = document.createElement('div'); 3402 - overlay.className = 'sheet-dialog-overlay'; 3403 - 3404 - const isEdit = !!existingId; 3405 - const cfg = existingConfig || { type: 'bar', range: '', title: '', xAxisLabel: '', yAxisLabel: '' }; 3406 - 3407 - // Auto-fill range from selection 3408 - if (!isEdit && selectionRange) { 3409 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 3410 - if (startCol !== endCol || startRow !== endRow) { 3411 - cfg.range = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 3412 - } 3413 - } 3414 - 3415 - overlay.innerHTML = ` 3416 - <div class="sheet-dialog"> 3417 - <h3>${isEdit ? 'Edit' : 'Insert'} Chart</h3> 3418 - <label>Chart Type</label> 3419 - <select id="chart-type"> 3420 - ${CHART_TYPES.map(t => `<option value="${t}" ${t === cfg.type ? 'selected' : ''}>${t.charAt(0).toUpperCase() + t.slice(1)}</option>`).join('')} 3421 - </select> 3422 - <label>Data Range (e.g. A1:D10)</label> 3423 - <input id="chart-range" value="${cfg.range}" placeholder="A1:D10"> 3424 - <label>Title</label> 3425 - <input id="chart-title" value="${cfg.title || ''}" placeholder="Chart title"> 3426 - <label>X Axis Label</label> 3427 - <input id="chart-x-label" value="${cfg.xAxisLabel || ''}"> 3428 - <label>Y Axis Label</label> 3429 - <input id="chart-y-label" value="${cfg.yAxisLabel || ''}"> 3430 - <div class="sheet-dialog-actions"> 3431 - <button id="chart-cancel">Cancel</button> 3432 - <button id="chart-ok" class="btn-primary">${isEdit ? 'Update' : 'Insert'}</button> 3433 - </div> 3434 - </div> 3435 - `; 3436 - document.body.appendChild(overlay); 3437 - 3438 - overlay.querySelector('#chart-cancel').addEventListener('click', () => overlay.remove()); 3439 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 3440 - 3441 - overlay.querySelector('#chart-ok').addEventListener('click', () => { 3442 - const config = { 3443 - type: overlay.querySelector('#chart-type').value, 3444 - range: overlay.querySelector('#chart-range').value.trim(), 3445 - title: overlay.querySelector('#chart-title').value.trim(), 3446 - xAxisLabel: overlay.querySelector('#chart-x-label').value.trim(), 3447 - yAxisLabel: overlay.querySelector('#chart-y-label').value.trim(), 3448 - }; 3449 - const validation = validateChartConfig(config); 3450 - if (!validation.valid) { 3451 - alert(validation.errors.join('\n')); 3452 - return; 3453 - } 3454 - const charts = getCharts(); 3455 - const id = existingId || 'chart_' + Date.now(); 3456 - charts.set(id, JSON.stringify(config)); 3457 - overlay.remove(); 3458 - renderCharts(); 3459 - }); 3460 - 3461 - // Focus range input 3462 - setTimeout(() => overlay.querySelector('#chart-range').focus(), 50); 3463 - } 3464 - 3465 - function getCellValueForChart(id) { 3466 - const data = getCellData(id); 3467 - if (!data) return ''; 3468 - if (data.f) return evaluateFormula(data.f); 3469 - return data.v ?? ''; 3470 - } 3471 - 3472 - async function renderCharts() { 3473 - const Chart = await ensureChartJS(); 3474 - const charts = getCharts(); 3475 - chartsSection.innerHTML = ''; 3476 - 3477 - // Destroy old instances 3478 - for (const [, inst] of chartInstances) inst.destroy(); 3479 - chartInstances.clear(); 3480 - 3481 - charts.forEach((cfgStr, id) => { 3482 - const config = typeof cfgStr === 'string' ? JSON.parse(cfgStr) : cfgStr; 3483 - const rawData = extractChartData(config.range, getCellValueForChart); 3484 - const transformed = transformChartData(rawData, config); 3485 - const chartJsConfig = buildChartJsConfig(config, transformed); 3486 - 3487 - const container = document.createElement('div'); 3488 - container.className = 'chart-container'; 3489 - container.dataset.chartId = id; 3490 - 3491 - const actions = document.createElement('div'); 3492 - actions.className = 'chart-actions'; 3493 - actions.innerHTML = '<button class="chart-edit">Edit</button><button class="chart-delete">Delete</button>'; 3494 - container.appendChild(actions); 3495 - 3496 - const canvas = document.createElement('canvas'); 3497 - container.appendChild(canvas); 3498 - chartsSection.appendChild(container); 3499 - 3500 - const inst = new Chart(canvas, chartJsConfig); 3501 - chartInstances.set(id, inst); 3502 - 3503 - actions.querySelector('.chart-edit').addEventListener('click', () => showChartDialog(id, config)); 3504 - actions.querySelector('.chart-delete').addEventListener('click', () => { 3505 - const charts = getCharts(); 3506 - ydoc.transact(() => charts.delete(id)); 3507 - renderCharts(); 3508 - }); 3509 - }); 3084 + function _chartsDeps() { 3085 + return { 3086 + getActiveSheet, getCellData, evaluateFormula, getCharts, 3087 + selectedCell, selectionRange, ydoc, chartsSection, 3088 + }; 3510 3089 } 3090 + function showChartDialog(existingId?, existingConfig?) { _showChartDialogUI(_chartsDeps(), existingId, existingConfig); } 3091 + function renderCharts() { return _renderChartsUI(_chartsDeps()); } 3511 3092 3512 - document.getElementById('tb-chart').addEventListener('click', () => { 3513 - showChartDialog(null, null); 3514 - closeAllDropdowns(); 3515 - }); 3516 - 3517 - // Charts re-render is handled by the main cell observer + scheduleRenderGrid 3093 + document.getElementById('tb-chart').addEventListener('click', () => { showChartDialog(null, null); closeAllDropdowns(); }); 3518 3094 3519 3095 // ======================================================== 3520 3096 // Pivot Table Feature ··· 3625 3201 }); 3626 3202 } 3627 3203 3628 - // ======================================================== 3629 - // Database Views Feature (Kanban / Gallery / Calendar) 3630 - // ======================================================== 3631 - 3204 + // ── Database Views (extracted to database-views-ui.ts) ── 3632 3205 const dbViewSection = document.getElementById('database-view-section'); 3633 - let activeDbView: ViewConfig | null = null; 3634 - 3635 - function getCellValueForView(row: number, col: number): string { 3636 - const data = getCellData(cellId(col, row)); 3637 - if (!data) return ''; 3638 - if (data.f) { 3639 - const result = evaluateFormula(data.f); 3640 - return result == null ? '' : String(result); 3641 - } 3642 - return data.v == null ? '' : String(data.v); 3643 - } 3644 - 3645 - function getDataRowIndices(): number[] { 3646 - const sheet = getActiveSheet(); 3647 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 3648 - const indices: number[] = []; 3649 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 3650 - for (let r = 2; r <= rowCount; r++) { 3651 - let hasData = false; 3652 - for (let c = 1; c <= colCount; c++) { 3653 - const data = getCellData(cellId(c, r)); 3654 - if (data?.v !== '' && data?.v != null) { hasData = true; break; } 3655 - } 3656 - if (hasData) indices.push(r); 3657 - } 3658 - return indices; 3659 - } 3660 - 3661 - function showDbViewDialog() { 3662 - if (document.querySelector('.dbview-dialog-overlay')) return; 3663 - const overlay = document.createElement('div'); 3664 - overlay.className = 'sheet-dialog-overlay dbview-dialog-overlay'; 3665 - 3666 - const sheet = getActiveSheet(); 3667 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 3668 - const colOptions: string[] = []; 3669 - for (let c = 1; c <= colCount; c++) { 3670 - const data = getCellData(cellId(c, 1)); 3671 - const label = data?.v ? String(data.v) : colToLetter(c); 3672 - colOptions.push(`<option value="${c}">${colToLetter(c)}: ${label}</option>`); 3673 - } 3674 - 3675 - overlay.innerHTML = ` 3676 - <div class="sheet-dialog"> 3677 - <h3>Database View</h3> 3678 - <label>View Type</label> 3679 - <select id="dbview-type"> 3680 - <option value="kanban">Kanban</option> 3681 - <option value="gallery">Gallery</option> 3682 - <option value="calendar">Calendar</option> 3683 - </select> 3684 - <label>Group By / Date Column</label> 3685 - <select id="dbview-group">${colOptions.join('')}</select> 3686 - <label>Title Column</label> 3687 - <select id="dbview-title">${colOptions.join('')}</select> 3688 - <label>Display Columns</label> 3689 - <select id="dbview-display" multiple size="4">${colOptions.join('')}</select> 3690 - <div class="sheet-dialog-actions"> 3691 - <button id="dbview-cancel">Cancel</button> 3692 - <button id="dbview-ok" class="btn-primary">Open View</button> 3693 - </div> 3694 - </div> 3695 - `; 3696 - document.body.appendChild(overlay); 3697 - 3698 - overlay.querySelector('#dbview-cancel')!.addEventListener('click', () => overlay.remove()); 3699 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 3700 - 3701 - overlay.querySelector('#dbview-ok')!.addEventListener('click', () => { 3702 - const viewType = overlay.querySelector('#dbview-type')!.value as ViewType; 3703 - const groupByColumn = Number(overlay.querySelector('#dbview-group')!.value); 3704 - const titleColumn = Number(overlay.querySelector('#dbview-title')!.value); 3705 - const displayColumns = Array.from(overlay.querySelector('#dbview-display')!.selectedOptions, o => Number(o.value)); 3706 - 3707 - activeDbView = createViewConfig(viewType, groupByColumn, titleColumn); 3708 - activeDbView.displayColumns = displayColumns; 3709 - overlay.remove(); 3710 - renderDbView(); 3711 - }); 3712 - } 3713 - 3714 - function renderDbView() { 3715 - if (!activeDbView) { 3716 - dbViewSection.style.display = 'none'; 3717 - return; 3718 - } 3719 - 3720 - dbViewSection.style.display = ''; 3721 - dbViewSection.innerHTML = ''; 3722 - 3723 - // Toolbar 3724 - const toolbar = document.createElement('div'); 3725 - toolbar.className = 'db-view-toolbar'; 3726 - toolbar.innerHTML = ` 3727 - <strong>${activeDbView.type.charAt(0).toUpperCase() + activeDbView.type.slice(1)} View</strong> 3728 - <button class="db-view-close" title="Close view">✕ Close</button> 3729 - `; 3730 - dbViewSection.appendChild(toolbar); 3731 - toolbar.querySelector('.db-view-close')!.addEventListener('click', () => { 3732 - activeDbView = null; 3733 - renderDbView(); 3734 - }); 3735 - 3736 - const rowIndices = getDataRowIndices(); 3737 - 3738 - if (activeDbView.type === 'kanban') { 3739 - renderKanbanView(rowIndices); 3740 - } else if (activeDbView.type === 'gallery') { 3741 - renderGalleryView(rowIndices); 3742 - } else if (activeDbView.type === 'calendar') { 3743 - renderCalendarView(rowIndices); 3744 - } 3745 - } 3746 - 3747 - function renderKanbanView(rowIndices: number[]) { 3748 - const columns = buildKanbanColumns(rowIndices, getCellValueForView, activeDbView!); 3749 - const board = document.createElement('div'); 3750 - board.className = 'kanban-board'; 3751 - 3752 - for (const col of columns) { 3753 - const column = document.createElement('div'); 3754 - column.className = 'kanban-column'; 3755 - column.innerHTML = ` 3756 - <div class="kanban-column-header"> 3757 - <span>${col.groupValue}</span> 3758 - <span class="kanban-column-count">${col.cards.length}</span> 3759 - </div> 3760 - `; 3761 - 3762 - for (const card of col.cards) { 3763 - const cardEl = document.createElement('div'); 3764 - cardEl.className = 'kanban-card'; 3765 - cardEl.dataset.row = String(card.rowIndex); 3766 - let fieldsHtml = ''; 3767 - for (const f of card.fields) { 3768 - const hdr = getCellData(cellId(f.columnIndex, 1)); 3769 - const label = hdr?.v ? String(hdr.v) : colToLetter(f.columnIndex); 3770 - fieldsHtml += `<div class="kanban-card-field"><span class="kanban-card-field-label">${label}:</span> ${f.value}</div>`; 3771 - } 3772 - cardEl.innerHTML = `<div class="kanban-card-title">${card.title || '(untitled)'}</div>${fieldsHtml}`; 3773 - column.appendChild(cardEl); 3774 - } 3775 - 3776 - board.appendChild(column); 3777 - } 3778 - 3779 - dbViewSection.appendChild(board); 3780 - } 3781 - 3782 - function renderGalleryView(rowIndices: number[]) { 3783 - const cards = buildGalleryCards(rowIndices, getCellValueForView, activeDbView!); 3784 - const grid = document.createElement('div'); 3785 - grid.className = 'gallery-grid'; 3786 - 3787 - for (const card of cards) { 3788 - const cardEl = document.createElement('div'); 3789 - cardEl.className = 'gallery-card'; 3790 - cardEl.dataset.row = String(card.rowIndex); 3791 - let fieldsHtml = ''; 3792 - for (const f of card.fields) { 3793 - const hdr = getCellData(cellId(f.columnIndex, 1)); 3794 - const label = hdr?.v ? String(hdr.v) : colToLetter(f.columnIndex); 3795 - fieldsHtml += `<div class="gallery-card-field"><span class="gallery-card-field-label">${label}:</span> ${f.value}</div>`; 3796 - } 3797 - cardEl.innerHTML = `<div class="gallery-card-title">${card.title || '(untitled)'}</div>${fieldsHtml}`; 3798 - grid.appendChild(cardEl); 3799 - } 3800 - 3801 - dbViewSection.appendChild(grid); 3802 - } 3803 - 3804 - function renderCalendarView(rowIndices: number[]) { 3805 - const events = buildCalendarEvents(rowIndices, getCellValueForView, activeDbView!); 3806 - const months = groupEventsByMonth(events); 3807 - 3808 - if (months.length === 0) { 3809 - dbViewSection.innerHTML += '<p style="color:var(--color-text-secondary);padding:var(--space-md)">No dates found in the selected column.</p>'; 3810 - return; 3811 - } 3812 - 3813 - for (const month of months) { 3814 - const monthEl = document.createElement('div'); 3815 - monthEl.className = 'calendar-view'; 3816 - 3817 - const monthName = new Date(month.year, month.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); 3818 - monthEl.innerHTML = `<div class="calendar-month-header">${monthName}</div>`; 3819 - 3820 - const grid = document.createElement('div'); 3821 - grid.className = 'calendar-grid'; 3822 - 3823 - // Day headers 3824 - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 3825 - for (const d of dayNames) { 3826 - const dh = document.createElement('div'); 3827 - dh.className = 'calendar-day-header'; 3828 - dh.textContent = d; 3829 - grid.appendChild(dh); 3830 - } 3831 - 3832 - // Calendar days 3833 - const firstDay = new Date(month.year, month.month, 1); 3834 - const lastDay = new Date(month.year, month.month + 1, 0); 3835 - const startOffset = firstDay.getDay(); 3836 - 3837 - // Previous month filler 3838 - for (let i = 0; i < startOffset; i++) { 3839 - const filler = document.createElement('div'); 3840 - filler.className = 'calendar-day calendar-day-other'; 3841 - grid.appendChild(filler); 3842 - } 3843 - 3844 - // Actual days 3845 - for (let d = 1; d <= lastDay.getDate(); d++) { 3846 - const dateStr = `${month.year}-${String(month.month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; 3847 - const dayEvents = eventsForDate(events, dateStr); 3848 - 3849 - const dayEl = document.createElement('div'); 3850 - dayEl.className = 'calendar-day'; 3851 - dayEl.innerHTML = `<div class="calendar-day-number">${d}</div>`; 3852 - 3853 - for (const evt of dayEvents) { 3854 - const evtEl = document.createElement('div'); 3855 - evtEl.className = 'calendar-event'; 3856 - evtEl.textContent = evt.title || '(untitled)'; 3857 - evtEl.title = evt.title; 3858 - dayEl.appendChild(evtEl); 3859 - } 3860 - 3861 - grid.appendChild(dayEl); 3862 - } 3863 - 3864 - monthEl.appendChild(grid); 3865 - dbViewSection.appendChild(monthEl); 3866 - } 3867 - } 3868 - 3869 - document.getElementById('tb-view-mode')!.addEventListener('click', () => { 3870 - showDbViewDialog(); 3871 - closeAllDropdowns(); 3872 - }); 3873 - 3874 - // ======================================================== 3875 - // Multi-Column Filter Feature 3876 - // ======================================================== 3877 - 3878 - let filterMode = false; 3879 - let filterState = {}; // { colNum: { value: checked } } 3880 - 3881 - function toggleFilterMode() { 3882 - filterMode = !filterMode; 3883 - const btn = document.getElementById('tb-filter'); 3884 - btn.classList.toggle('active', filterMode); 3885 - 3886 - if (!filterMode) { 3887 - filterState = {}; 3888 - // Sync to Yjs 3889 - const sheet = getActiveSheet(); 3890 - if (sheet.has('filterState')) sheet.delete('filterState'); 3891 - } 3892 - renderGrid(); 3893 - } 3894 - 3895 - function buildRowObjects() { 3896 - const sheet = getActiveSheet(); 3897 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 3898 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 3899 - const rows = []; 3900 - for (let r = 1; r <= rowCount; r++) { 3901 - const obj = { _row: r }; 3902 - for (let c = 1; c <= colCount; c++) { 3903 - const data = getCellData(cellId(c, r)); 3904 - obj[c] = data?.v ?? ''; 3905 - } 3906 - rows.push(obj); 3907 - } 3908 - return rows; 3909 - } 3910 - 3911 - function getFilterHiddenRows() { 3912 - if (!filterMode || Object.keys(filterState).length === 0) return new Set(); 3913 - const rows = buildRowObjects(); 3914 - const visible = applyFilters(rows, filterState); 3915 - const visibleSet = new Set(visible.map(r => r._row)); 3916 - const hidden = new Set(); 3917 - for (const row of rows) { 3918 - if (!visibleSet.has(row._row)) hidden.add(row._row); 3919 - } 3920 - return hidden; 3206 + function _dbViewsDeps() { 3207 + return { getActiveSheet, getCellData, evaluateFormula, DEFAULT_ROWS, DEFAULT_COLS, dbViewSection }; 3921 3208 } 3922 - 3923 - function showFilterDropdown(col, th) { 3924 - // Close any existing filter dropdown 3925 - document.querySelectorAll('.filter-dropdown').forEach(d => d.remove()); 3926 - 3927 - const rows = buildRowObjects(); 3928 - const values = getUniqueColumnValues(rows, col); 3929 - const currentState = filterState[col] || buildFilterState(rows, col); 3930 - 3931 - const dropdown = document.createElement('div'); 3932 - dropdown.className = 'filter-dropdown'; 3933 - 3934 - let html = ''; 3935 - for (const val of values) { 3936 - const checked = currentState[val] !== false ? 'checked' : ''; 3937 - const displayVal = val === '' ? '(Empty)' : val; 3938 - html += `<label><input type="checkbox" data-val="${val.replace(/"/g, '&quot;')}" ${checked}> ${displayVal}</label>`; 3939 - } 3940 - html += `<div class="filter-actions"><button class="filter-clear">Clear</button><button class="filter-all">Select All</button></div>`; 3941 - dropdown.innerHTML = html; 3209 + function showDbViewDialog() { _showDbViewDialogUI(_dbViewsDeps()); } 3210 + function renderDbView() { _renderDbViewUI(_dbViewsDeps()); } 3942 3211 3943 - th.style.position = 'relative'; 3944 - th.appendChild(dropdown); 3212 + document.getElementById('tb-view-mode')!.addEventListener('click', () => { showDbViewDialog(); closeAllDropdowns(); }); 3945 3213 3946 - dropdown.addEventListener('change', (e) => { 3947 - const cb = e.target; 3948 - if (!cb.dataset.val && cb.dataset.val !== '') return; 3949 - if (!filterState[col]) filterState[col] = { ...currentState }; 3950 - filterState[col][cb.dataset.val] = cb.checked; 3951 - applyFilterToGrid(); 3952 - }); 3214 + // ── Filter UI (extracted to filter-ui.ts) ── 3215 + function _filterDeps() { return { getActiveSheet, getCellData, grid, DEFAULT_ROWS, DEFAULT_COLS }; } 3216 + function toggleFilterMode() { _toggleFilterMode(_filterDeps()); } 3217 + function getFilterHiddenRows() { return _getFilterHiddenRows(_filterDeps()); } 3218 + function applyFilterToGrid() { _applyFilterToGrid(_filterDeps()); } 3953 3219 3954 - dropdown.querySelector('.filter-clear').addEventListener('click', () => { 3955 - filterState = clearColumnFilter(filterState, col); 3956 - dropdown.remove(); 3957 - applyFilterToGrid(); 3958 - }); 3220 + document.getElementById('tb-filter').addEventListener('click', () => { toggleFilterMode(); closeAllDropdowns(); }); 3959 3221 3960 - dropdown.querySelector('.filter-all').addEventListener('click', () => { 3961 - filterState[col] = buildFilterState(rows, col); 3962 - dropdown.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.checked = true; }); 3963 - applyFilterToGrid(); 3964 - }); 3965 - 3966 - // Close on click outside 3967 - const closeHandler = (e) => { 3968 - if (!dropdown.contains(e.target) && e.target !== th) { 3969 - dropdown.remove(); 3970 - document.removeEventListener('click', closeHandler); 3971 - } 3972 - }; 3973 - setTimeout(() => document.addEventListener('click', closeHandler), 0); 3974 - } 3975 - 3976 - function applyFilterToGrid() { 3977 - const hidden = getFilterHiddenRows(); 3978 - grid.querySelectorAll('tbody tr').forEach(tr => { 3979 - const rowHeader = tr.querySelector('th.row-header'); 3980 - if (!rowHeader) return; 3981 - const row = parseInt(rowHeader.dataset.row); 3982 - tr.classList.toggle('filter-hidden', hidden.has(row)); 3983 - }); 3984 - 3985 - // Sync filter state to Yjs for collaboration 3986 - const sheet = getActiveSheet(); 3987 - if (Object.keys(filterState).length > 0) { 3988 - sheet.set('filterState', JSON.stringify(filterState)); 3989 - } else if (sheet.has('filterState')) { 3990 - sheet.delete('filterState'); 3991 - } 3992 - } 3993 - 3994 - document.getElementById('tb-filter').addEventListener('click', () => { 3995 - toggleFilterMode(); 3996 - closeAllDropdowns(); 3997 - }); 3998 - 3999 - // ======================================================== 4000 - // Multi-Column Sort Dialog Feature 4001 - // ======================================================== 4002 - 4003 - // showSortDialog extracted to sheet-dialogs.ts 3222 + // Sort dialog (extracted to sheet-dialogs.ts) 4004 3223 function showSortDialog() { 4005 3224 _showSortDialog({ 4006 3225 getActiveSheet, selectedCell, selectionRange, getCellData, getCells, setCellData, ··· 4008 3227 invalidateRecalcEngine, refreshVisibleCells, 4009 3228 }); 4010 3229 } 3230 + document.getElementById('tb-sort-multi').addEventListener('click', () => { showSortDialog(); closeAllDropdowns(); }); 4011 3231 4012 - document.getElementById('tb-sort-multi').addEventListener('click', () => { 4013 - showSortDialog(); 4014 - closeAllDropdowns(); 4015 - }); 4016 - 4017 - // ======================================================== 4018 - // Patch renderGrid to support filter arrows and hidden rows 4019 - // ======================================================== 4020 - 4021 - // Observe when renderGrid completes by watching for grid changes 4022 - // to add filter arrows and apply row visibility 4023 - const gridObserver = new MutationObserver(() => { 4024 - if (filterMode) { 4025 - // Add filter arrows 4026 - grid.querySelectorAll('thead th[data-col]').forEach(th => { 4027 - if (th.querySelector('.filter-arrow')) return; // already added 4028 - const col = parseInt(th.dataset.col); 4029 - const arrow = document.createElement('span'); 4030 - arrow.className = 'filter-arrow'; 4031 - arrow.textContent = '\u25BC'; 4032 - arrow.addEventListener('click', (e) => { 4033 - e.stopPropagation(); 4034 - showFilterDropdown(col, th); 4035 - }); 4036 - th.appendChild(arrow); 4037 - 4038 - if (filterState[col]) { 4039 - const hasFiltered = Object.values(filterState[col]).some(v => v === false); 4040 - if (hasFiltered) th.classList.add('filter-active'); 4041 - } 4042 - }); 4043 - applyFilterToGrid(); 4044 - } 4045 - }); 4046 - gridObserver.observe(grid, { childList: true, subtree: true }); 4047 - 4048 - // ======================================================== 4049 - // Load filter state and charts from Yjs on sync 4050 - // ======================================================== 4051 - 3232 + // Filter grid observer + load filter state on sync 3233 + _setupFilterGridObserver(_filterDeps()); 4052 3234 provider.on('sync', () => { 4053 - const sheet = getActiveSheet(); 4054 - if (sheet.has('filterState')) { 4055 - try { 4056 - filterState = JSON.parse(sheet.get('filterState')); 4057 - filterMode = Object.keys(filterState).length > 0; 4058 - document.getElementById('tb-filter').classList.toggle('active', filterMode); 4059 - } catch {} 4060 - } 3235 + _loadFilterStateFromYjs(_filterDeps()); 4061 3236 renderCharts(); 4062 3237 }); 4063 - 4064 - // Watch for chart changes from collaborators 4065 3238 getCharts().observeDeep(() => { renderCharts(); }); 4066 3239 4067 3240 // --- Cell Borders dropdown --- ··· 4299 3472 } 4300 3473 } 4301 3474 4302 - // ======================================================== 4303 - // Status Bar (SUM/AVG/COUNT/MIN/MAX of selection) 4304 - // ======================================================== 4305 - 4306 - const statusBar = document.getElementById('status-bar'); 4307 - const statusBarStats = document.getElementById('status-bar-stats'); 4308 - const statusBarInfo = document.getElementById('status-bar-info'); 4309 - 4310 - function updateStatusBar() { 4311 - // Left side: cell reference + freeze indicator 4312 - let infoHtml = ''; 4313 - if (selectionRange) { 4314 - const norm = normalizeRange(selectionRange); 4315 - const isMulti = norm.startCol !== norm.endCol || norm.startRow !== norm.endRow; 4316 - if (isMulti) { 4317 - infoHtml += '<span class="status-bar-cell-ref">' + colToLetter(norm.startCol) + norm.startRow + ':' + colToLetter(norm.endCol) + norm.endRow + '</span>'; 4318 - const rows = norm.endRow - norm.startRow + 1; 4319 - const cols = norm.endCol - norm.startCol + 1; 4320 - infoHtml += '<span class="status-bar-dim">' + rows + 'R \u00d7 ' + cols + 'C</span>'; 4321 - } else { 4322 - infoHtml += '<span class="status-bar-cell-ref">' + colToLetter(selectedCell.col) + selectedCell.row + '</span>'; 4323 - } 4324 - } 4325 - const fr = getFreezeRows(); 4326 - const fc = getFreezeCols(); 4327 - if (fr > 0 || fc > 0) { 4328 - const parts = []; 4329 - if (fr > 0) parts.push(fr + ' row' + (fr > 1 ? 's' : '')); 4330 - if (fc > 0) parts.push(fc + ' col' + (fc > 1 ? 's' : '')); 4331 - infoHtml += '<span class="status-bar-freeze" title="Click to unfreeze">Frozen: ' + parts.join(', ') + '</span>'; 4332 - } 4333 - if (statusBarInfo) statusBarInfo.innerHTML = infoHtml; 4334 - 4335 - // Right side: selection stats (only for multi-cell) 4336 - if (!selectionRange) { statusBarStats.innerHTML = ''; return; } 4337 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 4338 - const isMultiCell = startCol !== endCol || startRow !== endRow; 4339 - if (!isMultiCell) { statusBarStats.innerHTML = ''; return; } 4340 - 4341 - const values = []; 4342 - for (let r = startRow; r <= endRow; r++) { 4343 - for (let c = startCol; c <= endCol; c++) { 4344 - const id = cellId(c, r); 4345 - const cellData = getCellData(id); 4346 - const display = computeDisplayValue(id, cellData); 4347 - values.push(display); 4348 - } 4349 - } 4350 - 4351 - const stats = computeSelectionStats(values); 4352 - if (!stats) { statusBarStats.innerHTML = ''; return; } 4353 - 4354 - let html = ''; 4355 - if (stats.count > 0) { 4356 - html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Sum</span><span class="status-bar-stat-value">' + formatStatValue(stats.sum) + '</span></span>'; 4357 - html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Avg</span><span class="status-bar-stat-value">' + formatStatValue(stats.average) + '</span></span>'; 4358 - html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Count</span><span class="status-bar-stat-value">' + stats.count + '</span></span>'; 4359 - html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Min</span><span class="status-bar-stat-value">' + formatStatValue(stats.min) + '</span></span>'; 4360 - html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Max</span><span class="status-bar-stat-value">' + formatStatValue(stats.max) + '</span></span>'; 4361 - } else { 4362 - html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Count</span><span class="status-bar-stat-value">0</span></span>'; 4363 - } 4364 - statusBarStats.innerHTML = html; 3475 + // ── Status Bar (extracted to status-bar-ui.ts) ── 3476 + function _statusBarDeps() { 3477 + return { 3478 + getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 3479 + getCellData, computeDisplayValue, getFreezeRows, getFreezeCols, 3480 + setFreezeRows, setFreezeCols, renderGrid, 3481 + }; 4365 3482 } 4366 - 4367 - // Click on freeze indicator to unfreeze 4368 - if (statusBarInfo) { 4369 - statusBarInfo.addEventListener('click', (e) => { 4370 - const freezeEl = (e.target as HTMLElement).closest('.status-bar-freeze'); 4371 - if (freezeEl) { 4372 - setFreezeRows(0); 4373 - setFreezeCols(0); 4374 - renderGrid(); 4375 - updateStatusBar(); 4376 - showToast('Panes unfrozen'); 4377 - } 4378 - }); 4379 - } 3483 + function updateStatusBar() { _updateStatusBarUI(_statusBarDeps()); } 3484 + _wireStatusBarFreezeClick(_statusBarDeps()); 4380 3485 4381 3486 // ======================================================== 4382 3487 // Formula Auto-Complete ··· 4582 3687 }); 4583 3688 } 4584 3689 4585 - // ======================================================== 4586 - // Cell Notes 4587 - // ======================================================== 4588 - 4589 - const noteTooltip = document.getElementById('cell-note-tooltip'); 4590 - 4591 - function getNotesMap() { 4592 - const sheet = getActiveSheet(); 4593 - if (!sheet.has('notes')) sheet.set('notes', new Y.Map()); 4594 - return sheet.get('notes'); 4595 - } 4596 - 4597 - function getNotesObject() { 4598 - const yNotes = getNotesMap(); 4599 - const obj = {}; 4600 - yNotes.forEach((val, key) => { obj[key] = val; }); 4601 - return obj; 4602 - } 4603 - 4604 - function setNoteInYjs(cellIdStr, text) { 4605 - const yNotes = getNotesMap(); 4606 - if (text) { 4607 - yNotes.set(cellIdStr, text); 4608 - } else { 4609 - if (yNotes.has(cellIdStr)) yNotes.delete(cellIdStr); 4610 - } 4611 - } 4612 - 4613 - function showNoteTooltip(cellIdStr, td) { 4614 - const notes = getNotesObject(); 4615 - const text = getNote(notes, cellIdStr); 4616 - if (!text) return; 4617 - 4618 - noteTooltip.textContent = text; 4619 - noteTooltip.style.display = ''; 4620 - 4621 - const rect = td.getBoundingClientRect(); 4622 - noteTooltip.style.left = (rect.right + 4) + 'px'; 4623 - noteTooltip.style.top = rect.top + 'px'; 4624 - 4625 - // Adjust if tooltip goes off-screen 4626 - requestAnimationFrame(() => { 4627 - const ttRect = noteTooltip.getBoundingClientRect(); 4628 - if (ttRect.right > window.innerWidth) { 4629 - noteTooltip.style.left = (rect.left - ttRect.width - 4) + 'px'; 4630 - } 4631 - if (ttRect.bottom > window.innerHeight) { 4632 - noteTooltip.style.top = (rect.bottom - ttRect.height) + 'px'; 4633 - } 4634 - }); 4635 - } 4636 - 4637 - function hideNoteTooltip() { 4638 - noteTooltip.style.display = 'none'; 4639 - } 4640 - 4641 - function showNoteDialog(cellIdStr) { 4642 - const notes = getNotesObject(); 4643 - const existing = getNote(notes, cellIdStr); 4644 - 4645 - const overlay = document.createElement('div'); 4646 - overlay.className = 'sheet-dialog-overlay'; 4647 - overlay.innerHTML = '<div class="sheet-dialog">' 4648 - + '<h3>' + (existing ? 'Edit' : 'Add') + ' Note for ' + cellIdStr + '</h3>' 4649 - + '<textarea id="note-text" rows="4" style="width:100%;resize:vertical;font-family:var(--font-body);font-size:0.85rem;padding:6px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);">' + (existing || '') + '</textarea>' 4650 - + '<div class="sheet-dialog-actions">' 4651 - + (existing ? '<button id="note-delete" class="btn-danger" style="margin-right:auto;">Delete</button>' : '') 4652 - + '<button id="note-cancel">Cancel</button>' 4653 - + '<button id="note-save" class="btn-primary">Save</button>' 4654 - + '</div></div>'; 4655 - 4656 - document.body.appendChild(overlay); 4657 - 4658 - const textarea = overlay.querySelector('#note-text'); 4659 - setTimeout(() => textarea.focus(), 50); 4660 - 4661 - overlay.querySelector('#note-cancel').addEventListener('click', () => overlay.remove()); 4662 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 4663 - 4664 - overlay.querySelector('#note-save').addEventListener('click', () => { 4665 - const text = textarea.value.trim(); 4666 - if (text) { 4667 - setNoteInYjs(cellIdStr, text); 4668 - } else { 4669 - setNoteInYjs(cellIdStr, null); 4670 - } 4671 - overlay.remove(); 4672 - renderNoteIndicators(); 4673 - }); 4674 - 4675 - if (existing) { 4676 - overlay.querySelector('#note-delete').addEventListener('click', () => { 4677 - setNoteInYjs(cellIdStr, null); 4678 - overlay.remove(); 4679 - renderNoteIndicators(); 4680 - }); 4681 - } 4682 - } 4683 - 4684 - function renderNoteIndicators() { 4685 - // Remove existing indicators 4686 - grid.querySelectorAll('.cell-note-indicator').forEach(el => el.remove()); 4687 - grid.querySelectorAll('.has-note').forEach(el => el.classList.remove('has-note')); 4688 - 4689 - const notes = getNotesObject(); 4690 - const cellIds = getAllNotes(notes); 4691 - for (const cid of cellIds) { 4692 - const td = grid.querySelector('td[data-id="' + cid + '"]'); 4693 - if (td) { 4694 - td.classList.add('has-note'); 4695 - const indicator = document.createElement('div'); 4696 - indicator.className = 'cell-note-indicator'; 4697 - td.appendChild(indicator); 4698 - } 4699 - } 4700 - } 3690 + // ── Cell Notes (extracted to cell-notes-ui.ts) ── 3691 + function _cellNotesDeps() { return { getActiveSheet, grid }; } 3692 + function getNotesMap() { return _getNotesMap(_cellNotesDeps()); } 3693 + function getNotesObject() { return _getNotesObject(_cellNotesDeps()); } 3694 + function setNoteInYjs(id, text) { _setNoteInYjs(_cellNotesDeps(), id, text); } 3695 + function showNoteDialog(id) { _showNoteDialogUI(_cellNotesDeps(), id, renderNoteIndicators); } 3696 + function renderNoteIndicators() { _renderNoteIndicatorsUI(_cellNotesDeps()); } 4701 3697 4702 3698 function renderSparklines() { 4703 3699 grid.querySelectorAll('canvas.sparkline-canvas').forEach(canvas => { ··· 4723 3719 }); 4724 3720 } 4725 3721 4726 - // Show note tooltip on hover 4727 - grid.addEventListener('mouseover', (e) => { 4728 - const td = e.target.closest('td[data-id]'); 4729 - if (!td) return; 4730 - const cid = td.dataset.id; 4731 - const notes = getNotesObject(); 4732 - if (hasNote(notes, cid)) { 4733 - showNoteTooltip(cid, td); 4734 - } 4735 - }); 4736 - 4737 - grid.addEventListener('mouseout', (e) => { 4738 - const td = e.target.closest('td[data-id]'); 4739 - if (td) hideNoteTooltip(); 4740 - }); 4741 - 4742 - // --- Error tooltip popup (#208) --- 4743 - const errorTooltipEl = document.getElementById('error-tooltip'); 4744 - const errorTitleEl = errorTooltipEl.querySelector('.error-tooltip-title'); 4745 - const errorDescEl = errorTooltipEl.querySelector('.error-tooltip-desc'); 4746 - const errorHintEl = errorTooltipEl.querySelector('.error-tooltip-hint'); 4747 - 4748 - function showErrorTooltip(cellDiv, td) { 4749 - const title = cellDiv.dataset.errorTitle; 4750 - if (!title) return; 4751 - errorTitleEl.textContent = title; 4752 - errorDescEl.textContent = cellDiv.dataset.errorDesc || ''; 4753 - errorHintEl.textContent = cellDiv.dataset.errorHint ? 'Hint: ' + cellDiv.dataset.errorHint : ''; 4754 - errorTooltipEl.style.display = ''; 4755 - const rect = td.getBoundingClientRect(); 4756 - errorTooltipEl.style.left = (rect.left) + 'px'; 4757 - errorTooltipEl.style.top = (rect.bottom + 4) + 'px'; 4758 - requestAnimationFrame(() => { 4759 - const ttRect = errorTooltipEl.getBoundingClientRect(); 4760 - if (ttRect.right > window.innerWidth) { 4761 - errorTooltipEl.style.left = (window.innerWidth - ttRect.width - 8) + 'px'; 4762 - } 4763 - if (ttRect.bottom > window.innerHeight) { 4764 - errorTooltipEl.style.top = (rect.top - ttRect.height - 4) + 'px'; 4765 - } 4766 - }); 4767 - } 3722 + // ── Note hover + Error tooltip (extracted to cell-notes-ui.ts) ── 3723 + _wireNoteHover(_cellNotesDeps()); 3724 + _wireErrorTooltip(grid); 4768 3725 4769 - function hideErrorTooltip() { 4770 - errorTooltipEl.style.display = 'none'; 3726 + // ── Context Menu (extracted to context-menu-handler.ts) ── 3727 + function _contextMenuDeps() { 3728 + return { 3729 + grid, getActiveSheet, getCellData, 3730 + getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 3731 + getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 3732 + copySelection, deleteSelectedCells, pasteAtSelection, showPasteSpecialDialog, 3733 + doInsertRow, doInsertColumn, doDeleteRow, doDeleteColumn, sortColumn, 3734 + hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 3735 + getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 3736 + getColWidth, setColWidth, getRowHeight, setRowHeight, 3737 + isAtHiddenRowBoundary, isAtHiddenColBoundary, 3738 + buildHiddenRowSet, buildHiddenColSet, renderGrid, 3739 + showNoteDialog, setNoteInYjs, renderNoteIndicators, getNotesObject, 3740 + DEFAULT_ROWS, DEFAULT_COLS, 3741 + }; 4771 3742 } 4772 - 4773 - grid.addEventListener('mouseover', (e) => { 4774 - const cellDiv = e.target.closest('.cell-error[data-error-title]'); 4775 - if (!cellDiv) return; 4776 - const td = cellDiv.closest('td[data-id]'); 4777 - if (td) showErrorTooltip(cellDiv, td); 4778 - }); 4779 - 4780 - grid.addEventListener('mouseout', (e) => { 4781 - const cellDiv = e.target.closest('.cell-error[data-error-title]'); 4782 - if (cellDiv) hideErrorTooltip(); 4783 - }); 4784 - 4785 - // Right-click context menu (#149 — wired-up actions, #113 — row/col insert/delete) 4786 - let _activeContextMenu = null; 4787 - 4788 - function hideActiveContextMenu() { 4789 - if (_activeContextMenu) { 4790 - _activeContextMenu.destroy(); 4791 - _activeContextMenu = null; 4792 - } 4793 - } 4794 - 4795 - grid.addEventListener('contextmenu', (e) => { 4796 - e.preventDefault(); 4797 - hideActiveContextMenu(); 4798 - 4799 - const colHeader = e.target.closest('thead th[data-col]'); 4800 - const rowHeader = e.target.closest('th.row-header[data-row]'); 4801 - const td = e.target.closest('td[data-id]'); 4802 - 4803 - const sheet = getActiveSheet(); 4804 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 4805 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 4806 - 4807 - let items; 4808 - if (colHeader) { 4809 - // Column header right-click 4810 - const col = parseInt(colHeader.dataset.col); 4811 - const hasAdjacentHiddenCol = isAtHiddenColBoundary(col, colCount, buildHiddenColSet()); 4812 - items = [ 4813 - { label: 'Sort A \u2192 Z', icon: '\u2191', action: () => { selectedCell = { col, row: 1 }; const sheet = getActiveSheet(); selectionRange = { startCol: col, startRow: 1, endCol: col, endRow: sheet.get('rowCount') || DEFAULT_ROWS }; sortColumn(true); } }, 4814 - { label: 'Sort Z \u2192 A', icon: '\u2193', action: () => { selectedCell = { col, row: 1 }; const sheet = getActiveSheet(); selectionRange = { startCol: col, startRow: 1, endCol: col, endRow: sheet.get('rowCount') || DEFAULT_ROWS }; sortColumn(false); } }, 4815 - SEPARATOR, 4816 - { label: 'Insert Column Left', action: () => doInsertColumn(col) }, 4817 - { label: 'Insert Column Right', action: () => doInsertColumn(col + 1) }, 4818 - { label: 'Delete Column', action: () => doDeleteColumn(col) }, 4819 - SEPARATOR, 4820 - { label: 'Hide Column', shortcut: '\u2318+0', action: () => { selectedCell = { col, row: 1 }; selectionRange = { startCol: col, startRow: 1, endCol: col, endRow: rowCount }; hideSelectedCols(); } }, 4821 - ...(hasAdjacentHiddenCol ? [{ label: 'Unhide Columns', shortcut: '\u2318\u21e7+0', action: () => unhideAdjacentCols(col) }] : []), 4822 - SEPARATOR, 4823 - ...(getFreezeCols() !== col ? [{ label: 'Freeze up to column ' + colToLetter(col), action: () => { setFreezeCols(col); renderGrid(); } }] : []), 4824 - ...(getFreezeCols() > 0 ? [{ label: 'Unfreeze Columns', action: () => { setFreezeCols(0); renderGrid(); } }] : []), 4825 - { label: 'Resize Column\u2026', action: () => { const w = prompt('Column width (px):', String(getColWidth(col))); if (w && !isNaN(Number(w))) { setColWidth(col, Number(w)); renderGrid(); } } }, 4826 - ]; 4827 - } else if (rowHeader) { 4828 - // Row header right-click 4829 - const row = parseInt(rowHeader.dataset.row); 4830 - const hasAdjacentHiddenRow = isAtHiddenRowBoundary(row, rowCount, buildHiddenRowSet()); 4831 - items = [ 4832 - { label: 'Insert Row Above', action: () => doInsertRow(row) }, 4833 - { label: 'Insert Row Below', action: () => doInsertRow(row + 1) }, 4834 - { label: 'Delete Row', action: () => doDeleteRow(row) }, 4835 - SEPARATOR, 4836 - { label: 'Hide Row', shortcut: '\u2318+9', action: () => { selectedCell = { col: 1, row }; selectionRange = { startCol: 1, startRow: row, endCol: colCount, endRow: row }; hideSelectedRows(); } }, 4837 - ...(hasAdjacentHiddenRow ? [{ label: 'Unhide Rows', shortcut: '\u2318\u21e7+9', action: () => unhideAdjacentRows(row) }] : []), 4838 - SEPARATOR, 4839 - ...(getFreezeRows() !== row ? [{ label: 'Freeze at row ' + row, action: () => { setFreezeRows(row); renderGrid(); } }] : []), 4840 - ...(getFreezeRows() > 0 ? [{ label: 'Unfreeze Rows', action: () => { setFreezeRows(0); renderGrid(); } }] : []), 4841 - { label: 'Resize Row\u2026', action: () => { const h = prompt('Row height (px):', String(getRowHeight(row))); if (h && !isNaN(Number(h))) { setRowHeight(row, Number(h)); renderGrid(); } } }, 4842 - ]; 4843 - } else if (td) { 4844 - // Cell right-click 4845 - const col = parseInt(td.dataset.col); 4846 - const row = parseInt(td.dataset.row); 4847 - const cid = td.dataset.id; 4848 - const notes = getNotesObject(); 4849 - const noteExists = hasNote(notes, cid); 4850 - items = [ 4851 - { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => { copySelection(); deleteSelectedCells(); } }, 4852 - { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => copySelection() }, 4853 - { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => { navigator.clipboard.readText().then(text => pasteAtSelection(text)).catch(() => {}); } }, 4854 - { label: 'Paste Special...', shortcut: '\u2318\u21e7V', action: () => showPasteSpecialDialog() }, 4855 - SEPARATOR, 4856 - // Copy range as formula reference (only useful with multi-cell selection) 4857 - ...(selectionRange && (() => { 4858 - const nr = normalizeRange(selectionRange); 4859 - const isMulti = nr.startCol !== nr.endCol || nr.startRow !== nr.endRow; 4860 - if (!isMulti) return []; 4861 - const rangeRef = cellId(nr.startCol, nr.startRow) + ':' + cellId(nr.endCol, nr.endRow); 4862 - return [ 4863 - { label: 'Copy as reference', icon: 'f\u2099', action: () => { navigator.clipboard.writeText(rangeRef); showToast('Copied ' + rangeRef); } }, 4864 - { label: 'Copy as =SUM()', action: () => { navigator.clipboard.writeText('=SUM(' + rangeRef + ')'); showToast('Copied =SUM(' + rangeRef + ')'); } }, 4865 - { label: 'Copy as =AVERAGE()', action: () => { navigator.clipboard.writeText('=AVERAGE(' + rangeRef + ')'); showToast('Copied =AVERAGE(' + rangeRef + ')'); } }, 4866 - { label: 'Copy as =COUNT()', action: () => { navigator.clipboard.writeText('=COUNT(' + rangeRef + ')'); showToast('Copied =COUNT(' + rangeRef + ')'); } }, 4867 - SEPARATOR, 4868 - ]; 4869 - })() || []), 4870 - { label: 'Insert Row Above', action: () => doInsertRow(row) }, 4871 - { label: 'Insert Row Below', action: () => doInsertRow(row + 1) }, 4872 - { label: 'Insert Column Left', action: () => doInsertColumn(col) }, 4873 - { label: 'Insert Column Right', action: () => doInsertColumn(col + 1) }, 4874 - SEPARATOR, 4875 - { label: 'Delete Row', action: () => doDeleteRow(row) }, 4876 - { label: 'Delete Column', action: () => doDeleteColumn(col) }, 4877 - SEPARATOR, 4878 - { label: 'Clear Cells', action: () => deleteSelectedCells() }, 4879 - SEPARATOR, 4880 - { label: 'Hide Column ' + colToLetter(col), action: () => { selectedCell = { col, row }; selectionRange = { startCol: col, startRow: 1, endCol: col, endRow: rowCount }; hideSelectedCols(); } }, 4881 - { label: 'Hide Row ' + row, action: () => { selectedCell = { col: 1, row }; selectionRange = { startCol: 1, startRow: row, endCol: colCount, endRow: row }; hideSelectedRows(); } }, 4882 - ...(getFreezeCols() !== col ? [{ label: 'Freeze up to column ' + colToLetter(col), action: () => { setFreezeCols(col); renderGrid(); } }] : []), 4883 - ...(getFreezeCols() > 0 ? [{ label: 'Unfreeze Columns', action: () => { setFreezeCols(0); renderGrid(); } }] : []), 4884 - ...(getFreezeRows() !== row ? [{ label: 'Freeze at row ' + row, action: () => { setFreezeRows(row); renderGrid(); } }] : []), 4885 - ...(getFreezeRows() > 0 ? [{ label: 'Unfreeze Rows', action: () => { setFreezeRows(0); renderGrid(); } }] : []), 4886 - SEPARATOR, 4887 - { label: noteExists ? 'Edit Note' : 'Add Note', icon: '\uD83D\uDCDD', action: () => showNoteDialog(cid) }, 4888 - ...(noteExists ? [{ label: 'Delete Note', action: () => { setNoteInYjs(cid, null); renderNoteIndicators(); } }] : []), 4889 - ]; 4890 - } else { 4891 - return; 4892 - } 4893 - 4894 - const ctxMenu = createContextMenu(items); 4895 - document.body.appendChild(ctxMenu.el); 4896 - ctxMenu.show(e.clientX, e.clientY); 4897 - _activeContextMenu = ctxMenu; 4898 - 4899 - // Close on click outside 4900 - const closeHandler = (ev) => { 4901 - if (!ctxMenu.el.contains(ev.target)) { 4902 - hideActiveContextMenu(); 4903 - document.removeEventListener('mousedown', closeHandler); 4904 - } 4905 - }; 4906 - setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 4907 - }); 3743 + _wireContextMenu(_contextMenuDeps()); 4908 3744 4909 3745 // Observe notes changes from collaborators 4910 3746 getNotesMap().observe(() => renderNoteIndicators()); ··· 4912 3748 // No scroll handler — all rows are rendered upfront and the browser 4913 3749 // handles scrolling natively. Zero JS during scroll = zero jank. 4914 3750 4915 - // ======================================================== 4916 - // Find & Replace Bar (Sheets) 4917 - // ======================================================== 4918 - 4919 - function createFindReplaceBar() { 4920 - const bar = document.createElement('div'); 4921 - bar.className = 'sheets-find-bar find-bar'; 4922 - bar.style.display = 'none'; 4923 - bar.innerHTML = '<div class="find-bar-row">' 4924 - + '<input class="find-bar-input" id="sheets-find-input" placeholder="Find in sheet" aria-label="Find in sheet">' 4925 - + '<span class="find-bar-count" id="sheets-find-count"></span>' 4926 - + '<button class="tb-btn find-bar-btn" id="sheets-find-prev" title="Previous (Shift+Enter)" aria-label="Previous match">\u25B2</button>' 4927 - + '<button class="tb-btn find-bar-btn" id="sheets-find-next" title="Next (Enter)" aria-label="Next match">\u25BC</button>' 4928 - + '<label class="find-bar-btn" title="Case sensitive" style="display:flex;align-items:center;gap:2px;cursor:pointer;font-size:0.75rem">' 4929 - + '<input type="checkbox" id="sheets-find-case"> Aa</label>' 4930 - + '<button class="tb-btn find-bar-btn" id="sheets-find-replace-toggle" title="Show replace" aria-label="Toggle replace">\u2026</button>' 4931 - + '<button class="tb-btn find-bar-btn" id="sheets-find-close" title="Close (Escape)" aria-label="Close find bar">\u2715</button>' 4932 - + '</div>' 4933 - + '<div class="find-bar-row find-bar-replace" id="sheets-replace-row" style="display:none">' 4934 - + '<input class="find-bar-input" id="sheets-replace-input" placeholder="Replace with" aria-label="Replace with">' 4935 - + '<button class="tb-btn find-bar-btn" id="sheets-replace-one" title="Replace">Replace</button>' 4936 - + '<button class="tb-btn find-bar-btn" id="sheets-replace-all" title="Replace all">All</button>' 4937 - + '</div>'; 4938 - return bar; 4939 - } 4940 - 3751 + // ── Find & Replace Bar (extracted to find-replace-bar.ts) ── 4941 3752 const findBar = createFindReplaceBar(); 4942 3753 sheetContainer.parentNode.insertBefore(findBar, sheetContainer); 4943 3754 4944 - function showFindReplaceBar(showReplace) { 4945 - findBar.style.display = ''; 4946 - findReplaceBarVisible = true; 4947 - const replaceRow = findBar.querySelector('#sheets-replace-row'); 4948 - if (showReplace) replaceRow.style.display = ''; 4949 - const input = findBar.querySelector('#sheets-find-input'); 4950 - input.focus(); 4951 - input.select(); 4952 - } 4953 - 4954 - function hideFindReplaceBar() { 4955 - findBar.style.display = 'none'; 4956 - findReplaceBarVisible = false; 4957 - sheetsFindState = createFindState(); 4958 - renderGrid(); // clear highlights 4959 - } 4960 - 4961 - function runSheetsFind() { 4962 - const input = findBar.querySelector('#sheets-find-input'); 4963 - const caseCb = findBar.querySelector('#sheets-find-case'); 4964 - const query = input.value; 4965 - const caseSensitive = caseCb.checked; 4966 - 4967 - const sheet = getActiveSheet(); 4968 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 4969 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 4970 - 4971 - sheetsFindState.query = query; 4972 - sheetsFindState.caseSensitive = caseSensitive; 4973 - sheetsFindState.matches = findInCells( 4974 - (id) => { const d = getCellData(id); return computeDisplayValue(id, d); }, 4975 - rowCount, 4976 - colCount, 4977 - cellId, 4978 - query, 4979 - caseSensitive, 4980 - ); 4981 - sheetsFindState.currentIndex = sheetsFindState.matches.length > 0 ? 0 : -1; 4982 - 4983 - updateFindBarCount(); 4984 - navigateToCurrentMatch(); 4985 - renderGrid(); 4986 - } 4987 - 4988 - function updateFindBarCount() { 4989 - const countEl = findBar.querySelector('#sheets-find-count'); 4990 - const info = getMatchInfo(sheetsFindState); 4991 - countEl.textContent = info.total > 0 ? info.current + ' of ' + info.total : 'No results'; 4992 - } 4993 - 4994 - function navigateToCurrentMatch() { 4995 - if (sheetsFindState.currentIndex < 0) return; 4996 - const match = sheetsFindState.matches[sheetsFindState.currentIndex]; 4997 - if (!match) return; 4998 - selectedCell = { col: match.col, row: match.row }; 4999 - selectionRange = { startCol: match.col, startRow: match.row, endCol: match.col, endRow: match.row }; 5000 - scrollCellIntoView(match.col, match.row); 3755 + function _findReplaceDeps() { 3756 + return { 3757 + getActiveSheet, getCellData, setCellData, computeDisplayValue, 3758 + evalCache, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 3759 + renderGrid, scrollCellIntoView, 3760 + getSelectedCell: () => selectedCell, 3761 + setSelectedCell: (c) => { selectedCell = c; }, 3762 + setSelectionRange: (r) => { selectionRange = r; }, 3763 + getFindReplaceBarVisible: () => findReplaceBarVisible, 3764 + setFindReplaceBarVisible: (v) => { findReplaceBarVisible = v; }, 3765 + ydoc, sheetContainer, DEFAULT_ROWS, DEFAULT_COLS, 3766 + }; 5001 3767 } 5002 - 5003 - // Wire find bar events 5004 - findBar.querySelector('#sheets-find-input').addEventListener('input', () => runSheetsFind()); 5005 - findBar.querySelector('#sheets-find-case').addEventListener('change', () => runSheetsFind()); 5006 - 5007 - findBar.querySelector('#sheets-find-next').addEventListener('click', () => { 5008 - nextMatch(sheetsFindState); 5009 - updateFindBarCount(); 5010 - navigateToCurrentMatch(); 5011 - renderGrid(); 5012 - }); 5013 - 5014 - findBar.querySelector('#sheets-find-prev').addEventListener('click', () => { 5015 - prevMatch(sheetsFindState); 5016 - updateFindBarCount(); 5017 - navigateToCurrentMatch(); 5018 - renderGrid(); 5019 - }); 5020 - 5021 - findBar.querySelector('#sheets-find-close').addEventListener('click', () => hideFindReplaceBar()); 5022 - 5023 - findBar.querySelector('#sheets-find-replace-toggle').addEventListener('click', () => { 5024 - const replaceRow = findBar.querySelector('#sheets-replace-row'); 5025 - replaceRow.style.display = replaceRow.style.display === 'none' ? '' : 'none'; 5026 - }); 5027 - 5028 - findBar.querySelector('#sheets-replace-one').addEventListener('click', () => { 5029 - const replaceInput = findBar.querySelector('#sheets-replace-input'); 5030 - const result = replaceCurrentMatch(sheetsFindState, replaceInput.value); 5031 - if (result) { 5032 - const numVal = Number(result.newValue); 5033 - const value = result.newValue === '' ? '' : (!isNaN(numVal) && result.newValue !== '' ? numVal : result.newValue); 5034 - setCellData(result.cellId, { v: value, f: '' }); 5035 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 5036 - runSheetsFind(); // re-search after replace 5037 - } 5038 - }); 5039 - 5040 - findBar.querySelector('#sheets-replace-all').addEventListener('click', () => { 5041 - const replaceInput = findBar.querySelector('#sheets-replace-input'); 5042 - const results = replaceAllMatches(sheetsFindState, replaceInput.value); 5043 - if (results.length > 0) { 5044 - ydoc.transact(() => { 5045 - for (const r of results) { 5046 - const numVal = Number(r.newValue); 5047 - const value = r.newValue === '' ? '' : (!isNaN(numVal) && r.newValue !== '' ? numVal : r.newValue); 5048 - setCellData(r.cellId, { v: value, f: '' }); 5049 - } 5050 - }); 5051 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 5052 - showToast('Replaced ' + results.length + ' match' + (results.length > 1 ? 'es' : '')); 5053 - runSheetsFind(); 5054 - } 5055 - }); 5056 - 5057 - // Keyboard in find bar 5058 - findBar.addEventListener('keydown', (e) => { 5059 - if (e.key === 'Escape') { e.preventDefault(); hideFindReplaceBar(); } 5060 - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); nextMatch(sheetsFindState); updateFindBarCount(); navigateToCurrentMatch(); renderGrid(); } 5061 - if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); prevMatch(sheetsFindState); updateFindBarCount(); navigateToCurrentMatch(); renderGrid(); } 5062 - // Prevent propagation so grid shortcuts don't fire 5063 - e.stopPropagation(); 5064 - }); 3768 + function showFindReplaceBar(showReplace) { _showFindReplaceBarUI(_findReplaceDeps(), findBar, showReplace); } 3769 + function hideFindReplaceBar() { _hideFindReplaceBarUI(_findReplaceDeps(), findBar); } 3770 + _wireFindReplaceBar(_findReplaceDeps(), findBar); 5065 3771 5066 3772 // ======================================================== 5067 3773 // Row Resize (drag on row header border)
+108
src/sheets/status-bar-ui.ts
··· 1 + /** 2 + * Status Bar UI — cell reference, freeze indicator, selection stats. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId, colToLetter } from './formulas.js'; 8 + import { normalizeRange } from './selection-utils.js'; 9 + import { computeSelectionStats, formatStatValue } from './status-bar.js'; 10 + import { showToast } from './import-export.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface StatusBarDeps { 15 + getSelectedCell: () => { col: number; row: number }; 16 + getSelectionRange: () => any; 17 + getCellData: (id: string) => any; 18 + computeDisplayValue: (id: string, data: any) => any; 19 + getFreezeRows: () => number; 20 + getFreezeCols: () => number; 21 + setFreezeRows: (n: number) => void; 22 + setFreezeCols: (n: number) => void; 23 + renderGrid: () => void; 24 + } 25 + 26 + // ── Update Status Bar ─────────────────────────────────────── 27 + 28 + export function updateStatusBar(deps: StatusBarDeps): void { 29 + const statusBarStats = document.getElementById('status-bar-stats'); 30 + const statusBarInfo = document.getElementById('status-bar-info'); 31 + if (!statusBarStats) return; 32 + 33 + const selectedCell = deps.getSelectedCell(); 34 + const selectionRange = deps.getSelectionRange(); 35 + 36 + // Left side: cell reference + freeze indicator 37 + let infoHtml = ''; 38 + if (selectionRange) { 39 + const norm = normalizeRange(selectionRange); 40 + const isMulti = norm.startCol !== norm.endCol || norm.startRow !== norm.endRow; 41 + if (isMulti) { 42 + infoHtml += '<span class="status-bar-cell-ref">' + colToLetter(norm.startCol) + norm.startRow + ':' + colToLetter(norm.endCol) + norm.endRow + '</span>'; 43 + const rows = norm.endRow - norm.startRow + 1; 44 + const cols = norm.endCol - norm.startCol + 1; 45 + infoHtml += '<span class="status-bar-dim">' + rows + 'R \u00d7 ' + cols + 'C</span>'; 46 + } else { 47 + infoHtml += '<span class="status-bar-cell-ref">' + colToLetter(selectedCell.col) + selectedCell.row + '</span>'; 48 + } 49 + } 50 + const fr = deps.getFreezeRows(); 51 + const fc = deps.getFreezeCols(); 52 + if (fr > 0 || fc > 0) { 53 + const parts: string[] = []; 54 + if (fr > 0) parts.push(fr + ' row' + (fr > 1 ? 's' : '')); 55 + if (fc > 0) parts.push(fc + ' col' + (fc > 1 ? 's' : '')); 56 + infoHtml += '<span class="status-bar-freeze" title="Click to unfreeze">Frozen: ' + parts.join(', ') + '</span>'; 57 + } 58 + if (statusBarInfo) statusBarInfo.innerHTML = infoHtml; 59 + 60 + // Right side: selection stats (only for multi-cell) 61 + if (!selectionRange) { statusBarStats.innerHTML = ''; return; } 62 + const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 63 + const isMultiCell = startCol !== endCol || startRow !== endRow; 64 + if (!isMultiCell) { statusBarStats.innerHTML = ''; return; } 65 + 66 + const values: any[] = []; 67 + for (let r = startRow; r <= endRow; r++) { 68 + for (let c = startCol; c <= endCol; c++) { 69 + const id = cellId(c, r); 70 + const cellData = deps.getCellData(id); 71 + const display = deps.computeDisplayValue(id, cellData); 72 + values.push(display); 73 + } 74 + } 75 + 76 + const stats = computeSelectionStats(values); 77 + if (!stats) { statusBarStats.innerHTML = ''; return; } 78 + 79 + let html = ''; 80 + if (stats.count > 0) { 81 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Sum</span><span class="status-bar-stat-value">' + formatStatValue(stats.sum) + '</span></span>'; 82 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Avg</span><span class="status-bar-stat-value">' + formatStatValue(stats.average) + '</span></span>'; 83 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Count</span><span class="status-bar-stat-value">' + stats.count + '</span></span>'; 84 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Min</span><span class="status-bar-stat-value">' + formatStatValue(stats.min) + '</span></span>'; 85 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Max</span><span class="status-bar-stat-value">' + formatStatValue(stats.max) + '</span></span>'; 86 + } else { 87 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Count</span><span class="status-bar-stat-value">0</span></span>'; 88 + } 89 + statusBarStats.innerHTML = html; 90 + } 91 + 92 + // ── Wire Freeze Click ─────────────────────────────────────── 93 + 94 + export function wireStatusBarFreezeClick(deps: StatusBarDeps): void { 95 + const statusBarInfo = document.getElementById('status-bar-info'); 96 + if (!statusBarInfo) return; 97 + 98 + statusBarInfo.addEventListener('click', (e) => { 99 + const freezeEl = (e.target as HTMLElement).closest('.status-bar-freeze'); 100 + if (freezeEl) { 101 + deps.setFreezeRows(0); 102 + deps.setFreezeCols(0); 103 + deps.renderGrid(); 104 + updateStatusBar(deps); 105 + showToast('Panes unfrozen'); 106 + } 107 + }); 108 + }