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 'feat: sheets decomposition, master slides (0.47.0)' (#386) from feat/sheets-decomp-master-slides-v047 into main

scott 29984b20 7d74f45c

+1339 -589
+9
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.47.0] — 2026-04-15 11 + 12 + ### Added 13 + - Slides: customizable master slides — reusable layout templates with placeholder enforcement, 7 default masters from existing layouts (#657) 14 + - Diagrams: operation-based undo/redo history — replaces snapshot cloning with invertible operations for collaboration-safe undo (#668) 15 + 16 + ### Changed 17 + - Sheets: decomposed main.ts monolith into focused modules — core-state, cell-computation, selection-state, session-bootstrap (#656) 18 + 10 19 ## [0.46.0] — 2026-04-15 11 20 12 21 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.46.0", 3 + "version": "0.47.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+86
src/sheets/cell-computation.ts
··· 1 + import { evaluate, formatCell } from './formulas.js'; 2 + import { RecalcEngine } from './recalc.js'; 3 + import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget } from './spill-tracking.js'; 4 + import type { SpillState } from './spill-tracking.js'; 5 + import { isSparklineResult } from './sparkline.js'; 6 + import type { CellData, CellStore, RecalcCellData } from './types.js'; 7 + import { getCellData, getCells, getActiveSheet } from './core-state.js'; 8 + 9 + export const evalCache = new Map<string, unknown>(); 10 + const _spillState: SpillState = createSpillState(); 11 + let recalcEngine: RecalcEngine | null = null; 12 + 13 + export function clearEvalCache(): void { evalCache.clear(); } 14 + 15 + export function spillClearMaps(): void { clearSpillMaps(_spillState); } 16 + export function spillRegister(sourceId: string, arr: unknown[]): void { 17 + const sheet = getActiveSheet(); 18 + registerSpill( 19 + _spillState, sourceId, arr as never, 20 + getCellData, (sheet.get('rowCount') as number) || 100, (sheet.get('colCount') as number) || 26, 21 + ); 22 + } 23 + export function spillIsSource(id: string): boolean { return isSpillSource(_spillState, id); } 24 + export function spillIsTarget(id: string): boolean { return isSpillTarget(_spillState, id); } 25 + 26 + export function computeDisplayValue(id: string, cellData: CellData | null): unknown { 27 + if (!cellData) { 28 + const spillInfo = _spillState.targets.get(id); 29 + if (spillInfo) return formatCell(spillInfo.value, undefined); 30 + return ''; 31 + } 32 + if (cellData.f) { 33 + const val = evaluateFormula(cellData.f); 34 + if (isSparklineResult(val)) return val; 35 + if (Array.isArray(val) && (val as { _rangeRows?: number })._rangeRows) { 36 + spillRegister(id, val); 37 + const spillInfo = _spillState.sources.get(id); 38 + if (spillInfo && spillInfo.data[0] === '#SPILL!') return '#SPILL!'; 39 + return formatCell(val[0], cellData.s?.format); 40 + } 41 + return formatCell(val, cellData.s?.format); 42 + } 43 + if (!cellData.v && !cellData.f) { 44 + const spillInfo = _spillState.targets.get(id); 45 + if (spillInfo) return formatCell(spillInfo.value, cellData.s?.format); 46 + } 47 + return formatCell(cellData.v, cellData.s?.format); 48 + } 49 + 50 + export function evaluateFormula(formula: string): unknown { 51 + if (evalCache.has(formula)) return evalCache.get(formula); 52 + const result = evaluate(formula, (ref: string) => { 53 + const cachedKey = '__cell__' + ref; 54 + if (evalCache.has(cachedKey)) return evalCache.get(cachedKey) as string; 55 + const data = getCellData(ref); 56 + if (!data) return ''; 57 + if (data.f) return evaluateFormula(data.f) as string; 58 + return (data.v ?? '') as string; 59 + }); 60 + evalCache.set(formula, result); 61 + return result; 62 + } 63 + 64 + export function buildRecalcCellStore(): CellStore { 65 + return { 66 + get(id: string) { const data = getCellData(id); if (!data) return null; return { v: data.v ?? '', f: data.f || '' }; }, 67 + set(id: string, cell) { evalCache.set('__cell__' + id, cell.v); }, 68 + has(id: string) { return getCellData(id) !== null; }, 69 + entries() { 70 + const cells = getCells(); const result: [string, RecalcCellData][] = []; 71 + cells.forEach((yCell, id) => { result.push([id, { v: (yCell.get('v') as RecalcCellData['v']) ?? '', f: (yCell.get('f') as string) || '' }]); }); 72 + return result[Symbol.iterator](); 73 + }, 74 + getAllFormulaCells() { 75 + const cells = getCells(); const result: [string, RecalcCellData][] = []; 76 + cells.forEach((yCell, id) => { const f = (yCell.get('f') as string) || ''; if (f) result.push([id, { v: (yCell.get('v') as RecalcCellData['v']) ?? '', f }]); }); 77 + return result; 78 + }, 79 + }; 80 + } 81 + 82 + export function getRecalcEngine(): RecalcEngine { 83 + if (!recalcEngine) { recalcEngine = new RecalcEngine(buildRecalcCellStore()); recalcEngine.buildFullGraph(); } 84 + return recalcEngine; 85 + } 86 + export function invalidateRecalcEngine(): void { recalcEngine = null; }
+144
src/sheets/core-state.ts
··· 1 + import * as Y from 'yjs'; 2 + import type { CellData, CellStyle, CellValue, CfRule } from './types.js'; 3 + 4 + export const DEFAULT_ROWS = 100; 5 + export const DEFAULT_COLS = 26; 6 + export const DEFAULT_COL_WIDTH = 96; 7 + export const MIN_COL_WIDTH = 40; 8 + export const ROW_HEADER_WIDTH = 48; 9 + 10 + let _ySheets: Y.Map<Y.Map<unknown>>; 11 + let _activeSheetIdx = 0; 12 + 13 + export function initCoreState(ySheets: Y.Map<Y.Map<unknown>>): void { 14 + _ySheets = ySheets; 15 + } 16 + 17 + export function getYSheets(): Y.Map<Y.Map<unknown>> { return _ySheets; } 18 + export function getActiveSheetIdx(): number { return _activeSheetIdx; } 19 + export function setActiveSheetIdx(idx: number): void { _activeSheetIdx = idx; } 20 + 21 + export function ensureSheet(idx: number): Y.Map<unknown> { 22 + const key = `sheet_${idx}`; 23 + if (!_ySheets.has(key)) { 24 + const sheet = new Y.Map(); 25 + sheet.set('name', `Sheet ${idx + 1}`); 26 + sheet.set('cells', new Y.Map()); 27 + sheet.set('colWidths', new Y.Map()); 28 + sheet.set('rowCount', DEFAULT_ROWS); 29 + sheet.set('colCount', DEFAULT_COLS); 30 + _ySheets.set(key, sheet); 31 + } 32 + return _ySheets.get(key)!; 33 + } 34 + 35 + export function getActiveSheet(): Y.Map<unknown> { return ensureSheet(_activeSheetIdx); } 36 + export function getCells(): Y.Map<Y.Map<unknown>> { return getActiveSheet().get('cells') as Y.Map<Y.Map<unknown>>; } 37 + 38 + export function getColWidths(): Y.Map<number> { 39 + const sheet = getActiveSheet(); 40 + if (!sheet.has('colWidths')) sheet.set('colWidths', new Y.Map()); 41 + return sheet.get('colWidths') as Y.Map<number>; 42 + } 43 + export function getColWidth(col: number): number { 44 + const w = getColWidths().get(String(col)); 45 + return (typeof w === 'number' && w >= MIN_COL_WIDTH) ? w : DEFAULT_COL_WIDTH; 46 + } 47 + export function setColWidth(col: number, width: number): void { 48 + getColWidths().set(String(col), Math.max(MIN_COL_WIDTH, Math.round(width))); 49 + } 50 + 51 + export function getRowHeights(): Y.Map<number> { 52 + const sheet = getActiveSheet(); 53 + if (!sheet.has('rowHeights')) sheet.set('rowHeights', new Y.Map()); 54 + return sheet.get('rowHeights') as Y.Map<number>; 55 + } 56 + export function getRowHeight(row: number): number { 57 + const h = getRowHeights().get(String(row)); 58 + return (typeof h === 'number' && h >= 14) ? h : 26; 59 + } 60 + export function setRowHeight(row: number, height: number): void { 61 + getRowHeights().set(String(row), Math.max(14, Math.round(height))); 62 + } 63 + 64 + export function getFreezeRows(): number { return (getActiveSheet().get('freezeRows') as number) || 0; } 65 + export function getFreezeCols(): number { return (getActiveSheet().get('freezeCols') as number) || 0; } 66 + export function setFreezeRows(n: number): void { getActiveSheet().set('freezeRows', n); } 67 + export function setFreezeCols(n: number): void { getActiveSheet().set('freezeCols', n); } 68 + 69 + export function getStripedRows(): boolean { return (getActiveSheet().get('stripedRows') as boolean) || false; } 70 + export function setStripedRows(enabled: boolean): void { getActiveSheet().set('stripedRows', !!enabled); } 71 + 72 + export function getCfRules(): Y.Array<string> { 73 + const sheet = getActiveSheet(); 74 + if (!sheet.has('cfRules')) sheet.set('cfRules', new Y.Array()); 75 + return sheet.get('cfRules') as Y.Array<string>; 76 + } 77 + export function getCfRulesArray(): CfRule[] { 78 + const yArr = getCfRules(); 79 + const rules: CfRule[] = []; 80 + for (let i = 0; i < yArr.length; i++) { try { rules.push(JSON.parse(yArr.get(i))); } catch {} } 81 + return rules; 82 + } 83 + 84 + export function getValidations(): Y.Map<string> { 85 + const sheet = getActiveSheet(); 86 + if (!sheet.has('validations')) sheet.set('validations', new Y.Map()); 87 + return sheet.get('validations') as Y.Map<string>; 88 + } 89 + export function getValidationForCell(id: string): unknown | null { 90 + const validations = getValidations(); 91 + if (validations.has(id)) { try { return JSON.parse(validations.get(id)!); } catch {} } 92 + return null; 93 + } 94 + 95 + export function getMerges(): Y.Map<string> { 96 + const sheet = getActiveSheet(); 97 + if (!sheet.has('merges')) sheet.set('merges', new Y.Map()); 98 + return sheet.get('merges') as Y.Map<string>; 99 + } 100 + 101 + export function getCharts(): Y.Map<unknown> { 102 + const sheet = getActiveSheet(); 103 + if (!sheet.has('charts')) sheet.set('charts', new Y.Map()); 104 + return sheet.get('charts') as Y.Map<unknown>; 105 + } 106 + 107 + export function getPivots(): Y.Map<unknown> { 108 + const sheet = getActiveSheet(); 109 + if (!sheet.has('pivots')) sheet.set('pivots', new Y.Map()); 110 + return sheet.get('pivots') as Y.Map<unknown>; 111 + } 112 + 113 + export function getCellData(id: string): CellData | null { 114 + const cells = getCells(); 115 + if (!cells.has(id)) return null; 116 + const cell = cells.get(id); 117 + if (cell instanceof Y.Map) { 118 + return { 119 + v: (cell.get('v') as CellValue) ?? '', 120 + f: (cell.get('f') as string) ?? '', 121 + s: cell.get('s') ? (() => { try { return JSON.parse(cell.get('s') as string); } catch { return {} as CellStyle; } })() : ({} as CellStyle), 122 + }; 123 + } 124 + return null; 125 + } 126 + 127 + export function setCellData(id: string, data: { v?: unknown; f?: string; s?: CellStyle }): void { 128 + const cells = getCells(); 129 + let cell: Y.Map<unknown>; 130 + if (cells.has(id)) { cell = cells.get(id)!; } 131 + else { cell = new Y.Map(); cells.set(id, cell); } 132 + let v = data.v; 133 + if (v instanceof Date) v = v.getTime(); 134 + else if (typeof v === 'object' && v !== null) { 135 + if ('text' in v) v = (v as { text: string }).text; 136 + else if ('hyperlink' in v) v = (v as { hyperlink: string }).hyperlink; 137 + else if ('richText' in v && Array.isArray((v as { richText: unknown[] }).richText)) 138 + v = ((v as { richText: { text?: string }[] }).richText).map((p) => p.text || '').join(''); 139 + else v = String(v); 140 + } 141 + if (v !== undefined) cell.set('v', v); 142 + if (data.f !== undefined) cell.set('f', data.f); 143 + if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 144 + }
+301 -579
src/sheets/main.ts
··· 1 - // @ts-nocheck — 6k-line monolith needs decomposition before strict types (see #409) 2 - /** 3 - * Tools Sheets — E2EE collaborative spreadsheet. 4 - * 5 - * Backed by Yjs for real-time collaboration. All data encrypted 6 - * before leaving the browser. 7 - */ 8 - 1 + // @ts-nocheck — deps objects passed to extracted modules need typed interfaces before this can be removed 9 2 import * as Y from 'yjs'; 10 - import { importKey } from '../lib/crypto.js'; 11 3 import { setupTooltips } from '../lib/tooltips.js'; 12 - import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 13 - import { ensureWrappingKey } from '../lib/key-passphrase.js'; 14 - import { EncryptedProvider } from '../lib/provider.js'; 15 4 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 16 - import { evaluate, formatCell, cellId } from './formulas.js'; 17 - import { RecalcEngine } from './recalc.js'; 18 - import { getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 19 - import { normalizeRange, isInRange } from './selection-utils.js'; 20 - import { buildMergeMap, findCellMerge } from './merge-utils.js'; 21 - import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget } from './spill-tracking.js'; 5 + import { cellId } from './formulas.js'; 6 + import { normalizeRange } from './selection-utils.js'; 7 + import { buildMergeMap } from './merge-utils.js'; 22 8 import { isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 23 - import { isSparklineResult } from './sparkline.js'; 24 9 import { 25 10 createChatSidebar, createChatState, loadConfig, initChatWiring, 26 11 } from '../lib/ai-chat.js'; 27 12 import type { PivotConfig } from './pivot-table.js'; 28 - // ── Extracted UI modules ──────────────────────────────────── 29 13 import { showToast, handleImportFile as _handleImportFile, printSheet as _printSheet, wireImportExportToolbar as _wireImportExportToolbar } from './import-export.js'; 30 14 import { showChartDialog as _showChartDialogUI, renderCharts as _renderChartsUI } from './charts-ui.js'; 31 15 import { showDbViewDialog as _showDbViewDialogUI, renderDbView as _renderDbViewUI } from './database-views-ui.js'; 32 16 import { toggleFilterMode as _toggleFilterMode, getFilterHiddenRows as _getFilterHiddenRows, applyFilterToGrid as _applyFilterToGrid, setupFilterGridObserver as _setupFilterGridObserver, loadFilterStateFromYjs as _loadFilterStateFromYjs } from './filter-ui.js'; 33 - import { createFindReplaceBar, showFindReplaceBar as _showFindReplaceBarUI, hideFindReplaceBar as _hideFindReplaceBarUI, wireFindReplaceBar as _wireFindReplaceBar, getSheetsFindState } from './find-replace-bar.js'; 17 + import { createFindReplaceBar, showFindReplaceBar as _showFindReplaceBarUI, hideFindReplaceBar as _hideFindReplaceBarUI, wireFindReplaceBar as _wireFindReplaceBar } from './find-replace-bar.js'; 34 18 import { getNotesMap as _getNotesMap, getNotesObject as _getNotesObject, setNoteInYjs as _setNoteInYjs, showNoteDialog as _showNoteDialogUI, renderNoteIndicators as _renderNoteIndicatorsUI, wireNoteHover as _wireNoteHover, wireErrorTooltip as _wireErrorTooltip } from './cell-notes-ui.js'; 35 19 import { wireConnectionStatus as _wireConnectionStatus, setupCollabAvatars as _setupCollabAvatars } from './collaboration-ui.js'; 36 20 import { updateStatusBar as _updateStatusBarUI, wireStatusBarFreezeClick as _wireStatusBarFreezeClick } from './status-bar-ui.js'; ··· 66 50 import { showPivotDialog as _showPivotDialog, renderPivots as _renderPivots } from './pivot-ui.js'; 67 51 import { clearGridHighlights } from './range-highlight.js'; 68 52 import { hideTooltip } from './formula-tooltip.js'; 69 - // ── Phase 8 extracted modules ─────────────────────────────── 70 53 import { renderImageCells as _renderImageCellsUI, wireImageCells as _wireImageCells } from './image-cells-ui.js'; 71 54 import { updateMergeButtonState as _updateMergeButtonStateUI, wireMergeButton as _wireMergeButton } from './merge-cells-ui.js'; 72 55 import { wireVersionPanel as _wireVersionPanel, wireDocTitle as _wireDocTitle } from './version-history-ui.js'; ··· 78 61 buildHiddenRowSet as _buildHiddenRowSet, buildHiddenColSet as _buildHiddenColSet, 79 62 } from './hidden-rows-cols-ui.js'; 80 63 import { attachPreSyncObservers as _attachPreSyncObservers, wireSyncEvent as _wireSyncEvent } from './sync-observers.js'; 81 - 82 - // --- Constants --- 83 - const DEFAULT_ROWS = 100; 84 - const DEFAULT_COLS = 26; 85 - const DEFAULT_COL_WIDTH = 96; 86 - const MIN_COL_WIDTH = 40; 87 - const ROW_HEADER_WIDTH = 48; 88 - 89 - // --- Clipboard buffer for paste-special operations --- 90 - let _clipboardBuffer: Array<Array<{ value: any; formula: string; style: Record<string, any> }>> | null = null; 91 - 92 - // --- Resolve document ID and encryption key --- 93 - const pathParts = location.pathname.split('/').filter(Boolean); 94 - const docId = pathParts[1]; 95 - const hash = location.hash.slice(1); 96 - 97 - // Migrate legacy localStorage keys 98 - if (localStorage.getItem('crypt-keys') && !localStorage.getItem('tools-keys')) { 99 - localStorage.setItem('tools-keys', localStorage.getItem('crypt-keys')); 100 - localStorage.removeItem('crypt-keys'); 101 - } 102 - if (localStorage.getItem('crypt-username') && !localStorage.getItem('tools-username')) { 103 - localStorage.setItem('tools-username', localStorage.getItem('crypt-username')); 104 - localStorage.removeItem('crypt-username'); 105 - } 106 - 107 - // Ensure key wrapping passphrase is available before accessing keys 108 - await ensureWrappingKey(); 109 - 110 - const storedKeysInit = await getLocalKeys(); 111 - let keyString = hash || storedKeysInit[docId]; 112 - 113 - if (!keyString) { 114 - const serverKeys = await fetchServerKeys(); 115 - if (serverKeys?.[docId]) { keyString = serverKeys[docId]; } 116 - } 117 - 118 - if (!docId || !keyString) { 119 - location.href = '/'; 120 - throw new Error('No document ID or key'); 121 - } 122 - 123 - await storeKey(docId, keyString); 124 - pushKeysToServer({ [docId]: keyString }); 125 - 126 - const cryptoKey = await importKey(keyString); 127 - 128 - // --- Yjs setup --- 129 - const ydoc = new Y.Doc(); 130 - const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 131 - await provider.whenReady; 132 - 133 - const ySheets = ydoc.getMap('sheets'); 134 - let activeSheetIdx = 0; 135 - 136 - function ensureSheet(idx) { 137 - const key = `sheet_${idx}`; 138 - if (!ySheets.has(key)) { 139 - const sheet = new Y.Map(); 140 - sheet.set('name', `Sheet ${idx + 1}`); 141 - sheet.set('cells', new Y.Map()); 142 - sheet.set('colWidths', new Y.Map()); 143 - sheet.set('rowCount', DEFAULT_ROWS); 144 - sheet.set('colCount', DEFAULT_COLS); 145 - ySheets.set(key, sheet); 146 - } 147 - return ySheets.get(key); 148 - } 149 - 150 - function getActiveSheet() { return ensureSheet(activeSheetIdx); } 151 - function getCells() { return getActiveSheet().get('cells'); } 152 - 153 - // --- Column width helpers --- 154 - function getColWidths() { 155 - const sheet = getActiveSheet(); 156 - if (!sheet.has('colWidths')) sheet.set('colWidths', new Y.Map()); 157 - return sheet.get('colWidths'); 158 - } 159 - function getColWidth(col) { 160 - const w = getColWidths().get(String(col)); 161 - return (typeof w === 'number' && w >= MIN_COL_WIDTH) ? w : DEFAULT_COL_WIDTH; 162 - } 163 - function setColWidth(col, width) { getColWidths().set(String(col), Math.max(MIN_COL_WIDTH, Math.round(width))); } 64 + import { createCommandPalette } from '../command-palette.js'; 164 65 165 - // --- Freeze pane state --- 166 - function getFreezeRows() { return getActiveSheet().get('freezeRows') || 0; } 167 - function getFreezeCols() { return getActiveSheet().get('freezeCols') || 0; } 168 - function setFreezeRows(n) { getActiveSheet().set('freezeRows', n); } 169 - function setFreezeCols(n) { getActiveSheet().set('freezeCols', n); } 66 + import { bootstrap } from './session-bootstrap.js'; 67 + import { 68 + initCoreState, ensureSheet, getActiveSheet, getCells, getYSheets, 69 + getActiveSheetIdx, setActiveSheetIdx, 70 + getCellData, setCellData, 71 + getColWidth, setColWidth, getColWidths, getRowHeight, setRowHeight, 72 + getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 73 + getCfRules, getCfRulesArray, getValidations, getValidationForCell, 74 + getStripedRows, setStripedRows, getMerges, getCharts, getPivots, 75 + DEFAULT_ROWS, DEFAULT_COLS, MIN_COL_WIDTH, ROW_HEADER_WIDTH, 76 + } from './core-state.js'; 77 + import { 78 + evalCache, clearEvalCache, spillClearMaps, spillIsSource, spillIsTarget, 79 + computeDisplayValue, evaluateFormula, invalidateRecalcEngine, 80 + } from './cell-computation.js'; 81 + import { 82 + sheetsFindState, 83 + getSelectedCell, setSelectedCell, getSelectionRange, setSelectionRange, 84 + getEditingCell, setEditingCell, setIsSelecting, setIsFillDragging, 85 + getFillPreviewRange, setFillPreviewRange, 86 + getFindReplaceBarVisible, setFindReplaceBarVisible, 87 + getClipboardBuffer, setClipboardBuffer, 88 + } from './selection-state.js'; 170 89 171 - // --- Conditional Formatting rules --- 172 - function getCfRules() { 173 - const sheet = getActiveSheet(); 174 - if (!sheet.has('cfRules')) sheet.set('cfRules', new Y.Array()); 175 - return sheet.get('cfRules'); 176 - } 177 - function getCfRulesArray() { 178 - const yArr = getCfRules(); 179 - const rules = []; 180 - for (let i = 0; i < yArr.length; i++) { try { rules.push(JSON.parse(yArr.get(i))); } catch {} } 181 - return rules; 182 - } 183 - 184 - // --- Data Validation rules --- 185 - function getValidations() { 186 - const sheet = getActiveSheet(); 187 - if (!sheet.has('validations')) sheet.set('validations', new Y.Map()); 188 - return sheet.get('validations'); 189 - } 190 - function getValidationForCell(id) { 191 - const validations = getValidations(); 192 - if (validations.has(id)) { try { return JSON.parse(validations.get(id)); } catch {} } 193 - return null; 194 - } 195 - 196 - // --- Striped rows --- 197 - function getStripedRows() { return getActiveSheet().get('stripedRows') || false; } 198 - function setStripedRows(enabled) { getActiveSheet().set('stripedRows', !!enabled); } 90 + // ── Session bootstrap ────────────────────────────────────── 91 + const { docId, cryptoKey, ydoc, provider, ySheets } = await bootstrap(); 92 + initCoreState(ySheets); 93 + const undoManager = new Y.UndoManager(ySheets); 199 94 200 - // --- Hidden rows/cols (extracted to hidden-rows-cols-ui.ts) --- 201 - function isRowHidden(row) { return _isRowHidden(getActiveSheet, row); } 202 - function isColHidden(col) { return _isColHidden(getActiveSheet, col); } 95 + // ── Hidden rows/cols ─────────────────────────────────────── 96 + function isRowHidden(row: number) { return _isRowHidden(getActiveSheet, row); } 97 + function isColHidden(col: number) { return _isColHidden(getActiveSheet, col); } 203 98 function _hiddenDeps() { 204 - return { ydoc, getActiveSheet, getSelectionRange: () => selectionRange, renderGrid, DEFAULT_ROWS, DEFAULT_COLS }; 99 + return { ydoc, getActiveSheet, getSelectionRange, renderGrid, DEFAULT_ROWS, DEFAULT_COLS }; 205 100 } 206 101 function hideSelectedRows() { _hideSelectedRowsUI(_hiddenDeps()); } 207 102 function hideSelectedCols() { _hideSelectedColsUI(_hiddenDeps()); } 208 - function unhideAdjacentRows(row) { _unhideAdjacentRowsUI(_hiddenDeps(), row); } 209 - function unhideAdjacentCols(col) { _unhideAdjacentColsUI(_hiddenDeps(), col); } 103 + function unhideAdjacentRows(row: number) { _unhideAdjacentRowsUI(_hiddenDeps(), row); } 104 + function unhideAdjacentCols(col: number) { _unhideAdjacentColsUI(_hiddenDeps(), col); } 210 105 function buildHiddenRowSet() { return _buildHiddenRowSet(getActiveSheet); } 211 106 function buildHiddenColSet() { return _buildHiddenColSet(getActiveSheet); } 212 - 213 - // --- Row heights --- 214 - function getRowHeights() { 215 - const sheet = getActiveSheet(); 216 - if (!sheet.has('rowHeights')) sheet.set('rowHeights', new Y.Map()); 217 - return sheet.get('rowHeights'); 218 - } 219 - function getRowHeight(row) { 220 - const h = getRowHeights().get(String(row)); 221 - return (typeof h === 'number' && h >= 14) ? h : 26; 222 - } 223 - function setRowHeight(row, height) { getRowHeights().set(String(row), Math.max(14, Math.round(height))); } 224 - 225 - // Yjs UndoManager 226 - const undoManager = new Y.UndoManager(ySheets); 227 - 228 - // --- State --- 229 - let selectedCell = { col: 1, row: 1 }; 230 - let selectionRange = null; 231 - let editingCell = null; 232 - let isSelecting = false; 233 - let isFillDragging = false; 234 - let fillPreviewRange = null; 235 - const sheetsFindState = getSheetsFindState(); 236 - let findReplaceBarVisible = false; 237 - 238 - // --- Merge helpers --- 239 - function getMerges() { 240 - const sheet = getActiveSheet(); 241 - if (!sheet.has('merges')) sheet.set('merges', new Y.Map()); 242 - return sheet.get('merges'); 243 - } 244 107 function _buildMergeMap() { return buildMergeMap(getMerges().entries()); } 245 - function isCellMerged(col, row) { return findCellMerge(col, row, getMerges().entries()); } 246 108 247 - // --- Measurement + DOM refs --- 109 + // ── DOM refs ─────────────────────────────────────────────── 248 110 const measureCanvas = document.createElement('canvas'); 249 - const measureCtx = measureCanvas.getContext('2d'); 250 - const grid = document.getElementById('sheet-grid'); 251 - const cellAddressInput = document.getElementById('cell-address'); 111 + const measureCtx = measureCanvas.getContext('2d')!; 112 + const grid = document.getElementById('sheet-grid')!; 113 + const cellAddressInput = document.getElementById('cell-address')!; 252 114 const formulaInput = document.getElementById('formula-input') as HTMLInputElement; 253 - const sheetContainer = document.getElementById('sheet-container'); 115 + const sheetContainer = document.getElementById('sheet-container')!; 254 116 const sheetTabsContainer = document.getElementById('sheet-tabs'); 117 + const formulaHighlightLayer = document.getElementById('formula-highlight-layer'); 118 + const autocompleteEl = document.getElementById('formula-autocomplete'); 119 + const chartsSection = document.getElementById('charts-section'); 120 + const pivotSection = document.getElementById('pivot-section'); 121 + const dbViewSection = document.getElementById('database-view-section'); 122 + const titleInput = document.getElementById('doc-title') as HTMLInputElement; 255 123 256 - // Translate vertical wheel into horizontal scroll on the sheet-tabs strip, 257 - // so users can flick through tabs with a trackpad or mouse wheel. We only 258 - // hijack the event when the user is scrolling mostly-vertically and the 259 - // strip actually has horizontal overflow to consume. 260 124 if (sheetTabsContainer) { 261 125 sheetTabsContainer.addEventListener('wheel', (e: WheelEvent) => { 262 126 const canScrollX = sheetTabsContainer.scrollWidth > sheetTabsContainer.clientWidth; ··· 268 132 }, { passive: false }); 269 133 } 270 134 271 - // --- Grid rendering (extracted to grid-rendering.ts) --- 135 + // ── Grid rendering ───────────────────────────────────────── 272 136 function _gridRenderingDeps() { 273 137 return { 274 138 grid, getActiveSheet, getCellData, computeDisplayValue, 275 139 getColWidth, getRowHeight, getFreezeRows, getFreezeCols, 276 140 getCfRulesArray, getStripedRows, getValidationForCell, 277 141 buildMergeMap: _buildMergeMap, buildHiddenRowSet, buildHiddenColSet, 278 - isSpillSource: _isSpillSource, isSpillTarget: _isSpillTarget, 279 - sheetsFindState, getEditingCell: () => editingCell, 280 - getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 142 + isSpillSource: spillIsSource, isSpillTarget: spillIsTarget, 143 + sheetsFindState, getEditingCell, 144 + getSelectedCell, getSelectionRange, 281 145 refreshVisibleCells, updateSelectionVisuals, updateFreezeToolbarState, 282 146 renderNoteIndicators, renderImageCells, attachGridEvents, 283 147 DEFAULT_ROWS, DEFAULT_COLS, MIN_COL_WIDTH, ROW_HEADER_WIDTH, ··· 286 150 function scheduleRenderGrid() { _scheduleRenderGrid(_gridRenderingDeps()); } 287 151 function renderGrid() { _renderGridGR(_gridRenderingDeps()); } 288 152 289 - function getCellData(id) { 290 - const cells = getCells(); 291 - if (!cells.has(id)) return null; 292 - const cell = cells.get(id); 293 - if (cell instanceof Y.Map) { 294 - return { 295 - v: cell.get('v') ?? '', 296 - f: cell.get('f') ?? '', 297 - s: cell.get('s') ? (() => { try { return JSON.parse(cell.get('s')); } catch { return {}; } })() : {}, 298 - }; 299 - } 300 - return null; 301 - } 302 - 303 - function setCellData(id, data) { 304 - const cells = getCells(); 305 - let cell; 306 - if (cells.has(id)) { cell = cells.get(id); } 307 - else { cell = new Y.Map(); cells.set(id, cell); } 308 - let v = data.v; 309 - if (v instanceof Date) v = v.getTime(); 310 - else if (typeof v === 'object' && v !== null) { 311 - if ('text' in v) v = v.text; 312 - else if ('hyperlink' in v) v = v.hyperlink; 313 - else if ('richText' in v && Array.isArray(v.richText)) v = v.richText.map((p: { text?: string }) => p.text || '').join(''); 314 - else v = String(v); 315 - } 316 - if (v !== undefined) cell.set('v', v); 317 - if (data.f !== undefined) cell.set('f', data.f); 318 - if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 319 - } 320 - 321 - function computeDisplayValue(id, cellData) { 322 - if (!cellData) { 323 - const spillInfo = _spillState.targets.get(id); 324 - if (spillInfo) return formatCell(spillInfo.value, undefined); 325 - return ''; 326 - } 327 - if (cellData.f) { 328 - const val = evaluateFormula(cellData.f); 329 - if (isSparklineResult(val)) return val; 330 - if (Array.isArray(val) && (val as any)._rangeRows) { 331 - _registerSpill(id, val); 332 - const spillInfo = _spillState.sources.get(id); 333 - if (spillInfo && spillInfo.data[0] === '#SPILL!') return '#SPILL!'; 334 - return formatCell(val[0], cellData.s?.format); 335 - } 336 - return formatCell(val, cellData.s?.format); 337 - } 338 - if (!cellData.v && !cellData.f) { 339 - const spillInfo = _spillState.targets.get(id); 340 - if (spillInfo) return formatCell(spillInfo.value, cellData.s?.format); 341 - } 342 - return formatCell(cellData.v, cellData.s?.format); 343 - } 344 - 345 - const evalCache = new Map(); 346 - 347 - // --- Spill tracking --- 348 - const _spillState = createSpillState(); 349 - function _clearSpillMaps() { clearSpillMaps(_spillState); } 350 - function _registerSpill(sourceId: string, arr: unknown[]): void { 351 - const sheet = getActiveSheet(); 352 - registerSpill(_spillState, sourceId, arr as any, getCellData, sheet.get('rowCount') || 100, sheet.get('colCount') || 26); 353 - } 354 - function _isSpillSource(id: string): boolean { return isSpillSource(_spillState, id); } 355 - function _isSpillTarget(id: string): boolean { return isSpillTarget(_spillState, id); } 356 - 357 - // --- Recalc engine --- 358 - function buildRecalcCellStore() { 359 - return { 360 - get(id) { const data = getCellData(id); if (!data) return null; return { v: data.v ?? '', f: data.f || '' }; }, 361 - set(id, cell) { evalCache.set('__cell__' + id, cell.v); }, 362 - has(id) { return getCellData(id) !== null; }, 363 - entries() { 364 - const cells = getCells(); const result = []; 365 - cells.forEach((yCell, id) => { result.push([id, { v: yCell.get('v') ?? '', f: yCell.get('f') || '' }]); }); 366 - return result[Symbol.iterator](); 367 - }, 368 - getAllFormulaCells() { 369 - const cells = getCells(); const result = []; 370 - cells.forEach((yCell, id) => { const f = yCell.get('f') || ''; if (f) result.push([id, { v: yCell.get('v') ?? '', f }]); }); 371 - return result; 372 - }, 373 - }; 374 - } 375 - 376 - let recalcEngine = null; 377 - function getRecalcEngine() { 378 - if (!recalcEngine) { recalcEngine = new RecalcEngine(buildRecalcCellStore()); recalcEngine.buildFullGraph(); } 379 - return recalcEngine; 380 - } 381 - function invalidateRecalcEngine() { recalcEngine = null; } 382 - 383 - function evaluateFormula(formula) { 384 - if (evalCache.has(formula)) return evalCache.get(formula); 385 - const result = evaluate(formula, (ref) => { 386 - const cachedKey = '__cell__' + ref; 387 - if (evalCache.has(cachedKey)) return evalCache.get(cachedKey); 388 - const data = getCellData(ref); 389 - if (!data) return ''; 390 - if (data.f) return evaluateFormula(data.f); 391 - return data.v ?? ''; 392 - }); 393 - evalCache.set(formula, result); 394 - return result; 395 - } 396 - 397 - function _isInRange(col, row) { return isInRange(col, row, selectionRange); } 398 - 399 - // --- Grid events --- 153 + // ── Grid events ──────────────────────────────────────────── 400 154 function attachGridEvents() { 401 155 grid.addEventListener('mousedown', onGridMouseDown); 402 156 grid.addEventListener('dblclick', onGridDblClick); 403 157 grid.addEventListener('touchstart', onGridTouchStart, { passive: false }); 404 158 } 405 159 406 - // --- Dep factories for extracted modules --- 160 + // ── Dep factories ────────────────────────────────────────── 407 161 function _mouseEventsDeps() { 408 162 return { 409 163 grid, sheetContainer, measureCtx, getActiveSheet, 410 - getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 411 - getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 412 - setIsSelecting: (v) => { isSelecting = v; }, 413 - getEditingCell: () => editingCell, commitEdit, startEditing, 164 + getSelectedCell, setSelectedCell, 165 + getSelectionRange, setSelectionRange, 166 + setIsSelecting, 167 + getEditingCell, commitEdit, startEditing, 414 168 getCellData, setCellData, computeDisplayValue, 415 169 getColWidth, setColWidth, getRowHeight, setRowHeight, 416 170 getCellEl, getFormatPainterFormat, applyFormatPainterToCell, 417 171 updateSelectionVisuals, updateFormulaBar, updateMergeButtonState, 418 172 unhideAdjacentRows, unhideAdjacentCols, renderGrid, refreshVisibleCells, 419 - autoFitColumn, autoFitRow, ydoc, evalCache: { clear: () => evalCache.clear() }, 420 - clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 421 - getFillPreviewRange: () => fillPreviewRange, setFillPreviewRange: (r) => { fillPreviewRange = r; }, 422 - setIsFillDragging: (v) => { isFillDragging = v; }, 173 + autoFitColumn, autoFitRow, ydoc, evalCache: { clear: () => clearEvalCache() }, 174 + clearSpillMaps: spillClearMaps, invalidateRecalcEngine, 175 + getFillPreviewRange, setFillPreviewRange, 176 + setIsFillDragging, 423 177 DEFAULT_ROWS, DEFAULT_COLS, 424 178 }; 425 179 } ··· 427 181 function _cellEditingDeps() { 428 182 return { 429 183 grid, formulaInput, cellAddressInput, 430 - getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 431 - getEditingCell: () => editingCell, setEditingCell: (c) => { editingCell = c; }, 184 + getSelectedCell, getSelectionRange, 185 + getEditingCell, setEditingCell, 432 186 getCellData, setCellData, computeDisplayValue, 433 - evalCache: { clear: () => evalCache.clear() }, 434 - clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 187 + evalCache: { clear: () => clearEvalCache() }, 188 + clearSpillMaps: spillClearMaps, invalidateRecalcEngine, refreshVisibleCells, 435 189 moveSelection, updateFormulaHighlight, updateFormulaRangeHighlights, 436 190 updateFormulaTooltip, hideAutocomplete, attachCellEditorAutocomplete, 437 191 }; ··· 439 193 440 194 function _touchEventsDeps() { 441 195 return { 442 - grid, getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 443 - getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 444 - setIsSelecting: (v) => { isSelecting = v; }, 445 - getEditingCell: () => editingCell, commitEdit, startEditing, 196 + grid, getSelectedCell, setSelectedCell, 197 + getSelectionRange, setSelectionRange, 198 + setIsSelecting, 199 + getEditingCell, commitEdit, startEditing, 446 200 getColWidth, setColWidth, getRowHeight, setRowHeight, 447 201 updateSelectionVisuals, updateFormulaBar, updateMergeButtonState, 448 202 renderGrid, MIN_COL_WIDTH, 449 203 }; 450 204 } 451 205 452 - // Touch/mouse/editing wrappers 453 - function onGridTouchStart(e) { _onGridTouchStart(_touchEventsDeps(), e); } 454 - _wireTouchDoubleTap(_touchEventsDeps()); 455 - function onGridMouseDown(e) { _onGridMouseDown(_mouseEventsDeps(), e); } 456 - function onGridDblClick(e) { _onGridDblClick(_mouseEventsDeps(), e); } 457 - function autoFitColumn(col) { _autoFitColumn(_mouseEventsDeps(), col); } 458 - function autoFitRow(row) { _autoFitRow(_mouseEventsDeps(), row); } 459 - function startEditing(col, row) { _startEditingCE(_cellEditingDeps(), col, row); } 460 - function commitEdit() { _commitEditCE(_cellEditingDeps()); } 461 - 462 - // --- Selection & navigation (extracted to selection-navigation.ts) --- 463 206 function _selNavDeps() { 464 207 return { 465 208 grid, cellAddressInput, sheetContainer, getActiveSheet, 466 - getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 467 - getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 209 + getSelectedCell, setSelectedCell, 210 + getSelectionRange, setSelectionRange, 468 211 getCells, getColWidth, getRowHeight, 469 212 updateFormulaBar, updateStatusBar, updateMergeButtonState, 470 213 updateWrapButtonState, updateBoldButtonState, updateItalicButtonState, ··· 473 216 ROW_HEADER_WIDTH, DEFAULT_COLS, DEFAULT_ROWS, 474 217 }; 475 218 } 476 - function moveSelection(dCol, dRow) { _moveSelection(_selNavDeps(), dCol, dRow); } 477 - function extendSelection(dCol, dRow) { _extendSelection(_selNavDeps(), dCol, dRow); } 478 - function moveSelectionTo(col, row) { _moveSelectionTo(_selNavDeps(), col, row); } 479 - function getDataExtent() { return _getDataExtent(_selNavDeps()); } 480 - function scrollCellIntoView(col, row) { _scrollCellIntoView(_selNavDeps(), col, row); } 481 - function getCellEl(col: number, row: number): Element | null { return _getCellEl(grid, col, row); } 482 - function updateSelectionVisuals() { _updateSelectionVisuals(_selNavDeps()); } 483 - function clearPrevSelection() { _clearPrevSelection(); } 484 219 485 - // --- Clipboard operations (extracted to clipboard-operations.ts) --- 486 220 function _clipboardDeps() { 487 221 return { 488 - ydoc, grid, getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 489 - getCellData, setCellData, getCells, evalCache: { clear: () => evalCache.clear() }, 490 - clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 491 - getClipboardBuffer: () => _clipboardBuffer, setClipboardBuffer: (buf) => { _clipboardBuffer = buf; }, 222 + ydoc, grid, getSelectedCell, getSelectionRange, 223 + getCellData, setCellData, getCells, evalCache: { clear: () => clearEvalCache() }, 224 + clearSpillMaps: spillClearMaps, invalidateRecalcEngine, refreshVisibleCells, 225 + getClipboardBuffer, setClipboardBuffer, 492 226 }; 493 227 } 494 - function deleteSelectedCells() { _deleteSelectedCells(_clipboardDeps()); } 495 - function copySelection() { _copySelection(_clipboardDeps()); } 496 - function pasteRowsAtSelection(rows) { _pasteRowsAtSelection(_clipboardDeps(), rows); } 497 - function pasteAtSelection(text) { _pasteAtSelection(_clipboardDeps(), text); } 498 - function showPasteSpecialDialog() { _showPasteSpecialDialogCO(_clipboardDeps()); } 499 228 500 - // --- Keyboard handler --- 501 - _wireKeyboardHandler({ 502 - grid, formulaInput, sheetContainer, provider, 503 - getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 504 - getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 505 - getActiveSheet, getCellData, setCellData, 506 - getEditingCell: () => editingCell, startEditing, 507 - moveSelection, extendSelection, moveSelectionTo, getDataExtent, 508 - updateSelectionVisuals, updateStatusBar, 509 - copySelection, deleteSelectedCells, showPasteSpecialDialog, 510 - applyStyleToSelection, clearFormattingSelection, 511 - updateUnderlineButtonState, updateStrikethroughButtonState, 512 - undoManager, evalCache: { clear: () => evalCache.clear() }, 513 - clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, renderGrid, 514 - hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 515 - toggleFilterMode, printSheet, showFindReplaceBar, DEFAULT_COLS, DEFAULT_ROWS, 516 - }); 517 - _wirePasteListener(_clipboardDeps(), { getEditingCell: () => editingCell, formulaInput }); 518 - 519 - function updateFormulaBar() { _updateFormulaBarCE(_cellEditingDeps()); } 229 + function _toolbarDeps() { 230 + return { 231 + ydoc, grid, getSelectedCell, getSelectionRange, 232 + getCellData, setCellData, getCells, getActiveSheet, computeDisplayValue, 233 + getColWidth, setColWidth, getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 234 + getStripedRows, setStripedRows, evalCache: { clear: () => clearEvalCache() }, 235 + clearSpillMaps: spillClearMaps, invalidateRecalcEngine, refreshVisibleCells, renderGrid, 236 + undoManager, DEFAULT_ROWS, DEFAULT_COLS, 237 + }; 238 + } 520 239 521 - // --- Formula bar + highlighting --- 522 - const formulaHighlightLayer = document.getElementById('formula-highlight-layer'); 523 240 function _formulaBarDeps() { 524 241 return { 525 242 formulaInput, formulaHighlightLayer, grid, 526 - getSelectedCell: () => selectedCell, setCellData, getCellData, 243 + getSelectedCell, setCellData, getCellData, 527 244 computeDisplayValue, getCfRulesArray, getValidationForCell, 528 - evalCache: { clear: () => evalCache.clear() }, 529 - clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 245 + evalCache: { clear: () => clearEvalCache() }, 246 + clearSpillMaps: spillClearMaps, invalidateRecalcEngine, 530 247 moveSelection, updateFormulaBar, renderSparklines, 531 248 }; 532 249 } 533 - function updateFormulaHighlight(text, useRangeColors = false) { _updateFormulaHighlight(_formulaBarDeps(), text, useRangeColors); } 534 - function updateFormulaRangeHighlights(text) { _updateFormulaRangeHighlights(_formulaBarDeps(), text); } 535 - function onFormulaInputUpdate() { _onFormulaInputUpdate(_formulaBarDeps()); } 536 - function refreshVisibleCells() { _refreshVisibleCells(_formulaBarDeps()); } 537 - function commitFormulaBar() { _commitFormulaBar(_formulaBarDeps()); } 538 - _wireFormulaBarKeys(_formulaBarDeps()); 539 250 540 - // --- Toolbar --- 541 - function _toolbarDeps() { 251 + function _statusBarDeps() { 542 252 return { 543 - ydoc, grid, getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 544 - getCellData, setCellData, getCells, getActiveSheet, computeDisplayValue, 545 - getColWidth, setColWidth, getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 546 - getStripedRows, setStripedRows, evalCache: { clear: () => evalCache.clear() }, 547 - clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, renderGrid, 548 - undoManager, DEFAULT_ROWS, DEFAULT_COLS, 253 + getSelectedCell, getSelectionRange, 254 + getCellData, computeDisplayValue, getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, renderGrid, 549 255 }; 550 256 } 551 - function applyStyleToSelection(styleProp, value) { _applyStyleToSelection(_toolbarDeps(), styleProp, value); } 552 - function clearFormattingSelection() { _clearFormattingSelection(_toolbarDeps()); } 553 - function closeAllDropdowns() { _closeAllDropdowns(); } 554 - function sortColumn(col, asc) { _sortColumn(_toolbarDeps(), col, asc); } 555 - function doInsertRow(rowIndex) { _doInsertRow(_toolbarDeps(), rowIndex); } 556 - function doDeleteRow(rowIndex) { _doDeleteRow(_toolbarDeps(), rowIndex); } 557 - function doInsertColumn(colIndex) { _doInsertColumn(_toolbarDeps(), colIndex); } 558 - function doDeleteColumn(colIndex) { _doDeleteColumn(_toolbarDeps(), colIndex); } 559 - function updateFreezeToolbarState() { _updateFreezeToolbarState(_toolbarDeps()); } 560 - function getFormatPainterFormat() { return _getFormatPainterFormat(_toolbarDeps()); } 561 - function applyFormatPainterToCell(col, row) { _applyFormatPainterToCell(_toolbarDeps(), col, row); } 562 - _wireToolbar(_toolbarDeps()); 563 257 564 - // --- Sheet tabs --- 258 + function _mergeDeps() { 259 + return { 260 + ydoc, grid, getSelectedCell, getSelectionRange, 261 + getMerges, getCells, getCellData, renderGrid, 262 + }; 263 + } 264 + 565 265 function _sheetTabsDeps() { 566 266 return { 567 - ySheets, ydoc, getActiveSheetIdx: () => activeSheetIdx, 568 - setActiveSheetIdx: (idx: number) => { activeSheetIdx = idx; }, 569 - ensureSheet, evalCache, clearSpillMaps: _clearSpillMaps, 267 + ySheets, ydoc, getActiveSheetIdx, setActiveSheetIdx, 268 + ensureSheet, evalCache, clearSpillMaps: spillClearMaps, 570 269 invalidateRecalcEngine, renderGrid, hideActiveContextMenu, setActiveContextMenu, sheetTabsContainer, 571 270 }; 572 271 } 573 - function renderSheetTabs() { _renderSheetTabs(_sheetTabsDeps()); } 574 272 575 - document.getElementById('add-sheet').addEventListener('click', () => { 576 - let count = 0; 577 - ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) count++; }); 578 - ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 579 - }); 580 - 581 - // --- Document title + version panel (extracted to version-history-ui.ts) --- 582 - const titleInput = document.getElementById('doc-title') as HTMLInputElement; 583 - _wireDocTitle({ docId, cryptoKey, ydoc, provider }); 584 - 585 - // --- Connection + collaboration --- 586 - _wireConnectionStatus({ provider, ydoc }); 587 - 588 - // --- Sync observers (extracted to sync-observers.ts) --- 589 273 function _syncObsDeps() { 590 274 return { 591 275 docId, ydoc, provider, ySheets, getCells, getCellData, setCellData, getColWidths, getCfRules, getValidations, 592 - getCharts, getNotesMap, evalCache: { clear: () => evalCache.clear() }, 593 - clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 276 + getCharts, getNotesMap, evalCache: { clear: () => clearEvalCache() }, 277 + clearSpillMaps: spillClearMaps, invalidateRecalcEngine, 594 278 scheduleRenderGrid, renderGrid, renderSheetTabs, renderCharts, renderNoteIndicators, 595 279 refreshVisibleCells, updateFormulaBar, handleImportFile, 596 280 }; 597 281 } 598 - _wireSyncEvent(_syncObsDeps()); 599 - _attachPreSyncObservers(_syncObsDeps()); 600 - 601 - _setupCollabAvatars({ provider, ydoc }); 602 282 603 - // --- Export/Import/Print --- 604 283 function _importExportDeps() { 605 284 return { 606 285 getActiveSheet, getCellData, setCellData, getCells, computeDisplayValue, 607 286 getColWidth, isRowHidden, isColHidden, buildMergeMap: _buildMergeMap, 608 - ydoc, provider, ensureSheet, evalCache, clearSpillMaps: _clearSpillMaps, 287 + ydoc, provider, ensureSheet, evalCache, clearSpillMaps: spillClearMaps, 609 288 invalidateRecalcEngine, renderGrid, renderSheetTabs, refreshVisibleCells, 610 289 DEFAULT_ROWS, DEFAULT_COLS, sheetContainer, 611 290 }; 612 291 } 613 - function handleImportFile(file) { return _handleImportFile(_importExportDeps(), file); } 614 - function printSheet() { _printSheet(_importExportDeps()); } 615 - _wireImportExportToolbar(_importExportDeps(), closeAllDropdowns); 616 292 617 - // --- Cell Merging (extracted to merge-cells-ui.ts) --- 618 - function _mergeDeps() { 619 - return { 620 - ydoc, grid, getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 621 - getMerges, getCells, getCellData, renderGrid, 622 - }; 293 + function _chartsDeps() { 294 + return { getActiveSheet, getCellData, evaluateFormula, getCharts, selectedCell: getSelectedCell(), selectionRange: getSelectionRange(), ydoc, chartsSection }; 623 295 } 624 - function updateMergeButtonState() { _updateMergeButtonStateUI(_mergeDeps()); } 625 - _wireMergeButton(_mergeDeps()); 626 296 627 - // --- Autosave --- 628 - wireSaveStatus({ provider, ydoc }); 297 + function _pivotDeps() { return { getActiveSheet, getCellData, getPivots, ydoc, DEFAULT_COLS, DEFAULT_ROWS, pivotSection }; } 629 298 630 - // --- Version Panel --- 631 - _wireVersionPanel({ docId, cryptoKey, ydoc, provider }); 299 + function _dbViewsDeps() { return { getActiveSheet, getCellData, evaluateFormula, DEFAULT_ROWS, DEFAULT_COLS, dbViewSection }; } 632 300 633 - // --- Shortcuts modal --- 634 - wireShortcutButton(); 301 + function _filterDeps() { return { getActiveSheet, getCellData, grid, DEFAULT_ROWS, DEFAULT_COLS }; } 635 302 636 - // ── Charts ── 637 - const chartsSection = document.getElementById('charts-section'); 638 - function getCharts() { 639 - const sheet = getActiveSheet(); 640 - if (!sheet.has('charts')) sheet.set('charts', new Y.Map()); 641 - return sheet.get('charts'); 303 + function _gridClickDeps() { 304 + return { 305 + grid, getCellData, setCellData, getValidationForCell, 306 + evalCache: { clear: () => clearEvalCache() }, clearSpillMaps: spillClearMaps, invalidateRecalcEngine, refreshVisibleCells, 307 + }; 642 308 } 643 - function _chartsDeps() { 644 - return { getActiveSheet, getCellData, evaluateFormula, getCharts, selectedCell, selectionRange, ydoc, chartsSection }; 309 + 310 + function _contextMenuDeps() { 311 + return { 312 + grid, getActiveSheet, getCellData, 313 + getSelectedCell, setSelectedCell, 314 + getSelectionRange, setSelectionRange, 315 + copySelection, deleteSelectedCells, pasteAtSelection, showPasteSpecialDialog, 316 + doInsertRow, doInsertColumn, doDeleteRow, doDeleteColumn, sortColumn, 317 + hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 318 + getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 319 + getColWidth, setColWidth, getRowHeight, setRowHeight, 320 + isAtHiddenRowBoundary, isAtHiddenColBoundary, buildHiddenRowSet, buildHiddenColSet, renderGrid, 321 + showNoteDialog, setNoteInYjs, renderNoteIndicators, getNotesObject, DEFAULT_ROWS, DEFAULT_COLS, 322 + }; 645 323 } 646 - function showChartDialog(existingId?, existingConfig?) { _showChartDialogUI(_chartsDeps(), existingId, existingConfig); } 647 - function renderCharts() { return _renderChartsUI(_chartsDeps()); } 648 - document.getElementById('tb-chart').addEventListener('click', () => { showChartDialog(null, null); closeAllDropdowns(); }); 649 324 650 - // ── Pivots ── 651 - const pivotSection = document.getElementById('pivot-section'); 652 - function getPivots() { 653 - const sheet = getActiveSheet(); 654 - if (!sheet.has('pivots')) sheet.set('pivots', new Y.Map()); 655 - return sheet.get('pivots'); 325 + function _findReplaceDeps() { 326 + return { 327 + getActiveSheet, getCellData, setCellData, computeDisplayValue, 328 + evalCache, clearSpillMaps: spillClearMaps, invalidateRecalcEngine, renderGrid, scrollCellIntoView, 329 + getSelectedCell, setSelectedCell, 330 + setSelectionRange, 331 + getFindReplaceBarVisible, setFindReplaceBarVisible, 332 + ydoc, sheetContainer, DEFAULT_ROWS, DEFAULT_COLS, 333 + }; 656 334 } 657 - function _pivotDeps() { return { getActiveSheet, getCellData, getPivots, ydoc, DEFAULT_COLS, DEFAULT_ROWS, pivotSection }; } 658 - function showPivotDialog(existingId?: string, existingConfig?: PivotConfig & { title?: string }) { _showPivotDialog(_pivotDeps(), existingId, existingConfig); } 659 - function renderPivots() { _renderPivots(_pivotDeps()); } 660 - document.getElementById('tb-pivot')!.addEventListener('click', () => { showPivotDialog(); closeAllDropdowns(); }); 661 335 662 - // ── Image Cells (extracted to image-cells-ui.ts) ── 663 - function renderImageCells() { _renderImageCellsUI(grid); } 664 - _wireImageCells({ grid, getSelectedCell: () => selectedCell, getCellData, setCellData, renderGrid, closeAllDropdowns }); 336 + function _cellNotesDeps() { return { getActiveSheet, grid }; } 337 + 338 + // ── Touch/mouse/editing wrappers ─────────────────────────── 339 + function onGridTouchStart(e: TouchEvent) { _onGridTouchStart(_touchEventsDeps(), e); } 340 + function onGridMouseDown(e: MouseEvent) { _onGridMouseDown(_mouseEventsDeps(), e); } 341 + function onGridDblClick(e: MouseEvent) { _onGridDblClick(_mouseEventsDeps(), e); } 342 + function autoFitColumn(col: number) { _autoFitColumn(_mouseEventsDeps(), col); } 343 + function autoFitRow(row: number) { _autoFitRow(_mouseEventsDeps(), row); } 344 + function startEditing(col: number, row: number) { _startEditingCE(_cellEditingDeps(), col, row); } 345 + function commitEdit() { _commitEditCE(_cellEditingDeps()); } 346 + function startRowResize(handle: HTMLElement, e: MouseEvent) { _startRowResize(_mouseEventsDeps(), handle, e); } 347 + 348 + // ── Selection & navigation ───────────────────────────────── 349 + function moveSelection(dCol: number, dRow: number) { _moveSelection(_selNavDeps(), dCol, dRow); } 350 + function extendSelection(dCol: number, dRow: number) { _extendSelection(_selNavDeps(), dCol, dRow); } 351 + function moveSelectionTo(col: number, row: number) { _moveSelectionTo(_selNavDeps(), col, row); } 352 + function getDataExtent() { return _getDataExtent(_selNavDeps()); } 353 + function scrollCellIntoView(col: number, row: number) { _scrollCellIntoView(_selNavDeps(), col, row); } 354 + function getCellEl(col: number, row: number): Element | null { return _getCellEl(grid, col, row); } 355 + function updateSelectionVisuals() { _updateSelectionVisuals(_selNavDeps()); } 356 + function clearPrevSelection() { _clearPrevSelection(); } 357 + 358 + // ── Clipboard operations ─────────────────────────────────── 359 + function deleteSelectedCells() { _deleteSelectedCells(_clipboardDeps()); } 360 + function copySelection() { _copySelection(_clipboardDeps()); } 361 + function pasteRowsAtSelection(rows: unknown[]) { _pasteRowsAtSelection(_clipboardDeps(), rows); } 362 + function pasteAtSelection(text: string) { _pasteAtSelection(_clipboardDeps(), text); } 363 + function showPasteSpecialDialog() { _showPasteSpecialDialogCO(_clipboardDeps()); } 665 364 666 - // ── Database Views ── 667 - const dbViewSection = document.getElementById('database-view-section'); 668 - function _dbViewsDeps() { return { getActiveSheet, getCellData, evaluateFormula, DEFAULT_ROWS, DEFAULT_COLS, dbViewSection }; } 365 + // ── Formula bar + highlighting ───────────────────────────── 366 + function updateFormulaBar() { _updateFormulaBarCE(_cellEditingDeps()); } 367 + function updateFormulaHighlight(text: string, useRangeColors = false) { _updateFormulaHighlight(_formulaBarDeps(), text, useRangeColors); } 368 + function updateFormulaRangeHighlights(text: string) { _updateFormulaRangeHighlights(_formulaBarDeps(), text); } 369 + function onFormulaInputUpdate() { _onFormulaInputUpdate(_formulaBarDeps()); } 370 + function refreshVisibleCells() { _refreshVisibleCells(_formulaBarDeps()); } 371 + function commitFormulaBar() { _commitFormulaBar(_formulaBarDeps()); } 372 + function attachCellEditorFormulaUX(inputEl: HTMLElement, anchorTd: HTMLElement) { _attachCellEditorFormulaUXCE(_cellEditingDeps(), inputEl, anchorTd); } 373 + 374 + // ── Toolbar ──────────────────────────────────────────────── 375 + function applyStyleToSelection(styleProp: string, value: unknown) { _applyStyleToSelection(_toolbarDeps(), styleProp, value); } 376 + function clearFormattingSelection() { _clearFormattingSelection(_toolbarDeps()); } 377 + function closeAllDropdowns() { _closeAllDropdowns(); } 378 + function sortColumn(col: number, asc: boolean) { _sortColumn(_toolbarDeps(), col, asc); } 379 + function doInsertRow(rowIndex: number) { _doInsertRow(_toolbarDeps(), rowIndex); } 380 + function doDeleteRow(rowIndex: number) { _doDeleteRow(_toolbarDeps(), rowIndex); } 381 + function doInsertColumn(colIndex: number) { _doInsertColumn(_toolbarDeps(), colIndex); } 382 + function doDeleteColumn(colIndex: number) { _doDeleteColumn(_toolbarDeps(), colIndex); } 383 + function updateFreezeToolbarState() { _updateFreezeToolbarState(_toolbarDeps()); } 384 + function getFormatPainterFormat() { return _getFormatPainterFormat(_toolbarDeps()); } 385 + function applyFormatPainterToCell(col: number, row: number) { _applyFormatPainterToCell(_toolbarDeps(), col, row); } 386 + function updateWrapButtonState() { _updateWrapButtonState(_toolbarDeps()); } 387 + function updateStripedButtonState() { _updateStripedButtonState(_toolbarDeps()); } 388 + function updateBoldButtonState() { _updateBoldButtonState(_toolbarDeps()); } 389 + function updateItalicButtonState() { _updateItalicButtonState(_toolbarDeps()); } 390 + function updateUnderlineButtonState() { _updateUnderlineButtonState(_toolbarDeps()); } 391 + function updateStrikethroughButtonState() { _updateStrikethroughButtonState(_toolbarDeps()); } 392 + function updateFontSizeSelect() { _updateFontSizeSelect(_toolbarDeps()); } 393 + function updateFontFamilySelect() { _updateFontFamilySelect(_toolbarDeps()); } 394 + function updateVerticalAlignButton() { _updateVerticalAlignButton(_toolbarDeps()); } 395 + 396 + // ── Status bar ───────────────────────────────────────────── 397 + function updateStatusBar() { _updateStatusBarUI(_statusBarDeps()); } 398 + function updateMergeButtonState() { _updateMergeButtonStateUI(_mergeDeps()); } 399 + 400 + // ── Formula autocomplete ─────────────────────────────────── 401 + function hideAutocomplete() { _hideAutocomplete(autocompleteEl); } 402 + function attachCellEditorAutocomplete(inputEl: HTMLElement) { _attachCellEditorAutocomplete(autocompleteEl, inputEl); } 403 + 404 + // ── Sheet tabs ───────────────────────────────────────────── 405 + function renderSheetTabs() { _renderSheetTabs(_sheetTabsDeps()); } 406 + 407 + // ── Import/Export/Print ──────────────────────────────────── 408 + function handleImportFile(file: File) { return _handleImportFile(_importExportDeps(), file); } 409 + function printSheet() { _printSheet(_importExportDeps()); } 410 + 411 + // ── Charts/Pivots/DB Views ───────────────────────────────── 412 + function showChartDialog(existingId?: string | null, existingConfig?: unknown) { _showChartDialogUI(_chartsDeps(), existingId, existingConfig); } 413 + function renderCharts() { return _renderChartsUI(_chartsDeps()); } 414 + function showPivotDialog(existingId?: string, existingConfig?: PivotConfig & { title?: string }) { _showPivotDialog(_pivotDeps(), existingId, existingConfig); } 415 + function renderPivots() { _renderPivots(_pivotDeps()); } 669 416 function showDbViewDialog() { _showDbViewDialogUI(_dbViewsDeps()); } 670 417 function renderDbView() { _renderDbViewUI(_dbViewsDeps()); } 671 - document.getElementById('tb-view-mode')!.addEventListener('click', () => { showDbViewDialog(); closeAllDropdowns(); }); 418 + function renderImageCells() { _renderImageCellsUI(grid); } 672 419 673 - // ── Filter ── 674 - function _filterDeps() { return { getActiveSheet, getCellData, grid, DEFAULT_ROWS, DEFAULT_COLS }; } 420 + // ── Filter/Sort/CF/Validation ────────────────────────────── 675 421 function toggleFilterMode() { _toggleFilterMode(_filterDeps()); } 676 422 function getFilterHiddenRows() { return _getFilterHiddenRows(_filterDeps()); } 677 - document.getElementById('tb-filter').addEventListener('click', () => { toggleFilterMode(); closeAllDropdowns(); }); 678 423 679 - // Sort dialog 680 424 function showSortDialog() { 681 425 _showSortDialog({ 682 - getActiveSheet, selectedCell, selectionRange, getCellData, getCells, setCellData, 683 - ydoc, DEFAULT_COLS, DEFAULT_ROWS, evalCache, clearSpillMaps: _clearSpillMaps, 426 + getActiveSheet, selectedCell: getSelectedCell(), selectionRange: getSelectionRange(), getCellData, getCells, setCellData, 427 + ydoc, DEFAULT_COLS, DEFAULT_ROWS, evalCache, clearSpillMaps: spillClearMaps, 684 428 invalidateRecalcEngine, refreshVisibleCells, 685 429 }); 686 430 } 687 - document.getElementById('tb-sort-multi').addEventListener('click', () => { showSortDialog(); closeAllDropdowns(); }); 688 431 689 - // Filter grid observer + load filter state on sync 690 - _setupFilterGridObserver(_filterDeps()); 691 - provider.on('sync', () => { _loadFilterStateFromYjs(_filterDeps()); renderCharts(); }); 692 - getCharts().observeDeep(() => { renderCharts(); }); 693 - 694 - // --- Conditional Formatting modal --- 695 432 function showCfModal() { 696 - _showCfModal({ getCfRulesArray, getCfRules, ydoc, evalCache, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells }); 433 + _showCfModal({ getCfRulesArray, getCfRules, ydoc, evalCache, clearSpillMaps: spillClearMaps, invalidateRecalcEngine, refreshVisibleCells }); 697 434 } 698 - document.getElementById('tb-cf').addEventListener('click', () => { closeAllDropdowns(); showCfModal(); }); 699 435 700 - // --- Validation modal --- 701 436 function showValidationModal() { 702 - _showValidationModal({ selectedCell, selectionRange, getValidationForCell, getValidations, ydoc, applyToSelectedCells, refreshVisibleCells, renderGrid }); 437 + _showValidationModal({ selectedCell: getSelectedCell(), selectionRange: getSelectionRange(), getValidationForCell, getValidations, ydoc, applyToSelectedCells, refreshVisibleCells, renderGrid }); 703 438 } 704 - function applyToSelectedCells(fn) { 705 - if (!selectionRange) { fn(cellId(selectedCell.col, selectedCell.row)); return; } 706 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 439 + 440 + function applyToSelectedCells(fn: (id: string) => void) { 441 + const range = getSelectionRange(); 442 + const sel = getSelectedCell(); 443 + if (!range) { fn(cellId(sel.col, sel.row)); return; } 444 + const { startCol, startRow, endCol, endRow } = normalizeRange(range); 707 445 for (let r = startRow; r <= endRow; r++) for (let c = startCol; c <= endCol; c++) fn(cellId(c, r)); 708 446 } 709 - document.getElementById('tb-validation').addEventListener('click', () => { closeAllDropdowns(); showValidationModal(); }); 710 447 711 - // --- Grid click handlers (extracted to grid-click-handlers.ts) --- 712 - function _gridClickDeps() { 713 - return { 714 - grid, getCellData, setCellData, getValidationForCell, 715 - evalCache: { clear: () => evalCache.clear() }, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 716 - }; 717 - } 718 - _wireRichCellClick(_gridClickDeps()); 719 - _wireValidationDropdown(_gridClickDeps()); 448 + // ── Cell Notes ───────────────────────────────────────────── 449 + function getNotesMap() { return _getNotesMap(_cellNotesDeps()); } 450 + function getNotesObject() { return _getNotesObject(_cellNotesDeps()); } 451 + function setNoteInYjs(id: string, text: string) { _setNoteInYjs(_cellNotesDeps(), id, text); } 452 + function showNoteDialog(id: string) { _showNoteDialogUI(_cellNotesDeps(), id, renderNoteIndicators); } 453 + function renderNoteIndicators() { _renderNoteIndicatorsUI(_cellNotesDeps()); } 454 + function renderSparklines() { _renderSparklines(_gridRenderingDeps()); } 720 455 721 - // --- Toolbar state helpers --- 722 - function updateWrapButtonState() { _updateWrapButtonState(_toolbarDeps()); } 723 - function updateStripedButtonState() { _updateStripedButtonState(_toolbarDeps()); } 724 - function updateBoldButtonState() { _updateBoldButtonState(_toolbarDeps()); } 725 - function updateItalicButtonState() { _updateItalicButtonState(_toolbarDeps()); } 726 - function updateUnderlineButtonState() { _updateUnderlineButtonState(_toolbarDeps()); } 727 - function updateStrikethroughButtonState() { _updateStrikethroughButtonState(_toolbarDeps()); } 728 - function updateFontSizeSelect() { _updateFontSizeSelect(_toolbarDeps()); } 729 - function updateFontFamilySelect() { _updateFontFamilySelect(_toolbarDeps()); } 730 - function updateVerticalAlignButton() { _updateVerticalAlignButton(_toolbarDeps()); } 456 + // ── Find & Replace ───────────────────────────────────────── 457 + const findBar = createFindReplaceBar(); 458 + sheetContainer.parentNode!.insertBefore(findBar, sheetContainer); 459 + function showFindReplaceBar(showReplace: boolean) { _showFindReplaceBarUI(_findReplaceDeps(), findBar, showReplace); } 460 + function hideFindReplaceBar() { _hideFindReplaceBarUI(_findReplaceDeps(), findBar); } 731 461 732 - // ── Status Bar ── 733 - function _statusBarDeps() { 734 - return { 735 - getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 736 - getCellData, computeDisplayValue, getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, renderGrid, 737 - }; 738 - } 739 - function updateStatusBar() { _updateStatusBarUI(_statusBarDeps()); } 740 - _wireStatusBarFreezeClick(_statusBarDeps()); 462 + // ── Wire everything ──────────────────────────────────────── 463 + _wireTouchDoubleTap(_touchEventsDeps()); 741 464 742 - // ── Formula Autocomplete ── 743 - const autocompleteEl = document.getElementById('formula-autocomplete'); 744 - function hideAutocomplete() { _hideAutocomplete(autocompleteEl); } 745 - function attachCellEditorAutocomplete(inputEl) { _attachCellEditorAutocomplete(autocompleteEl, inputEl); } 465 + _wireKeyboardHandler({ 466 + grid, formulaInput, sheetContainer, provider, 467 + getSelectedCell, setSelectedCell, 468 + getSelectionRange, setSelectionRange, 469 + getActiveSheet, getCellData, setCellData, 470 + getEditingCell, startEditing, 471 + moveSelection, extendSelection, moveSelectionTo, getDataExtent, 472 + updateSelectionVisuals, updateStatusBar, 473 + copySelection, deleteSelectedCells, showPasteSpecialDialog, 474 + applyStyleToSelection, clearFormattingSelection, 475 + updateUnderlineButtonState, updateStrikethroughButtonState, 476 + undoManager, evalCache: { clear: () => clearEvalCache() }, 477 + clearSpillMaps: spillClearMaps, invalidateRecalcEngine, refreshVisibleCells, renderGrid, 478 + hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 479 + toggleFilterMode, printSheet, showFindReplaceBar, DEFAULT_COLS, DEFAULT_ROWS, 480 + }); 481 + _wirePasteListener(_clipboardDeps(), { getEditingCell, formulaInput }); 482 + _wireFormulaBarKeys(_formulaBarDeps()); 483 + _wireToolbar(_toolbarDeps()); 484 + _wireConnectionStatus({ provider, ydoc }); 485 + _wireSyncEvent(_syncObsDeps()); 486 + _attachPreSyncObservers(_syncObsDeps()); 487 + _setupCollabAvatars({ provider, ydoc }); 488 + _wireImportExportToolbar(_importExportDeps(), closeAllDropdowns); 489 + _wireMergeButton(_mergeDeps()); 490 + wireSaveStatus({ provider, ydoc }); 491 + _wireVersionPanel({ docId, cryptoKey, ydoc, provider }); 492 + _wireDocTitle({ docId, cryptoKey, ydoc, provider }); 493 + wireShortcutButton(); 494 + _wireImageCells({ grid, getSelectedCell, getCellData, setCellData, renderGrid, closeAllDropdowns }); 495 + _setupFilterGridObserver(_filterDeps()); 496 + provider.on('sync', () => { _loadFilterStateFromYjs(_filterDeps()); renderCharts(); }); 497 + getCharts().observeDeep(() => { renderCharts(); }); 498 + _wireRichCellClick(_gridClickDeps()); 499 + _wireValidationDropdown(_gridClickDeps()); 500 + _wireStatusBarFreezeClick(_statusBarDeps()); 746 501 _wireAutocomplete({ autocompleteEl, formulaInput }); 502 + _wireNoteHover(_cellNotesDeps()); 503 + _wireErrorTooltip(grid); 504 + _wireContextMenu(_contextMenuDeps()); 505 + getNotesMap().observe(() => renderNoteIndicators()); 506 + _wireFindReplaceBar(_findReplaceDeps(), findBar); 747 507 748 - // ── Formula UX wiring ── 749 508 formulaInput.addEventListener('input', onFormulaInputUpdate); 750 509 formulaInput.addEventListener('click', () => { updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); }); 751 510 formulaInput.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); }); 752 511 formulaInput.addEventListener('scroll', () => { if (formulaHighlightLayer) formulaHighlightLayer.scrollLeft = formulaInput.scrollLeft; }); 753 512 formulaInput.addEventListener('focus', () => { updateFormulaHighlight(formulaInput.value, true); updateFormulaRangeHighlights(formulaInput.value); }); 754 513 formulaInput.addEventListener('blur', () => { hideTooltip(); clearGridHighlights(); updateFormulaHighlight(formulaInput.value); }); 755 - function attachCellEditorFormulaUX(inputEl, anchorTd) { _attachCellEditorFormulaUXCE(_cellEditingDeps(), inputEl, anchorTd); } 756 514 757 - // ── Cell Notes ── 758 - function _cellNotesDeps() { return { getActiveSheet, grid }; } 759 - function getNotesMap() { return _getNotesMap(_cellNotesDeps()); } 760 - function getNotesObject() { return _getNotesObject(_cellNotesDeps()); } 761 - function setNoteInYjs(id, text) { _setNoteInYjs(_cellNotesDeps(), id, text); } 762 - function showNoteDialog(id) { _showNoteDialogUI(_cellNotesDeps(), id, renderNoteIndicators); } 763 - function renderNoteIndicators() { _renderNoteIndicatorsUI(_cellNotesDeps()); } 764 - function renderSparklines() { _renderSparklines(_gridRenderingDeps()); } 765 - 766 - _wireNoteHover(_cellNotesDeps()); 767 - _wireErrorTooltip(grid); 768 - 769 - // ── Context Menu ── 770 - function _contextMenuDeps() { 771 - return { 772 - grid, getActiveSheet, getCellData, 773 - getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 774 - getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 775 - copySelection, deleteSelectedCells, pasteAtSelection, showPasteSpecialDialog, 776 - doInsertRow, doInsertColumn, doDeleteRow, doDeleteColumn, sortColumn, 777 - hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 778 - getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 779 - getColWidth, setColWidth, getRowHeight, setRowHeight, 780 - isAtHiddenRowBoundary, isAtHiddenColBoundary, buildHiddenRowSet, buildHiddenColSet, renderGrid, 781 - showNoteDialog, setNoteInYjs, renderNoteIndicators, getNotesObject, DEFAULT_ROWS, DEFAULT_COLS, 782 - }; 783 - } 784 - _wireContextMenu(_contextMenuDeps()); 785 - getNotesMap().observe(() => renderNoteIndicators()); 515 + document.getElementById('add-sheet')!.addEventListener('click', () => { 516 + let count = 0; 517 + getYSheets().forEach((_, key) => { if (key.startsWith('sheet_')) count++; }); 518 + ensureSheet(count); setActiveSheetIdx(count); renderSheetTabs(); clearEvalCache(); spillClearMaps(); invalidateRecalcEngine(); renderGrid(); 519 + }); 786 520 787 - // ── Find & Replace Bar ── 788 - const findBar = createFindReplaceBar(); 789 - sheetContainer.parentNode.insertBefore(findBar, sheetContainer); 790 - function _findReplaceDeps() { 791 - return { 792 - getActiveSheet, getCellData, setCellData, computeDisplayValue, 793 - evalCache, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, renderGrid, scrollCellIntoView, 794 - getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 795 - setSelectionRange: (r) => { selectionRange = r; }, 796 - getFindReplaceBarVisible: () => findReplaceBarVisible, setFindReplaceBarVisible: (v) => { findReplaceBarVisible = v; }, 797 - ydoc, sheetContainer, DEFAULT_ROWS, DEFAULT_COLS, 798 - }; 799 - } 800 - function showFindReplaceBar(showReplace) { _showFindReplaceBarUI(_findReplaceDeps(), findBar, showReplace); } 801 - function hideFindReplaceBar() { _hideFindReplaceBarUI(_findReplaceDeps(), findBar); } 802 - _wireFindReplaceBar(_findReplaceDeps(), findBar); 521 + document.getElementById('tb-chart')!.addEventListener('click', () => { showChartDialog(null, null); closeAllDropdowns(); }); 522 + document.getElementById('tb-pivot')!.addEventListener('click', () => { showPivotDialog(); closeAllDropdowns(); }); 523 + document.getElementById('tb-view-mode')!.addEventListener('click', () => { showDbViewDialog(); closeAllDropdowns(); }); 524 + document.getElementById('tb-filter')!.addEventListener('click', () => { toggleFilterMode(); closeAllDropdowns(); }); 525 + document.getElementById('tb-sort-multi')!.addEventListener('click', () => { showSortDialog(); closeAllDropdowns(); }); 526 + document.getElementById('tb-cf')!.addEventListener('click', () => { closeAllDropdowns(); showCfModal(); }); 527 + document.getElementById('tb-validation')!.addEventListener('click', () => { closeAllDropdowns(); showValidationModal(); }); 803 528 804 - function startRowResize(handle, e) { _startRowResize(_mouseEventsDeps(), handle, e); } 805 - 806 - // ── AI Chat Panel ── 529 + // ── AI Chat Panel ────────────────────────────────────────── 807 530 const chatUI = createChatSidebar(); 808 531 document.getElementById('sheet-body')!.appendChild(chatUI.container); 809 532 const chatState = createChatState(); ··· 813 536 }); 814 537 function getSheetContextText() { return _getSheetContextText(getCells); } 815 538 async function sendChatMessage() { 816 - return _sendChatMessage({ getCells, getCellData, setCellData, renderGrid, chatUI, chatState, chatWiring, titleInput: titleInput as HTMLInputElement }); 539 + return _sendChatMessage({ getCells, getCellData, setCellData, renderGrid, chatUI, chatState, chatWiring, titleInput }); 817 540 } 818 541 819 - // --- Command Palette --- 820 - import { createCommandPalette } from '../command-palette.js'; 542 + // ── Command Palette ──────────────────────────────────────── 821 543 createCommandPalette({ 822 544 actions: [ 823 545 { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } }, ··· 830 552 ], 831 553 }); 832 554 833 - // --- Initial render --- 555 + // ── Initial render ───────────────────────────────────────── 834 556 ensureSheet(0); 835 557 renderGrid(); 836 558 renderSheetTabs();
+30
src/sheets/selection-state.ts
··· 1 + import type { CellRef, SelectionRange } from './types.js'; 2 + import { getSheetsFindState } from './find-replace-bar.js'; 3 + 4 + let selectedCell: CellRef = { col: 1, row: 1 }; 5 + let selectionRange: SelectionRange | null = null; 6 + let editingCell: CellRef | null = null; 7 + let isSelecting = false; 8 + let isFillDragging = false; 9 + let fillPreviewRange: SelectionRange | null = null; 10 + let findReplaceBarVisible = false; 11 + let clipboardBuffer: Array<Array<{ value: unknown; formula: string; style: Record<string, unknown> }>> | null = null; 12 + 13 + export const sheetsFindState = getSheetsFindState(); 14 + 15 + export function getSelectedCell(): CellRef { return selectedCell; } 16 + export function setSelectedCell(c: CellRef): void { selectedCell = c; } 17 + export function getSelectionRange(): SelectionRange | null { return selectionRange; } 18 + export function setSelectionRange(r: SelectionRange | null): void { selectionRange = r; } 19 + export function getEditingCell(): CellRef | null { return editingCell; } 20 + export function setEditingCell(c: CellRef | null): void { editingCell = c; } 21 + export function getIsSelecting(): boolean { return isSelecting; } 22 + export function setIsSelecting(v: boolean): void { isSelecting = v; } 23 + export function getIsFillDragging(): boolean { return isFillDragging; } 24 + export function setIsFillDragging(v: boolean): void { isFillDragging = v; } 25 + export function getFillPreviewRange(): SelectionRange | null { return fillPreviewRange; } 26 + export function setFillPreviewRange(r: SelectionRange | null): void { fillPreviewRange = r; } 27 + export function getFindReplaceBarVisible(): boolean { return findReplaceBarVisible; } 28 + export function setFindReplaceBarVisible(v: boolean): void { findReplaceBarVisible = v; } 29 + export function getClipboardBuffer() { return clipboardBuffer; } 30 + export function setClipboardBuffer(buf: typeof clipboardBuffer): void { clipboardBuffer = buf; }
+61
src/sheets/session-bootstrap.ts
··· 1 + import * as Y from 'yjs'; 2 + import { importKey } from '../lib/crypto.js'; 3 + import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 4 + import { ensureWrappingKey } from '../lib/key-passphrase.js'; 5 + import { EncryptedProvider } from '../lib/provider.js'; 6 + 7 + export interface BootstrapResult { 8 + docId: string; 9 + cryptoKey: CryptoKey; 10 + ydoc: Y.Doc; 11 + provider: EncryptedProvider; 12 + ySheets: Y.Map<Y.Map<unknown>>; 13 + } 14 + 15 + export async function bootstrap(): Promise<BootstrapResult> { 16 + const pathParts = location.pathname.split('/').filter(Boolean); 17 + const docId = pathParts[1]; 18 + const hash = location.hash.slice(1); 19 + 20 + if (localStorage.getItem('crypt-keys') && !localStorage.getItem('tools-keys')) { 21 + localStorage.setItem('tools-keys', localStorage.getItem('crypt-keys')!); 22 + localStorage.removeItem('crypt-keys'); 23 + } 24 + if (localStorage.getItem('crypt-username') && !localStorage.getItem('tools-username')) { 25 + localStorage.setItem('tools-username', localStorage.getItem('crypt-username')!); 26 + localStorage.removeItem('crypt-username'); 27 + } 28 + 29 + await ensureWrappingKey(); 30 + 31 + if (!docId) { 32 + location.href = '/'; 33 + throw new Error('No document ID or key'); 34 + } 35 + 36 + const storedKeysInit = await getLocalKeys(); 37 + let keyString = hash || storedKeysInit[docId]; 38 + 39 + if (!keyString) { 40 + const serverKeys = await fetchServerKeys(); 41 + if (serverKeys?.[docId]) { keyString = serverKeys[docId]; } 42 + } 43 + 44 + if (!keyString) { 45 + location.href = '/'; 46 + throw new Error('No document ID or key'); 47 + } 48 + 49 + await storeKey(docId, keyString); 50 + pushKeysToServer({ [docId]: keyString }); 51 + 52 + const cryptoKey = await importKey(keyString); 53 + 54 + const ydoc = new Y.Doc(); 55 + const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 56 + await provider.whenReady; 57 + 58 + const ySheets = ydoc.getMap('sheets') as Y.Map<Y.Map<unknown>>; 59 + 60 + return { docId, cryptoKey, ydoc, provider, ySheets }; 61 + }
+4
src/slides/canvas-engine.ts
··· 38 38 currentSlide: number; 39 39 /** Fixed aspect ratio (width/height) */ 40 40 aspectRatio: number; 41 + /** Custom master slide definitions stored with deck (optional) */ 42 + masters?: import('./master-slides.js').MasterSlide[]; 43 + /** Maps slide ID to master ID (optional) */ 44 + masterAssignments?: Record<string, string>; 41 45 } 42 46 43 47 let _slideCounter = 0;
+26
src/slides/event-handlers.ts
··· 10 10 } from './canvas-engine.js'; 11 11 import { setSlideLayout, setDeckTheme, getTheme } from './layouts-themes.js'; 12 12 import type { LayoutType } from './layouts-themes.js'; 13 + import { getDefaultMasters, applyMasterToSlide } from './master-slides.js'; 13 14 import { createTransition, setDefaultTransition } from './transitions.js'; 14 15 import { setNotes, nextSlide as presenterNext, prevSlide as presenterPrev } from './presenter-mode.js'; 15 16 import { enterPresenter, exitPresenter, renderPresenter } from './presenter-ui.js'; ··· 119 120 actions.setState({ 120 121 themedDeck: setSlideLayout(s.themedDeck, s.deck.currentSlide, refs.layoutSelect.value as LayoutType), 121 122 }); 123 + actions.syncDeckToYjs(); 124 + actions.render(); 125 + }); 126 + 127 + refs.masterSelect.addEventListener('change', () => { 128 + const s = actions.getState(); 129 + const masterId = refs.masterSelect.value; 130 + const slide = currentSlide(s.deck); 131 + const assignments = { ...(s.deck.masterAssignments || {}) }; 132 + 133 + if (masterId) { 134 + const masters = getDefaultMasters(); 135 + const master = masters.find(m => m.id === masterId); 136 + if (master) { 137 + const updated = applyMasterToSlide(slide, master); 138 + const slides = s.deck.slides.map((sl, i) => 139 + i === s.deck.currentSlide ? updated : sl, 140 + ); 141 + assignments[slide.id] = masterId; 142 + actions.setState({ deck: { ...s.deck, slides, masterAssignments: assignments } }); 143 + } 144 + } else { 145 + delete assignments[slide.id]; 146 + actions.setState({ deck: { ...s.deck, masterAssignments: assignments } }); 147 + } 122 148 actions.syncDeckToYjs(); 123 149 actions.render(); 124 150 });
+1
src/slides/index.html
··· 57 57 <div class="slides-canvas-area" id="canvas-area"> 58 58 <div class="slides-toolbar" id="slides-toolbar"> 59 59 <select class="slides-layout-select" id="layout-select" title="Slide layout"></select> 60 + <select class="slides-master-select" id="master-select" title="Master slide"></select> 60 61 <select class="slides-theme-select" id="theme-select" title="Theme"></select> 61 62 <select class="slides-transition-select" id="transition-select" title="Transition"></select> 62 63 <span class="toolbar-divider"></span>
+20
src/slides/main.ts
··· 14 14 import { createDeck, slideCount } from './canvas-engine.js'; 15 15 import type { DeckState } from './canvas-engine.js'; 16 16 import { getLayouts, getThemes, createThemedDeck } from './layouts-themes.js'; 17 + import { getDefaultMasters } from './master-slides.js'; 17 18 import { 18 19 createSlideTransitions, getTransitionTypes, 19 20 } from './transitions.js'; ··· 36 37 thumbnailList: $('thumbnail-list'), 37 38 slideCanvas: $('slide-canvas'), 38 39 layoutSelect: $('layout-select') as HTMLSelectElement, 40 + masterSelect: $('master-select') as HTMLSelectElement, 39 41 themeSelect: $('theme-select') as HTMLSelectElement, 40 42 transitionSelect: $('transition-select') as HTMLSelectElement, 41 43 notesInput: $('notes-input') as HTMLTextAreaElement, ··· 78 80 [...state.animations.animations].map(([k, v]) => [k, v]) 79 81 ), 80 82 })); 83 + if (state.deck.masterAssignments) { 84 + yDeck.set('masterAssignments', JSON.stringify(state.deck.masterAssignments)); 85 + } 81 86 } 82 87 83 88 function loadDeckFromYjs() { ··· 103 108 )), 104 109 }; 105 110 } 111 + const masterJson = yDeck.get('masterAssignments') as string; 112 + if (masterJson) { 113 + state.deck = { ...state.deck, masterAssignments: JSON.parse(masterJson) }; 114 + } 106 115 } catch { /* use defaults */ } 107 116 } 108 117 ··· 122 131 opt.value = l.type; 123 132 opt.textContent = l.label; 124 133 refs.layoutSelect.appendChild(opt); 134 + }); 135 + // Master slides dropdown: "None" + all default masters 136 + const noneOpt = document.createElement('option'); 137 + noneOpt.value = ''; 138 + noneOpt.textContent = 'No Master'; 139 + refs.masterSelect.appendChild(noneOpt); 140 + getDefaultMasters().forEach(m => { 141 + const opt = document.createElement('option'); 142 + opt.value = m.id; 143 + opt.textContent = m.name; 144 + refs.masterSelect.appendChild(opt); 125 145 }); 126 146 getThemes().forEach(t => { 127 147 const opt = document.createElement('option');
+166
src/slides/master-slides.ts
··· 1 + /** 2 + * Master Slides -- customizable layout templates for the slide editor. 3 + * 4 + * Pure logic module (no DOM): defines master slide types and functions 5 + * to create, apply, and look up master slides. Each master defines 6 + * placeholder regions that map to slide elements. 7 + */ 8 + 9 + import { getLayouts } from './layouts-themes.js'; 10 + import { createSlide } from './canvas-engine.js'; 11 + import type { Slide, SlideElement, ElementType } from './canvas-engine.js'; 12 + 13 + // ---- Types ---- 14 + 15 + export interface MasterPlaceholder { 16 + id: string; 17 + role: 'title' | 'subtitle' | 'body' | 'image' | 'content'; 18 + x: number; 19 + y: number; 20 + width: number; 21 + height: number; 22 + style?: Record<string, string>; 23 + defaultText?: string; 24 + } 25 + 26 + export interface MasterSlide { 27 + id: string; 28 + name: string; 29 + placeholders: MasterPlaceholder[]; 30 + background?: string; 31 + } 32 + 33 + // ---- Internal counters ---- 34 + 35 + let _masterCounter = 0; 36 + let _masterElCounter = 0; 37 + 38 + // ---- Public API ---- 39 + 40 + /** 41 + * Convert built-in layouts into default master slide definitions. 42 + * Each layout becomes a master with placeholders matching its regions. 43 + */ 44 + export function getDefaultMasters(): MasterSlide[] { 45 + return getLayouts().map(layout => ({ 46 + id: `master-${layout.type}`, 47 + name: layout.label, 48 + placeholders: layout.regions.map(region => ({ 49 + id: `ph-${layout.type}-${region.name}`, 50 + role: region.role, 51 + x: region.x, 52 + y: region.y, 53 + width: region.width, 54 + height: region.height, 55 + })), 56 + })); 57 + } 58 + 59 + /** 60 + * Create a custom master slide with a name and placeholder definitions. 61 + */ 62 + export function createMaster( 63 + name: string, 64 + placeholders: MasterPlaceholder[], 65 + background?: string, 66 + ): MasterSlide { 67 + return { 68 + id: `master-custom-${Date.now()}-${++_masterCounter}`, 69 + name, 70 + placeholders, 71 + background, 72 + }; 73 + } 74 + 75 + /** 76 + * Apply a master to an existing slide. Creates placeholder elements 77 + * matching the master's layout, preserving existing content in matching 78 + * roles/placeholder IDs. Non-master (user-added) elements are preserved. 79 + */ 80 + export function applyMasterToSlide(slide: Slide, master: MasterSlide): Slide { 81 + // Separate existing elements into master-managed and user-added 82 + const existingMasterElements = slide.elements.filter( 83 + el => el.style._masterId !== undefined, 84 + ); 85 + const userElements = slide.elements.filter( 86 + el => el.style._masterId === undefined, 87 + ); 88 + 89 + // Build a lookup of existing master elements by placeholder ID for content preservation 90 + const existingByPlaceholderId = new Map<string, SlideElement>(); 91 + for (const el of existingMasterElements) { 92 + const phId = el.style._masterPlaceholderId; 93 + if (phId) { 94 + existingByPlaceholderId.set(phId, el); 95 + } 96 + } 97 + 98 + // Create new elements for each placeholder in the master 99 + const newMasterElements: SlideElement[] = master.placeholders.map( 100 + (ph, index) => { 101 + const existing = existingByPlaceholderId.get(ph.id); 102 + const elementType: ElementType = ph.role === 'image' ? 'image' : 'text'; 103 + 104 + // Determine content: preserve existing, or use defaultText, or empty 105 + let content = ''; 106 + if (existing && existing.content) { 107 + content = existing.content; 108 + } else if (ph.defaultText) { 109 + content = ph.defaultText; 110 + } 111 + 112 + // Build style with master metadata + any placeholder-defined styles 113 + const style: Record<string, string> = { 114 + ...(ph.style || {}), 115 + _masterId: master.id, 116 + _masterRole: ph.role, 117 + _masterPlaceholderId: ph.id, 118 + }; 119 + 120 + return { 121 + id: existing?.id || `master-el-${Date.now()}-${++_masterElCounter}`, 122 + type: elementType, 123 + x: ph.x, 124 + y: ph.y, 125 + width: ph.width, 126 + height: ph.height, 127 + rotation: 0, 128 + zIndex: index, 129 + content, 130 + style, 131 + }; 132 + }, 133 + ); 134 + 135 + return { 136 + ...slide, 137 + background: master.background ?? slide.background, 138 + elements: [...newMasterElements, ...userElements], 139 + }; 140 + } 141 + 142 + /** 143 + * Create a brand-new slide pre-populated with placeholder elements 144 + * from a master slide definition. 145 + */ 146 + export function createSlideFromMaster(master: MasterSlide): Slide { 147 + const slide = createSlide(master.background ?? '#ffffff'); 148 + return applyMasterToSlide(slide, master); 149 + } 150 + 151 + /** 152 + * Look up which master a slide is using, based on the _masterId metadata 153 + * stored in its placeholder elements. Returns undefined if the slide 154 + * has no master elements or the master ID is not in the provided list. 155 + */ 156 + export function getMasterForSlide( 157 + slide: Slide, 158 + masters: MasterSlide[], 159 + ): MasterSlide | undefined { 160 + // Find the first element with a _masterId 161 + const masterEl = slide.elements.find(el => el.style._masterId !== undefined); 162 + if (!masterEl) return undefined; 163 + 164 + const masterId = masterEl.style._masterId; 165 + return masters.find(m => m.id === masterId); 166 + }
+6 -1
src/slides/rendering.ts
··· 228 228 const div = document.createElement('div'); 229 229 div.className = 'slide-element' + (el.id === selectedElementId ? ' selected' : ''); 230 230 div.dataset.elementId = el.id; 231 + const isMasterPlaceholder = !!el.style._masterId; 231 232 div.style.cssText = `position:absolute;left:${el.x}px;top:${el.y}px;width:${el.width}px;height:${el.height}px;` 232 - + (el.rotation ? `transform:rotate(${el.rotation}deg);` : ''); 233 + + (el.rotation ? `transform:rotate(${el.rotation}deg);` : '') 234 + + (isMasterPlaceholder ? 'border:1.5px dashed var(--slide-accent, #3a8a7a);border-radius:3px;' : ''); 233 235 234 236 if (el.type === 'text') { 235 237 const textDiv = document.createElement('div'); ··· 299 301 if (state.themedDeck.layouts[state.deck.currentSlide]) { 300 302 refs.layoutSelect.value = state.themedDeck.layouts[state.deck.currentSlide]!; 301 303 } 304 + // Sync master select dropdown to current slide's assignment 305 + const currentMasterId = state.deck.masterAssignments?.[slide.id] || ''; 306 + refs.masterSelect.value = currentMasterId; 302 307 refs.themeSelect.value = state.themedDeck.themeId; 303 308 304 309 actions.setState({
+1
src/slides/types.ts
··· 34 34 thumbnailList: HTMLElement; 35 35 slideCanvas: HTMLElement; 36 36 layoutSelect: HTMLSelectElement; 37 + masterSelect: HTMLSelectElement; 37 38 themeSelect: HTMLSelectElement; 38 39 transitionSelect: HTMLSelectElement; 39 40 notesInput: HTMLTextAreaElement;
+466
tests/master-slides.test.ts
··· 1 + /** 2 + * Tests for master slides -- customizable layout templates. 3 + * VSDD: Red phase -- tests define the spec. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + getDefaultMasters, 8 + createMaster, 9 + applyMasterToSlide, 10 + createSlideFromMaster, 11 + getMasterForSlide, 12 + } from '../src/slides/master-slides.js'; 13 + import type { MasterSlide, MasterPlaceholder } from '../src/slides/master-slides.js'; 14 + import { createSlide } from '../src/slides/canvas-engine.js'; 15 + import type { Slide } from '../src/slides/canvas-engine.js'; 16 + 17 + describe('getDefaultMasters', () => { 18 + it('returns masters derived from built-in layouts', () => { 19 + const masters = getDefaultMasters(); 20 + // 7 built-in layouts -> 7 default masters 21 + expect(masters).toHaveLength(7); 22 + }); 23 + 24 + it('includes a blank master with no placeholders', () => { 25 + const masters = getDefaultMasters(); 26 + const blank = masters.find(m => m.name === 'Blank'); 27 + expect(blank).toBeDefined(); 28 + expect(blank!.placeholders).toHaveLength(0); 29 + }); 30 + 31 + it('includes a title master with title and subtitle placeholders', () => { 32 + const masters = getDefaultMasters(); 33 + const title = masters.find(m => m.name === 'Title Slide'); 34 + expect(title).toBeDefined(); 35 + expect(title!.placeholders).toHaveLength(2); 36 + expect(title!.placeholders[0].role).toBe('title'); 37 + expect(title!.placeholders[1].role).toBe('subtitle'); 38 + }); 39 + 40 + it('preserves layout region positions in placeholders', () => { 41 + const masters = getDefaultMasters(); 42 + const titleContent = masters.find(m => m.name === 'Title + Content'); 43 + expect(titleContent).toBeDefined(); 44 + const titlePh = titleContent!.placeholders.find(p => p.role === 'title'); 45 + expect(titlePh).toBeDefined(); 46 + expect(titlePh!.x).toBe(40); // PAD 47 + expect(titlePh!.y).toBe(40); // PAD 48 + expect(titlePh!.width).toBe(880); // W - PAD * 2 49 + expect(titlePh!.height).toBe(60); 50 + }); 51 + 52 + it('each master has a unique ID', () => { 53 + const masters = getDefaultMasters(); 54 + const ids = masters.map(m => m.id); 55 + expect(new Set(ids).size).toBe(ids.length); 56 + }); 57 + 58 + it('two-column master has three placeholders', () => { 59 + const masters = getDefaultMasters(); 60 + const twoCol = masters.find(m => m.name === 'Two Column'); 61 + expect(twoCol).toBeDefined(); 62 + expect(twoCol!.placeholders).toHaveLength(3); 63 + const roles = twoCol!.placeholders.map(p => p.role); 64 + expect(roles).toContain('title'); 65 + expect(roles).toContain('content'); 66 + }); 67 + 68 + it('image masters have an image placeholder', () => { 69 + const masters = getDefaultMasters(); 70 + const imageLeft = masters.find(m => m.name === 'Image Left'); 71 + expect(imageLeft).toBeDefined(); 72 + const imgPh = imageLeft!.placeholders.find(p => p.role === 'image'); 73 + expect(imgPh).toBeDefined(); 74 + }); 75 + }); 76 + 77 + describe('createMaster', () => { 78 + it('creates a master slide with given name and placeholders', () => { 79 + const placeholders: MasterPlaceholder[] = [ 80 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 81 + ]; 82 + const master = createMaster('My Custom', placeholders); 83 + expect(master.name).toBe('My Custom'); 84 + expect(master.placeholders).toHaveLength(1); 85 + expect(master.id).toBeTruthy(); 86 + }); 87 + 88 + it('generates a unique ID', () => { 89 + const a = createMaster('A', []); 90 + const b = createMaster('B', []); 91 + expect(a.id).not.toBe(b.id); 92 + }); 93 + 94 + it('accepts optional background', () => { 95 + const master = createMaster('Dark', [], '#1a1a2e'); 96 + expect(master.background).toBe('#1a1a2e'); 97 + }); 98 + 99 + it('background is undefined when not specified', () => { 100 + const master = createMaster('Default', []); 101 + expect(master.background).toBeUndefined(); 102 + }); 103 + }); 104 + 105 + describe('applyMasterToSlide', () => { 106 + it('creates placeholder elements on an empty slide', () => { 107 + const masters = getDefaultMasters(); 108 + const titleMaster = masters.find(m => m.name === 'Title Slide')!; 109 + const slide = createSlide(); 110 + 111 + const result = applyMasterToSlide(slide, titleMaster); 112 + expect(result.elements).toHaveLength(2); 113 + }); 114 + 115 + it('placeholder elements have correct positions from master', () => { 116 + const masters = getDefaultMasters(); 117 + const titleContent = masters.find(m => m.name === 'Title + Content')!; 118 + const slide = createSlide(); 119 + 120 + const result = applyMasterToSlide(slide, titleContent); 121 + const titleEl = result.elements.find(e => e.style._masterRole === 'title'); 122 + expect(titleEl).toBeDefined(); 123 + expect(titleEl!.x).toBe(40); 124 + expect(titleEl!.y).toBe(40); 125 + expect(titleEl!.width).toBe(880); 126 + expect(titleEl!.height).toBe(60); 127 + }); 128 + 129 + it('placeholder elements are text type by default', () => { 130 + const masters = getDefaultMasters(); 131 + const titleMaster = masters.find(m => m.name === 'Title Slide')!; 132 + const slide = createSlide(); 133 + 134 + const result = applyMasterToSlide(slide, titleMaster); 135 + for (const el of result.elements) { 136 + expect(el.type).toBe('text'); 137 + } 138 + }); 139 + 140 + it('image role placeholders are image type', () => { 141 + const masters = getDefaultMasters(); 142 + const imageLeft = masters.find(m => m.name === 'Image Left')!; 143 + const slide = createSlide(); 144 + 145 + const result = applyMasterToSlide(slide, imageLeft); 146 + const imgEl = result.elements.find(e => e.style._masterRole === 'image'); 147 + expect(imgEl).toBeDefined(); 148 + expect(imgEl!.type).toBe('image'); 149 + }); 150 + 151 + it('preserves existing content in matching roles', () => { 152 + const masters = getDefaultMasters(); 153 + const titleMaster = masters.find(m => m.name === 'Title Slide')!; 154 + const slide = createSlide(); 155 + 156 + // First apply to get placeholder elements 157 + const withPlaceholders = applyMasterToSlide(slide, titleMaster); 158 + // Simulate user editing the title 159 + const edited: Slide = { 160 + ...withPlaceholders, 161 + elements: withPlaceholders.elements.map(el => 162 + el.style._masterRole === 'title' 163 + ? { ...el, content: 'My Presentation Title' } 164 + : el, 165 + ), 166 + }; 167 + 168 + // Re-apply same master -- should preserve the title content 169 + const reapplied = applyMasterToSlide(edited, titleMaster); 170 + const titleEl = reapplied.elements.find(e => e.style._masterRole === 'title'); 171 + expect(titleEl!.content).toBe('My Presentation Title'); 172 + }); 173 + 174 + it('applies master background when specified', () => { 175 + const master = createMaster('Dark', [ 176 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 177 + ], '#1a1a2e'); 178 + const slide = createSlide(); 179 + 180 + const result = applyMasterToSlide(slide, master); 181 + expect(result.background).toBe('#1a1a2e'); 182 + }); 183 + 184 + it('preserves slide background when master has no background', () => { 185 + const master = createMaster('No BG', [ 186 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 187 + ]); 188 + const slide = createSlide('#ff0000'); 189 + 190 + const result = applyMasterToSlide(slide, master); 191 + expect(result.background).toBe('#ff0000'); 192 + }); 193 + 194 + it('preserves slide ID and notes', () => { 195 + const master = createMaster('Simple', [ 196 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 197 + ]); 198 + const slide: Slide = { ...createSlide(), notes: 'Speaker notes' }; 199 + 200 + const result = applyMasterToSlide(slide, master); 201 + expect(result.id).toBe(slide.id); 202 + expect(result.notes).toBe('Speaker notes'); 203 + }); 204 + 205 + it('removes old master placeholders not in new master', () => { 206 + const masterA = createMaster('Two Placeholders', [ 207 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 208 + { id: 'ph-2', role: 'subtitle', x: 40, y: 120, width: 880, height: 40 }, 209 + ]); 210 + const masterB = createMaster('One Placeholder', [ 211 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 80 }, 212 + ]); 213 + 214 + const slide = createSlide(); 215 + const withA = applyMasterToSlide(slide, masterA); 216 + expect(withA.elements).toHaveLength(2); 217 + 218 + const withB = applyMasterToSlide(withA, masterB); 219 + expect(withB.elements).toHaveLength(1); 220 + expect(withB.elements[0].style._masterRole).toBe('title'); 221 + }); 222 + 223 + it('preserves non-master elements (user-added elements)', () => { 224 + const master = createMaster('Simple', [ 225 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 226 + ]); 227 + const slide = createSlide(); 228 + const withMaster = applyMasterToSlide(slide, master); 229 + 230 + // User manually adds an extra shape element 231 + const withExtra: Slide = { 232 + ...withMaster, 233 + elements: [ 234 + ...withMaster.elements, 235 + { 236 + id: 'user-shape-1', 237 + type: 'shape', 238 + x: 500, y: 300, width: 100, height: 100, 239 + rotation: 0, zIndex: 10, content: '', 240 + style: { fill: '#ff0000' }, 241 + }, 242 + ], 243 + }; 244 + 245 + // Re-apply master -- should keep the user's shape 246 + const reapplied = applyMasterToSlide(withExtra, master); 247 + expect(reapplied.elements).toHaveLength(2); // 1 master + 1 user 248 + const userEl = reapplied.elements.find(e => e.id === 'user-shape-1'); 249 + expect(userEl).toBeDefined(); 250 + }); 251 + 252 + it('applies placeholder defaultText when content is empty', () => { 253 + const master = createMaster('Prompted', [ 254 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60, defaultText: 'Click to add title' }, 255 + ]); 256 + const slide = createSlide(); 257 + 258 + const result = applyMasterToSlide(slide, master); 259 + const titleEl = result.elements.find(e => e.style._masterRole === 'title'); 260 + expect(titleEl!.content).toBe('Click to add title'); 261 + }); 262 + 263 + it('applies placeholder style to elements', () => { 264 + const master = createMaster('Styled', [ 265 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60, style: { fontSize: '32px', fontWeight: 'bold' } }, 266 + ]); 267 + const slide = createSlide(); 268 + 269 + const result = applyMasterToSlide(slide, master); 270 + const titleEl = result.elements.find(e => e.style._masterRole === 'title'); 271 + expect(titleEl!.style.fontSize).toBe('32px'); 272 + expect(titleEl!.style.fontWeight).toBe('bold'); 273 + }); 274 + }); 275 + 276 + describe('createSlideFromMaster', () => { 277 + it('creates a new slide with placeholder elements', () => { 278 + const masters = getDefaultMasters(); 279 + const titleMaster = masters.find(m => m.name === 'Title Slide')!; 280 + 281 + const slide = createSlideFromMaster(titleMaster); 282 + expect(slide.elements).toHaveLength(2); 283 + expect(slide.id).toBeTruthy(); 284 + expect(slide.notes).toBe(''); 285 + }); 286 + 287 + it('uses master background when specified', () => { 288 + const master = createMaster('Dark', [ 289 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 290 + ], '#1a1a2e'); 291 + 292 + const slide = createSlideFromMaster(master); 293 + expect(slide.background).toBe('#1a1a2e'); 294 + }); 295 + 296 + it('uses white background when master has no background', () => { 297 + const master = createMaster('Default', [ 298 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 299 + ]); 300 + 301 + const slide = createSlideFromMaster(master); 302 + expect(slide.background).toBe('#ffffff'); 303 + }); 304 + 305 + it('produces elements with correct master role metadata', () => { 306 + const master = createMaster('TC', [ 307 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 308 + { id: 'ph-2', role: 'body', x: 40, y: 120, width: 880, height: 380 }, 309 + ]); 310 + 311 + const slide = createSlideFromMaster(master); 312 + const roles = slide.elements.map(e => e.style._masterRole); 313 + expect(roles).toContain('title'); 314 + expect(roles).toContain('body'); 315 + }); 316 + 317 + it('blank master creates slide with no elements', () => { 318 + const masters = getDefaultMasters(); 319 + const blank = masters.find(m => m.name === 'Blank')!; 320 + 321 + const slide = createSlideFromMaster(blank); 322 + expect(slide.elements).toHaveLength(0); 323 + }); 324 + 325 + it('elements have unique IDs', () => { 326 + const master = createMaster('Multi', [ 327 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 328 + { id: 'ph-2', role: 'body', x: 40, y: 120, width: 880, height: 380 }, 329 + ]); 330 + 331 + const slide = createSlideFromMaster(master); 332 + const ids = slide.elements.map(e => e.id); 333 + expect(new Set(ids).size).toBe(ids.length); 334 + }); 335 + 336 + it('elements have sequential zIndex', () => { 337 + const master = createMaster('Multi', [ 338 + { id: 'ph-1', role: 'title', x: 40, y: 40, width: 880, height: 60 }, 339 + { id: 'ph-2', role: 'body', x: 40, y: 120, width: 880, height: 380 }, 340 + { id: 'ph-3', role: 'image', x: 40, y: 120, width: 400, height: 380 }, 341 + ]); 342 + 343 + const slide = createSlideFromMaster(master); 344 + const zIndices = slide.elements.map(e => e.zIndex); 345 + expect(zIndices).toEqual([0, 1, 2]); 346 + }); 347 + }); 348 + 349 + describe('getMasterForSlide', () => { 350 + it('returns the master assigned to a slide via masterAssignment metadata', () => { 351 + const masters = getDefaultMasters(); 352 + const titleMaster = masters.find(m => m.name === 'Title Slide')!; 353 + const slide = createSlideFromMaster(titleMaster); 354 + 355 + // Slide has _masterId in style of its placeholder elements 356 + const found = getMasterForSlide(slide, masters); 357 + expect(found).toBeDefined(); 358 + expect(found!.id).toBe(titleMaster.id); 359 + }); 360 + 361 + it('returns undefined when slide has no master elements', () => { 362 + const masters = getDefaultMasters(); 363 + const slide = createSlide(); // plain slide, no master 364 + 365 + const found = getMasterForSlide(slide, masters); 366 + expect(found).toBeUndefined(); 367 + }); 368 + 369 + it('returns undefined when master ID does not match any known master', () => { 370 + const masters = getDefaultMasters(); 371 + const slide: Slide = { 372 + ...createSlide(), 373 + elements: [{ 374 + id: 'el-1', 375 + type: 'text', 376 + x: 0, y: 0, width: 100, height: 50, 377 + rotation: 0, zIndex: 0, content: '', 378 + style: { _masterId: 'nonexistent-master-id', _masterRole: 'title' }, 379 + }], 380 + }; 381 + 382 + const found = getMasterForSlide(slide, masters); 383 + expect(found).toBeUndefined(); 384 + }); 385 + }); 386 + 387 + describe('edge cases', () => { 388 + it('applyMasterToSlide with empty placeholders produces no master elements', () => { 389 + const master = createMaster('Empty', []); 390 + const slide = createSlide(); 391 + 392 + const result = applyMasterToSlide(slide, master); 393 + expect(result.elements).toHaveLength(0); 394 + }); 395 + 396 + it('master with duplicate roles creates separate elements for each', () => { 397 + const master = createMaster('Dual Content', [ 398 + { id: 'ph-1', role: 'content', x: 40, y: 40, width: 400, height: 460 }, 399 + { id: 'ph-2', role: 'content', x: 520, y: 40, width: 400, height: 460 }, 400 + ]); 401 + const slide = createSlide(); 402 + 403 + const result = applyMasterToSlide(slide, master); 404 + expect(result.elements).toHaveLength(2); 405 + const contentEls = result.elements.filter(e => e.style._masterRole === 'content'); 406 + expect(contentEls).toHaveLength(2); 407 + // They should be at different positions 408 + expect(contentEls[0].x).not.toBe(contentEls[1].x); 409 + }); 410 + 411 + it('master with all placeholder roles', () => { 412 + const allRoles: MasterPlaceholder['role'][] = ['title', 'subtitle', 'body', 'image', 'content']; 413 + const placeholders: MasterPlaceholder[] = allRoles.map((role, i) => ({ 414 + id: `ph-${i}`, 415 + role, 416 + x: 40, 417 + y: 40 + i * 100, 418 + width: 880, 419 + height: 80, 420 + })); 421 + const master = createMaster('All Roles', placeholders); 422 + const slide = createSlideFromMaster(master); 423 + 424 + expect(slide.elements).toHaveLength(5); 425 + const roles = slide.elements.map(e => e.style._masterRole); 426 + for (const role of allRoles) { 427 + expect(roles).toContain(role); 428 + } 429 + 430 + // Image role should be image type, rest should be text 431 + const imgEl = slide.elements.find(e => e.style._masterRole === 'image'); 432 + expect(imgEl!.type).toBe('image'); 433 + const textEls = slide.elements.filter(e => e.style._masterRole !== 'image'); 434 + for (const el of textEls) { 435 + expect(el.type).toBe('text'); 436 + } 437 + }); 438 + 439 + it('applying master to slide with content preserves content by placeholder ID', () => { 440 + const master = createMaster('Two Items', [ 441 + { id: 'ph-left', role: 'content', x: 40, y: 40, width: 400, height: 460 }, 442 + { id: 'ph-right', role: 'content', x: 520, y: 40, width: 400, height: 460 }, 443 + ]); 444 + const slide = createSlide(); 445 + const applied = applyMasterToSlide(slide, master); 446 + 447 + // Edit each content placeholder 448 + const edited: Slide = { 449 + ...applied, 450 + elements: applied.elements.map(el => 451 + el.style._masterPlaceholderId === 'ph-left' 452 + ? { ...el, content: 'Left content' } 453 + : el.style._masterPlaceholderId === 'ph-right' 454 + ? { ...el, content: 'Right content' } 455 + : el, 456 + ), 457 + }; 458 + 459 + // Re-apply same master 460 + const reapplied = applyMasterToSlide(edited, master); 461 + const left = reapplied.elements.find(e => e.style._masterPlaceholderId === 'ph-left'); 462 + const right = reapplied.elements.find(e => e.style._masterPlaceholderId === 'ph-right'); 463 + expect(left!.content).toBe('Left content'); 464 + expect(right!.content).toBe('Right content'); 465 + }); 466 + });
+17 -8
tests/provider-save.test.ts
··· 221 221 222 222 describe('Sheets must await whenReady before ydoc writes', () => { 223 223 it('should await provider.whenReady before ensureSheet', async () => { 224 - const source = await import('fs').then(fs => 225 - fs.readFileSync('src/sheets/main.ts', 'utf-8') 226 - ); 227 - // whenReady must appear BEFORE ensureSheet in the source 228 - const readyIdx = source.indexOf('await provider.whenReady'); 229 - const ensureIdx = source.indexOf('ensureSheet(0)'); 230 - expect(readyIdx).toBeGreaterThan(-1); 224 + const fs = await import('fs'); 225 + const mainSource = fs.readFileSync('src/sheets/main.ts', 'utf-8'); 226 + const ensureIdx = mainSource.indexOf('ensureSheet(0)'); 231 227 expect(ensureIdx).toBeGreaterThan(-1); 232 - expect(readyIdx).toBeLessThan(ensureIdx); 228 + 229 + // whenReady may live in main.ts directly or in session-bootstrap.ts (called via await bootstrap()) 230 + const readyIdx = mainSource.indexOf('await provider.whenReady'); 231 + if (readyIdx > -1) { 232 + expect(readyIdx).toBeLessThan(ensureIdx); 233 + } else { 234 + // bootstrap() must be awaited before ensureSheet(0) 235 + const bootstrapIdx = mainSource.indexOf('await bootstrap()'); 236 + expect(bootstrapIdx).toBeGreaterThan(-1); 237 + expect(bootstrapIdx).toBeLessThan(ensureIdx); 238 + // and session-bootstrap.ts must contain the whenReady await 239 + const bootstrapSource = fs.readFileSync('src/sheets/session-bootstrap.ts', 'utf-8'); 240 + expect(bootstrapSource.indexOf('await provider.whenReady')).toBeGreaterThan(-1); 241 + } 233 242 }); 234 243 }); 235 244