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

Configure Feed

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

Merge pull request 'refactor(sheets): phase 5 — extract toolbar wiring, borders, cell types' (#284) from refactor/sheets-decompose-phase5 into main

scott 6628d517 ee4b8271

+659 -563
+48 -563
src/sheets/main.ts
··· 15 15 import { evaluate, extractRefs, formatCell, parseRef, colToLetter, letterToCol, cellId } from './formulas.js'; 16 16 import { RecalcEngine } from './recalc.js'; 17 17 // xlsx-import, xlsx-export, charts, filter — now used via extracted UI modules 18 - import { multiColumnSort } from './sort.js'; 18 + // sort — now used via toolbar-wiring.ts 19 19 import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 20 20 import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 21 21 import { renderInteractiveCell, handleRichCellClick } from './rich-cells.js'; 22 22 import { parseDateValue, showDatePicker } from './date-picker.js'; 23 23 import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 24 - import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js'; 24 + import { buildBorderStyle, getWrapStyle, getStripedRowClass } from './cell-styles.js'; 25 25 import { normalizeRange, isInRange } from './selection-utils.js'; 26 26 import { hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle } from './cell-style-utils.js'; 27 27 import { buildMergeMap, findCellMerge } from './merge-utils.js'; ··· 39 39 // formula-highlighter, range-highlight, formula-tooltip — now used via formula-bar-ui.ts 40 40 import { clearGridHighlights } from './range-highlight.js'; 41 41 import { hideTooltip } from './formula-tooltip.js'; 42 - import { extractFormat, applyFormat } from './format-painter.js'; 43 - import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 42 + // format-painter, row-col-ops — now used via toolbar-wiring.ts 44 43 // context-menu — now used via context-menu-handler.ts 45 44 // Sheet tab management functions used via sheet-tabs-ui.ts (no longer directly imported here) 46 45 import { buildCopyHtml, buildCopyTsv } from './clipboard-copy.js'; ··· 77 76 import { hideAutocomplete as _hideAutocomplete, attachCellEditorAutocomplete as _attachCellEditorAutocomplete, wireAutocomplete as _wireAutocomplete } from './formula-autocomplete-ui.js'; 78 77 import { showShortcutModal, wireShortcutButton } from './shortcuts-modal.js'; 79 78 import { wireSaveStatus } from './save-status-ui.js'; 79 + import { 80 + applyStyleToSelection as _applyStyleToSelection, clearFormattingSelection as _clearFormattingSelection, 81 + closeAllDropdowns as _closeAllDropdowns, sortColumn as _sortColumn, 82 + doInsertRow as _doInsertRow, doDeleteRow as _doDeleteRow, 83 + doInsertColumn as _doInsertColumn, doDeleteColumn as _doDeleteColumn, 84 + updateUndoRedoState as _updateUndoRedoState, updateFreezeToolbarState as _updateFreezeToolbarState, 85 + updateWrapButtonState as _updateWrapButtonState, updateStripedButtonState as _updateStripedButtonState, 86 + updateBoldButtonState as _updateBoldButtonState, updateItalicButtonState as _updateItalicButtonState, 87 + updateUnderlineButtonState as _updateUnderlineButtonState, updateStrikethroughButtonState as _updateStrikethroughButtonState, 88 + updateFontSizeSelect as _updateFontSizeSelect, updateFontFamilySelect as _updateFontFamilySelect, 89 + updateVerticalAlignButton as _updateVerticalAlignButton, 90 + getFormatPainterFormat as _getFormatPainterFormat, applyFormatPainterToCell as _applyFormatPainterToCell, 91 + wireToolbar as _wireToolbar, 92 + } from './toolbar-wiring.js'; 80 93 81 94 // --- Constants --- 82 95 const DEFAULT_ROWS = 100; ··· 346 359 let selectionRange = null; 347 360 let editingCell = null; 348 361 let isSelecting = false; 349 - let formatPainterFormat = null; 350 - let formatPainterSticky = false; 351 362 let isFillDragging = false; 352 363 let fillPreviewRange = null; 353 364 ··· 1229 1240 if (editingCell) commitEdit(); 1230 1241 1231 1242 // Format painter: apply format to clicked cell 1232 - if (formatPainterFormat) { 1233 - const targetId = cellId(col, row); 1234 - const existing = getCellData(targetId); 1235 - const newStyle = applyFormat(existing?.s, formatPainterFormat); 1236 - setCellData(targetId, { s: newStyle }); 1237 - refreshVisibleCells(); 1238 - if (!formatPainterSticky) { 1239 - deactivateFormatPainter(); 1240 - } 1243 + if (_getFormatPainterFormat()) { 1244 + _applyFormatPainterToCell(_toolbarDeps(), col, row); 1241 1245 return; 1242 1246 } 1243 1247 ··· 2025 2029 function commitFormulaBar() { _commitFormulaBar(_formulaBarDeps()); } 2026 2030 _wireFormulaBarKeys(_formulaBarDeps()); 2027 2031 2028 - // --- Toolbar --- 2029 - function applyStyleToSelection(styleProp, value) { 2030 - if (!selectionRange) return; 2031 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 2032 - ydoc.transact(() => { 2033 - for (let r = startRow; r <= endRow; r++) { 2034 - for (let c = startCol; c <= endCol; c++) { 2035 - const id = cellId(c, r); 2036 - const existing = getCellData(id); 2037 - const s = existing?.s || {}; 2038 - s[styleProp] = value; 2039 - if (!existing) { setCellData(id, { v: '', f: '', s }); } 2040 - else { setCellData(id, { s }); } 2041 - } 2042 - } 2043 - }); 2044 - refreshVisibleCells(); 2045 - } 2046 - 2047 - function clearFormattingSelection() { 2048 - if (!selectionRange) return; 2049 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 2050 - const cells = getCells(); 2051 - ydoc.transact(() => { 2052 - for (let r = startRow; r <= endRow; r++) { 2053 - for (let c = startCol; c <= endCol; c++) { 2054 - const id = cellId(c, r); 2055 - if (!cells.has(id)) continue; 2056 - const cell = cells.get(id); 2057 - if (cell instanceof Y.Map && cell.has('s')) { 2058 - cell.delete('s'); 2059 - } 2060 - } 2061 - } 2062 - }); 2063 - refreshVisibleCells(); 2064 - } 2065 - 2066 - // --- Dropdown/overflow menu utilities --- 2067 - function closeAllDropdowns() { 2068 - document.querySelectorAll('.toolbar-dropdown.open, .toolbar-overflow.open').forEach(el => { 2069 - el.classList.remove('open'); 2070 - }); 2071 - } 2072 - 2073 - function toggleDropdown(dropdownEl) { 2074 - const wasOpen = dropdownEl.classList.contains('open'); 2075 - closeAllDropdowns(); 2076 - if (!wasOpen) dropdownEl.classList.add('open'); 2077 - } 2078 - 2079 - // Close dropdowns when clicking outside 2080 - document.addEventListener('click', (e) => { 2081 - if (!e.target.closest('.toolbar-dropdown') && !e.target.closest('.toolbar-overflow')) { 2082 - closeAllDropdowns(); 2083 - } 2084 - }); 2085 - 2086 - // Close dropdowns on Escape 2087 - document.addEventListener('keydown', (e) => { 2088 - if (e.key === 'Escape') closeAllDropdowns(); 2089 - }); 2090 - 2091 - // --- Overflow "More" menu --- 2092 - const overflowMenu = document.getElementById('overflow-menu'); 2093 - const overflowToggle = document.getElementById('overflow-toggle'); 2094 - if (overflowToggle) { 2095 - overflowToggle.addEventListener('click', (e) => { 2096 - e.stopPropagation(); 2097 - toggleDropdown(overflowMenu); 2098 - }); 2099 - } 2100 - 2101 - // Undo/Redo toolbar buttons 2102 - function updateUndoRedoState() { 2103 - const undoBtn = document.getElementById('tb-undo'); 2104 - const redoBtn = document.getElementById('tb-redo'); 2105 - if (undoBtn) { 2106 - const canUndo = undoManager && undoManager.undoStack.length > 0; 2107 - undoBtn.classList.toggle('btn-disabled', !canUndo); 2108 - undoBtn.setAttribute('aria-disabled', String(!canUndo)); 2109 - undoBtn.dataset.tooltip = canUndo ? 'Undo (' + undoManager.undoStack.length + ')' : 'Nothing to undo'; 2110 - } 2111 - if (redoBtn) { 2112 - const canRedo = undoManager && undoManager.redoStack.length > 0; 2113 - redoBtn.classList.toggle('btn-disabled', !canRedo); 2114 - redoBtn.setAttribute('aria-disabled', String(!canRedo)); 2115 - redoBtn.dataset.tooltip = canRedo ? 'Redo (' + undoManager.redoStack.length + ')' : 'Nothing to redo'; 2116 - } 2117 - } 2118 - document.getElementById('tb-undo').addEventListener('click', () => { 2119 - if (undoManager) { undoManager.undo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2120 - }); 2121 - document.getElementById('tb-redo').addEventListener('click', () => { 2122 - if (undoManager) { undoManager.redo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2123 - }); 2124 - // Update undo/redo state whenever stacks change 2125 - if (undoManager) { 2126 - undoManager.on('stack-item-added', updateUndoRedoState); 2127 - undoManager.on('stack-item-popped', updateUndoRedoState); 2128 - } 2129 - updateUndoRedoState(); 2130 - 2131 - document.getElementById('tb-bold').addEventListener('click', () => { 2132 - const id = cellId(selectedCell.col, selectedCell.row); 2133 - const current = getCellData(id)?.s?.bold; 2134 - applyStyleToSelection('bold', !current); 2135 - }); 2136 - 2137 - document.getElementById('tb-italic').addEventListener('click', () => { 2138 - const id = cellId(selectedCell.col, selectedCell.row); 2139 - const current = getCellData(id)?.s?.italic; 2140 - applyStyleToSelection('italic', !current); 2141 - }); 2142 - 2143 - // Underline button 2144 - document.getElementById('tb-underline').addEventListener('click', () => { 2145 - const id = cellId(selectedCell.col, selectedCell.row); 2146 - const current = getCellData(id)?.s?.underline; 2147 - applyStyleToSelection('underline', !current); 2148 - updateUnderlineButtonState(); 2149 - }); 2150 - 2151 - // Strikethrough button 2152 - document.getElementById('tb-strikethrough').addEventListener('click', () => { 2153 - const id = cellId(selectedCell.col, selectedCell.row); 2154 - const current = getCellData(id)?.s?.strikethrough; 2155 - applyStyleToSelection('strikethrough', !current); 2156 - updateStrikethroughButtonState(); 2157 - }); 2158 - 2159 - // Font size select 2160 - document.getElementById('tb-font-size').addEventListener('change', (e) => { 2161 - const size = parseInt(e.target.value); 2162 - if (size) applyStyleToSelection('fontSize', size); 2163 - }); 2164 - 2165 - // Font family select 2166 - document.getElementById('tb-font-family').addEventListener('change', (e) => { 2167 - applyStyleToSelection('fontFamily', e.target.value || undefined); 2168 - }); 2169 - 2170 - // --- Vertical align dropdown --- 2171 - const vAlignDropdown = document.getElementById('dd-valign'); 2172 - const vAlignToggle = document.getElementById('tb-valign-toggle'); 2173 - const VALIGN_SVGS = { 2174 - top: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg>', 2175 - middle: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8" stroke-width="2"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M5.5 10.5L8 13l2.5-2.5"/></svg>', 2176 - bottom: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="14" x2="14" y2="14" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="12"/><path d="M5.5 9.5L8 12l2.5-2.5"/></svg>', 2177 - }; 2178 - 2179 - vAlignToggle.addEventListener('click', (e) => { 2180 - e.stopPropagation(); 2181 - toggleDropdown(vAlignDropdown); 2182 - }); 2183 - 2184 - vAlignDropdown.querySelectorAll('[data-valign]').forEach(btn => { 2185 - btn.addEventListener('click', (e) => { 2186 - e.stopPropagation(); 2187 - const va = btn.dataset.valign; 2188 - applyStyleToSelection('verticalAlign', va); 2189 - vAlignToggle.querySelector('.dd-icon').innerHTML = VALIGN_SVGS[va] || VALIGN_SVGS.top; 2190 - closeAllDropdowns(); 2191 - }); 2192 - }); 2193 - 2194 - // --- Format painter --- 2195 - let formatPainterClickTimeout = null; 2196 - const formatPainterBtn = document.getElementById('tb-format-painter'); 2197 - 2198 - formatPainterBtn.addEventListener('click', () => { 2199 - // Double-click detection for sticky mode 2200 - if (formatPainterClickTimeout) { 2201 - clearTimeout(formatPainterClickTimeout); 2202 - formatPainterClickTimeout = null; 2203 - // Double click: enable sticky mode 2204 - formatPainterSticky = true; 2205 - activateFormatPainter(); 2206 - return; 2207 - } 2208 - formatPainterClickTimeout = setTimeout(() => { 2209 - formatPainterClickTimeout = null; 2210 - if (formatPainterFormat) { 2211 - // Already active, deactivate 2212 - deactivateFormatPainter(); 2213 - } else { 2214 - // Single click: one-shot mode 2215 - formatPainterSticky = false; 2216 - activateFormatPainter(); 2217 - } 2218 - }, 250); 2219 - }); 2220 - 2221 - function activateFormatPainter() { 2222 - const id = cellId(selectedCell.col, selectedCell.row); 2223 - const cellData = getCellData(id); 2224 - formatPainterFormat = extractFormat(cellData); 2225 - formatPainterBtn.classList.add('active'); 2226 - document.body.classList.add('format-painter-active'); 2032 + // --- Toolbar (extracted to toolbar-wiring.ts) --- 2033 + function _toolbarDeps() { 2034 + return { 2035 + ydoc, grid, getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 2036 + getCellData, setCellData, getCells, getActiveSheet, computeDisplayValue, 2037 + getColWidth, setColWidth, getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 2038 + getStripedRows, setStripedRows, evalCache: { clear: () => evalCache.clear() }, 2039 + clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, renderGrid, 2040 + undoManager, DEFAULT_ROWS, DEFAULT_COLS, 2041 + }; 2227 2042 } 2228 - 2229 - function deactivateFormatPainter() { 2230 - formatPainterFormat = null; 2231 - formatPainterSticky = false; 2232 - formatPainterBtn.classList.remove('active'); 2233 - document.body.classList.remove('format-painter-active'); 2234 - } 2235 - 2236 - // Escape key cancels format painter 2237 - document.addEventListener('keydown', (e) => { 2238 - if (e.key === 'Escape' && formatPainterFormat) { 2239 - deactivateFormatPainter(); 2240 - } 2241 - }); 2242 - 2243 - // --- Inline alignment buttons (Google Sheets style) --- 2244 - document.getElementById('tb-align-left').addEventListener('click', () => applyStyleToSelection('align', 'left')); 2245 - document.getElementById('tb-align-center').addEventListener('click', () => applyStyleToSelection('align', 'center')); 2246 - document.getElementById('tb-align-right').addEventListener('click', () => applyStyleToSelection('align', 'right')); 2247 - 2248 - const sheetsTextColorInput = document.getElementById('tb-text-color'); 2249 - const sheetsTextColorSwatch = document.getElementById('tb-text-color-swatch'); 2250 - const sheetsBgColorInput = document.getElementById('tb-bg-color'); 2251 - const sheetsBgColorSwatch = document.getElementById('tb-bg-color-swatch'); 2252 - 2253 - // Default text/bg colors per theme (must match CSS --color-text / --color-bg) 2254 - const THEME_DEFAULTS = { 2255 - light: { text: '#1a1815', bg: '#f5f4f1' }, 2256 - dark: { text: '#ddd8ce', bg: '#2a2825' }, 2257 - }; 2258 - 2259 - function getEffectiveTheme(): string { 2260 - const saved = localStorage.getItem('tools-theme'); 2261 - if (saved === 'dark' || saved === 'light') return saved; 2262 - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 2263 - } 2264 - 2265 - function updateColorPickerDefaults() { 2266 - const theme = getEffectiveTheme(); 2267 - const defaults = THEME_DEFAULTS[theme]; 2268 - // Only update if the user hasn't explicitly picked a custom color 2269 - // (check if the current value matches the OTHER theme's default) 2270 - const otherTheme = theme === 'dark' ? 'light' : 'dark'; 2271 - const otherDefaults = THEME_DEFAULTS[otherTheme]; 2272 - if (sheetsTextColorInput.value === otherDefaults.text) { 2273 - (sheetsTextColorInput as HTMLInputElement).value = defaults.text; 2274 - } 2275 - if (sheetsBgColorInput.value === otherDefaults.bg || sheetsBgColorInput.value === '#ffffff') { 2276 - (sheetsBgColorInput as HTMLInputElement).value = defaults.bg; 2277 - } 2278 - updateSheetsColorSwatches(); 2279 - } 2280 - 2281 - function updateSheetsColorSwatches() { 2282 - if (sheetsTextColorSwatch) sheetsTextColorSwatch.style.background = sheetsTextColorInput.value; 2283 - if (sheetsBgColorSwatch) sheetsBgColorSwatch.style.background = sheetsBgColorInput.value; 2284 - } 2285 - 2286 - // Set initial defaults based on current theme 2287 - (function initColorDefaults() { 2288 - const theme = getEffectiveTheme(); 2289 - const defaults = THEME_DEFAULTS[theme]; 2290 - (sheetsTextColorInput as HTMLInputElement).value = defaults.text; 2291 - (sheetsBgColorInput as HTMLInputElement).value = defaults.bg; 2292 - })(); 2293 - updateSheetsColorSwatches(); 2294 - 2295 - // Listen for theme changes (theme toggle dispatches a click that sets data-theme) 2296 - new MutationObserver(() => updateColorPickerDefaults()) 2297 - .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); 2298 - 2299 - sheetsTextColorInput.addEventListener('input', (e) => { applyStyleToSelection('color', e.target.value); updateSheetsColorSwatches(); }); 2300 - sheetsBgColorInput.addEventListener('input', (e) => { applyStyleToSelection('bg', e.target.value); updateSheetsColorSwatches(); }); 2301 - document.getElementById('tb-format').addEventListener('change', (e) => applyStyleToSelection('format', e.target.value)); 2302 - 2303 - // --- Inline format shortcut buttons (Google Sheets style) --- 2304 - document.getElementById('tb-fmt-currency').addEventListener('click', () => { 2305 - applyStyleToSelection('format', 'currency'); 2306 - (document.getElementById('tb-format') as HTMLSelectElement).value = 'currency'; 2307 - }); 2308 - document.getElementById('tb-fmt-percent').addEventListener('click', () => { 2309 - applyStyleToSelection('format', 'percent'); 2310 - (document.getElementById('tb-format') as HTMLSelectElement).value = 'percent'; 2311 - }); 2312 - document.getElementById('tb-dec-decrease').addEventListener('click', () => { 2313 - applyStyleToSelection('format', 'number'); 2314 - (document.getElementById('tb-format') as HTMLSelectElement).value = 'number'; 2315 - }); 2316 - document.getElementById('tb-dec-increase').addEventListener('click', () => { 2317 - applyStyleToSelection('format', 'number'); 2318 - (document.getElementById('tb-format') as HTMLSelectElement).value = 'number'; 2319 - }); 2320 - 2321 - // Sort 2322 - document.getElementById('tb-sort-asc').addEventListener('click', () => sortColumn(true)); 2323 - document.getElementById('tb-sort-desc').addEventListener('click', () => sortColumn(false)); 2324 - 2325 - function sortColumn(ascending) { 2326 - if (!selectionRange) return; 2327 - const col = selectedCell.col; 2328 - const { startRow, endRow } = normalizeRange(selectionRange); 2329 - const sheet = getActiveSheet(); 2330 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 2331 - const rows = []; 2332 - for (let r = startRow; r <= endRow; r++) { 2333 - const row = {}; 2334 - for (let c = 1; c <= colCount; c++) { const data = getCellData(cellId(c, r)); if (data) row[c] = data; } 2335 - row._sortVal = getCellData(cellId(col, r))?.v ?? ''; 2336 - rows.push(row); 2337 - } 2338 - rows.sort((a, b) => { 2339 - const va = a._sortVal, vb = b._sortVal; 2340 - if (typeof va === 'number' && typeof vb === 'number') return ascending ? va - vb : vb - va; 2341 - return ascending ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); 2342 - }); 2343 - ydoc.transact(() => { 2344 - const cells = getCells(); 2345 - rows.forEach((row, idx) => { 2346 - const targetRow = startRow + idx; 2347 - for (let c = 1; c <= colCount; c++) { 2348 - const id = cellId(c, targetRow); 2349 - if (row[c]) { setCellData(id, row[c]); } 2350 - else if (cells.has(id)) { cells.delete(id); } 2351 - } 2352 - }); 2353 - }); 2354 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2355 - refreshVisibleCells(); 2356 - } 2357 - 2358 - // Add/remove rows and columns 2359 - document.getElementById('tb-add-row').addEventListener('click', () => { const sheet = getActiveSheet(); sheet.set('rowCount', (sheet.get('rowCount') || DEFAULT_ROWS) + 1); renderGrid(); closeAllDropdowns(); }); 2360 - document.getElementById('tb-add-col').addEventListener('click', () => { const sheet = getActiveSheet(); sheet.set('colCount', (sheet.get('colCount') || DEFAULT_COLS) + 1); renderGrid(); closeAllDropdowns(); }); 2361 - document.getElementById('tb-del-row').addEventListener('click', () => { 2362 - const sheet = getActiveSheet(); 2363 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 2364 - if (rowCount > 1) { 2365 - const cells = getCells(); const colCount = sheet.get('colCount') || DEFAULT_COLS; 2366 - ydoc.transact(() => { for (let c = 1; c <= colCount; c++) { const id = cellId(c, rowCount); if (cells.has(id)) cells.delete(id); } }); 2367 - sheet.set('rowCount', rowCount - 1); renderGrid(); 2368 - } 2369 - closeAllDropdowns(); 2370 - }); 2371 - document.getElementById('tb-del-col').addEventListener('click', () => { 2372 - const sheet = getActiveSheet(); 2373 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 2374 - if (colCount > 1) { 2375 - const cells = getCells(); const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 2376 - ydoc.transact(() => { for (let r = 1; r <= rowCount; r++) { const id = cellId(colCount, r); if (cells.has(id)) cells.delete(id); } }); 2377 - sheet.set('colCount', colCount - 1); renderGrid(); 2378 - } 2379 - closeAllDropdowns(); 2380 - }); 2381 - 2382 - // --- Row/Column Insert/Delete operations (#113) --- 2383 - function doInsertRow(rowIndex) { 2384 - const sheet = getActiveSheet(); 2385 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 2386 - ydoc.transact(() => { 2387 - rowColInsertRow(getCells, setCellData, rowIndex, colCount); 2388 - }); 2389 - sheet.set('rowCount', (sheet.get('rowCount') || DEFAULT_ROWS) + 1); 2390 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2391 - renderGrid(); 2392 - } 2393 - 2394 - function doDeleteRow(rowIndex) { 2395 - const sheet = getActiveSheet(); 2396 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 2397 - if (rowCount <= 1) return; 2398 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 2399 - ydoc.transact(() => { 2400 - rowColDeleteRow(getCells, setCellData, rowIndex, colCount); 2401 - }); 2402 - sheet.set('rowCount', rowCount - 1); 2403 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2404 - renderGrid(); 2405 - } 2406 - 2407 - function doInsertColumn(colIndex) { 2408 - const sheet = getActiveSheet(); 2409 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 2410 - ydoc.transact(() => { 2411 - rowColInsertColumn(getCells, setCellData, colIndex, rowCount); 2412 - }); 2413 - sheet.set('colCount', (sheet.get('colCount') || DEFAULT_COLS) + 1); 2414 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2415 - renderGrid(); 2416 - } 2417 - 2418 - function doDeleteColumn(colIndex) { 2419 - const sheet = getActiveSheet(); 2420 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 2421 - if (colCount <= 1) return; 2422 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 2423 - ydoc.transact(() => { 2424 - rowColDeleteColumn(getCells, setCellData, colIndex, rowCount); 2425 - }); 2426 - sheet.set('colCount', colCount - 1); 2427 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2428 - renderGrid(); 2429 - } 2430 - 2431 - // --- Frozen panes (Issue #7) --- 2432 - document.getElementById('tb-freeze-rows').addEventListener('click', () => { const rows = selectedCell.row - 1; setFreezeRows(rows > 0 ? rows : 0); renderGrid(); closeAllDropdowns(); showToast(rows > 0 ? `Froze ${rows} row${rows > 1 ? 's' : ''}` : 'Rows unfrozen'); }); 2433 - document.getElementById('tb-freeze-cols').addEventListener('click', () => { const cols = selectedCell.col - 1; setFreezeCols(cols > 0 ? cols : 0); renderGrid(); closeAllDropdowns(); showToast(cols > 0 ? `Froze ${cols} column${cols > 1 ? 's' : ''}` : 'Columns unfrozen'); }); 2434 - document.getElementById('tb-unfreeze').addEventListener('click', () => { setFreezeRows(0); setFreezeCols(0); renderGrid(); closeAllDropdowns(); showToast('Panes unfrozen'); }); 2435 - 2436 - function updateFreezeToolbarState() { 2437 - const fr = getFreezeRows(); 2438 - const fc = getFreezeCols(); 2439 - const frBtn = document.getElementById('tb-freeze-rows'); 2440 - const fcBtn = document.getElementById('tb-freeze-cols'); 2441 - const freezeToggleBtn = document.getElementById('tb-freeze-toggle'); 2442 - if (frBtn) { frBtn.dataset.tooltip = fr > 0 ? 'Frozen: ' + fr + ' rows' : 'Freeze rows above cursor'; frBtn.classList.toggle('active', fr > 0); } 2443 - if (fcBtn) { fcBtn.dataset.tooltip = fc > 0 ? 'Frozen: ' + fc + ' cols' : 'Freeze columns left of cursor'; fcBtn.classList.toggle('active', fc > 0); } 2444 - if (freezeToggleBtn) { freezeToggleBtn.classList.toggle('active', fr > 0 || fc > 0); } 2445 - } 2043 + function applyStyleToSelection(styleProp, value) { _applyStyleToSelection(_toolbarDeps(), styleProp, value); } 2044 + function clearFormattingSelection() { _clearFormattingSelection(_toolbarDeps()); } 2045 + function closeAllDropdowns() { _closeAllDropdowns(); } 2046 + function doInsertRow(rowIndex) { _doInsertRow(_toolbarDeps(), rowIndex); } 2047 + function doDeleteRow(rowIndex) { _doDeleteRow(_toolbarDeps(), rowIndex); } 2048 + function doInsertColumn(colIndex) { _doInsertColumn(_toolbarDeps(), colIndex); } 2049 + function doDeleteColumn(colIndex) { _doDeleteColumn(_toolbarDeps(), colIndex); } 2050 + function updateFreezeToolbarState() { _updateFreezeToolbarState(_toolbarDeps()); } 2051 + _wireToolbar(_toolbarDeps()); 2446 2052 2447 2053 // --- Sheet tabs (extracted to sheet-tabs-ui.ts) --- 2448 2054 function _sheetTabsDeps() { ··· 2859 2465 }); 2860 2466 getCharts().observeDeep(() => { renderCharts(); }); 2861 2467 2862 - // --- Cell Borders dropdown --- 2863 - const bordersDropdown = document.getElementById('dd-borders'); 2864 - const bordersToggle = document.getElementById('tb-borders-toggle'); 2865 - 2866 - bordersToggle.addEventListener('click', (e) => { 2867 - e.stopPropagation(); 2868 - toggleDropdown(bordersDropdown); 2869 - }); 2870 - 2871 - bordersDropdown.querySelectorAll('[data-border]').forEach(btn => { 2872 - btn.addEventListener('click', (e) => { 2873 - e.stopPropagation(); 2874 - const preset = btn.dataset.border; 2875 - const borders = applyBorderPreset(preset, '1px solid var(--color-border-strong)'); 2876 - applyStyleToSelection('borders', Object.keys(borders).length > 0 ? borders : undefined); 2877 - closeAllDropdowns(); 2878 - }); 2879 - }); 2880 - 2881 - // --- Wrap Text toggle --- 2882 - document.getElementById('tb-wrap').addEventListener('click', () => { 2883 - const id = cellId(selectedCell.col, selectedCell.row); 2884 - const current = getCellData(id)?.s?.wrap; 2885 - applyStyleToSelection('wrap', !current); 2886 - document.getElementById('tb-wrap').classList.toggle('active', !current); 2887 - }); 2888 - 2889 - // --- Striped Rows toggle --- 2890 - document.getElementById('tb-striped').addEventListener('click', () => { 2891 - const current = getStripedRows(); 2892 - setStripedRows(!current); 2893 - document.getElementById('tb-striped').classList.toggle('active', !current); 2894 - renderGrid(); 2895 - closeAllDropdowns(); 2896 - }); 2897 - 2898 2468 // --- Conditional Formatting modal --- 2899 2469 // showCfModal extracted to sheet-dialogs.ts 2900 2470 function showCfModal() { ··· 2929 2499 2930 2500 document.getElementById('tb-validation').addEventListener('click', () => { closeAllDropdowns(); showValidationModal(); }); 2931 2501 2932 - // --- Cell type toolbar buttons --- 2933 - function setCellTypeForSelection(cellType: string | undefined) { 2934 - closeAllDropdowns(); 2935 - const range = selectionRange ? normalizeRange(selectionRange) : { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 2936 - for (let r = range.startRow; r <= range.endRow; r++) { 2937 - for (let c = range.startCol; c <= range.endCol; c++) { 2938 - const id = cellId(c, r); 2939 - const data = getCellData(id); 2940 - const style = data?.s ?? {}; 2941 - if (cellType) { 2942 - (style as Record<string, unknown>).cellType = cellType; 2943 - } else { 2944 - delete (style as Record<string, unknown>).cellType; 2945 - } 2946 - // Set default value if empty 2947 - let value = data?.v ?? ''; 2948 - if (cellType && (value === '' || value === undefined)) { 2949 - if (cellType === 'boolean') value = false; 2950 - else if (cellType === 'rating') value = 0; 2951 - else if (cellType === 'progress') value = 0; 2952 - } 2953 - setCellData(id, { v: value, f: data?.f ?? '', s: style }); 2954 - } 2955 - } 2956 - renderGrid(); 2957 - } 2958 - document.getElementById('tb-celltype-checkbox')!.addEventListener('click', () => setCellTypeForSelection('boolean')); 2959 - document.getElementById('tb-celltype-rating')!.addEventListener('click', () => setCellTypeForSelection('rating')); 2960 - document.getElementById('tb-celltype-progress')!.addEventListener('click', () => setCellTypeForSelection('progress')); 2961 - document.getElementById('tb-celltype-clear')!.addEventListener('click', () => setCellTypeForSelection(undefined)); 2962 - 2963 2502 // --- Rich cell click handler (checkbox toggle, star rating) --- 2964 2503 grid.addEventListener('click', (e) => { 2965 2504 const target = e.target as HTMLElement; ··· 3029 2568 setTimeout(() => document.addEventListener('click', closeDropdown), 0); 3030 2569 }); 3031 2570 3032 - // --- Update toolbar state helpers --- 3033 - function updateWrapButtonState() { 3034 - const id = cellId(selectedCell.col, selectedCell.row); 3035 - const isWrapped = getCellData(id)?.s?.wrap; 3036 - document.getElementById('tb-wrap').classList.toggle('active', !!isWrapped); 3037 - } 3038 - 3039 - function updateStripedButtonState() { 3040 - document.getElementById('tb-striped').classList.toggle('active', getStripedRows()); 3041 - } 3042 - 3043 - function updateBoldButtonState() { 3044 - const id = cellId(selectedCell.col, selectedCell.row); 3045 - const isBold = getCellData(id)?.s?.bold; 3046 - const el = document.getElementById('tb-bold'); 3047 - if (el) el.classList.toggle('active', !!isBold); 3048 - } 3049 - 3050 - function updateItalicButtonState() { 3051 - const id = cellId(selectedCell.col, selectedCell.row); 3052 - const isItalic = getCellData(id)?.s?.italic; 3053 - const el = document.getElementById('tb-italic'); 3054 - if (el) el.classList.toggle('active', !!isItalic); 3055 - } 3056 - 3057 - function updateUnderlineButtonState() { 3058 - const id = cellId(selectedCell.col, selectedCell.row); 3059 - const isUnderline = getCellData(id)?.s?.underline; 3060 - document.getElementById('tb-underline').classList.toggle('active', !!isUnderline); 3061 - } 3062 - 3063 - function updateStrikethroughButtonState() { 3064 - const id = cellId(selectedCell.col, selectedCell.row); 3065 - const isStrike = getCellData(id)?.s?.strikethrough; 3066 - document.getElementById('tb-strikethrough').classList.toggle('active', !!isStrike); 3067 - } 3068 - 3069 - function updateFontSizeSelect() { 3070 - const id = cellId(selectedCell.col, selectedCell.row); 3071 - const fontSize = getCellData(id)?.s?.fontSize || 11; 3072 - const sel = document.getElementById('tb-font-size'); 3073 - if (sel) sel.value = String(fontSize); 3074 - } 3075 - 3076 - function updateFontFamilySelect() { 3077 - const id = cellId(selectedCell.col, selectedCell.row); 3078 - const fontFamily = getCellData(id)?.s?.fontFamily || 'sans-serif'; 3079 - const sel = document.getElementById('tb-font-family'); 3080 - if (sel) sel.value = fontFamily; 3081 - } 3082 - 3083 - function updateVerticalAlignButton() { 3084 - const id = cellId(selectedCell.col, selectedCell.row); 3085 - const va = getCellData(id)?.s?.verticalAlign || 'top'; 3086 - const toggle = document.getElementById('tb-valign-toggle'); 3087 - if (toggle) { 3088 - const VALIGN_SVGS = { 3089 - top: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg>', 3090 - middle: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8" stroke-width="2"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M5.5 10.5L8 13l2.5-2.5"/></svg>', 3091 - bottom: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="14" x2="14" y2="14" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="12"/><path d="M5.5 9.5L8 12l2.5-2.5"/></svg>', 3092 - }; 3093 - toggle.querySelector('.dd-icon').innerHTML = VALIGN_SVGS[va] || VALIGN_SVGS.top; 3094 - } 3095 - } 2571 + // --- Toolbar state helpers (extracted to toolbar-wiring.ts) --- 2572 + function updateWrapButtonState() { _updateWrapButtonState(_toolbarDeps()); } 2573 + function updateStripedButtonState() { _updateStripedButtonState(_toolbarDeps()); } 2574 + function updateBoldButtonState() { _updateBoldButtonState(_toolbarDeps()); } 2575 + function updateItalicButtonState() { _updateItalicButtonState(_toolbarDeps()); } 2576 + function updateUnderlineButtonState() { _updateUnderlineButtonState(_toolbarDeps()); } 2577 + function updateStrikethroughButtonState() { _updateStrikethroughButtonState(_toolbarDeps()); } 2578 + function updateFontSizeSelect() { _updateFontSizeSelect(_toolbarDeps()); } 2579 + function updateFontFamilySelect() { _updateFontFamilySelect(_toolbarDeps()); } 2580 + function updateVerticalAlignButton() { _updateVerticalAlignButton(_toolbarDeps()); } 3096 2581 3097 2582 // ── Status Bar (extracted to status-bar-ui.ts) ── 3098 2583 function _statusBarDeps() {
+611
src/sheets/toolbar-wiring.ts
··· 1 + /** 2 + * Toolbar Wiring — button event handlers, dropdowns, color pickers, format painter. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { cellId } from './formulas.js'; 9 + import { normalizeRange } from './selection-utils.js'; 10 + import { extractFormat, applyFormat } from './format-painter.js'; 11 + import { applyBorderPreset } from './cell-styles.js'; 12 + import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 13 + import { showToast } from './import-export.js'; 14 + 15 + // ── Types ─────────────────────────────────────────────────── 16 + 17 + export interface ToolbarDeps { 18 + ydoc: any; 19 + grid: HTMLElement; 20 + getSelectedCell: () => { col: number; row: number }; 21 + getSelectionRange: () => any; 22 + getCellData: (id: string) => any; 23 + setCellData: (id: string, data: any) => void; 24 + getCells: () => any; 25 + getActiveSheet: () => any; 26 + computeDisplayValue: (id: string, data: any) => any; 27 + getColWidth: (col: number) => number; 28 + setColWidth: (col: number, width: number) => void; 29 + getFreezeRows: () => number; 30 + getFreezeCols: () => number; 31 + setFreezeRows: (n: number) => void; 32 + setFreezeCols: (n: number) => void; 33 + getStripedRows: () => boolean; 34 + setStripedRows: (enabled: boolean) => void; 35 + evalCache: { clear: () => void }; 36 + clearSpillMaps: () => void; 37 + invalidateRecalcEngine: () => void; 38 + refreshVisibleCells: () => void; 39 + renderGrid: () => void; 40 + undoManager: any; 41 + DEFAULT_ROWS: number; 42 + DEFAULT_COLS: number; 43 + } 44 + 45 + // ── Style application ─────────────────────────────────────── 46 + 47 + export function applyStyleToSelection(deps: ToolbarDeps, styleProp: string, value: any): void { 48 + const range = deps.getSelectionRange(); 49 + if (!range) return; 50 + const { startCol, startRow, endCol, endRow } = normalizeRange(range); 51 + deps.ydoc.transact(() => { 52 + for (let r = startRow; r <= endRow; r++) { 53 + for (let c = startCol; c <= endCol; c++) { 54 + const id = cellId(c, r); 55 + const existing = deps.getCellData(id); 56 + const s = existing?.s || {}; 57 + s[styleProp] = value; 58 + if (!existing) { deps.setCellData(id, { v: '', f: '', s }); } 59 + else { deps.setCellData(id, { s }); } 60 + } 61 + } 62 + }); 63 + deps.refreshVisibleCells(); 64 + } 65 + 66 + export function clearFormattingSelection(deps: ToolbarDeps): void { 67 + const range = deps.getSelectionRange(); 68 + if (!range) return; 69 + const { startCol, startRow, endCol, endRow } = normalizeRange(range); 70 + const cells = deps.getCells(); 71 + deps.ydoc.transact(() => { 72 + for (let r = startRow; r <= endRow; r++) { 73 + for (let c = startCol; c <= endCol; c++) { 74 + const id = cellId(c, r); 75 + if (!cells.has(id)) continue; 76 + const cell = cells.get(id); 77 + if (cell instanceof Y.Map && cell.has('s')) { 78 + cell.delete('s'); 79 + } 80 + } 81 + } 82 + }); 83 + deps.refreshVisibleCells(); 84 + } 85 + 86 + // ── Dropdown utilities ────────────────────────────────────── 87 + 88 + export function closeAllDropdowns(): void { 89 + document.querySelectorAll('.toolbar-dropdown.open, .toolbar-overflow.open').forEach(el => { 90 + el.classList.remove('open'); 91 + }); 92 + } 93 + 94 + function toggleDropdown(dropdownEl: HTMLElement): void { 95 + const wasOpen = dropdownEl.classList.contains('open'); 96 + closeAllDropdowns(); 97 + if (!wasOpen) dropdownEl.classList.add('open'); 98 + } 99 + 100 + // ── Sort ──────────────────────────────────────────────────── 101 + 102 + export function sortColumn(deps: ToolbarDeps, ascending: boolean): void { 103 + const range = deps.getSelectionRange(); 104 + if (!range) return; 105 + const selectedCell = deps.getSelectedCell(); 106 + const col = selectedCell.col; 107 + const { startRow, endRow } = normalizeRange(range); 108 + const sheet = deps.getActiveSheet(); 109 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 110 + const rows: any[] = []; 111 + for (let r = startRow; r <= endRow; r++) { 112 + const row: any = {}; 113 + for (let c = 1; c <= colCount; c++) { const data = deps.getCellData(cellId(c, r)); if (data) row[c] = data; } 114 + row._sortVal = deps.getCellData(cellId(col, r))?.v ?? ''; 115 + rows.push(row); 116 + } 117 + rows.sort((a, b) => { 118 + const va = a._sortVal, vb = b._sortVal; 119 + if (typeof va === 'number' && typeof vb === 'number') return ascending ? va - vb : vb - va; 120 + return ascending ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); 121 + }); 122 + deps.ydoc.transact(() => { 123 + const cells = deps.getCells(); 124 + rows.forEach((row, idx) => { 125 + const targetRow = startRow + idx; 126 + for (let c = 1; c <= colCount; c++) { 127 + const id = cellId(c, targetRow); 128 + if (row[c]) { deps.setCellData(id, row[c]); } 129 + else if (cells.has(id)) { cells.delete(id); } 130 + } 131 + }); 132 + }); 133 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 134 + deps.refreshVisibleCells(); 135 + } 136 + 137 + // ── Row/Column Insert/Delete ──────────────────────────────── 138 + 139 + export function doInsertRow(deps: ToolbarDeps, rowIndex: number): void { 140 + const sheet = deps.getActiveSheet(); 141 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 142 + deps.ydoc.transact(() => { rowColInsertRow(deps.getCells, deps.setCellData, rowIndex, colCount); }); 143 + sheet.set('rowCount', (sheet.get('rowCount') || deps.DEFAULT_ROWS) + 1); 144 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 145 + deps.renderGrid(); 146 + } 147 + 148 + export function doDeleteRow(deps: ToolbarDeps, rowIndex: number): void { 149 + const sheet = deps.getActiveSheet(); 150 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 151 + if (rowCount <= 1) return; 152 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 153 + deps.ydoc.transact(() => { rowColDeleteRow(deps.getCells, deps.setCellData, rowIndex, colCount); }); 154 + sheet.set('rowCount', rowCount - 1); 155 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 156 + deps.renderGrid(); 157 + } 158 + 159 + export function doInsertColumn(deps: ToolbarDeps, colIndex: number): void { 160 + const sheet = deps.getActiveSheet(); 161 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 162 + deps.ydoc.transact(() => { rowColInsertColumn(deps.getCells, deps.setCellData, colIndex, rowCount); }); 163 + sheet.set('colCount', (sheet.get('colCount') || deps.DEFAULT_COLS) + 1); 164 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 165 + deps.renderGrid(); 166 + } 167 + 168 + export function doDeleteColumn(deps: ToolbarDeps, colIndex: number): void { 169 + const sheet = deps.getActiveSheet(); 170 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 171 + if (colCount <= 1) return; 172 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 173 + deps.ydoc.transact(() => { rowColDeleteColumn(deps.getCells, deps.setCellData, colIndex, rowCount); }); 174 + sheet.set('colCount', colCount - 1); 175 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 176 + deps.renderGrid(); 177 + } 178 + 179 + // ── Undo/Redo ─────────────────────────────────────────────── 180 + 181 + export function updateUndoRedoState(undoManager: any): void { 182 + const undoBtn = document.getElementById('tb-undo'); 183 + const redoBtn = document.getElementById('tb-redo'); 184 + if (undoBtn) { 185 + const canUndo = undoManager && undoManager.undoStack.length > 0; 186 + undoBtn.classList.toggle('btn-disabled', !canUndo); 187 + undoBtn.setAttribute('aria-disabled', String(!canUndo)); 188 + undoBtn.dataset.tooltip = canUndo ? 'Undo (' + undoManager.undoStack.length + ')' : 'Nothing to undo'; 189 + } 190 + if (redoBtn) { 191 + const canRedo = undoManager && undoManager.redoStack.length > 0; 192 + redoBtn.classList.toggle('btn-disabled', !canRedo); 193 + redoBtn.setAttribute('aria-disabled', String(!canRedo)); 194 + redoBtn.dataset.tooltip = canRedo ? 'Redo (' + undoManager.redoStack.length + ')' : 'Nothing to redo'; 195 + } 196 + } 197 + 198 + // ── Freeze toolbar state ──────────────────────────────────── 199 + 200 + export function updateFreezeToolbarState(deps: ToolbarDeps): void { 201 + const fr = deps.getFreezeRows(); 202 + const fc = deps.getFreezeCols(); 203 + const frBtn = document.getElementById('tb-freeze-rows'); 204 + const fcBtn = document.getElementById('tb-freeze-cols'); 205 + const freezeToggleBtn = document.getElementById('tb-freeze-toggle'); 206 + if (frBtn) { frBtn.dataset.tooltip = fr > 0 ? 'Frozen: ' + fr + ' rows' : 'Freeze rows above cursor'; frBtn.classList.toggle('active', fr > 0); } 207 + if (fcBtn) { fcBtn.dataset.tooltip = fc > 0 ? 'Frozen: ' + fc + ' cols' : 'Freeze columns left of cursor'; fcBtn.classList.toggle('active', fc > 0); } 208 + if (freezeToggleBtn) { freezeToggleBtn.classList.toggle('active', fr > 0 || fc > 0); } 209 + } 210 + 211 + // ── Toolbar state helpers ─────────────────────────────────── 212 + 213 + export function updateWrapButtonState(deps: ToolbarDeps): void { 214 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 215 + const isWrapped = deps.getCellData(id)?.s?.wrap; 216 + document.getElementById('tb-wrap')?.classList.toggle('active', !!isWrapped); 217 + } 218 + 219 + export function updateStripedButtonState(deps: ToolbarDeps): void { 220 + document.getElementById('tb-striped')?.classList.toggle('active', deps.getStripedRows()); 221 + } 222 + 223 + export function updateBoldButtonState(deps: ToolbarDeps): void { 224 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 225 + const isBold = deps.getCellData(id)?.s?.bold; 226 + const el = document.getElementById('tb-bold'); 227 + if (el) el.classList.toggle('active', !!isBold); 228 + } 229 + 230 + export function updateItalicButtonState(deps: ToolbarDeps): void { 231 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 232 + const isItalic = deps.getCellData(id)?.s?.italic; 233 + const el = document.getElementById('tb-italic'); 234 + if (el) el.classList.toggle('active', !!isItalic); 235 + } 236 + 237 + export function updateUnderlineButtonState(deps: ToolbarDeps): void { 238 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 239 + const isUnderline = deps.getCellData(id)?.s?.underline; 240 + document.getElementById('tb-underline')?.classList.toggle('active', !!isUnderline); 241 + } 242 + 243 + export function updateStrikethroughButtonState(deps: ToolbarDeps): void { 244 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 245 + const isStrike = deps.getCellData(id)?.s?.strikethrough; 246 + document.getElementById('tb-strikethrough')?.classList.toggle('active', !!isStrike); 247 + } 248 + 249 + export function updateFontSizeSelect(deps: ToolbarDeps): void { 250 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 251 + const fontSize = deps.getCellData(id)?.s?.fontSize || 11; 252 + const sel = document.getElementById('tb-font-size') as HTMLSelectElement | null; 253 + if (sel) sel.value = String(fontSize); 254 + } 255 + 256 + export function updateFontFamilySelect(deps: ToolbarDeps): void { 257 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 258 + const fontFamily = deps.getCellData(id)?.s?.fontFamily || 'sans-serif'; 259 + const sel = document.getElementById('tb-font-family') as HTMLSelectElement | null; 260 + if (sel) sel.value = fontFamily; 261 + } 262 + 263 + export function updateVerticalAlignButton(deps: ToolbarDeps): void { 264 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 265 + const va = deps.getCellData(id)?.s?.verticalAlign || 'top'; 266 + const toggle = document.getElementById('tb-valign-toggle'); 267 + if (toggle) { 268 + const VALIGN_SVGS: Record<string, string> = { 269 + top: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg>', 270 + middle: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8" stroke-width="2"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M5.5 10.5L8 13l2.5-2.5"/></svg>', 271 + bottom: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="14" x2="14" y2="14" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="12"/><path d="M5.5 9.5L8 12l2.5-2.5"/></svg>', 272 + }; 273 + const icon = toggle.querySelector('.dd-icon'); 274 + if (icon) icon.innerHTML = VALIGN_SVGS[va] || VALIGN_SVGS.top; 275 + } 276 + } 277 + 278 + // ── Format Painter state ──────────────────────────────────── 279 + 280 + let formatPainterFormat: any = null; 281 + let formatPainterSticky = false; 282 + 283 + export function getFormatPainterFormat(): any { return formatPainterFormat; } 284 + export function isFormatPainterSticky(): boolean { return formatPainterSticky; } 285 + 286 + export function activateFormatPainter(deps: ToolbarDeps): void { 287 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 288 + const cellData = deps.getCellData(id); 289 + formatPainterFormat = extractFormat(cellData); 290 + document.getElementById('tb-format-painter')?.classList.add('active'); 291 + document.body.classList.add('format-painter-active'); 292 + } 293 + 294 + export function deactivateFormatPainter(): void { 295 + formatPainterFormat = null; 296 + formatPainterSticky = false; 297 + document.getElementById('tb-format-painter')?.classList.remove('active'); 298 + document.body.classList.remove('format-painter-active'); 299 + } 300 + 301 + export function applyFormatPainterToCell(deps: ToolbarDeps, col: number, row: number): void { 302 + if (!formatPainterFormat) return; 303 + const targetId = cellId(col, row); 304 + const existing = deps.getCellData(targetId); 305 + const newStyle = applyFormat(existing?.s, formatPainterFormat); 306 + deps.setCellData(targetId, { s: newStyle }); 307 + deps.refreshVisibleCells(); 308 + if (!formatPainterSticky) { 309 + deactivateFormatPainter(); 310 + } 311 + } 312 + 313 + // ── Color picker helpers ──────────────────────────────────── 314 + 315 + const THEME_DEFAULTS: Record<string, { text: string; bg: string }> = { 316 + light: { text: '#1a1815', bg: '#f5f4f1' }, 317 + dark: { text: '#ddd8ce', bg: '#2a2825' }, 318 + }; 319 + 320 + function getEffectiveTheme(): string { 321 + const saved = localStorage.getItem('tools-theme'); 322 + if (saved === 'dark' || saved === 'light') return saved; 323 + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 324 + } 325 + 326 + // ── Wire all toolbar buttons ──────────────────────────────── 327 + 328 + export function wireToolbar(deps: ToolbarDeps): void { 329 + const _apply = (prop: string, value: any) => applyStyleToSelection(deps, prop, value); 330 + 331 + // Dropdown close on outside click / Escape 332 + document.addEventListener('click', (e) => { 333 + if (!(e.target as HTMLElement).closest('.toolbar-dropdown') && !(e.target as HTMLElement).closest('.toolbar-overflow')) { 334 + closeAllDropdowns(); 335 + } 336 + }); 337 + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllDropdowns(); }); 338 + 339 + // Overflow menu 340 + const overflowMenu = document.getElementById('overflow-menu'); 341 + const overflowToggle = document.getElementById('overflow-toggle'); 342 + if (overflowToggle && overflowMenu) { 343 + overflowToggle.addEventListener('click', (e) => { e.stopPropagation(); toggleDropdown(overflowMenu); }); 344 + } 345 + 346 + // Undo/Redo 347 + const um = deps.undoManager; 348 + document.getElementById('tb-undo')?.addEventListener('click', () => { 349 + if (um) { um.undo(); deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); deps.refreshVisibleCells(); updateUndoRedoState(um); } 350 + }); 351 + document.getElementById('tb-redo')?.addEventListener('click', () => { 352 + if (um) { um.redo(); deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); deps.refreshVisibleCells(); updateUndoRedoState(um); } 353 + }); 354 + if (um) { 355 + um.on('stack-item-added', () => updateUndoRedoState(um)); 356 + um.on('stack-item-popped', () => updateUndoRedoState(um)); 357 + } 358 + updateUndoRedoState(um); 359 + 360 + // Bold / Italic / Underline / Strikethrough 361 + document.getElementById('tb-bold')?.addEventListener('click', () => { 362 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 363 + _apply('bold', !deps.getCellData(id)?.s?.bold); 364 + }); 365 + document.getElementById('tb-italic')?.addEventListener('click', () => { 366 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 367 + _apply('italic', !deps.getCellData(id)?.s?.italic); 368 + }); 369 + document.getElementById('tb-underline')?.addEventListener('click', () => { 370 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 371 + _apply('underline', !deps.getCellData(id)?.s?.underline); 372 + updateUnderlineButtonState(deps); 373 + }); 374 + document.getElementById('tb-strikethrough')?.addEventListener('click', () => { 375 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 376 + _apply('strikethrough', !deps.getCellData(id)?.s?.strikethrough); 377 + updateStrikethroughButtonState(deps); 378 + }); 379 + 380 + // Font size / family 381 + document.getElementById('tb-font-size')?.addEventListener('change', (e) => { 382 + const size = parseInt((e.target as HTMLSelectElement).value); 383 + if (size) _apply('fontSize', size); 384 + }); 385 + document.getElementById('tb-font-family')?.addEventListener('change', (e) => { 386 + _apply('fontFamily', (e.target as HTMLSelectElement).value || undefined); 387 + }); 388 + 389 + // Vertical align dropdown 390 + const vAlignDropdown = document.getElementById('dd-valign'); 391 + const vAlignToggle = document.getElementById('tb-valign-toggle'); 392 + const VALIGN_SVGS: Record<string, string> = { 393 + top: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="2" x2="14" y2="2" stroke-width="2"/><line x1="8" y1="4" x2="8" y2="14"/><path d="M5.5 6.5L8 4l2.5 2.5"/></svg>', 394 + middle: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8" stroke-width="2"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M5.5 10.5L8 13l2.5-2.5"/></svg>', 395 + bottom: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="14" x2="14" y2="14" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="12"/><path d="M5.5 9.5L8 12l2.5-2.5"/></svg>', 396 + }; 397 + if (vAlignToggle && vAlignDropdown) { 398 + vAlignToggle.addEventListener('click', (e) => { e.stopPropagation(); toggleDropdown(vAlignDropdown); }); 399 + vAlignDropdown.querySelectorAll('[data-valign]').forEach(btn => { 400 + btn.addEventListener('click', (e) => { 401 + e.stopPropagation(); 402 + const va = (btn as HTMLElement).dataset.valign!; 403 + _apply('verticalAlign', va); 404 + const icon = vAlignToggle.querySelector('.dd-icon'); 405 + if (icon) icon.innerHTML = VALIGN_SVGS[va] || VALIGN_SVGS.top; 406 + closeAllDropdowns(); 407 + }); 408 + }); 409 + } 410 + 411 + // Format painter 412 + let formatPainterClickTimeout: ReturnType<typeof setTimeout> | null = null; 413 + const formatPainterBtn = document.getElementById('tb-format-painter'); 414 + if (formatPainterBtn) { 415 + formatPainterBtn.addEventListener('click', () => { 416 + if (formatPainterClickTimeout) { 417 + clearTimeout(formatPainterClickTimeout); 418 + formatPainterClickTimeout = null; 419 + formatPainterSticky = true; 420 + activateFormatPainter(deps); 421 + return; 422 + } 423 + formatPainterClickTimeout = setTimeout(() => { 424 + formatPainterClickTimeout = null; 425 + if (formatPainterFormat) { 426 + deactivateFormatPainter(); 427 + } else { 428 + formatPainterSticky = false; 429 + activateFormatPainter(deps); 430 + } 431 + }, 250); 432 + }); 433 + } 434 + document.addEventListener('keydown', (e) => { 435 + if (e.key === 'Escape' && formatPainterFormat) deactivateFormatPainter(); 436 + }); 437 + 438 + // Alignment 439 + document.getElementById('tb-align-left')?.addEventListener('click', () => _apply('align', 'left')); 440 + document.getElementById('tb-align-center')?.addEventListener('click', () => _apply('align', 'center')); 441 + document.getElementById('tb-align-right')?.addEventListener('click', () => _apply('align', 'right')); 442 + 443 + // Color pickers 444 + const sheetsTextColorInput = document.getElementById('tb-text-color') as HTMLInputElement; 445 + const sheetsTextColorSwatch = document.getElementById('tb-text-color-swatch'); 446 + const sheetsBgColorInput = document.getElementById('tb-bg-color') as HTMLInputElement; 447 + const sheetsBgColorSwatch = document.getElementById('tb-bg-color-swatch'); 448 + 449 + function updateSheetsColorSwatches() { 450 + if (sheetsTextColorSwatch) sheetsTextColorSwatch.style.background = sheetsTextColorInput.value; 451 + if (sheetsBgColorSwatch) sheetsBgColorSwatch.style.background = sheetsBgColorInput.value; 452 + } 453 + 454 + function updateColorPickerDefaults() { 455 + const theme = getEffectiveTheme(); 456 + const defaults = THEME_DEFAULTS[theme]; 457 + const otherTheme = theme === 'dark' ? 'light' : 'dark'; 458 + const otherDefaults = THEME_DEFAULTS[otherTheme]; 459 + if (sheetsTextColorInput.value === otherDefaults.text) { 460 + sheetsTextColorInput.value = defaults.text; 461 + } 462 + if (sheetsBgColorInput.value === otherDefaults.bg || sheetsBgColorInput.value === '#ffffff') { 463 + sheetsBgColorInput.value = defaults.bg; 464 + } 465 + updateSheetsColorSwatches(); 466 + } 467 + 468 + // Init color defaults 469 + const theme = getEffectiveTheme(); 470 + const defaults = THEME_DEFAULTS[theme]; 471 + sheetsTextColorInput.value = defaults.text; 472 + sheetsBgColorInput.value = defaults.bg; 473 + updateSheetsColorSwatches(); 474 + 475 + new MutationObserver(() => updateColorPickerDefaults()) 476 + .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); 477 + 478 + sheetsTextColorInput.addEventListener('input', (e) => { _apply('color', (e.target as HTMLInputElement).value); updateSheetsColorSwatches(); }); 479 + sheetsBgColorInput.addEventListener('input', (e) => { _apply('bg', (e.target as HTMLInputElement).value); updateSheetsColorSwatches(); }); 480 + document.getElementById('tb-format')?.addEventListener('change', (e) => _apply('format', (e.target as HTMLSelectElement).value)); 481 + 482 + // Inline format shortcuts 483 + document.getElementById('tb-fmt-currency')?.addEventListener('click', () => { 484 + _apply('format', 'currency'); 485 + (document.getElementById('tb-format') as HTMLSelectElement).value = 'currency'; 486 + }); 487 + document.getElementById('tb-fmt-percent')?.addEventListener('click', () => { 488 + _apply('format', 'percent'); 489 + (document.getElementById('tb-format') as HTMLSelectElement).value = 'percent'; 490 + }); 491 + document.getElementById('tb-dec-decrease')?.addEventListener('click', () => { 492 + _apply('format', 'number'); 493 + (document.getElementById('tb-format') as HTMLSelectElement).value = 'number'; 494 + }); 495 + document.getElementById('tb-dec-increase')?.addEventListener('click', () => { 496 + _apply('format', 'number'); 497 + (document.getElementById('tb-format') as HTMLSelectElement).value = 'number'; 498 + }); 499 + 500 + // Sort 501 + document.getElementById('tb-sort-asc')?.addEventListener('click', () => sortColumn(deps, true)); 502 + document.getElementById('tb-sort-desc')?.addEventListener('click', () => sortColumn(deps, false)); 503 + 504 + // Add/remove rows/cols 505 + document.getElementById('tb-add-row')?.addEventListener('click', () => { 506 + const sheet = deps.getActiveSheet(); 507 + sheet.set('rowCount', (sheet.get('rowCount') || deps.DEFAULT_ROWS) + 1); 508 + deps.renderGrid(); closeAllDropdowns(); 509 + }); 510 + document.getElementById('tb-add-col')?.addEventListener('click', () => { 511 + const sheet = deps.getActiveSheet(); 512 + sheet.set('colCount', (sheet.get('colCount') || deps.DEFAULT_COLS) + 1); 513 + deps.renderGrid(); closeAllDropdowns(); 514 + }); 515 + document.getElementById('tb-del-row')?.addEventListener('click', () => { 516 + const sheet = deps.getActiveSheet(); 517 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 518 + if (rowCount > 1) { 519 + const cells = deps.getCells(); const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 520 + deps.ydoc.transact(() => { for (let c = 1; c <= colCount; c++) { const id = cellId(c, rowCount); if (cells.has(id)) cells.delete(id); } }); 521 + sheet.set('rowCount', rowCount - 1); deps.renderGrid(); 522 + } 523 + closeAllDropdowns(); 524 + }); 525 + document.getElementById('tb-del-col')?.addEventListener('click', () => { 526 + const sheet = deps.getActiveSheet(); 527 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 528 + if (colCount > 1) { 529 + const cells = deps.getCells(); const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 530 + deps.ydoc.transact(() => { for (let r = 1; r <= rowCount; r++) { const id = cellId(colCount, r); if (cells.has(id)) cells.delete(id); } }); 531 + sheet.set('colCount', colCount - 1); deps.renderGrid(); 532 + } 533 + closeAllDropdowns(); 534 + }); 535 + 536 + // Freeze panes 537 + document.getElementById('tb-freeze-rows')?.addEventListener('click', () => { 538 + const rows = deps.getSelectedCell().row - 1; 539 + deps.setFreezeRows(rows > 0 ? rows : 0); deps.renderGrid(); closeAllDropdowns(); 540 + showToast(rows > 0 ? `Froze ${rows} row${rows > 1 ? 's' : ''}` : 'Rows unfrozen'); 541 + }); 542 + document.getElementById('tb-freeze-cols')?.addEventListener('click', () => { 543 + const cols = deps.getSelectedCell().col - 1; 544 + deps.setFreezeCols(cols > 0 ? cols : 0); deps.renderGrid(); closeAllDropdowns(); 545 + showToast(cols > 0 ? `Froze ${cols} column${cols > 1 ? 's' : ''}` : 'Columns unfrozen'); 546 + }); 547 + document.getElementById('tb-unfreeze')?.addEventListener('click', () => { 548 + deps.setFreezeRows(0); deps.setFreezeCols(0); deps.renderGrid(); closeAllDropdowns(); 549 + showToast('Panes unfrozen'); 550 + }); 551 + 552 + // Wrap text 553 + document.getElementById('tb-wrap')?.addEventListener('click', () => { 554 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 555 + const isWrapped = deps.getCellData(id)?.s?.wrap; 556 + _apply('wrap', !isWrapped); 557 + updateWrapButtonState(deps); 558 + }); 559 + 560 + // Striped rows 561 + document.getElementById('tb-striped')?.addEventListener('click', () => { 562 + deps.setStripedRows(!deps.getStripedRows()); 563 + deps.renderGrid(); 564 + updateStripedButtonState(deps); 565 + }); 566 + 567 + // Borders dropdown 568 + const bordersDropdown = document.getElementById('dd-borders'); 569 + const bordersToggle = document.getElementById('tb-borders-toggle'); 570 + if (bordersToggle && bordersDropdown) { 571 + bordersToggle.addEventListener('click', (e) => { e.stopPropagation(); toggleDropdown(bordersDropdown); }); 572 + bordersDropdown.querySelectorAll('[data-border]').forEach(btn => { 573 + btn.addEventListener('click', (e) => { 574 + e.stopPropagation(); 575 + const preset = (btn as HTMLElement).dataset.border!; 576 + const borders = applyBorderPreset(preset, '1px solid var(--color-border-strong)'); 577 + applyStyleToSelection(deps, 'borders', Object.keys(borders).length > 0 ? borders : undefined); 578 + closeAllDropdowns(); 579 + }); 580 + }); 581 + } 582 + 583 + // Cell type buttons 584 + const setCellType = (cellType: string | undefined) => { 585 + closeAllDropdowns(); 586 + const selRange = deps.getSelectionRange(); 587 + const sel = deps.getSelectedCell(); 588 + const range = selRange ? normalizeRange(selRange) : { startCol: sel.col, startRow: sel.row, endCol: sel.col, endRow: sel.row }; 589 + for (let r = range.startRow; r <= range.endRow; r++) { 590 + for (let c = range.startCol; c <= range.endCol; c++) { 591 + const id = cellId(c, r); 592 + const data = deps.getCellData(id); 593 + const style = data?.s ?? {}; 594 + if (cellType) { (style as Record<string, unknown>).cellType = cellType; } 595 + else { delete (style as Record<string, unknown>).cellType; } 596 + let value = data?.v ?? ''; 597 + if (cellType && (value === '' || value === undefined)) { 598 + if (cellType === 'boolean') value = false; 599 + else if (cellType === 'rating') value = 0; 600 + else if (cellType === 'progress') value = 0; 601 + } 602 + deps.setCellData(id, { v: value, f: data?.f ?? '', s: style }); 603 + } 604 + } 605 + deps.renderGrid(); 606 + }; 607 + document.getElementById('tb-celltype-checkbox')?.addEventListener('click', () => setCellType('boolean')); 608 + document.getElementById('tb-celltype-rating')?.addEventListener('click', () => setCellType('rating')); 609 + document.getElementById('tb-celltype-progress')?.addEventListener('click', () => setCellType('progress')); 610 + document.getElementById('tb-celltype-clear')?.addEventListener('click', () => setCellType(undefined)); 611 + }