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 4 — extract formula-bar, autocomplete, paste-special, shortcuts, save-status' (#283) from refactor/sheets-decompose-phase4 into main

scott ee4b8271 e664a981

+699 -609
+170
src/sheets/formula-autocomplete-ui.ts
··· 1 + /** 2 + * Formula Autocomplete UI — dropdown, navigation, acceptance. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js'; 8 + 9 + // ── Types ─────────────────────────────────────────────────── 10 + 11 + export interface AutocompleteDeps { 12 + autocompleteEl: HTMLElement; 13 + formulaInput: HTMLInputElement; 14 + } 15 + 16 + // ── State ─────────────────────────────────────────────────── 17 + 18 + let acFiltered: any[] = []; 19 + let acSelectedIndex = -1; 20 + let acActive = false; 21 + 22 + // ── Show / Hide ───────────────────────────────────────────── 23 + 24 + function showAutocomplete(autocompleteEl: HTMLElement, inputEl: HTMLElement, query: string): void { 25 + acFiltered = filterFunctions(query); 26 + if (acFiltered.length === 0) { 27 + hideAutocomplete(autocompleteEl); 28 + return; 29 + } 30 + acSelectedIndex = -1; 31 + acActive = true; 32 + renderAutocompleteList(autocompleteEl); 33 + positionAutocomplete(autocompleteEl, inputEl); 34 + autocompleteEl.style.display = ''; 35 + } 36 + 37 + export function hideAutocomplete(autocompleteEl: HTMLElement): void { 38 + acActive = false; 39 + acFiltered = []; 40 + acSelectedIndex = -1; 41 + autocompleteEl.style.display = 'none'; 42 + } 43 + 44 + // ── Render ────────────────────────────────────────────────── 45 + 46 + function renderAutocompleteList(autocompleteEl: HTMLElement): void { 47 + let html = ''; 48 + acFiltered.forEach((fn, i) => { 49 + const cls = i === acSelectedIndex ? 'formula-autocomplete-item selected' : 'formula-autocomplete-item'; 50 + html += '<div class="' + cls + '" data-ac-index="' + i + '">' 51 + + '<span class="formula-autocomplete-name">' + fn.name + '</span>' 52 + + '<span class="formula-autocomplete-signature">' + fn.signature + '</span>' 53 + + '</div>'; 54 + }); 55 + autocompleteEl.innerHTML = html; 56 + 57 + const selected = autocompleteEl.querySelector('.selected'); 58 + if (selected) selected.scrollIntoView({ block: 'nearest' }); 59 + } 60 + 61 + function positionAutocomplete(autocompleteEl: HTMLElement, inputEl: HTMLElement): void { 62 + const rect = inputEl.getBoundingClientRect(); 63 + autocompleteEl.style.left = rect.left + 'px'; 64 + autocompleteEl.style.top = (rect.bottom + 2) + 'px'; 65 + } 66 + 67 + function acceptAutocomplete(autocompleteEl: HTMLElement, inputEl: HTMLInputElement): void { 68 + const fn = getSelectedFunction(acSelectedIndex, acFiltered); 69 + if (!fn) { 70 + hideAutocomplete(autocompleteEl); 71 + return; 72 + } 73 + const value = inputEl.value; 74 + const eqIdx = value.lastIndexOf('='); 75 + const prefix = eqIdx >= 0 ? value.slice(0, eqIdx + 1) : '='; 76 + inputEl.value = prefix + fn.name + '('; 77 + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); 78 + hideAutocomplete(autocompleteEl); 79 + } 80 + 81 + // ── Key / Input Handlers ──────────────────────────────────── 82 + 83 + export function handleFormulaAutocompleteKeyDown(autocompleteEl: HTMLElement, e: KeyboardEvent, inputEl: HTMLInputElement): boolean { 84 + if (!acActive) return false; 85 + 86 + if (e.key === 'ArrowDown') { 87 + e.preventDefault(); 88 + acSelectedIndex = navigateAutocomplete(acSelectedIndex, acFiltered.length, 'down'); 89 + renderAutocompleteList(autocompleteEl); 90 + return true; 91 + } 92 + if (e.key === 'ArrowUp') { 93 + e.preventDefault(); 94 + acSelectedIndex = navigateAutocomplete(acSelectedIndex, acFiltered.length, 'up'); 95 + renderAutocompleteList(autocompleteEl); 96 + return true; 97 + } 98 + if (e.key === 'Tab' || e.key === 'Enter') { 99 + if (acSelectedIndex >= 0) { 100 + e.preventDefault(); 101 + acceptAutocomplete(autocompleteEl, inputEl); 102 + return true; 103 + } 104 + } 105 + if (e.key === 'Escape') { 106 + e.preventDefault(); 107 + hideAutocomplete(autocompleteEl); 108 + return true; 109 + } 110 + return false; 111 + } 112 + 113 + export function handleFormulaAutocompleteInput(autocompleteEl: HTMLElement, inputEl: HTMLInputElement): void { 114 + const value = inputEl.value; 115 + if (!value.includes('=')) { 116 + hideAutocomplete(autocompleteEl); 117 + return; 118 + } 119 + const afterEq = value.slice(value.lastIndexOf('=') + 1); 120 + const match = afterEq.match(/([A-Za-z]+)$/); 121 + if (match) { 122 + showAutocomplete(autocompleteEl, inputEl, match[1]); 123 + } else if (afterEq === '') { 124 + showAutocomplete(autocompleteEl, inputEl, ''); 125 + } else { 126 + hideAutocomplete(autocompleteEl); 127 + } 128 + } 129 + 130 + // ── Attach to cell editor ─────────────────────────────────── 131 + 132 + export function attachCellEditorAutocomplete(autocompleteEl: HTMLElement, inputEl: HTMLInputElement): void { 133 + inputEl.addEventListener('input', () => handleFormulaAutocompleteInput(autocompleteEl, inputEl)); 134 + inputEl.addEventListener('keydown', (e) => { 135 + if (handleFormulaAutocompleteKeyDown(autocompleteEl, e, inputEl)) return; 136 + }, true); 137 + } 138 + 139 + // ── Wire Events ───────────────────────────────────────────── 140 + 141 + export function wireAutocomplete(deps: AutocompleteDeps): void { 142 + const { autocompleteEl, formulaInput } = deps; 143 + 144 + // Wire autocomplete into formula bar 145 + formulaInput.addEventListener('input', () => handleFormulaAutocompleteInput(autocompleteEl, formulaInput)); 146 + 147 + // Intercept keydown on formula bar for autocomplete nav 148 + formulaInput.addEventListener('keydown', (e) => { 149 + if (handleFormulaAutocompleteKeyDown(autocompleteEl, e as KeyboardEvent, formulaInput)) return; 150 + }, true); 151 + 152 + // Click on autocomplete items 153 + autocompleteEl.addEventListener('mousedown', (e) => { 154 + const item = (e.target as HTMLElement).closest('.formula-autocomplete-item') as HTMLElement; 155 + if (!item) return; 156 + e.preventDefault(); 157 + acSelectedIndex = parseInt(item.dataset.acIndex!); 158 + const activeInput = document.activeElement; 159 + if (activeInput && (activeInput === formulaInput || (activeInput as HTMLElement).classList.contains('cell-editor'))) { 160 + acceptAutocomplete(autocompleteEl, activeInput as HTMLInputElement); 161 + } 162 + }); 163 + 164 + // Hide autocomplete on blur 165 + document.addEventListener('click', (e) => { 166 + if (!autocompleteEl.contains(e.target as Node) && e.target !== formulaInput && !(e.target as HTMLElement).classList.contains('cell-editor')) { 167 + hideAutocomplete(autocompleteEl); 168 + } 169 + }); 170 + }
+204
src/sheets/formula-bar-ui.ts
··· 1 + /** 2 + * Formula Bar UI — editing, syntax highlighting, range highlights, tooltip. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId, colToLetter } from './formulas.js'; 8 + import { tokenizeForHighlighting, renderHighlightedFormula } from './formula-highlighter.js'; 9 + import { extractFormulaRanges, assignRangeColors, renderGridHighlights, clearGridHighlights } from './range-highlight.js'; 10 + import { detectCurrentFunction, renderTooltip, hideTooltip } from './formula-tooltip.js'; 11 + import { isSparklineResult, drawSparkline } from './sparkline.js'; 12 + import { evaluateRules, buildCfStyle } from './conditional-format.js'; 13 + import { getCellBgColor, getCellStyle } from './cell-style-utils.js'; 14 + import { validateCell } from './data-validation.js'; 15 + import { parseRef } from './formulas.js'; 16 + 17 + // ── Types ─────────────────────────────────────────────────── 18 + 19 + export interface FormulaBarDeps { 20 + formulaInput: HTMLInputElement; 21 + formulaHighlightLayer: HTMLElement | null; 22 + grid: HTMLElement; 23 + getSelectedCell: () => { col: number; row: number }; 24 + setCellData: (id: string, data: any) => void; 25 + getCellData: (id: string) => any; 26 + computeDisplayValue: (id: string, data: any) => any; 27 + getCfRulesArray: () => any[]; 28 + getValidationForCell: (id: string) => any; 29 + evalCache: { clear: () => void }; 30 + clearSpillMaps: () => void; 31 + invalidateRecalcEngine: () => void; 32 + moveSelection: (dCol: number, dRow: number) => void; 33 + updateFormulaBar: () => void; 34 + renderSparklines: () => void; 35 + } 36 + 37 + // ── Formula Syntax Highlighting ───────────────────────────── 38 + 39 + export function updateFormulaHighlight(deps: FormulaBarDeps, text: string, useRangeColors = false): void { 40 + const { formulaHighlightLayer, formulaInput } = deps; 41 + if (!formulaHighlightLayer) return; 42 + if (text && text.startsWith('=')) { 43 + const tokens = tokenizeForHighlighting(text); 44 + 45 + let colorMap: Map<string, string> | undefined; 46 + if (useRangeColors) { 47 + const formula = text.slice(1); 48 + const ranges = extractFormulaRanges(formula); 49 + if (ranges.length > 0) { 50 + const colored = assignRangeColors(ranges); 51 + colorMap = new Map<string, string>(); 52 + for (const cr of colored) { 53 + if (!colorMap.has(cr.ref)) { 54 + colorMap.set(cr.ref, cr.color); 55 + } 56 + const localRef = cr.ref.includes('!') ? cr.ref.split('!').pop()! : cr.ref; 57 + if (localRef.includes(':')) { 58 + const [startRef, endRef] = localRef.split(':'); 59 + if (startRef && !colorMap.has(startRef)) { 60 + colorMap.set(startRef, cr.color); 61 + } 62 + if (endRef && !colorMap.has(endRef)) { 63 + colorMap.set(endRef, cr.color); 64 + } 65 + } 66 + } 67 + } 68 + } 69 + 70 + formulaHighlightLayer.innerHTML = renderHighlightedFormula(tokens, colorMap); 71 + formulaHighlightLayer.style.display = ''; 72 + formulaInput.classList.add('formula-highlighting'); 73 + } else { 74 + formulaHighlightLayer.innerHTML = ''; 75 + formulaHighlightLayer.style.display = 'none'; 76 + formulaInput.classList.remove('formula-highlighting'); 77 + } 78 + } 79 + 80 + export function updateFormulaRangeHighlights(deps: FormulaBarDeps, text: string): void { 81 + clearGridHighlights(); 82 + if (!text || !text.startsWith('=')) return; 83 + const formula = text.slice(1); 84 + const ranges = extractFormulaRanges(formula); 85 + if (ranges.length === 0) return; 86 + const colored = assignRangeColors(ranges); 87 + renderGridHighlights(colored, deps.grid, parseRef, colToLetter); 88 + } 89 + 90 + export function updateFormulaTooltip(text: string, cursorPos: number | null, anchorEl: HTMLElement): void { 91 + if (!text || !text.startsWith('=')) { 92 + hideTooltip(); 93 + return; 94 + } 95 + const result = detectCurrentFunction(text, cursorPos); 96 + if (result) { 97 + renderTooltip(result.functionName, result.paramIndex, anchorEl); 98 + } else { 99 + hideTooltip(); 100 + } 101 + } 102 + 103 + export function onFormulaInputUpdate(deps: FormulaBarDeps): void { 104 + const text = deps.formulaInput.value; 105 + updateFormulaHighlight(deps, text, true); 106 + updateFormulaRangeHighlights(deps, text); 107 + updateFormulaTooltip(text, deps.formulaInput.selectionStart, deps.formulaInput); 108 + if (deps.formulaHighlightLayer) { 109 + deps.formulaHighlightLayer.scrollLeft = deps.formulaInput.scrollLeft; 110 + } 111 + } 112 + 113 + // ── Formula Bar Commit ────────────────────────────────────── 114 + 115 + export function commitFormulaBar(deps: FormulaBarDeps): void { 116 + const selectedCell = deps.getSelectedCell(); 117 + const id = cellId(selectedCell.col, selectedCell.row); 118 + const raw = deps.formulaInput.value.trim(); 119 + 120 + if (raw.startsWith('=')) { 121 + deps.setCellData(id, { v: '', f: raw.slice(1) }); 122 + } else { 123 + const numVal = Number(raw); 124 + const value = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 125 + deps.setCellData(id, { v: value, f: '' }); 126 + } 127 + 128 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 129 + refreshVisibleCells(deps); 130 + } 131 + 132 + // ── Formula Bar Key Handler ───────────────────────────────── 133 + 134 + export function wireFormulaBarKeys(deps: FormulaBarDeps): void { 135 + deps.formulaInput.addEventListener('keydown', (e) => { 136 + if (e.key === 'Enter') { 137 + e.preventDefault(); 138 + commitFormulaBar(deps); 139 + deps.moveSelection(0, 1); 140 + deps.grid.focus(); 141 + } else if (e.key === 'Tab') { 142 + e.preventDefault(); 143 + commitFormulaBar(deps); 144 + deps.moveSelection(e.shiftKey ? -1 : 1, 0); 145 + deps.grid.focus(); 146 + } else if (e.key === 'Escape') { 147 + deps.updateFormulaBar(); 148 + deps.grid.focus(); 149 + } 150 + }); 151 + } 152 + 153 + // ── Refresh Visible Cells ─────────────────────────────────── 154 + 155 + export function refreshVisibleCells(deps: FormulaBarDeps): void { 156 + const cfRules = deps.getCfRulesArray(); 157 + let hasSparklines = false; 158 + deps.grid.querySelectorAll('td[data-id]').forEach((td: Element) => { 159 + const tdEl = td as HTMLElement; 160 + const id = tdEl.dataset.id!; 161 + const cellData = deps.getCellData(id); 162 + const display = deps.computeDisplayValue(id, cellData); 163 + const displayDiv = tdEl.querySelector('.cell-display') as HTMLElement; 164 + const isFrozen = tdEl.classList.contains('frozen-col') || tdEl.classList.contains('frozen-row') || tdEl.classList.contains('frozen-corner'); 165 + if (displayDiv) { 166 + if (isSparklineResult(display)) { 167 + if (!displayDiv.querySelector('canvas.sparkline-canvas')) { 168 + displayDiv.innerHTML = '<canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas>'; 169 + displayDiv.style.cssText = 'padding:0;overflow:hidden;' + getCellStyle(cellData, ''); 170 + } 171 + const bg = getCellBgColor(cellData, ''); 172 + if (isFrozen) { 173 + tdEl.style.setProperty('background', bg || 'var(--color-bg)', 'important'); 174 + } else { 175 + tdEl.style.background = bg || ''; 176 + } 177 + hasSparklines = true; 178 + } else { 179 + const existingCanvas = displayDiv.querySelector('canvas.sparkline-canvas'); 180 + if (existingCanvas) existingCanvas.remove(); 181 + displayDiv.textContent = display; 182 + const cfResult = evaluateRules(display, cfRules); 183 + const cfStyleStr = buildCfStyle(cfResult); 184 + displayDiv.style.cssText = getCellStyle(cellData, cfStyleStr); 185 + const bg = getCellBgColor(cellData, cfStyleStr); 186 + if (isFrozen) { 187 + tdEl.style.setProperty('background', bg || 'var(--color-bg)', 'important'); 188 + } else { 189 + tdEl.style.background = bg || ''; 190 + } 191 + if (cellData?.s?.wrap) displayDiv.classList.add('cell-wrap'); 192 + else displayDiv.classList.remove('cell-wrap'); 193 + } 194 + } 195 + const validation = deps.getValidationForCell(id); 196 + if (validation && cellData) { 197 + const valResult = validateCell(display, validation); 198 + tdEl.classList.toggle('validation-invalid', !valResult.valid); 199 + } else { 200 + tdEl.classList.remove('validation-invalid'); 201 + } 202 + }); 203 + if (hasSparklines) deps.renderSparklines(); 204 + }
+47 -609
src/sheets/main.ts
··· 26 26 import { hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle } from './cell-style-utils.js'; 27 27 import { buildMergeMap, findCellMerge } from './merge-utils.js'; 28 28 import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget, getSpillTargetValue } from './spill-tracking.js'; 29 - import { formatSaveTimestamp, getSaveDisplayText } from './save-indicator.js'; 29 + // save-indicator — now used via save-status-ui.ts 30 30 // csv-utils — now used via import-export.ts 31 31 import type { SpillState } from './spill-tracking.js'; 32 32 // status-bar — now used via status-bar-ui.ts ··· 34 34 import { getSheetContextText as _getSheetContextText, sendChatMessage as _sendChatMessage } from './ai-chat-panel.js'; 35 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'; 36 36 import { showPivotDialog as _showPivotDialog, renderPivots as _renderPivots } from './pivot-ui.js'; 37 - import { FORMULA_FUNCTIONS, filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js'; 37 + // formula-autocomplete — now used via formula-autocomplete-ui.ts 38 38 // cell-notes — now used via cell-notes-ui.ts 39 - import { tokenizeForHighlighting, renderHighlightedFormula } from './formula-highlighter.js'; 40 - import { extractFormulaRanges, assignRangeColors, renderGridHighlights, clearGridHighlights } from './range-highlight.js'; 41 - import { detectCurrentFunction, renderTooltip, hideTooltip } from './formula-tooltip.js'; 39 + // formula-highlighter, range-highlight, formula-tooltip — now used via formula-bar-ui.ts 40 + import { clearGridHighlights } from './range-highlight.js'; 41 + import { hideTooltip } from './formula-tooltip.js'; 42 42 import { extractFormat, applyFormat } from './format-painter.js'; 43 43 import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 44 44 // context-menu — now used via context-menu-handler.ts 45 45 // Sheet tab management functions used via sheet-tabs-ui.ts (no longer directly imported here) 46 46 import { buildCopyHtml, buildCopyTsv } from './clipboard-copy.js'; 47 47 import { parseClipboardHtml, parseClipboardTsv } from './clipboard-paste.js'; 48 - import { extractValuesOnly, extractFormulasOnly, extractFormattingOnly, transposeGrid, PASTE_MODES } from './paste-special.js'; 48 + // paste-special — now used via paste-special-ui.ts 49 49 import { computeVisibleRows, computeVisibleCols, hiddenRowsSpacerAdjustment, getAdjacentHiddenRows, getAdjacentHiddenCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 50 50 import { isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 51 51 import { isSparklineResult, drawSparkline } from './sparkline.js'; ··· 72 72 import { wireConnectionStatus as _wireConnectionStatus, setupCollabAvatars as _setupCollabAvatars } from './collaboration-ui.js'; 73 73 import { updateStatusBar as _updateStatusBarUI, wireStatusBarFreezeClick as _wireStatusBarFreezeClick } from './status-bar-ui.js'; 74 74 import { hideActiveContextMenu, wireContextMenu as _wireContextMenu, setActiveContextMenu } from './context-menu-handler.js'; 75 + import { showPasteSpecialDialog as _showPasteSpecialDialogUI } from './paste-special-ui.js'; 76 + import { updateFormulaHighlight as _updateFormulaHighlight, updateFormulaRangeHighlights as _updateFormulaRangeHighlights, updateFormulaTooltip, onFormulaInputUpdate as _onFormulaInputUpdate, commitFormulaBar as _commitFormulaBar, wireFormulaBarKeys as _wireFormulaBarKeys, refreshVisibleCells as _refreshVisibleCells } from './formula-bar-ui.js'; 77 + import { hideAutocomplete as _hideAutocomplete, attachCellEditorAutocomplete as _attachCellEditorAutocomplete, wireAutocomplete as _wireAutocomplete } from './formula-autocomplete-ui.js'; 78 + import { showShortcutModal, wireShortcutButton } from './shortcuts-modal.js'; 79 + import { wireSaveStatus } from './save-status-ui.js'; 75 80 76 81 // --- Constants --- 77 82 const DEFAULT_ROWS = 100; ··· 740 745 const _spillState = createSpillState(); 741 746 742 747 // Wrappers that pass local state/deps: 743 - function __clearSpillMaps() { clearSpillMaps(_spillState); } 748 + function _clearSpillMaps() { clearSpillMaps(_spillState); } 744 749 function _registerSpill(sourceId: string, arr: unknown[]): void { 745 750 const sheet = getActiveSheet(); 746 751 const maxRows = sheet.get('rowCount') || 100; ··· 1890 1895 } 1891 1896 } 1892 1897 1893 - // --- Paste Special Dialog --- 1894 - 1898 + // --- Paste Special (extracted to paste-special-ui.ts) --- 1895 1899 function showPasteSpecialDialog() { 1896 - // Prevent duplicate dialogs 1897 - if (document.querySelector('.paste-special-overlay')) return; 1898 - 1899 - const overlay = document.createElement('div'); 1900 - overlay.className = 'sheet-dialog-overlay paste-special-overlay'; 1901 - 1902 - overlay.innerHTML = '<div class="sheet-dialog paste-special-dialog">' 1903 - + '<h3>Paste Special</h3>' 1904 - + '<div class="paste-special-options">' 1905 - + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="all" checked> <span>All (default)</span></label>' 1906 - + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="values_only"> <span>Values Only</span></label>' 1907 - + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="formulas_only"> <span>Formulas Only</span></label>' 1908 - + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="formatting_only"> <span>Formatting Only</span></label>' 1909 - + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="transpose"> <span>Transpose</span></label>' 1910 - + '</div>' 1911 - + '<div class="sheet-dialog-actions">' 1912 - + '<button class="paste-special-cancel">Cancel</button>' 1913 - + '<button class="paste-special-submit btn-primary">Paste</button>' 1914 - + '</div>' 1915 - + '</div>'; 1916 - 1917 - document.body.appendChild(overlay); 1918 - 1919 - const dialog = overlay.querySelector('.paste-special-dialog'); 1920 - const cancelBtn = overlay.querySelector('.paste-special-cancel'); 1921 - const submitBtn = overlay.querySelector('.paste-special-submit'); 1922 - 1923 - function close() { 1924 - overlay.remove(); 1925 - document.removeEventListener('keydown', escHandler); 1926 - } 1927 - 1928 - function escHandler(ev) { 1929 - if (ev.key === 'Escape') { close(); } 1930 - } 1931 - 1932 - document.addEventListener('keydown', escHandler); 1933 - 1934 - // Close on click outside dialog 1935 - overlay.addEventListener('click', (ev) => { 1936 - if (ev.target === overlay) close(); 1937 - }); 1938 - 1939 - cancelBtn.addEventListener('click', close); 1940 - 1941 - submitBtn.addEventListener('click', () => { 1942 - const selected = overlay.querySelector('input[name="paste-mode"]:checked'); 1943 - const mode = selected ? selected.value : 'all'; 1944 - close(); 1945 - executePasteSpecial(mode); 1946 - }); 1947 - 1948 - // Focus the first radio for keyboard navigation 1949 - const firstRadio = overlay.querySelector('input[type="radio"]'); 1950 - if (firstRadio) firstRadio.focus(); 1951 - } 1952 - 1953 - function executePasteSpecial(mode) { 1954 - // Get data from internal clipboard buffer if available 1955 - let rows = _clipboardBuffer; 1956 - 1957 - if (!rows || rows.length === 0) { 1958 - // Fallback: try to read from system clipboard (plain text only) 1959 - navigator.clipboard.readText().then(text => { 1960 - if (!text) return; 1961 - const parsed = parseClipboardTsv(text); 1962 - if (parsed) { 1963 - applyPasteSpecialMode(parsed.rows, mode); 1964 - } 1965 - }).catch(() => {}); 1966 - return; 1967 - } 1968 - 1969 - applyPasteSpecialMode(rows, mode); 1970 - } 1971 - 1972 - function applyPasteSpecialMode(rows, mode) { 1973 - if (mode === 'all') { 1974 - pasteRowsAtSelection(rows); 1975 - return; 1976 - } 1977 - 1978 - // Convert to PasteCellData grid for paste-special transforms 1979 - const grid = rows.map(row => 1980 - row.map(cell => ({ 1981 - v: cell.value ?? '', 1982 - f: cell.formula || '', 1983 - s: cell.style || {}, 1984 - })) 1985 - ); 1986 - 1987 - let transformed; 1988 - switch (mode) { 1989 - case PASTE_MODES.VALUES_ONLY: 1990 - transformed = extractValuesOnly(grid); 1991 - break; 1992 - case PASTE_MODES.FORMULAS_ONLY: 1993 - transformed = extractFormulasOnly(grid); 1994 - break; 1995 - case PASTE_MODES.FORMATTING_ONLY: 1996 - transformed = extractFormattingOnly(grid); 1997 - break; 1998 - case PASTE_MODES.TRANSPOSE: 1999 - transformed = transposeGrid(grid); 2000 - break; 2001 - default: 2002 - pasteRowsAtSelection(rows); 2003 - return; 2004 - } 2005 - 2006 - // Convert back to rows format for pasteRowsAtSelection 2007 - const outputRows = transformed.map(row => 2008 - row.map(cell => ({ 2009 - value: cell ? cell.v : '', 2010 - formula: cell ? cell.f : '', 2011 - style: cell ? cell.s : {}, 2012 - })) 2013 - ); 2014 - pasteRowsAtSelection(outputRows); 1900 + _showPasteSpecialDialogUI({ getClipboardBuffer: () => _clipboardBuffer, pasteRowsAtSelection }); 2015 1901 } 2016 1902 2017 1903 // --- Visual updates (#18: improved range selection) --- ··· 2119 2005 updateFormulaHighlight(formulaInput.value); 2120 2006 } 2121 2007 2122 - // --- Formula syntax highlighting helpers --- 2008 + // --- Formula bar + highlighting (extracted to formula-bar-ui.ts) --- 2123 2009 const formulaHighlightLayer = document.getElementById('formula-highlight-layer'); 2124 2010 2125 - function updateFormulaHighlight(text, useRangeColors = false) { 2126 - if (!formulaHighlightLayer) return; 2127 - if (text && text.startsWith('=')) { 2128 - const tokens = tokenizeForHighlighting(text); 2129 - 2130 - // Build a color map from range highlights so formula-bar refs match grid borders. 2131 - // The tokenizer splits "A1:B5" into tokens [A1, :, B5] while extractFormulaRanges 2132 - // returns the full ref "A1:B5". We add both the full ref and the individual parts 2133 - // so either token shape gets the right color. Cross-sheet refs like "Sheet2!A1" 2134 - // are emitted as a single token matching the extracted ref directly. 2135 - let colorMap: Map<string, string> | undefined; 2136 - if (useRangeColors) { 2137 - const formula = text.slice(1); 2138 - const ranges = extractFormulaRanges(formula); 2139 - if (ranges.length > 0) { 2140 - const colored = assignRangeColors(ranges); 2141 - colorMap = new Map<string, string>(); 2142 - for (const cr of colored) { 2143 - if (!colorMap.has(cr.ref)) { 2144 - colorMap.set(cr.ref, cr.color); 2145 - } 2146 - // For range refs (A1:B5), also map the individual cell parts 2147 - // so the tokenizer's separate cell_ref tokens get colored 2148 - const localRef = cr.ref.includes('!') ? cr.ref.split('!').pop()! : cr.ref; 2149 - if (localRef.includes(':')) { 2150 - const [startRef, endRef] = localRef.split(':'); 2151 - if (startRef && !colorMap.has(startRef)) { 2152 - colorMap.set(startRef, cr.color); 2153 - } 2154 - if (endRef && !colorMap.has(endRef)) { 2155 - colorMap.set(endRef, cr.color); 2156 - } 2157 - } 2158 - } 2159 - } 2160 - } 2161 - 2162 - formulaHighlightLayer.innerHTML = renderHighlightedFormula(tokens, colorMap); 2163 - formulaHighlightLayer.style.display = ''; 2164 - formulaInput.classList.add('formula-highlighting'); 2165 - } else { 2166 - formulaHighlightLayer.innerHTML = ''; 2167 - formulaHighlightLayer.style.display = 'none'; 2168 - formulaInput.classList.remove('formula-highlighting'); 2169 - } 2011 + function _formulaBarDeps() { 2012 + return { 2013 + formulaInput, formulaHighlightLayer, grid, 2014 + getSelectedCell: () => selectedCell, setCellData, getCellData, 2015 + computeDisplayValue, getCfRulesArray, getValidationForCell, 2016 + evalCache: { clear: () => evalCache.clear() }, 2017 + clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 2018 + moveSelection, updateFormulaBar, renderSparklines, 2019 + }; 2170 2020 } 2171 - 2172 - function updateFormulaRangeHighlights(text) { 2173 - clearGridHighlights(); 2174 - if (!text || !text.startsWith('=')) return; 2175 - const formula = text.slice(1); 2176 - const ranges = extractFormulaRanges(formula); 2177 - if (ranges.length === 0) return; 2178 - const colored = assignRangeColors(ranges); 2179 - renderGridHighlights(colored, grid, parseRef, colToLetter); 2180 - } 2181 - 2182 - function updateFormulaTooltip(text, cursorPos, anchorEl) { 2183 - if (!text || !text.startsWith('=')) { 2184 - hideTooltip(); 2185 - return; 2186 - } 2187 - const result = detectCurrentFunction(text, cursorPos); 2188 - if (result) { 2189 - renderTooltip(result.functionName, result.paramIndex, anchorEl); 2190 - } else { 2191 - hideTooltip(); 2192 - } 2193 - } 2194 - 2195 - function onFormulaInputUpdate() { 2196 - const text = formulaInput.value; 2197 - updateFormulaHighlight(text, true); 2198 - updateFormulaRangeHighlights(text); 2199 - updateFormulaTooltip(text, formulaInput.selectionStart, formulaInput); 2200 - if (formulaHighlightLayer) { 2201 - formulaHighlightLayer.scrollLeft = formulaInput.scrollLeft; 2202 - } 2203 - } 2204 - 2205 - function refreshVisibleCells() { 2206 - const cfRules = getCfRulesArray(); 2207 - let hasSparklines = false; 2208 - grid.querySelectorAll('td[data-id]').forEach(td => { 2209 - const id = td.dataset.id; 2210 - const cellData = getCellData(id); 2211 - const display = computeDisplayValue(id, cellData); 2212 - const displayDiv = td.querySelector('.cell-display'); 2213 - const isFrozen = td.classList.contains('frozen-col') || td.classList.contains('frozen-row') || td.classList.contains('frozen-corner'); 2214 - if (displayDiv) { 2215 - if (isSparklineResult(display)) { 2216 - // Replace text content with sparkline canvas if needed 2217 - if (!displayDiv.querySelector('canvas.sparkline-canvas')) { 2218 - displayDiv.innerHTML = '<canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas>'; 2219 - displayDiv.style.cssText = 'padding:0;overflow:hidden;' + getCellStyle(cellData, ''); 2220 - } 2221 - // Background on td for sparklines too 2222 - // Frozen cells use !important so CSS rules like .in-range can't override with semi-transparent bg 2223 - const bg = getCellBgColor(cellData, ''); 2224 - if (isFrozen) { 2225 - td.style.setProperty('background', bg || 'var(--color-bg)', 'important'); 2226 - } else { 2227 - td.style.background = bg || ''; 2228 - } 2229 - hasSparklines = true; 2230 - } else { 2231 - // Remove sparkline canvas if value is no longer a sparkline 2232 - const existingCanvas = displayDiv.querySelector('canvas.sparkline-canvas'); 2233 - if (existingCanvas) existingCanvas.remove(); 2234 - displayDiv.textContent = display; 2235 - const cfResult = evaluateRules(display, cfRules); 2236 - const cfStyleStr = buildCfStyle(cfResult); 2237 - displayDiv.style.cssText = getCellStyle(cellData, cfStyleStr); 2238 - // Background on td so inset box-shadow grid lines paint on top 2239 - // Frozen cells use !important so CSS rules like .in-range can't override with semi-transparent bg 2240 - const bg = getCellBgColor(cellData, cfStyleStr); 2241 - if (isFrozen) { 2242 - td.style.setProperty('background', bg || 'var(--color-bg)', 'important'); 2243 - } else { 2244 - td.style.background = bg || ''; 2245 - } 2246 - // Update wrap class 2247 - if (cellData?.s?.wrap) displayDiv.classList.add('cell-wrap'); 2248 - else displayDiv.classList.remove('cell-wrap'); 2249 - } 2250 - } 2251 - // Update validation state 2252 - const validation = getValidationForCell(id); 2253 - if (validation && cellData) { 2254 - const valResult = validateCell(display, validation); 2255 - td.classList.toggle('validation-invalid', !valResult.valid); 2256 - } else { 2257 - td.classList.remove('validation-invalid'); 2258 - } 2259 - }); 2260 - if (hasSparklines) renderSparklines(); 2261 - } 2262 - 2263 - // --- Formula bar editing --- 2264 - function commitFormulaBar() { 2265 - const id = cellId(selectedCell.col, selectedCell.row); 2266 - const raw = formulaInput.value.trim(); 2267 - 2268 - if (raw.startsWith('=')) { 2269 - setCellData(id, { v: '', f: raw.slice(1) }); 2270 - } else { 2271 - const numVal = Number(raw); 2272 - const value = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 2273 - setCellData(id, { v: value, f: '' }); 2274 - } 2275 - 2276 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2277 - refreshVisibleCells(); 2278 - } 2279 - 2280 - formulaInput.addEventListener('keydown', (e) => { 2281 - if (e.key === 'Enter') { 2282 - e.preventDefault(); 2283 - commitFormulaBar(); 2284 - moveSelection(0, 1); 2285 - grid.focus(); 2286 - } else if (e.key === 'Tab') { 2287 - // Tab in formula bar: commit value and move to next/prev cell 2288 - e.preventDefault(); 2289 - commitFormulaBar(); 2290 - moveSelection(e.shiftKey ? -1 : 1, 0); 2291 - grid.focus(); 2292 - } else if (e.key === 'Escape') { 2293 - updateFormulaBar(); 2294 - grid.focus(); 2295 - } 2296 - }); 2021 + function updateFormulaHighlight(text, useRangeColors = false) { _updateFormulaHighlight(_formulaBarDeps(), text, useRangeColors); } 2022 + function updateFormulaRangeHighlights(text) { _updateFormulaRangeHighlights(_formulaBarDeps(), text); } 2023 + function onFormulaInputUpdate() { _onFormulaInputUpdate(_formulaBarDeps()); } 2024 + function refreshVisibleCells() { _refreshVisibleCells(_formulaBarDeps()); } 2025 + function commitFormulaBar() { _commitFormulaBar(_formulaBarDeps()); } 2026 + _wireFormulaBarKeys(_formulaBarDeps()); 2297 2027 2298 2028 // --- Toolbar --- 2299 2029 function applyStyleToSelection(styleProp, value) { ··· 2932 2662 mergeCells(); showToast('Cells merged'); 2933 2663 }); 2934 2664 2935 - // --- Autosave indicator (#17) --- 2936 - const saveIndicator = document.getElementById('save-indicator'); 2937 - const saveTextEl = document.getElementById('save-text'); 2938 - let lastSaveTime = Date.now(); 2939 - let saveState = 'saved'; 2940 - 2941 - function setSaveState(state, time) { 2942 - saveState = state; 2943 - saveIndicator.classList.remove('saved', 'saving', 'unsaved'); 2944 - saveIndicator.classList.add(state); 2945 - if (state === 'saved') { lastSaveTime = time || Date.now(); updateSaveTimestamp(); } 2946 - else { saveTextEl.textContent = getSaveDisplayText(state) || ''; } 2947 - } 2948 - 2949 - function updateSaveTimestamp() { 2950 - if (saveState !== 'saved') return; 2951 - const prefix = !provider.connected ? 'Saved locally' : 'Saved'; 2952 - const seconds = Math.floor((Date.now() - lastSaveTime) / 1000); 2953 - saveTextEl.textContent = formatSaveTimestamp(seconds, prefix); 2954 - } 2955 - 2956 - setInterval(updateSaveTimestamp, 30_000); 2957 - 2958 - // Listen for save-status events from the provider (replaces monkey-patching) 2959 - provider.on('save-status', (payload) => { 2960 - if (payload.status === 'saving') setSaveState('saving'); 2961 - else if (payload.status === 'saved') { 2962 - setSaveState('saved', Date.now()); 2963 - // Show "Saved locally" when offline (saved to IDB only, not server) 2964 - if (!provider.connected && saveTextEl) { 2965 - saveTextEl.textContent = 'Saved locally'; 2966 - } 2967 - } 2968 - else if (payload.status === 'error') setSaveState('unsaved'); 2969 - }); 2970 - 2971 - // Update save dot color based on save-status events 2972 - const saveDotEl = document.querySelector('.save-dot'); 2973 - if (saveDotEl) { 2974 - provider.on('save-status', (payload) => { 2975 - saveDotEl.classList.remove('save-dot--saved', 'save-dot--saving', 'save-dot--error'); 2976 - if (payload.status === 'saving') saveDotEl.classList.add('save-dot--saving'); 2977 - else if (payload.status === 'saved') saveDotEl.classList.add('save-dot--saved'); 2978 - else if (payload.status === 'error') saveDotEl.classList.add('save-dot--error'); 2979 - }); 2980 - } 2981 - 2982 - ydoc.on('update', (update, origin) => { 2983 - if (origin !== provider && saveState === 'saved') setSaveState('unsaved'); 2984 - }); 2665 + // --- Autosave indicator (extracted to save-status-ui.ts) --- 2666 + wireSaveStatus({ provider, ydoc }); 2985 2667 2986 2668 2987 2669 // --- Version Panel (slide-in, Cmd+Shift+H) --- ··· 3009 2691 btnHistory.addEventListener('click', () => sheetsVersionPanel.toggle()); 3010 2692 } 3011 2693 3012 - // --- Keyboard Shortcut Cheatsheet Modal (#15) --- 3013 - const SHEETS_SHORTCUTS = [ 3014 - { category: 'Formatting', shortcuts: [ 3015 - { keys: ['\u2318', 'B'], label: 'Bold' }, 3016 - { keys: ['\u2318', 'I'], label: 'Italic' }, 3017 - ]}, 3018 - { category: 'Navigation', shortcuts: [ 3019 - { keys: ['Arrow Keys'], label: 'Move between cells' }, 3020 - { keys: ['Tab'], label: 'Move right' }, 3021 - { keys: ['\u21e7', 'Tab'], label: 'Move left' }, 3022 - { keys: ['Enter'], label: 'Start editing / Move down' }, 3023 - { keys: ['F2'], label: 'Edit cell' }, 3024 - { keys: ['Escape'], label: 'Cancel editing' }, 3025 - { keys: ['\u21e7', 'Click'], label: 'Extend selection' }, 3026 - ]}, 3027 - { category: 'Editing', shortcuts: [ 3028 - { keys: ['Delete'], label: 'Clear selected cells' }, 3029 - { keys: ['\u2318', 'C'], label: 'Copy' }, 3030 - { keys: ['\u2318', 'V'], label: 'Paste' }, 3031 - { keys: ['\u2318', '\u21e7', 'V'], label: 'Paste Special' }, 3032 - { keys: ['\u2318', 'Z'], label: 'Undo' }, 3033 - ]}, 3034 - { category: 'Document', shortcuts: [ 3035 - { keys: ['\u2318', 'S'], label: 'Save snapshot' }, 3036 - { keys: ['\u2318', 'P'], label: 'Print' }, 3037 - { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' }, 3038 - ]}, 3039 - ]; 3040 - 3041 - function buildShortcutModal(shortcuts) { 3042 - const overlay = document.createElement('div'); 3043 - overlay.className = 'modal-overlay'; 3044 - const modal = document.createElement('div'); 3045 - modal.className = 'modal shortcuts-modal'; 3046 - let html = '<h2>Keyboard Shortcuts <button class="shortcuts-modal-close" title="Close (Escape)">\u2715</button></h2>'; 3047 - for (const cat of shortcuts) { 3048 - html += '<div class="shortcut-category"><div class="shortcut-category-title">' + cat.category + '</div>'; 3049 - for (const sc of cat.shortcuts) { 3050 - html += '<div class="shortcut-row"><span class="shortcut-label">' + sc.label + '</span><span class="shortcut-keys">'; 3051 - html += sc.keys.map(k => '<span class="shortcut-key">' + k + '</span>').join(''); 3052 - html += '</span></div>'; 3053 - } 3054 - html += '</div>'; 3055 - } 3056 - modal.innerHTML = html; 3057 - overlay.appendChild(modal); 3058 - const close = () => overlay.remove(); 3059 - modal.querySelector('.shortcuts-modal-close').addEventListener('click', close); 3060 - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 3061 - const handler = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } }; 3062 - document.addEventListener('keydown', handler); 3063 - return overlay; 3064 - } 3065 - 3066 - function showShortcutModal() { 3067 - if (document.querySelector('.shortcuts-modal')) return; 3068 - document.body.appendChild(buildShortcutModal(SHEETS_SHORTCUTS)); 3069 - } 3070 - 3071 - document.getElementById('btn-shortcuts').addEventListener('click', showShortcutModal); 3072 - 3073 - // (Responsive toolbar collapse removed -- flat single-row toolbar with overflow menu) 2694 + // --- Keyboard shortcuts modal (extracted to shortcuts-modal.ts) --- 2695 + wireShortcutButton(); 3074 2696 3075 2697 // ── Charts Feature (extracted to charts-ui.ts) ── 3076 2698 const chartsSection = document.getElementById('charts-section'); ··· 3483 3105 function updateStatusBar() { _updateStatusBarUI(_statusBarDeps()); } 3484 3106 _wireStatusBarFreezeClick(_statusBarDeps()); 3485 3107 3486 - // ======================================================== 3487 - // Formula Auto-Complete 3488 - // ======================================================== 3489 - 3108 + // ── Formula Autocomplete (extracted to formula-autocomplete-ui.ts) ── 3490 3109 const autocompleteEl = document.getElementById('formula-autocomplete'); 3491 - let acFiltered = []; 3492 - let acSelectedIndex = -1; 3493 - let acActive = false; 3494 - 3495 - function showAutocomplete(inputEl, query) { 3496 - acFiltered = filterFunctions(query); 3497 - if (acFiltered.length === 0) { 3498 - hideAutocomplete(); 3499 - return; 3500 - } 3501 - acSelectedIndex = -1; 3502 - acActive = true; 3503 - renderAutocompleteList(); 3504 - positionAutocomplete(inputEl); 3505 - autocompleteEl.style.display = ''; 3506 - } 3507 - 3508 - function hideAutocomplete() { 3509 - acActive = false; 3510 - acFiltered = []; 3511 - acSelectedIndex = -1; 3512 - autocompleteEl.style.display = 'none'; 3513 - } 3514 - 3515 - function renderAutocompleteList() { 3516 - let html = ''; 3517 - acFiltered.forEach((fn, i) => { 3518 - const cls = i === acSelectedIndex ? 'formula-autocomplete-item selected' : 'formula-autocomplete-item'; 3519 - html += '<div class="' + cls + '" data-ac-index="' + i + '">' 3520 - + '<span class="formula-autocomplete-name">' + fn.name + '</span>' 3521 - + '<span class="formula-autocomplete-signature">' + fn.signature + '</span>' 3522 - + '</div>'; 3523 - }); 3524 - autocompleteEl.innerHTML = html; 3525 - 3526 - // Scroll selected item into view 3527 - const selected = autocompleteEl.querySelector('.selected'); 3528 - if (selected) selected.scrollIntoView({ block: 'nearest' }); 3529 - } 3530 - 3531 - function positionAutocomplete(inputEl) { 3532 - const rect = inputEl.getBoundingClientRect(); 3533 - autocompleteEl.style.left = rect.left + 'px'; 3534 - autocompleteEl.style.top = (rect.bottom + 2) + 'px'; 3535 - } 3536 - 3537 - function acceptAutocomplete(inputEl) { 3538 - const fn = getSelectedFunction(acSelectedIndex, acFiltered); 3539 - if (!fn) { 3540 - hideAutocomplete(); 3541 - return; 3542 - } 3543 - // Insert function name + opening paren into the input 3544 - const value = inputEl.value; 3545 - // Find the start of the function name being typed (after = or last operator) 3546 - const eqIdx = value.lastIndexOf('='); 3547 - const prefix = eqIdx >= 0 ? value.slice(0, eqIdx + 1) : '='; 3548 - inputEl.value = prefix + fn.name + '('; 3549 - inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); 3550 - hideAutocomplete(); 3551 - } 3552 - 3553 - // Attach autocomplete to formula bar input 3554 - function handleFormulaAutocompleteKeyDown(e, inputEl) { 3555 - if (!acActive) return false; 3556 - 3557 - if (e.key === 'ArrowDown') { 3558 - e.preventDefault(); 3559 - acSelectedIndex = navigateAutocomplete(acSelectedIndex, acFiltered.length, 'down'); 3560 - renderAutocompleteList(); 3561 - return true; 3562 - } 3563 - if (e.key === 'ArrowUp') { 3564 - e.preventDefault(); 3565 - acSelectedIndex = navigateAutocomplete(acSelectedIndex, acFiltered.length, 'up'); 3566 - renderAutocompleteList(); 3567 - return true; 3568 - } 3569 - if (e.key === 'Tab' || e.key === 'Enter') { 3570 - if (acSelectedIndex >= 0) { 3571 - e.preventDefault(); 3572 - acceptAutocomplete(inputEl); 3573 - return true; 3574 - } 3575 - } 3576 - if (e.key === 'Escape') { 3577 - e.preventDefault(); 3578 - hideAutocomplete(); 3579 - return true; 3580 - } 3581 - return false; 3582 - } 3583 - 3584 - function handleFormulaAutocompleteInput(inputEl) { 3585 - const value = inputEl.value; 3586 - // Only activate after `=` 3587 - if (!value.includes('=')) { 3588 - hideAutocomplete(); 3589 - return; 3590 - } 3591 - // Extract the function name being typed (last token after = or operator) 3592 - const afterEq = value.slice(value.lastIndexOf('=') + 1); 3593 - // Get the last word-like token being typed 3594 - const match = afterEq.match(/([A-Za-z]+)$/); 3595 - if (match) { 3596 - showAutocomplete(inputEl, match[1]); 3597 - } else if (afterEq === '') { 3598 - // Just typed `=`, show all functions 3599 - showAutocomplete(inputEl, ''); 3600 - } else { 3601 - hideAutocomplete(); 3602 - } 3603 - } 3604 - 3605 - // Wire autocomplete into formula bar 3606 - formulaInput.addEventListener('input', () => handleFormulaAutocompleteInput(formulaInput)); 3607 - 3608 - // Intercept keydown on formula bar for autocomplete nav 3609 - const origFormulaKeydown = formulaInput.onkeydown; 3610 - formulaInput.addEventListener('keydown', (e) => { 3611 - if (handleFormulaAutocompleteKeyDown(e, formulaInput)) return; 3612 - }, true); 3613 - 3614 - // Wire autocomplete into cell editor inputs 3615 - function attachCellEditorAutocomplete(inputEl) { 3616 - inputEl.addEventListener('input', () => handleFormulaAutocompleteInput(inputEl)); 3617 - inputEl.addEventListener('keydown', (e) => { 3618 - if (handleFormulaAutocompleteKeyDown(e, inputEl)) return; 3619 - }, true); 3620 - } 3110 + function hideAutocomplete() { _hideAutocomplete(autocompleteEl); } 3111 + function attachCellEditorAutocomplete(inputEl) { _attachCellEditorAutocomplete(autocompleteEl, inputEl); } 3112 + _wireAutocomplete({ autocompleteEl, formulaInput }); 3621 3113 3622 - // Click on autocomplete items 3623 - autocompleteEl.addEventListener('mousedown', (e) => { 3624 - const item = e.target.closest('.formula-autocomplete-item'); 3625 - if (!item) return; 3626 - e.preventDefault(); 3627 - acSelectedIndex = parseInt(item.dataset.acIndex); 3628 - // Find the active input 3629 - const activeInput = document.activeElement; 3630 - if (activeInput && (activeInput === formulaInput || activeInput.classList.contains('cell-editor'))) { 3631 - acceptAutocomplete(activeInput); 3632 - } 3633 - }); 3634 - 3635 - // Hide autocomplete on blur 3636 - document.addEventListener('click', (e) => { 3637 - if (!autocompleteEl.contains(e.target) && e.target !== formulaInput && !e.target.classList.contains('cell-editor')) { 3638 - hideAutocomplete(); 3639 - } 3640 - }); 3641 - 3642 - // ======================================================== 3643 - // Formula UX: Syntax Highlighting, Range Highlights, Tooltips 3644 - // ======================================================== 3645 - 3114 + // ── Formula UX wiring (highlighting, tooltips, range highlights) ── 3646 3115 formulaInput.addEventListener('input', onFormulaInputUpdate); 3647 - formulaInput.addEventListener('click', () => { 3648 - updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); 3649 - }); 3650 - formulaInput.addEventListener('keyup', (e) => { 3651 - if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { 3652 - updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); 3653 - } 3654 - }); 3655 - formulaInput.addEventListener('scroll', () => { 3656 - if (formulaHighlightLayer) { 3657 - formulaHighlightLayer.scrollLeft = formulaInput.scrollLeft; 3658 - } 3659 - }); 3660 - formulaInput.addEventListener('focus', () => { 3661 - const text = formulaInput.value; 3662 - updateFormulaHighlight(text, true); 3663 - updateFormulaRangeHighlights(text); 3664 - }); 3665 - formulaInput.addEventListener('blur', () => { 3666 - hideTooltip(); 3667 - clearGridHighlights(); 3668 - // Re-render without range colors when editing ends 3669 - updateFormulaHighlight(formulaInput.value); 3670 - }); 3116 + formulaInput.addEventListener('click', () => { updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); }); 3117 + formulaInput.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); }); 3118 + formulaInput.addEventListener('scroll', () => { if (formulaHighlightLayer) formulaHighlightLayer.scrollLeft = formulaInput.scrollLeft; }); 3119 + formulaInput.addEventListener('focus', () => { updateFormulaHighlight(formulaInput.value, true); updateFormulaRangeHighlights(formulaInput.value); }); 3120 + formulaInput.addEventListener('blur', () => { hideTooltip(); clearGridHighlights(); updateFormulaHighlight(formulaInput.value); }); 3671 3121 3672 3122 function attachCellEditorFormulaUX(inputEl, anchorTd) { 3673 - inputEl.addEventListener('input', () => { 3674 - const text = inputEl.value; 3675 - formulaInput.value = text; 3676 - updateFormulaHighlight(text, true); 3677 - updateFormulaRangeHighlights(text); 3678 - updateFormulaTooltip(text, inputEl.selectionStart, anchorTd); 3679 - }); 3680 - inputEl.addEventListener('click', () => { 3681 - updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); 3682 - }); 3683 - inputEl.addEventListener('keyup', (e) => { 3684 - if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { 3685 - updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); 3686 - } 3687 - }); 3123 + inputEl.addEventListener('input', () => { const text = inputEl.value; formulaInput.value = text; updateFormulaHighlight(text, true); updateFormulaRangeHighlights(text); updateFormulaTooltip(text, inputEl.selectionStart, anchorTd); }); 3124 + inputEl.addEventListener('click', () => { updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); }); 3125 + inputEl.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); }); 3688 3126 } 3689 3127 3690 3128 // ── Cell Notes (extracted to cell-notes-ui.ts) ──
+127
src/sheets/paste-special-ui.ts
··· 1 + /** 2 + * Paste Special Dialog — paste mode selector and transform application. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { extractValuesOnly, extractFormulasOnly, extractFormattingOnly, transposeGrid, PASTE_MODES } from './paste-special.js'; 8 + import { parseClipboardTsv } from './clipboard-paste.js'; 9 + 10 + // ── Types ─────────────────────────────────────────────────── 11 + 12 + export interface PasteSpecialDeps { 13 + getClipboardBuffer: () => Array<Array<{ value: any; formula: string; style: Record<string, any> }>> | null; 14 + pasteRowsAtSelection: (rows: any[]) => void; 15 + } 16 + 17 + // ── Paste Special Dialog ──────────────────────────────────── 18 + 19 + export function showPasteSpecialDialog(deps: PasteSpecialDeps): void { 20 + if (document.querySelector('.paste-special-overlay')) return; 21 + 22 + const overlay = document.createElement('div'); 23 + overlay.className = 'sheet-dialog-overlay paste-special-overlay'; 24 + 25 + overlay.innerHTML = '<div class="sheet-dialog paste-special-dialog">' 26 + + '<h3>Paste Special</h3>' 27 + + '<div class="paste-special-options">' 28 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="all" checked> <span>All (default)</span></label>' 29 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="values_only"> <span>Values Only</span></label>' 30 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="formulas_only"> <span>Formulas Only</span></label>' 31 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="formatting_only"> <span>Formatting Only</span></label>' 32 + + '<label class="paste-special-option"><input type="radio" name="paste-mode" value="transpose"> <span>Transpose</span></label>' 33 + + '</div>' 34 + + '<div class="sheet-dialog-actions">' 35 + + '<button class="paste-special-cancel">Cancel</button>' 36 + + '<button class="paste-special-submit btn-primary">Paste</button>' 37 + + '</div>' 38 + + '</div>'; 39 + 40 + document.body.appendChild(overlay); 41 + 42 + const cancelBtn = overlay.querySelector('.paste-special-cancel')!; 43 + const submitBtn = overlay.querySelector('.paste-special-submit')!; 44 + 45 + function close() { 46 + overlay.remove(); 47 + document.removeEventListener('keydown', escHandler); 48 + } 49 + 50 + function escHandler(ev: KeyboardEvent) { 51 + if (ev.key === 'Escape') { close(); } 52 + } 53 + 54 + document.addEventListener('keydown', escHandler); 55 + overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); }); 56 + cancelBtn.addEventListener('click', close); 57 + 58 + submitBtn.addEventListener('click', () => { 59 + const selected = overlay.querySelector('input[name="paste-mode"]:checked') as HTMLInputElement; 60 + const mode = selected ? selected.value : 'all'; 61 + close(); 62 + executePasteSpecial(deps, mode); 63 + }); 64 + 65 + const firstRadio = overlay.querySelector('input[type="radio"]') as HTMLElement; 66 + if (firstRadio) firstRadio.focus(); 67 + } 68 + 69 + function executePasteSpecial(deps: PasteSpecialDeps, mode: string): void { 70 + let rows = deps.getClipboardBuffer(); 71 + 72 + if (!rows || rows.length === 0) { 73 + navigator.clipboard.readText().then(text => { 74 + if (!text) return; 75 + const parsed = parseClipboardTsv(text); 76 + if (parsed) { 77 + applyPasteSpecialMode(deps, parsed.rows, mode); 78 + } 79 + }).catch(() => {}); 80 + return; 81 + } 82 + 83 + applyPasteSpecialMode(deps, rows, mode); 84 + } 85 + 86 + function applyPasteSpecialMode(deps: PasteSpecialDeps, rows: any[], mode: string): void { 87 + if (mode === 'all') { 88 + deps.pasteRowsAtSelection(rows); 89 + return; 90 + } 91 + 92 + const grid = rows.map((row: any[]) => 93 + row.map((cell: any) => ({ 94 + v: cell.value ?? '', 95 + f: cell.formula || '', 96 + s: cell.style || {}, 97 + })) 98 + ); 99 + 100 + let transformed; 101 + switch (mode) { 102 + case PASTE_MODES.VALUES_ONLY: 103 + transformed = extractValuesOnly(grid); 104 + break; 105 + case PASTE_MODES.FORMULAS_ONLY: 106 + transformed = extractFormulasOnly(grid); 107 + break; 108 + case PASTE_MODES.FORMATTING_ONLY: 109 + transformed = extractFormattingOnly(grid); 110 + break; 111 + case PASTE_MODES.TRANSPOSE: 112 + transformed = transposeGrid(grid); 113 + break; 114 + default: 115 + deps.pasteRowsAtSelection(rows); 116 + return; 117 + } 118 + 119 + const outputRows = transformed.map((row: any[]) => 120 + row.map((cell: any) => ({ 121 + value: cell ? cell.v : '', 122 + formula: cell ? cell.f : '', 123 + style: cell ? cell.s : {}, 124 + })) 125 + ); 126 + deps.pasteRowsAtSelection(outputRows); 127 + }
+78
src/sheets/save-status-ui.ts
··· 1 + /** 2 + * Save Status UI — autosave indicator, save dot, and status text. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { formatSaveTimestamp, getSaveDisplayText } from './save-indicator.js'; 8 + 9 + // ── Types ─────────────────────────────────────────────────── 10 + 11 + export interface SaveStatusDeps { 12 + provider: { connected: boolean; on: (event: string, handler: (payload: any) => void) => void }; 13 + ydoc: { on: (event: string, handler: (...args: any[]) => void) => void }; 14 + } 15 + 16 + // ── State ─────────────────────────────────────────────────── 17 + 18 + let lastSaveTime = Date.now(); 19 + let saveState = 'saved'; 20 + 21 + // ── DOM refs ──────────────────────────────────────────────── 22 + 23 + let _saveIndicator: HTMLElement | null = null; 24 + let _saveTextEl: HTMLElement | null = null; 25 + 26 + // ── Internal ──────────────────────────────────────────────── 27 + 28 + function setSaveState(state: string, time?: number): void { 29 + saveState = state; 30 + _saveIndicator?.classList.remove('saved', 'saving', 'unsaved'); 31 + _saveIndicator?.classList.add(state); 32 + if (state === 'saved') { lastSaveTime = time || Date.now(); updateSaveTimestamp(); } 33 + else if (_saveTextEl) { _saveTextEl.textContent = getSaveDisplayText(state) || ''; } 34 + } 35 + 36 + let _providerRef: SaveStatusDeps['provider'] | null = null; 37 + 38 + function updateSaveTimestamp(): void { 39 + if (saveState !== 'saved') return; 40 + const prefix = _providerRef && !_providerRef.connected ? 'Saved locally' : 'Saved'; 41 + const seconds = Math.floor((Date.now() - lastSaveTime) / 1000); 42 + if (_saveTextEl) _saveTextEl.textContent = formatSaveTimestamp(seconds, prefix); 43 + } 44 + 45 + // ── Wire ──────────────────────────────────────────────────── 46 + 47 + export function wireSaveStatus(deps: SaveStatusDeps): void { 48 + _saveIndicator = document.getElementById('save-indicator'); 49 + _saveTextEl = document.getElementById('save-text'); 50 + _providerRef = deps.provider; 51 + 52 + setInterval(updateSaveTimestamp, 30_000); 53 + 54 + deps.provider.on('save-status', (payload) => { 55 + if (payload.status === 'saving') setSaveState('saving'); 56 + else if (payload.status === 'saved') { 57 + setSaveState('saved', Date.now()); 58 + if (!deps.provider.connected && _saveTextEl) { 59 + _saveTextEl.textContent = 'Saved locally'; 60 + } 61 + } 62 + else if (payload.status === 'error') setSaveState('unsaved'); 63 + }); 64 + 65 + const saveDotEl = document.querySelector('.save-dot'); 66 + if (saveDotEl) { 67 + deps.provider.on('save-status', (payload) => { 68 + saveDotEl.classList.remove('save-dot--saved', 'save-dot--saving', 'save-dot--error'); 69 + if (payload.status === 'saving') saveDotEl.classList.add('save-dot--saving'); 70 + else if (payload.status === 'saved') saveDotEl.classList.add('save-dot--saved'); 71 + else if (payload.status === 'error') saveDotEl.classList.add('save-dot--error'); 72 + }); 73 + } 74 + 75 + deps.ydoc.on('update', (update: any, origin: any) => { 76 + if (origin !== deps.provider && saveState === 'saved') setSaveState('unsaved'); 77 + }); 78 + }
+73
src/sheets/shortcuts-modal.ts
··· 1 + /** 2 + * Keyboard Shortcut Cheatsheet Modal (#15). 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + // ── Shortcut data ─────────────────────────────────────────── 8 + 9 + const SHEETS_SHORTCUTS = [ 10 + { category: 'Formatting', shortcuts: [ 11 + { keys: ['\u2318', 'B'], label: 'Bold' }, 12 + { keys: ['\u2318', 'I'], label: 'Italic' }, 13 + ]}, 14 + { category: 'Navigation', shortcuts: [ 15 + { keys: ['Arrow Keys'], label: 'Move between cells' }, 16 + { keys: ['Tab'], label: 'Move right' }, 17 + { keys: ['\u21e7', 'Tab'], label: 'Move left' }, 18 + { keys: ['Enter'], label: 'Start editing / Move down' }, 19 + { keys: ['F2'], label: 'Edit cell' }, 20 + { keys: ['Escape'], label: 'Cancel editing' }, 21 + { keys: ['\u21e7', 'Click'], label: 'Extend selection' }, 22 + ]}, 23 + { category: 'Editing', shortcuts: [ 24 + { keys: ['Delete'], label: 'Clear selected cells' }, 25 + { keys: ['\u2318', 'C'], label: 'Copy' }, 26 + { keys: ['\u2318', 'V'], label: 'Paste' }, 27 + { keys: ['\u2318', '\u21e7', 'V'], label: 'Paste Special' }, 28 + { keys: ['\u2318', 'Z'], label: 'Undo' }, 29 + ]}, 30 + { category: 'Document', shortcuts: [ 31 + { keys: ['\u2318', 'S'], label: 'Save snapshot' }, 32 + { keys: ['\u2318', 'P'], label: 'Print' }, 33 + { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' }, 34 + ]}, 35 + ]; 36 + 37 + // ── Modal builder ─────────────────────────────────────────── 38 + 39 + function buildShortcutModal(shortcuts: typeof SHEETS_SHORTCUTS): HTMLElement { 40 + const overlay = document.createElement('div'); 41 + overlay.className = 'modal-overlay'; 42 + const modal = document.createElement('div'); 43 + modal.className = 'modal shortcuts-modal'; 44 + let html = '<h2>Keyboard Shortcuts <button class="shortcuts-modal-close" title="Close (Escape)">\u2715</button></h2>'; 45 + for (const cat of shortcuts) { 46 + html += '<div class="shortcut-category"><div class="shortcut-category-title">' + cat.category + '</div>'; 47 + for (const sc of cat.shortcuts) { 48 + html += '<div class="shortcut-row"><span class="shortcut-label">' + sc.label + '</span><span class="shortcut-keys">'; 49 + html += sc.keys.map(k => '<span class="shortcut-key">' + k + '</span>').join(''); 50 + html += '</span></div>'; 51 + } 52 + html += '</div>'; 53 + } 54 + modal.innerHTML = html; 55 + overlay.appendChild(modal); 56 + const close = () => overlay.remove(); 57 + modal.querySelector('.shortcuts-modal-close')!.addEventListener('click', close); 58 + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 59 + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } }; 60 + document.addEventListener('keydown', handler); 61 + return overlay; 62 + } 63 + 64 + // ── Public API ────────────────────────────────────────────── 65 + 66 + export function showShortcutModal(): void { 67 + if (document.querySelector('.shortcuts-modal')) return; 68 + document.body.appendChild(buildShortcutModal(SHEETS_SHORTCUTS)); 69 + } 70 + 71 + export function wireShortcutButton(): void { 72 + document.getElementById('btn-shortcuts')?.addEventListener('click', showShortcutModal); 73 + }