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

Configure Feed

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

Merge pull request 'refactor(sheets): phase 2 — extract dialogs, tabs, pivots, AI chat from main.ts' (#281) from refactor/sheets-decompose-phase2 into main

scott 594179dd 9c173ce4

+1424 -1034
+178
src/sheets/ai-chat-panel.ts
··· 1 + /** 2 + * AI chat panel logic for sheets — context extraction and message sending. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { parseRef, colToLetter, cellId, letterToCol } from './formulas.js'; 9 + import { escapeHtml } from '../lib/escape-html.js'; 10 + import { 11 + isConfigured, buildSystemMessage, streamChat, 12 + appendMessage, appendStreamingBubble, renderMarkdown, 13 + appendActionCard, 14 + } from '../lib/ai-chat.js'; 15 + import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 16 + import { executeSheetAction } from './ai-sheet-actions.js'; 17 + 18 + // ── Types ─────────────────────────────────────────────────── 19 + 20 + export interface ChatPanelDeps { 21 + getCells: () => any; 22 + getCellData: (id: string) => { v?: any; f?: string; s?: any } | null; 23 + setCellData: (id: string, data: any) => void; 24 + renderGrid: () => void; 25 + chatUI: { 26 + input: HTMLTextAreaElement; 27 + settingsPanel: HTMLElement; 28 + endpointInput: HTMLInputElement; 29 + messageList: HTMLElement; 30 + sendBtn: HTMLElement; 31 + stopBtn: HTMLElement; 32 + contextToggle: HTMLInputElement; 33 + actionsToggle: HTMLInputElement; 34 + }; 35 + chatState: { 36 + messages: any[]; 37 + loading: boolean; 38 + error: string | null; 39 + abortController: AbortController | null; 40 + }; 41 + chatWiring: { getConfig: () => any }; 42 + titleInput: HTMLInputElement; 43 + } 44 + 45 + // ── Sheet context extraction ──────────────────────────────── 46 + 47 + export function getSheetContextText(getCells: () => any): string { 48 + const cells = getCells(); 49 + if (!cells || cells.size === 0) return ''; 50 + 51 + const rows: Record<number, Record<number, string>> = {}; 52 + let maxCol = 0; 53 + let maxRow = 0; 54 + cells.forEach((cell: any, id: string) => { 55 + const ref = parseRef(id); 56 + if (!ref) return; 57 + const { col, row } = ref; 58 + if (!rows[row]) rows[row] = {}; 59 + const c = cell instanceof Y.Map ? cell : null; 60 + const formula = c ? c.get('f') : ''; 61 + const value = c ? (c.get('v') ?? '') : ''; 62 + rows[row][col] = formula ? `=${formula}` : String(value); 63 + if (col > maxCol) maxCol = col; 64 + if (row > maxRow) maxRow = row; 65 + }); 66 + 67 + const lines: string[] = []; 68 + const headers = ['']; 69 + for (let c = 0; c <= maxCol; c++) headers.push(colToLetter(c)); 70 + lines.push(headers.join('\t')); 71 + 72 + for (let r = 0; r <= maxRow; r++) { 73 + if (!rows[r]) continue; 74 + const cols = [String(r + 1)]; 75 + for (let c = 0; c <= maxCol; c++) { 76 + cols.push(rows[r]?.[c] || ''); 77 + } 78 + if (cols.slice(1).every(v => v === '')) continue; 79 + lines.push(cols.join('\t')); 80 + } 81 + return lines.join('\n'); 82 + } 83 + 84 + // ── Send chat message ─────────────────────────────────────── 85 + 86 + export async function sendChatMessage(deps: ChatPanelDeps): Promise<void> { 87 + const text = deps.chatUI.input.value.trim(); 88 + if (!text || deps.chatState.loading) return; 89 + 90 + const cfg = deps.chatWiring.getConfig(); 91 + if (!isConfigured(cfg)) { 92 + deps.chatUI.settingsPanel.style.display = ''; 93 + deps.chatUI.endpointInput.focus(); 94 + return; 95 + } 96 + 97 + const userMsg = { role: 'user', content: text, ts: Date.now() }; 98 + deps.chatState.messages.push(userMsg); 99 + appendMessage(deps.chatUI.messageList, userMsg); 100 + 101 + deps.chatUI.input.value = ''; 102 + deps.chatUI.input.style.height = ''; 103 + deps.chatUI.sendBtn.style.display = 'none'; 104 + deps.chatUI.stopBtn.style.display = ''; 105 + deps.chatState.loading = true; 106 + deps.chatState.error = null; 107 + 108 + const sheetTitle = deps.titleInput.value.trim() || 'Untitled Spreadsheet'; 109 + const includeContext = deps.chatUI.contextToggle.checked; 110 + const actionsEnabled = deps.chatUI.actionsToggle.checked; 111 + const sheetText = includeContext ? getSheetContextText(deps.getCells) : ''; 112 + const systemPrompt = buildSystemMessage(sheetTitle, sheetText, { 113 + editorType: 'sheet', 114 + actionsEnabled, 115 + }); 116 + 117 + const sheetActionDeps = { 118 + setCellData: deps.setCellData, 119 + getCellData: deps.getCellData, 120 + cellId, 121 + parseRef, 122 + letterToCol, 123 + colToLetter, 124 + renderGrid: deps.renderGrid, 125 + }; 126 + 127 + const abortController = new AbortController(); 128 + deps.chatState.abortController = abortController; 129 + const bubble = appendStreamingBubble(deps.chatUI.messageList); 130 + let fullText = ''; 131 + 132 + await streamChat( 133 + cfg, 134 + deps.chatState.messages, 135 + systemPrompt, 136 + { 137 + onChunk(chunk: string) { 138 + fullText += chunk; 139 + bubble.update(renderMarkdown(fullText)); 140 + }, 141 + onDone(text: string) { 142 + if (text) { 143 + deps.chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 144 + 145 + if (actionsEnabled) { 146 + const { displayText, actions } = splitResponse(text); 147 + if (actions.length > 0) { 148 + bubble.update(renderMarkdown(displayText)); 149 + for (const action of actions) { 150 + if (!isSheetAction(action)) continue; 151 + appendActionCard(deps.chatUI.messageList, action, { 152 + onApply: (a: any) => { 153 + const result = executeSheetAction(a, sheetActionDeps); 154 + if (!result.success && result.error) { 155 + appendMessage(deps.chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 156 + } 157 + }, 158 + onDismiss: () => {}, 159 + }); 160 + } 161 + } 162 + } 163 + } 164 + }, 165 + onError(err: string) { 166 + deps.chatState.error = err; 167 + bubble.el.classList.add('ai-chat-bubble--error'); 168 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 169 + }, 170 + }, 171 + abortController.signal, 172 + ); 173 + 174 + deps.chatState.loading = false; 175 + deps.chatState.abortController = null; 176 + deps.chatUI.sendBtn.style.display = ''; 177 + deps.chatUI.stopBtn.style.display = 'none'; 178 + }
+45 -1034
src/sheets/main.ts
··· 19 19 import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js'; 20 20 import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js'; 21 21 import { multiColumnSort } from './sort.js'; 22 - import { evaluateRules, buildCfStyle, computeColorScale, formatRuleLabel } from './conditional-format.js'; 22 + import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 23 23 import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 24 24 import { renderInteractiveCell, handleRichCellClick } from './rich-cells.js'; 25 25 import { parseDateValue, showDatePicker } from './date-picker.js'; ··· 33 33 import { parseCSVLine, detectHeaders } from './csv-utils.js'; 34 34 import type { SpillState } from './spill-tracking.js'; 35 35 import { computeSelectionStats, formatStatValue } from './status-bar.js'; 36 + import { showSortDialog as _showSortDialog, showCfModal as _showCfModal, showValidationModal as _showValidationModal } from './sheet-dialogs.js'; 37 + import { getSheetContextText as _getSheetContextText, sendChatMessage as _sendChatMessage } from './ai-chat-panel.js'; 38 + import { renderSheetTabs as _renderSheetTabs, reorderSheets as _reorderSheets, swapSheetData as _swapSheetData, beginInlineRename as _beginInlineRename, showSheetTabContextMenu as _showSheetTabContextMenu, showTabColorPicker as _showTabColorPicker, confirmAndDeleteSheet as _confirmAndDeleteSheet, doDuplicateSheet as _doDuplicateSheet } from './sheet-tabs-ui.js'; 39 + import { showPivotDialog as _showPivotDialog, renderPivots as _renderPivots } from './pivot-ui.js'; 36 40 import { FORMULA_FUNCTIONS, filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js'; 37 41 import { createNote, updateNote, deleteNote, getNote, hasNote, getAllNotes } from './cell-notes.js'; 38 42 import { tokenizeForHighlighting, renderHighlightedFormula } from './formula-highlighter.js'; ··· 42 46 import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 43 47 import { createContextMenu, buildSheetsCellItems, buildSheetsColumnHeaderItems, buildSheetsRowHeaderItems, SEPARATOR } from '../lib/context-menu.js'; 44 48 import type { MenuItem, MenuItemConfig } from '../lib/context-menu.js'; 45 - import { duplicateSheet, deleteSheet, sheetHasData, countSheets, setTabColor, getTabColor, renameSheet, canMoveLeft, canMoveRight, TAB_COLORS } from './sheet-tab-management.js'; 49 + // Sheet tab management functions used via sheet-tabs-ui.ts (no longer directly imported here) 46 50 import { buildCopyHtml, buildCopyTsv } from './clipboard-copy.js'; 47 51 import { parseClipboardHtml, parseClipboardTsv } from './clipboard-paste.js'; 48 52 import { extractValuesOnly, extractFormulasOnly, extractFormattingOnly, transposeGrid, PASTE_MODES } from './paste-special.js'; ··· 53 57 import { buildSheetsPrintHtml } from '../lib/print-layout.js'; 54 58 import type { PrintCell, PrintRow, SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 55 59 import { 56 - createChatSidebar, createChatState, loadConfig, isConfigured, 57 - buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 58 - renderMarkdown, appendActionCard, initChatWiring, 60 + createChatSidebar, createChatState, loadConfig, initChatWiring, 59 61 } from '../lib/ai-chat.js'; 60 62 import { escapeHtml } from '../lib/escape-html.js'; 61 - import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 62 - import { executeSheetAction } from './ai-sheet-actions.js'; 63 - import { computePivot, formatAggregateValue } from './pivot-table.js'; 63 + // splitResponse/isSheetAction/executeSheetAction used via ai-chat-panel.ts 64 + // computePivot/formatAggregateValue used via pivot-ui.ts 64 65 import type { PivotConfig, AggregateFunction } from './pivot-table.js'; 65 66 import { buildKanbanColumns, buildGalleryCards, buildCalendarEvents, groupEventsByMonth, eventsForDate, createViewConfig, getViewTypes } from './database-views.js'; 66 67 import type { ViewConfig, ViewType } from './database-views.js'; ··· 2709 2710 if (freezeToggleBtn) { freezeToggleBtn.classList.toggle('active', fr > 0 || fc > 0); } 2710 2711 } 2711 2712 2712 - // --- Sheet tabs --- 2713 - let dragSourceSheetIdx = -1; 2714 - 2715 - /** Start inline rename on a sheet tab. */ 2716 - function beginInlineRename(tab: HTMLElement, sheet: any, sheetIdx: number) { 2717 - // Prevent re-entry if already editing 2718 - if (tab.querySelector('.sheet-tab-rename')) return; 2719 - 2720 - const currentName = sheet.get('name') || 'Sheet ' + (sheetIdx + 1); 2721 - 2722 - // Create a contenteditable span 2723 - const input = document.createElement('span'); 2724 - input.className = 'sheet-tab-rename'; 2725 - input.contentEditable = 'true'; 2726 - input.spellcheck = false; 2727 - input.textContent = currentName; 2728 - 2729 - // Replace tab content with the editable span (preserve color bar) 2730 - const colorBar = tab.querySelector('.sheet-tab-color-bar'); 2731 - tab.textContent = ''; 2732 - if (colorBar) tab.appendChild(colorBar); 2733 - tab.appendChild(input); 2734 - tab.draggable = false; // disable drag while editing 2735 - 2736 - // Select all text 2737 - const range = document.createRange(); 2738 - range.selectNodeContents(input); 2739 - const sel = window.getSelection(); 2740 - sel.removeAllRanges(); 2741 - sel.addRange(range); 2742 - input.focus(); 2743 - 2744 - let committed = false; 2745 - 2746 - function commit() { 2747 - if (committed) return; 2748 - committed = true; 2749 - const newName = (input.textContent || '').trim(); 2750 - if (newName && newName !== currentName) { 2751 - renameSheet(sheet, newName); 2752 - } 2753 - renderSheetTabs(); 2754 - } 2755 - 2756 - function cancel() { 2757 - if (committed) return; 2758 - committed = true; 2759 - renderSheetTabs(); 2760 - } 2761 - 2762 - input.addEventListener('keydown', (e) => { 2763 - if (e.key === 'Enter') { e.preventDefault(); commit(); } 2764 - else if (e.key === 'Escape') { e.preventDefault(); cancel(); } 2765 - }); 2766 - 2767 - input.addEventListener('blur', () => { 2768 - // Small delay to allow Escape keydown to fire before blur 2769 - setTimeout(() => { if (!committed) commit(); }, 0); 2770 - }); 2771 - } 2772 - 2773 - /** Show the tab color picker popup near a tab element. */ 2774 - function showTabColorPicker(tab: HTMLElement, sheet: any, sheetIdx: number) { 2775 - // Remove any existing color picker 2776 - document.querySelectorAll('.sheet-tab-color-picker').forEach(el => el.remove()); 2777 - 2778 - const picker = document.createElement('div'); 2779 - picker.className = 'sheet-tab-color-picker'; 2780 - 2781 - const currentColor = getTabColor(sheet); 2782 - 2783 - // "No color" option 2784 - const noneBtn = document.createElement('button'); 2785 - noneBtn.className = 'sheet-tab-color-swatch sheet-tab-color-none'; 2786 - noneBtn.title = 'No color'; 2787 - noneBtn.textContent = '\u2715'; 2788 - if (!currentColor) noneBtn.classList.add('selected'); 2789 - noneBtn.addEventListener('click', () => { 2790 - setTabColor(sheet, null); 2791 - renderSheetTabs(); 2792 - picker.remove(); 2793 - }); 2794 - picker.appendChild(noneBtn); 2795 - 2796 - // Preset colors 2797 - for (const color of TAB_COLORS) { 2798 - const swatch = document.createElement('button'); 2799 - swatch.className = 'sheet-tab-color-swatch'; 2800 - swatch.style.backgroundColor = color.value; 2801 - swatch.title = color.name; 2802 - if (currentColor === color.value) swatch.classList.add('selected'); 2803 - swatch.addEventListener('click', () => { 2804 - setTabColor(sheet, color.value); 2805 - renderSheetTabs(); 2806 - picker.remove(); 2807 - }); 2808 - picker.appendChild(swatch); 2809 - } 2810 - 2811 - // Position below the tab 2812 - const rect = tab.getBoundingClientRect(); 2813 - picker.style.position = 'fixed'; 2814 - picker.style.left = rect.left + 'px'; 2815 - picker.style.top = (rect.top - 40) + 'px'; // above the tab 2816 - picker.style.zIndex = '10000'; 2817 - 2818 - document.body.appendChild(picker); 2819 - 2820 - // Close on click outside 2821 - const closeHandler = (ev) => { 2822 - if (!picker.contains(ev.target)) { 2823 - picker.remove(); 2824 - document.removeEventListener('mousedown', closeHandler); 2825 - } 2713 + // --- Sheet tabs (extracted to sheet-tabs-ui.ts) --- 2714 + function _sheetTabsDeps() { 2715 + return { 2716 + ySheets, ydoc, getActiveSheetIdx: () => activeSheetIdx, 2717 + setActiveSheetIdx: (idx: number) => { activeSheetIdx = idx; }, 2718 + ensureSheet, evalCache, clearSpillMaps: _clearSpillMaps, 2719 + invalidateRecalcEngine, renderGrid, hideActiveContextMenu, 2720 + setActiveContextMenu: (menu: any) => { _activeContextMenu = menu; }, 2721 + sheetTabsContainer, 2826 2722 }; 2827 - setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 2828 2723 } 2829 - 2830 - /** Show confirmation dialog and delete a sheet. */ 2831 - function confirmAndDeleteSheet(sheetIdx: number) { 2832 - const total = countSheets(ySheets); 2833 - if (total <= 1) return; // cannot delete last sheet 2834 - 2835 - const sheet = ySheets.get('sheet_' + sheetIdx); 2836 - if (!sheet) return; 2837 - 2838 - const hasData = sheetHasData(sheet); 2839 - if (hasData) { 2840 - if (!confirm('This sheet contains data. Are you sure you want to delete it?')) return; 2841 - } 2842 - 2843 - const newActive = deleteSheet(ydoc, ySheets, sheetIdx, activeSheetIdx); 2844 - if (newActive >= 0) { 2845 - activeSheetIdx = newActive; 2846 - evalCache.clear(); _clearSpillMaps(); 2847 - invalidateRecalcEngine(); 2848 - renderSheetTabs(); 2849 - renderGrid(); 2850 - } 2851 - } 2852 - 2853 - /** Duplicate a sheet and switch to the copy. */ 2854 - function doDuplicateSheet(sheetIdx: number) { 2855 - const total = countSheets(ySheets); 2856 - const targetIdx = total; // append at end 2857 - const newSheet = duplicateSheet(ydoc, ySheets, sheetIdx, targetIdx); 2858 - if (newSheet) { 2859 - activeSheetIdx = targetIdx; 2860 - evalCache.clear(); _clearSpillMaps(); 2861 - invalidateRecalcEngine(); 2862 - renderSheetTabs(); 2863 - renderGrid(); 2864 - } 2865 - } 2866 - 2867 - /** Show the sheet tab context menu. */ 2868 - function showSheetTabContextMenu(e: MouseEvent, tab: HTMLElement, sheetIdx: number) { 2869 - e.preventDefault(); 2870 - hideActiveContextMenu(); 2871 - 2872 - const sheet = ensureSheet(sheetIdx); 2873 - const total = countSheets(ySheets); 2874 - 2875 - const items: MenuItem[] = [ 2876 - { label: 'Rename', icon: '\u270F', action: () => beginInlineRename(tab, sheet, sheetIdx) }, 2877 - { label: 'Duplicate', icon: '\u29C9', action: () => doDuplicateSheet(sheetIdx) }, 2878 - { label: 'Delete', icon: '\u2715', action: () => confirmAndDeleteSheet(sheetIdx), disabled: total <= 1 }, 2879 - SEPARATOR, 2880 - { label: 'Move Left', icon: '\u2190', action: () => { reorderSheets(sheetIdx, sheetIdx - 1); }, disabled: !canMoveLeft(sheetIdx) }, 2881 - { label: 'Move Right', icon: '\u2192', action: () => { reorderSheets(sheetIdx, sheetIdx + 1); }, disabled: !canMoveRight(sheetIdx, total) }, 2882 - SEPARATOR, 2883 - { label: 'Tab Color', icon: '\u25CF', action: () => showTabColorPicker(tab, sheet, sheetIdx) }, 2884 - ]; 2885 - 2886 - const ctxMenu = createContextMenu(items); 2887 - document.body.appendChild(ctxMenu.el); 2888 - ctxMenu.show(e.clientX, e.clientY); 2889 - _activeContextMenu = ctxMenu; 2890 - 2891 - // Close on click outside 2892 - const closeHandler = (ev) => { 2893 - if (!ctxMenu.el.contains(ev.target)) { 2894 - hideActiveContextMenu(); 2895 - document.removeEventListener('mousedown', closeHandler); 2896 - } 2897 - }; 2898 - setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 2899 - } 2900 - 2901 - function renderSheetTabs() { 2902 - sheetTabsContainer.querySelectorAll('.sheet-tab').forEach(t => t.remove()); 2903 - let sheetCount = 0; 2904 - ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) sheetCount++; }); 2905 - if (sheetCount === 0) ensureSheet(0); 2906 - sheetCount = Math.max(sheetCount, 1); 2907 - const addBtn = document.getElementById('add-sheet'); 2908 - for (let i = 0; i < sheetCount; i++) { 2909 - const sheet = ensureSheet(i); 2910 - const tab = document.createElement('button'); 2911 - tab.className = 'sheet-tab' + (i === activeSheetIdx ? ' active' : ''); 2912 - tab.dataset.sheet = i; 2913 - 2914 - // Tab color indicator bar 2915 - const tabColor = getTabColor(sheet); 2916 - if (tabColor) { 2917 - const colorBar = document.createElement('span'); 2918 - colorBar.className = 'sheet-tab-color-bar'; 2919 - colorBar.style.backgroundColor = tabColor; 2920 - tab.appendChild(colorBar); 2921 - } 2922 - 2923 - // Tab label text 2924 - const label = document.createElement('span'); 2925 - label.className = 'sheet-tab-label'; 2926 - label.textContent = sheet.get('name') || 'Sheet ' + (i + 1); 2927 - tab.appendChild(label); 2928 - 2929 - tab.draggable = true; 2930 - tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); }); 2931 - 2932 - // Double-click for inline rename 2933 - tab.addEventListener('dblclick', (e) => { 2934 - e.preventDefault(); 2935 - beginInlineRename(tab, sheet, i); 2936 - }); 2937 - 2938 - // Right-click context menu 2939 - tab.addEventListener('contextmenu', (e) => { 2940 - showSheetTabContextMenu(e, tab, i); 2941 - }); 2942 - 2943 - // Drag reorder events 2944 - tab.addEventListener('dragstart', (e) => { 2945 - dragSourceSheetIdx = i; 2946 - tab.classList.add('dragging'); 2947 - e.dataTransfer.effectAllowed = 'move'; 2948 - e.dataTransfer.setData('text/plain', String(i)); 2949 - }); 2950 - tab.addEventListener('dragend', () => { 2951 - tab.classList.remove('dragging'); 2952 - dragSourceSheetIdx = -1; 2953 - sheetTabsContainer.querySelectorAll('.sheet-tab').forEach(t => { 2954 - t.classList.remove('drag-over-left', 'drag-over-right'); 2955 - }); 2956 - }); 2957 - tab.addEventListener('dragover', (e) => { 2958 - e.preventDefault(); 2959 - e.dataTransfer.dropEffect = 'move'; 2960 - const targetIdx = parseInt(tab.dataset.sheet); 2961 - if (targetIdx === dragSourceSheetIdx) return; 2962 - sheetTabsContainer.querySelectorAll('.sheet-tab').forEach(t => { 2963 - t.classList.remove('drag-over-left', 'drag-over-right'); 2964 - }); 2965 - if (targetIdx < dragSourceSheetIdx) { 2966 - tab.classList.add('drag-over-left'); 2967 - } else { 2968 - tab.classList.add('drag-over-right'); 2969 - } 2970 - }); 2971 - tab.addEventListener('dragleave', () => { 2972 - tab.classList.remove('drag-over-left', 'drag-over-right'); 2973 - }); 2974 - tab.addEventListener('drop', (e) => { 2975 - e.preventDefault(); 2976 - const fromIdx = dragSourceSheetIdx; 2977 - const toIdx = parseInt(tab.dataset.sheet); 2978 - if (fromIdx === toIdx || fromIdx < 0) return; 2979 - reorderSheets(fromIdx, toIdx); 2980 - }); 2981 - 2982 - sheetTabsContainer.insertBefore(tab, addBtn); 2983 - } 2984 - } 2985 - 2986 - function reorderSheets(fromIdx, toIdx) { 2987 - // Collect all sheet data in order 2988 - let sheetCount = 0; 2989 - ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) sheetCount++; }); 2990 - 2991 - // Build ordered list of sheet keys 2992 - const sheetKeys = []; 2993 - for (let i = 0; i < sheetCount; i++) sheetKeys.push('sheet_' + i); 2994 - 2995 - // Reorder: remove fromIdx and insert at toIdx 2996 - const movedKey = sheetKeys.splice(fromIdx, 1)[0]; 2997 - sheetKeys.splice(toIdx, 0, movedKey); 2998 - 2999 - // Re-assign sheets to their new indices via adjacent swaps 3000 - ydoc.transact(() => { 3001 - const direction = fromIdx < toIdx ? 1 : -1; 3002 - let current = fromIdx; 3003 - while (current !== toIdx) { 3004 - const next = current + direction; 3005 - swapSheetData(current, next); 3006 - current = next; 3007 - } 3008 - }); 3009 - 3010 - // Update active sheet index to follow the moved sheet 3011 - if (activeSheetIdx === fromIdx) { 3012 - activeSheetIdx = toIdx; 3013 - } else if (fromIdx < toIdx && activeSheetIdx > fromIdx && activeSheetIdx <= toIdx) { 3014 - activeSheetIdx--; 3015 - } else if (fromIdx > toIdx && activeSheetIdx >= toIdx && activeSheetIdx < fromIdx) { 3016 - activeSheetIdx++; 3017 - } 3018 - 3019 - evalCache.clear(); _clearSpillMaps(); 3020 - invalidateRecalcEngine(); 3021 - renderSheetTabs(); 3022 - renderGrid(); 3023 - } 3024 - 3025 - function swapSheetData(idxA, idxB) { 3026 - const sheetA = ySheets.get('sheet_' + idxA); 3027 - const sheetB = ySheets.get('sheet_' + idxB); 3028 - if (!sheetA || !sheetB) return; 3029 - 3030 - // Swap names 3031 - const nameA = sheetA.get('name'); 3032 - const nameB = sheetB.get('name'); 3033 - sheetA.set('name', nameB); 3034 - sheetB.set('name', nameA); 3035 - 3036 - // Swap cells: we need to swap the Y.Map references 3037 - // Since Y.Map children can't be moved between parents, we swap the cell data 3038 - const cellsA = sheetA.get('cells'); 3039 - const cellsB = sheetB.get('cells'); 3040 - 3041 - // Collect all cell data from both 3042 - const dataA = new Map(); 3043 - const dataB = new Map(); 3044 - 3045 - if (cellsA) cellsA.forEach((val, key) => { 3046 - const cell = val; 3047 - dataA.set(key, { v: cell.get('v'), f: cell.get('f'), s: cell.get('s') }); 3048 - }); 3049 - 3050 - if (cellsB) cellsB.forEach((val, key) => { 3051 - const cell = val; 3052 - dataB.set(key, { v: cell.get('v'), f: cell.get('f'), s: cell.get('s') }); 3053 - }); 3054 - 3055 - // Clear both cell maps 3056 - if (cellsA) { 3057 - const keysA = []; 3058 - cellsA.forEach((_, key) => keysA.push(key)); 3059 - keysA.forEach(k => cellsA.delete(k)); 3060 - } 3061 - 3062 - if (cellsB) { 3063 - const keysB = []; 3064 - cellsB.forEach((_, key) => keysB.push(key)); 3065 - keysB.forEach(k => cellsB.delete(k)); 3066 - } 3067 - 3068 - // Write B's data into A, A's data into B 3069 - dataB.forEach((data, key) => { 3070 - const cell = new Y.Map(); 3071 - if (data.v !== undefined) cell.set('v', data.v); 3072 - if (data.f) cell.set('f', data.f); 3073 - if (data.s) cell.set('s', data.s); 3074 - cellsA.set(key, cell); 3075 - }); 3076 - 3077 - dataA.forEach((data, key) => { 3078 - const cell = new Y.Map(); 3079 - if (data.v !== undefined) cell.set('v', data.v); 3080 - if (data.f) cell.set('f', data.f); 3081 - if (data.s) cell.set('s', data.s); 3082 - cellsB.set(key, cell); 3083 - }); 3084 - 3085 - // Swap other properties 3086 - const propsToSwap = ['colWidths', 'rowCount', 'colCount', 'freezeRows', 'freezeCols', 'stripedRows', 'merges', 'cfRules', 'validations', 'notes', 'tabColor']; 3087 - for (const prop of propsToSwap) { 3088 - const valA = sheetA.get(prop); 3089 - const valB = sheetB.get(prop); 3090 - // Only swap simple values (numbers, booleans, strings). Y.Map/Y.Array refs can't be swapped directly. 3091 - if (typeof valA !== 'object' && typeof valB !== 'object') { 3092 - sheetA.set(prop, valB !== undefined ? valB : null); 3093 - sheetB.set(prop, valA !== undefined ? valA : null); 3094 - } 3095 - } 3096 - } 2724 + function renderSheetTabs() { _renderSheetTabs(_sheetTabsDeps()); } 2725 + function reorderSheets(fromIdx: number, toIdx: number) { _reorderSheets(_sheetTabsDeps(), fromIdx, toIdx); } 3097 2726 3098 2727 document.getElementById('add-sheet').addEventListener('click', () => { 3099 2728 let count = 0; ··· 3899 3528 return sheet.get('pivots'); 3900 3529 } 3901 3530 3531 + // showPivotDialog and renderPivots extracted to pivot-ui.ts 3532 + function _pivotDeps() { 3533 + return { getActiveSheet, getCellData, getPivots, ydoc, DEFAULT_COLS, DEFAULT_ROWS, pivotSection }; 3534 + } 3902 3535 function showPivotDialog(existingId?: string, existingConfig?: PivotConfig & { title?: string }) { 3903 - if (document.querySelector('.pivot-dialog-overlay')) return; 3904 - const overlay = document.createElement('div'); 3905 - overlay.className = 'sheet-dialog-overlay pivot-dialog-overlay'; 3906 - 3907 - const isEdit = !!existingId; 3908 - const sheet = getActiveSheet(); 3909 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 3910 - 3911 - // Build column options from headers (row 1) 3912 - const colOptions: string[] = []; 3913 - for (let c = 1; c <= colCount; c++) { 3914 - const data = getCellData(cellId(c, 1)); 3915 - const label = data?.v ? String(data.v) : colToLetter(c); 3916 - colOptions.push(`<option value="${c}">${colToLetter(c)}: ${label}</option>`); 3917 - } 3918 - 3919 - const cfg = existingConfig || { rowFields: [], colFields: [], valueField: 1, aggregation: 'sum' as AggregateFunction, title: '' }; 3920 - const aggTypes: AggregateFunction[] = ['sum', 'count', 'avg', 'min', 'max', 'countDistinct']; 3921 - 3922 - overlay.innerHTML = ` 3923 - <div class="sheet-dialog"> 3924 - <h3>${isEdit ? 'Edit' : 'Create'} Pivot Table</h3> 3925 - <label>Title</label> 3926 - <input id="pivot-title" value="${cfg.title || ''}" placeholder="Pivot table title"> 3927 - <label>Row Fields (group by)</label> 3928 - <select id="pivot-rows" multiple size="4">${colOptions.map((o, i) => 3929 - o.replace('>', cfg.rowFields.includes(i + 1) ? ' selected>' : '>') 3930 - ).join('')}</select> 3931 - <label>Column Fields (optional)</label> 3932 - <select id="pivot-cols" multiple size="3">${colOptions.map((o, i) => 3933 - o.replace('>', cfg.colFields.includes(i + 1) ? ' selected>' : '>') 3934 - ).join('')}</select> 3935 - <label>Value Field</label> 3936 - <select id="pivot-value">${colOptions.map((o, i) => 3937 - o.replace('>', (i + 1) === cfg.valueField ? ' selected>' : '>') 3938 - ).join('')}</select> 3939 - <label>Aggregation</label> 3940 - <select id="pivot-agg">${aggTypes.map(a => 3941 - `<option value="${a}" ${a === cfg.aggregation ? 'selected' : ''}>${a}</option>` 3942 - ).join('')}</select> 3943 - <div class="sheet-dialog-actions"> 3944 - <button id="pivot-cancel">Cancel</button> 3945 - <button id="pivot-ok" class="btn-primary">${isEdit ? 'Update' : 'Create'}</button> 3946 - </div> 3947 - </div> 3948 - `; 3949 - document.body.appendChild(overlay); 3950 - 3951 - overlay.querySelector('#pivot-cancel')!.addEventListener('click', () => overlay.remove()); 3952 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 3953 - 3954 - overlay.querySelector('#pivot-ok')!.addEventListener('click', () => { 3955 - const rowFields = Array.from(overlay.querySelector('#pivot-rows')!.selectedOptions, o => Number(o.value)); 3956 - const colFields = Array.from(overlay.querySelector('#pivot-cols')!.selectedOptions, o => Number(o.value)); 3957 - const valueField = Number(overlay.querySelector('#pivot-value')!.value); 3958 - const aggregation = overlay.querySelector('#pivot-agg')!.value as AggregateFunction; 3959 - const title = overlay.querySelector('#pivot-title')!.value.trim(); 3960 - 3961 - if (rowFields.length === 0) { 3962 - alert('Select at least one row field.'); 3963 - return; 3964 - } 3965 - 3966 - const config = { rowFields, colFields, valueField, aggregation, title }; 3967 - const pivots = getPivots(); 3968 - const id = existingId || 'pivot_' + Date.now(); 3969 - pivots.set(id, JSON.stringify(config)); 3970 - overlay.remove(); 3971 - renderPivots(); 3972 - }); 3973 - 3974 - setTimeout(() => overlay.querySelector('#pivot-title')?.focus(), 50); 3536 + _showPivotDialog(_pivotDeps(), existingId, existingConfig); 3975 3537 } 3976 - 3977 3538 function renderPivots() { 3978 - const pivots = getPivots(); 3979 - pivotSection.innerHTML = ''; 3980 - 3981 - pivots.forEach((cfgStr: string, id: string) => { 3982 - const config = typeof cfgStr === 'string' ? JSON.parse(cfgStr) : cfgStr; 3983 - 3984 - // Build data from sheet rows (skip header row 1) 3985 - const sheet = getActiveSheet(); 3986 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 3987 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 3988 - const dataRows: Map<string, unknown>[] = []; 3989 - 3990 - for (let r = 2; r <= rowCount; r++) { 3991 - const row = new Map<string, unknown>(); 3992 - let hasData = false; 3993 - for (let c = 1; c <= colCount; c++) { 3994 - const data = getCellData(cellId(c, r)); 3995 - const val = data?.v ?? ''; 3996 - if (val !== '') hasData = true; 3997 - row.set(cellId(c, r), val); 3998 - } 3999 - if (hasData) dataRows.push(row); 4000 - } 4001 - 4002 - if (dataRows.length === 0) return; 4003 - 4004 - const result = computePivot(dataRows, config, colToLetter, 2); 4005 - 4006 - // Build HTML table 4007 - const container = document.createElement('div'); 4008 - container.className = 'pivot-container'; 4009 - container.dataset.pivotId = id; 4010 - 4011 - const actions = document.createElement('div'); 4012 - actions.className = 'pivot-actions'; 4013 - actions.innerHTML = '<button class="pivot-edit">Edit</button><button class="pivot-delete">Delete</button>'; 4014 - container.appendChild(actions); 4015 - 4016 - if (config.title) { 4017 - const heading = document.createElement('h4'); 4018 - heading.textContent = config.title; 4019 - container.appendChild(heading); 4020 - } 4021 - 4022 - const table = document.createElement('table'); 4023 - table.className = 'pivot-table'; 4024 - 4025 - // Header row 4026 - const thead = document.createElement('thead'); 4027 - const headerRow = document.createElement('tr'); 4028 - 4029 - // Row field headers 4030 - for (const f of config.rowFields) { 4031 - const th = document.createElement('th'); 4032 - const hdr = getCellData(cellId(f, 1)); 4033 - th.textContent = hdr?.v ? String(hdr.v) : colToLetter(f); 4034 - headerRow.appendChild(th); 4035 - } 4036 - 4037 - // Column group headers 4038 - if (result.colKeys.length > 0 && result.colKeys[0].length > 0) { 4039 - for (const ck of result.colKeys) { 4040 - const th = document.createElement('th'); 4041 - th.textContent = ck.join(' / ') || '(empty)'; 4042 - headerRow.appendChild(th); 4043 - } 4044 - } else { 4045 - const th = document.createElement('th'); 4046 - const vHdr = getCellData(cellId(config.valueField, 1)); 4047 - th.textContent = (vHdr?.v ? String(vHdr.v) : colToLetter(config.valueField)) + ` (${config.aggregation})`; 4048 - headerRow.appendChild(th); 4049 - } 4050 - 4051 - const totalTh = document.createElement('th'); 4052 - totalTh.textContent = 'Total'; 4053 - headerRow.appendChild(totalTh); 4054 - thead.appendChild(headerRow); 4055 - table.appendChild(thead); 4056 - 4057 - // Body rows 4058 - const tbody = document.createElement('tbody'); 4059 - for (let ri = 0; ri < result.rowKeys.length; ri++) { 4060 - const tr = document.createElement('tr'); 4061 - for (const val of result.rowKeys[ri]) { 4062 - const td = document.createElement('td'); 4063 - td.className = 'pivot-row-header'; 4064 - td.textContent = val || '(empty)'; 4065 - tr.appendChild(td); 4066 - } 4067 - for (let ci = 0; ci < (result.colKeys.length || 1); ci++) { 4068 - const td = document.createElement('td'); 4069 - const cell = result.cells[ri]?.[ci]; 4070 - td.textContent = cell ? formatAggregateValue(cell.value, config.aggregation) : ''; 4071 - tr.appendChild(td); 4072 - } 4073 - const totalTd = document.createElement('td'); 4074 - totalTd.className = 'pivot-total'; 4075 - totalTd.textContent = formatAggregateValue(result.rowTotals[ri]?.value ?? 0, config.aggregation); 4076 - tr.appendChild(totalTd); 4077 - tbody.appendChild(tr); 4078 - } 4079 - table.appendChild(tbody); 4080 - 4081 - // Footer (column totals) 4082 - const tfoot = document.createElement('tfoot'); 4083 - const footRow = document.createElement('tr'); 4084 - const footLabel = document.createElement('td'); 4085 - footLabel.colSpan = config.rowFields.length; 4086 - footLabel.textContent = 'Total'; 4087 - footRow.appendChild(footLabel); 4088 - for (let ci = 0; ci < (result.colKeys.length || 1); ci++) { 4089 - const td = document.createElement('td'); 4090 - td.textContent = formatAggregateValue(result.colTotals[ci]?.value ?? 0, config.aggregation); 4091 - footRow.appendChild(td); 4092 - } 4093 - const grandTd = document.createElement('td'); 4094 - grandTd.className = 'pivot-total'; 4095 - grandTd.textContent = formatAggregateValue(result.grandTotal.value, config.aggregation); 4096 - footRow.appendChild(grandTd); 4097 - tfoot.appendChild(footRow); 4098 - table.appendChild(tfoot); 4099 - 4100 - container.appendChild(table); 4101 - pivotSection.appendChild(container); 4102 - 4103 - actions.querySelector('.pivot-edit')!.addEventListener('click', () => showPivotDialog(id, config)); 4104 - actions.querySelector('.pivot-delete')!.addEventListener('click', () => { 4105 - ydoc.transact(() => getPivots().delete(id)); 4106 - renderPivots(); 4107 - }); 4108 - }); 3539 + _renderPivots(_pivotDeps()); 4109 3540 } 4110 3541 4111 3542 document.getElementById('tb-pivot')!.addEventListener('click', () => { ··· 4569 4000 // Multi-Column Sort Dialog Feature 4570 4001 // ======================================================== 4571 4002 4003 + // showSortDialog extracted to sheet-dialogs.ts 4572 4004 function showSortDialog() { 4573 - const sheet = getActiveSheet(); 4574 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 4575 - 4576 - const overlay = document.createElement('div'); 4577 - overlay.className = 'sheet-dialog-overlay'; 4578 - 4579 - let colOptions = ''; 4580 - for (let c = 1; c <= colCount; c++) { 4581 - colOptions += `<option value="${c}">Column ${colToLetter(c)}</option>`; 4582 - } 4583 - 4584 - overlay.innerHTML = ` 4585 - <div class="sheet-dialog"> 4586 - <h3>Sort</h3> 4587 - <div class="sort-level"> 4588 - <span class="sort-level-label">Sort by</span> 4589 - <select id="sort-col-1">${colOptions}</select> 4590 - <select id="sort-order-1"><option value="asc">A to Z</option><option value="desc">Z to A</option></select> 4591 - </div> 4592 - <div class="sort-level"> 4593 - <span class="sort-level-label">Then by</span> 4594 - <select id="sort-col-2"><option value="">(none)</option>${colOptions}</select> 4595 - <select id="sort-order-2"><option value="asc">A to Z</option><option value="desc">Z to A</option></select> 4596 - </div> 4597 - <div class="sort-level"> 4598 - <span class="sort-level-label">Then by</span> 4599 - <select id="sort-col-3"><option value="">(none)</option>${colOptions}</select> 4600 - <select id="sort-order-3"><option value="asc">A to Z</option><option value="desc">Z to A</option></select> 4601 - </div> 4602 - <div class="sheet-dialog-actions"> 4603 - <button id="sort-cancel">Cancel</button> 4604 - <button id="sort-ok" class="btn-primary">Sort</button> 4605 - </div> 4606 - </div> 4607 - `; 4608 - document.body.appendChild(overlay); 4609 - 4610 - // Pre-select current column 4611 - overlay.querySelector('#sort-col-1').value = String(selectedCell.col); 4612 - 4613 - overlay.querySelector('#sort-cancel').addEventListener('click', () => overlay.remove()); 4614 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 4615 - 4616 - overlay.querySelector('#sort-ok').addEventListener('click', () => { 4617 - const sortKeys = []; 4618 - for (let i = 1; i <= 3; i++) { 4619 - const colVal = overlay.querySelector('#sort-col-' + i).value; 4620 - if (!colVal) continue; 4621 - sortKeys.push({ 4622 - col: parseInt(colVal), 4623 - order: overlay.querySelector('#sort-order-' + i).value, 4624 - }); 4625 - } 4626 - if (sortKeys.length === 0) { overlay.remove(); return; } 4627 - 4628 - // Determine sort range 4629 - const sht = getActiveSheet(); 4630 - const colCt = sht.get('colCount') || DEFAULT_COLS; 4631 - let startRow = 1, endRow = sht.get('rowCount') || DEFAULT_ROWS; 4632 - if (selectionRange) { 4633 - const norm = normalizeRange(selectionRange); 4634 - startRow = norm.startRow; 4635 - endRow = norm.endRow; 4636 - } 4637 - 4638 - // Build row objects 4639 - const rows = []; 4640 - for (let r = startRow; r <= endRow; r++) { 4641 - const obj = { _row: r }; 4642 - for (let c = 1; c <= colCt; c++) { 4643 - const data = getCellData(cellId(c, r)); 4644 - obj[c] = data ? data : null; 4645 - } 4646 - // Extract sort values from cell values 4647 - for (const key of sortKeys) { 4648 - const data = obj[key.col]; 4649 - obj['_sort_' + key.col] = data?.v ?? ''; 4650 - } 4651 - rows.push(obj); 4652 - } 4653 - 4654 - // Sort using sort key values 4655 - const sortKeysForModule = sortKeys.map(k => ({ col: '_sort_' + k.col, order: k.order })); 4656 - 4657 - // Custom sort since our keys are in _sort_ prefixed fields 4658 - const sorted = [...rows]; 4659 - sorted.sort((a, b) => { 4660 - for (const key of sortKeys) { 4661 - const aVal = a['_sort_' + key.col]; 4662 - const bVal = b['_sort_' + key.col]; 4663 - let cmp = 0; 4664 - const aEmpty = aVal === '' || aVal === null || aVal === undefined; 4665 - const bEmpty = bVal === '' || bVal === null || bVal === undefined; 4666 - if (aEmpty && bEmpty) cmp = 0; 4667 - else if (aEmpty) cmp = -1; 4668 - else if (bEmpty) cmp = 1; 4669 - else if (typeof aVal === 'number' && typeof bVal === 'number') cmp = aVal - bVal; 4670 - else cmp = String(aVal).localeCompare(String(bVal)); 4671 - if (key.order === 'desc') cmp = -cmp; 4672 - if (cmp !== 0) return cmp; 4673 - } 4674 - return 0; 4675 - }); 4676 - 4677 - // Write sorted data back 4678 - ydoc.transact(() => { 4679 - const cells = getCells(); 4680 - sorted.forEach((row, idx) => { 4681 - const targetRow = startRow + idx; 4682 - for (let c = 1; c <= colCt; c++) { 4683 - const id = cellId(c, targetRow); 4684 - const data = row[c]; 4685 - if (data) { setCellData(id, data); } 4686 - else if (cells.has(id)) { cells.delete(id); } 4687 - } 4688 - }); 4689 - }); 4690 - 4691 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 4692 - refreshVisibleCells(); 4693 - overlay.remove(); 4005 + _showSortDialog({ 4006 + getActiveSheet, selectedCell, selectionRange, getCellData, getCells, setCellData, 4007 + ydoc, DEFAULT_COLS, DEFAULT_ROWS, evalCache, clearSpillMaps: _clearSpillMaps, 4008 + invalidateRecalcEngine, refreshVisibleCells, 4694 4009 }); 4695 4010 } 4696 4011 ··· 4786 4101 }); 4787 4102 4788 4103 // --- Conditional Formatting modal --- 4104 + // showCfModal extracted to sheet-dialogs.ts 4789 4105 function showCfModal() { 4790 - if (document.querySelector('.cf-modal')) return; 4791 - const overlay = document.createElement('div'); 4792 - overlay.className = 'modal-overlay'; 4793 - const modal = document.createElement('div'); 4794 - modal.className = 'modal cf-modal'; 4795 - 4796 - function renderCfModal() { 4797 - const rules = getCfRulesArray(); 4798 - let html = '<h2>Conditional Formatting <button class="shortcuts-modal-close cf-close" title="Close">\u2715</button></h2>'; 4799 - 4800 - // Existing rules list 4801 - html += '<ul class="cf-rule-list">'; 4802 - if (rules.length === 0) { 4803 - html += '<li style="color:var(--color-text-faint); font-size:0.8rem;">No rules yet</li>'; 4804 - } 4805 - rules.forEach((rule, idx) => { 4806 - const bgPreview = rule.bgColor || 'transparent'; 4807 - const label = formatRuleLabel(rule); 4808 - html += '<li class="cf-rule-item">'; 4809 - html += '<span class="cf-rule-preview" style="background:' + bgPreview + '"></span>'; 4810 - html += '<span class="cf-rule-text">' + escapeHtml(label) + '</span>'; 4811 - html += '<button class="cf-delete-rule" data-cf-idx="' + idx + '" title="Delete rule">\u2715</button>'; 4812 - html += '</li>'; 4813 - }); 4814 - html += '</ul>'; 4815 - 4816 - // Add new rule form 4817 - html += '<div class="cf-add-form">'; 4818 - html += '<label>Rule type</label>'; 4819 - html += '<select id="cf-type">'; 4820 - html += '<option value="greaterThan">Greater than</option>'; 4821 - html += '<option value="lessThan">Less than</option>'; 4822 - html += '<option value="equalTo">Equal to</option>'; 4823 - html += '<option value="between">Between</option>'; 4824 - html += '<option value="textContains">Text contains</option>'; 4825 - html += '<option value="isEmpty">Is empty</option>'; 4826 - html += '<option value="isNotEmpty">Is not empty</option>'; 4827 - html += '</select>'; 4828 - html += '<div class="cf-value-row">'; 4829 - html += '<input id="cf-value1" type="text" placeholder="Value">'; 4830 - html += '<input id="cf-value2" type="text" placeholder="Value 2 (for between)" style="display:none">'; 4831 - html += '</div>'; 4832 - html += '<div class="cf-color-row">'; 4833 - html += '<label>Background</label><input type="color" id="cf-bg" value="#fce4e4">'; 4834 - html += '<label>Text</label><input type="color" id="cf-text" value="#9b1c1c">'; 4835 - html += '</div>'; 4836 - html += '<div class="cf-btn-row">'; 4837 - html += '<button class="cf-btn-add cf-btn-primary">Add rule</button>'; 4838 - html += '</div>'; 4839 - html += '</div>'; 4840 - 4841 - modal.innerHTML = html; 4842 - 4843 - // Show/hide value2 based on type 4844 - const typeSelect = modal.querySelector('#cf-type'); 4845 - const value1Input = modal.querySelector('#cf-value1'); 4846 - const value2Input = modal.querySelector('#cf-value2'); 4847 - typeSelect.addEventListener('change', () => { 4848 - const t = typeSelect.value; 4849 - value2Input.style.display = t === 'between' ? '' : 'none'; 4850 - const needsValue = !['isEmpty', 'isNotEmpty'].includes(t); 4851 - value1Input.style.display = needsValue ? '' : 'none'; 4852 - }); 4853 - 4854 - // Delete rule buttons 4855 - modal.querySelectorAll('.cf-delete-rule').forEach(btn => { 4856 - btn.addEventListener('click', () => { 4857 - const idx = parseInt(btn.dataset.cfIdx); 4858 - const yArr = getCfRules(); 4859 - ydoc.transact(() => { yArr.delete(idx, 1); }); 4860 - renderCfModal(); 4861 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 4862 - refreshVisibleCells(); 4863 - }); 4864 - }); 4865 - 4866 - // Add rule button 4867 - modal.querySelector('.cf-btn-add').addEventListener('click', () => { 4868 - const type = typeSelect.value; 4869 - const value = value1Input.value; 4870 - const value2 = value2Input.value; 4871 - const bgColor = modal.querySelector('#cf-bg').value; 4872 - const textColor = modal.querySelector('#cf-text').value; 4873 - const rule = { type, bgColor, textColor }; 4874 - if (!['isEmpty', 'isNotEmpty'].includes(type)) rule.value = value; 4875 - if (type === 'between') rule.value2 = value2; 4876 - const yArr = getCfRules(); 4877 - ydoc.transact(() => { yArr.push([JSON.stringify(rule)]); }); 4878 - renderCfModal(); 4879 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 4880 - refreshVisibleCells(); 4881 - }); 4882 - 4883 - // Close button 4884 - modal.querySelector('.cf-close').addEventListener('click', () => overlay.remove()); 4885 - } 4886 - 4887 - renderCfModal(); 4888 - overlay.appendChild(modal); 4889 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 4890 - const handler = (e) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', handler); } }; 4891 - document.addEventListener('keydown', handler); 4892 - document.body.appendChild(overlay); 4106 + _showCfModal({ 4107 + getCfRulesArray, getCfRules, ydoc, evalCache, clearSpillMaps: _clearSpillMaps, 4108 + invalidateRecalcEngine, refreshVisibleCells, 4109 + }); 4893 4110 } 4894 - 4895 - // formatRuleLabel extracted to conditional-format.ts 4896 4111 4897 4112 document.getElementById('tb-cf').addEventListener('click', () => { closeAllDropdowns(); showCfModal(); }); 4898 4113 4899 - // --- Data Validation modal --- 4114 + // showValidationModal extracted to sheet-dialogs.ts 4900 4115 function showValidationModal() { 4901 - if (document.querySelector('.dv-modal')) return; 4902 - const id = cellId(selectedCell.col, selectedCell.row); 4903 - const existing = getValidationForCell(id); 4904 - 4905 - const overlay = document.createElement('div'); 4906 - overlay.className = 'modal-overlay'; 4907 - const modal = document.createElement('div'); 4908 - modal.className = 'modal dv-modal'; 4909 - 4910 - let html = '<h2>Data Validation <button class="shortcuts-modal-close dv-close" title="Close">\u2715</button></h2>'; 4911 - html += '<div class="dv-form">'; 4912 - html += '<label>Cell: ' + id + (selectionRange ? ' (applied to selected range)' : '') + '</label>'; 4913 - html += '<label>Validation type</label>'; 4914 - html += '<select id="dv-type">'; 4915 - html += '<option value="">None</option>'; 4916 - html += '<option value="list"' + (existing?.type === 'list' ? ' selected' : '') + '>List of items</option>'; 4917 - html += '<option value="numberBetween"' + (existing?.type === 'numberBetween' ? ' selected' : '') + '>Number between</option>'; 4918 - html += '<option value="textLength"' + (existing?.type === 'textLength' ? ' selected' : '') + '>Text length</option>'; 4919 - html += '</select>'; 4920 - html += '<input id="dv-value1" type="text" placeholder="Items (comma-separated) or min" value="' + escapeHtml(existing?.value || '') + '">'; 4921 - html += '<input id="dv-value2" type="text" placeholder="Max value" value="' + escapeHtml(existing?.value2 || '') + '" style="display:' + (existing?.type === 'numberBetween' || existing?.type === 'textLength' ? 'block' : 'none') + '">'; 4922 - html += '<div class="dv-btn-row">'; 4923 - if (existing) html += '<button class="dv-btn-danger dv-remove">Remove</button>'; 4924 - html += '<button class="dv-btn-primary dv-apply">Apply</button>'; 4925 - html += '</div>'; 4926 - html += '</div>'; 4927 - 4928 - modal.innerHTML = html; 4929 - 4930 - const typeSelect = modal.querySelector('#dv-type'); 4931 - const value1Input = modal.querySelector('#dv-value1'); 4932 - const value2Input = modal.querySelector('#dv-value2'); 4933 - 4934 - typeSelect.addEventListener('change', () => { 4935 - const t = typeSelect.value; 4936 - value1Input.style.display = t ? '' : 'none'; 4937 - value2Input.style.display = (t === 'numberBetween' || t === 'textLength') ? '' : 'none'; 4938 - if (t === 'list') { value1Input.placeholder = 'Items (comma-separated)'; } 4939 - else if (t === 'numberBetween') { value1Input.placeholder = 'Min'; value2Input.placeholder = 'Max'; } 4940 - else if (t === 'textLength') { value1Input.placeholder = 'Min length'; value2Input.placeholder = 'Max length'; } 4116 + _showValidationModal({ 4117 + selectedCell, selectionRange, getValidationForCell, getValidations, 4118 + ydoc, applyToSelectedCells, refreshVisibleCells, renderGrid, 4941 4119 }); 4942 - 4943 - // Apply button 4944 - modal.querySelector('.dv-apply').addEventListener('click', () => { 4945 - const type = typeSelect.value; 4946 - const validations = getValidations(); 4947 - if (!type) { 4948 - // Remove validation from selected cells 4949 - applyToSelectedCells((cellId) => { 4950 - ydoc.transact(() => { if (validations.has(cellId)) validations.delete(cellId); }); 4951 - }); 4952 - } else { 4953 - const rule: { type: string; value: string; value2?: string } = { type, value: value1Input.value }; 4954 - if (type === 'numberBetween' || type === 'textLength') rule.value2 = value2Input.value; 4955 - applyToSelectedCells((cellId) => { 4956 - ydoc.transact(() => { validations.set(cellId, JSON.stringify(rule)); }); 4957 - }); 4958 - } 4959 - overlay.remove(); 4960 - refreshVisibleCells(); 4961 - }); 4962 - 4963 - // Remove button 4964 - const removeBtn = modal.querySelector('.dv-remove'); 4965 - if (removeBtn) { 4966 - removeBtn.addEventListener('click', () => { 4967 - const validations = getValidations(); 4968 - applyToSelectedCells((cellId) => { 4969 - ydoc.transact(() => { if (validations.has(cellId)) validations.delete(cellId); }); 4970 - }); 4971 - overlay.remove(); 4972 - renderGrid(); 4973 - }); 4974 - } 4975 - 4976 - // Close 4977 - modal.querySelector('.dv-close').addEventListener('click', () => overlay.remove()); 4978 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 4979 - const handler = (e) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', handler); } }; 4980 - document.addEventListener('keydown', handler); 4981 - 4982 - overlay.appendChild(modal); 4983 - document.body.appendChild(overlay); 4984 4120 } 4985 4121 4986 4122 function applyToSelectedCells(fn) { ··· 5986 5122 }); 5987 5123 5988 5124 /** Extract spreadsheet content as text for AI context */ 5989 - function getSheetContextText() { 5990 - const cells = getCells(); 5991 - if (!cells || cells.size === 0) return ''; 5992 - 5993 - const rows = {}; 5994 - let maxCol = 0; 5995 - let maxRow = 0; 5996 - cells.forEach((cell, id) => { 5997 - const ref = parseRef(id); 5998 - if (!ref) return; 5999 - const { col, row } = ref; 6000 - if (!rows[row]) rows[row] = {}; 6001 - const c = cell instanceof Y.Map ? cell : null; 6002 - const formula = c ? c.get('f') : ''; 6003 - const value = c ? (c.get('v') ?? '') : ''; 6004 - rows[row][col] = formula ? `=${formula}` : String(value); 6005 - if (col > maxCol) maxCol = col; 6006 - if (row > maxRow) maxRow = row; 6007 - }); 6008 - 6009 - const lines = []; 6010 - // Header row with column letters 6011 - const headers = ['']; 6012 - for (let c = 0; c <= maxCol; c++) headers.push(colToLetter(c)); 6013 - lines.push(headers.join('\t')); 6014 - 6015 - for (let r = 0; r <= maxRow; r++) { 6016 - if (!rows[r]) continue; 6017 - const cols = [String(r + 1)]; 6018 - for (let c = 0; c <= maxCol; c++) { 6019 - cols.push(rows[r]?.[c] || ''); 6020 - } 6021 - // Skip completely empty rows 6022 - if (cols.slice(1).every(v => v === '')) continue; 6023 - lines.push(cols.join('\t')); 6024 - } 6025 - return lines.join('\n'); 6026 - } 6027 - 5125 + // getSheetContextText and sendChatMessage extracted to ai-chat-panel.ts 5126 + function getSheetContextText() { return _getSheetContextText(getCells); } 6028 5127 async function sendChatMessage() { 6029 - const text = chatUI.input.value.trim(); 6030 - if (!text || chatState.loading) return; 6031 - 6032 - const cfg = chatWiring.getConfig(); 6033 - if (!isConfigured(cfg)) { 6034 - chatUI.settingsPanel.style.display = ''; 6035 - chatUI.endpointInput.focus(); 6036 - return; 6037 - } 6038 - 6039 - const userMsg = { role: 'user', content: text, ts: Date.now() }; 6040 - chatState.messages.push(userMsg); 6041 - appendMessage(chatUI.messageList, userMsg); 6042 - 6043 - chatUI.input.value = ''; 6044 - chatUI.input.style.height = ''; 6045 - chatUI.sendBtn.style.display = 'none'; 6046 - chatUI.stopBtn.style.display = ''; 6047 - chatState.loading = true; 6048 - chatState.error = null; 6049 - 6050 - const sheetTitle = (titleInput).value.trim() || 'Untitled Spreadsheet'; 6051 - const includeContext = chatUI.contextToggle.checked; 6052 - const actionsEnabled = chatUI.actionsToggle.checked; 6053 - const sheetText = includeContext ? getSheetContextText() : ''; 6054 - const systemPrompt = buildSystemMessage(sheetTitle, sheetText, { 6055 - editorType: 'sheet', 6056 - actionsEnabled, 5128 + return _sendChatMessage({ 5129 + getCells, getCellData, setCellData, renderGrid, chatUI, chatState, chatWiring, 5130 + titleInput: titleInput as HTMLInputElement, 6057 5131 }); 6058 - 6059 - const sheetActionDeps = { 6060 - setCellData, 6061 - getCellData, 6062 - cellId, 6063 - parseRef, 6064 - letterToCol, 6065 - colToLetter, 6066 - renderGrid, 6067 - }; 6068 - 6069 - const abortController = new AbortController(); 6070 - chatState.abortController = abortController; 6071 - const bubble = appendStreamingBubble(chatUI.messageList); 6072 - let fullText = ''; 6073 - 6074 - await streamChat( 6075 - cfg, 6076 - chatState.messages, 6077 - systemPrompt, 6078 - { 6079 - onChunk(chunk) { 6080 - fullText += chunk; 6081 - bubble.update(renderMarkdown(fullText)); 6082 - }, 6083 - onDone(text) { 6084 - if (text) { 6085 - chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 6086 - 6087 - // Parse and render action cards 6088 - if (actionsEnabled) { 6089 - const { displayText, actions } = splitResponse(text); 6090 - if (actions.length > 0) { 6091 - bubble.update(renderMarkdown(displayText)); 6092 - for (const action of actions) { 6093 - if (!isSheetAction(action)) continue; 6094 - appendActionCard(chatUI.messageList, action, { 6095 - onApply: (a) => { 6096 - const result = executeSheetAction(a, sheetActionDeps); 6097 - if (!result.success && result.error) { 6098 - appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 6099 - } 6100 - }, 6101 - onDismiss: () => {}, 6102 - }); 6103 - } 6104 - } 6105 - } 6106 - } 6107 - }, 6108 - onError(err) { 6109 - chatState.error = err; 6110 - bubble.el.classList.add('ai-chat-bubble--error'); 6111 - bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 6112 - }, 6113 - }, 6114 - abortController.signal, 6115 - ); 6116 - 6117 - chatState.loading = false; 6118 - chatState.abortController = null; 6119 - chatUI.sendBtn.style.display = ''; 6120 - chatUI.stopBtn.style.display = 'none'; 6121 5132 } 6122 5133 6123 5134
+229
src/sheets/pivot-ui.ts
··· 1 + /** 2 + * Pivot table UI — dialog and rendering. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { colToLetter, cellId } from './formulas.js'; 8 + import { computePivot, formatAggregateValue } from './pivot-table.js'; 9 + import type { PivotConfig, AggregateFunction } from './pivot-table.js'; 10 + 11 + // ── Types ─────────────────────────────────────────────────── 12 + 13 + export interface PivotUIDeps { 14 + getActiveSheet: () => any; 15 + getCellData: (id: string) => { v?: any; f?: string; s?: any } | null; 16 + getPivots: () => any; 17 + ydoc: { transact: (fn: () => void) => void }; 18 + DEFAULT_COLS: number; 19 + DEFAULT_ROWS: number; 20 + pivotSection: HTMLElement; 21 + } 22 + 23 + // ── Pivot Dialog ──────────────────────────────────────────── 24 + 25 + export function showPivotDialog(deps: PivotUIDeps, existingId?: string, existingConfig?: PivotConfig & { title?: string }): void { 26 + if (document.querySelector('.pivot-dialog-overlay')) return; 27 + const overlay = document.createElement('div'); 28 + overlay.className = 'sheet-dialog-overlay pivot-dialog-overlay'; 29 + 30 + const isEdit = !!existingId; 31 + const sheet = deps.getActiveSheet(); 32 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 33 + 34 + const colOptions: string[] = []; 35 + for (let c = 1; c <= colCount; c++) { 36 + const data = deps.getCellData(cellId(c, 1)); 37 + const label = data?.v ? String(data.v) : colToLetter(c); 38 + colOptions.push(`<option value="${c}">${colToLetter(c)}: ${label}</option>`); 39 + } 40 + 41 + const cfg = existingConfig || { rowFields: [] as number[], colFields: [] as number[], valueField: 1, aggregation: 'sum' as AggregateFunction, title: '' }; 42 + const aggTypes: AggregateFunction[] = ['sum', 'count', 'avg', 'min', 'max', 'countDistinct']; 43 + 44 + overlay.innerHTML = ` 45 + <div class="sheet-dialog"> 46 + <h3>${isEdit ? 'Edit' : 'Create'} Pivot Table</h3> 47 + <label>Title</label> 48 + <input id="pivot-title" value="${cfg.title || ''}" placeholder="Pivot table title"> 49 + <label>Row Fields (group by)</label> 50 + <select id="pivot-rows" multiple size="4">${colOptions.map((o, i) => 51 + o.replace('>', cfg.rowFields.includes(i + 1) ? ' selected>' : '>') 52 + ).join('')}</select> 53 + <label>Column Fields (optional)</label> 54 + <select id="pivot-cols" multiple size="3">${colOptions.map((o, i) => 55 + o.replace('>', cfg.colFields.includes(i + 1) ? ' selected>' : '>') 56 + ).join('')}</select> 57 + <label>Value Field</label> 58 + <select id="pivot-value">${colOptions.map((o, i) => 59 + o.replace('>', (i + 1) === cfg.valueField ? ' selected>' : '>') 60 + ).join('')}</select> 61 + <label>Aggregation</label> 62 + <select id="pivot-agg">${aggTypes.map(a => 63 + `<option value="${a}" ${a === cfg.aggregation ? 'selected' : ''}>${a}</option>` 64 + ).join('')}</select> 65 + <div class="sheet-dialog-actions"> 66 + <button id="pivot-cancel">Cancel</button> 67 + <button id="pivot-ok" class="btn-primary">${isEdit ? 'Update' : 'Create'}</button> 68 + </div> 69 + </div> 70 + `; 71 + document.body.appendChild(overlay); 72 + 73 + overlay.querySelector('#pivot-cancel')!.addEventListener('click', () => overlay.remove()); 74 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 75 + 76 + overlay.querySelector('#pivot-ok')!.addEventListener('click', () => { 77 + const rowFields = Array.from((overlay.querySelector('#pivot-rows') as HTMLSelectElement).selectedOptions, o => Number(o.value)); 78 + const colFields = Array.from((overlay.querySelector('#pivot-cols') as HTMLSelectElement).selectedOptions, o => Number(o.value)); 79 + const valueField = Number((overlay.querySelector('#pivot-value') as HTMLSelectElement).value); 80 + const aggregation = (overlay.querySelector('#pivot-agg') as HTMLSelectElement).value as AggregateFunction; 81 + const title = (overlay.querySelector('#pivot-title') as HTMLInputElement).value.trim(); 82 + 83 + if (rowFields.length === 0) { 84 + alert('Select at least one row field.'); 85 + return; 86 + } 87 + 88 + const config = { rowFields, colFields, valueField, aggregation, title }; 89 + const pivots = deps.getPivots(); 90 + const id = existingId || 'pivot_' + Date.now(); 91 + pivots.set(id, JSON.stringify(config)); 92 + overlay.remove(); 93 + renderPivots(deps); 94 + }); 95 + 96 + setTimeout(() => (overlay.querySelector('#pivot-title') as HTMLInputElement)?.focus(), 50); 97 + } 98 + 99 + // ── Render Pivots ─────────────────────────────────────────── 100 + 101 + export function renderPivots(deps: PivotUIDeps): void { 102 + const pivots = deps.getPivots(); 103 + deps.pivotSection.innerHTML = ''; 104 + 105 + pivots.forEach((cfgStr: string, id: string) => { 106 + const config = typeof cfgStr === 'string' ? JSON.parse(cfgStr) : cfgStr; 107 + 108 + const sheet = deps.getActiveSheet(); 109 + const rowCount = sheet.get('rowCount') || deps.DEFAULT_ROWS; 110 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 111 + const dataRows: Map<string, unknown>[] = []; 112 + 113 + for (let r = 2; r <= rowCount; r++) { 114 + const row = new Map<string, unknown>(); 115 + let hasData = false; 116 + for (let c = 1; c <= colCount; c++) { 117 + const data = deps.getCellData(cellId(c, r)); 118 + const val = data?.v ?? ''; 119 + if (val !== '') hasData = true; 120 + row.set(cellId(c, r), val); 121 + } 122 + if (hasData) dataRows.push(row); 123 + } 124 + 125 + if (dataRows.length === 0) return; 126 + 127 + const result = computePivot(dataRows, config, colToLetter, 2); 128 + 129 + const container = document.createElement('div'); 130 + container.className = 'pivot-container'; 131 + container.dataset.pivotId = id; 132 + 133 + const actions = document.createElement('div'); 134 + actions.className = 'pivot-actions'; 135 + actions.innerHTML = '<button class="pivot-edit">Edit</button><button class="pivot-delete">Delete</button>'; 136 + container.appendChild(actions); 137 + 138 + if (config.title) { 139 + const heading = document.createElement('h4'); 140 + heading.textContent = config.title; 141 + container.appendChild(heading); 142 + } 143 + 144 + const table = document.createElement('table'); 145 + table.className = 'pivot-table'; 146 + 147 + // Header row 148 + const thead = document.createElement('thead'); 149 + const headerRow = document.createElement('tr'); 150 + 151 + for (const f of config.rowFields) { 152 + const th = document.createElement('th'); 153 + const hdr = deps.getCellData(cellId(f, 1)); 154 + th.textContent = hdr?.v ? String(hdr.v) : colToLetter(f); 155 + headerRow.appendChild(th); 156 + } 157 + 158 + if (result.colKeys.length > 0 && result.colKeys[0].length > 0) { 159 + for (const ck of result.colKeys) { 160 + const th = document.createElement('th'); 161 + th.textContent = ck.join(' / ') || '(empty)'; 162 + headerRow.appendChild(th); 163 + } 164 + } else { 165 + const th = document.createElement('th'); 166 + const vHdr = deps.getCellData(cellId(config.valueField, 1)); 167 + th.textContent = (vHdr?.v ? String(vHdr.v) : colToLetter(config.valueField)) + ` (${config.aggregation})`; 168 + headerRow.appendChild(th); 169 + } 170 + 171 + const totalTh = document.createElement('th'); 172 + totalTh.textContent = 'Total'; 173 + headerRow.appendChild(totalTh); 174 + thead.appendChild(headerRow); 175 + table.appendChild(thead); 176 + 177 + // Body rows 178 + const tbody = document.createElement('tbody'); 179 + for (let ri = 0; ri < result.rowKeys.length; ri++) { 180 + const tr = document.createElement('tr'); 181 + for (const val of result.rowKeys[ri]) { 182 + const td = document.createElement('td'); 183 + td.className = 'pivot-row-header'; 184 + td.textContent = val || '(empty)'; 185 + tr.appendChild(td); 186 + } 187 + for (let ci = 0; ci < (result.colKeys.length || 1); ci++) { 188 + const td = document.createElement('td'); 189 + const cell = result.cells[ri]?.[ci]; 190 + td.textContent = cell ? formatAggregateValue(cell.value, config.aggregation) : ''; 191 + tr.appendChild(td); 192 + } 193 + const totalTd = document.createElement('td'); 194 + totalTd.className = 'pivot-total'; 195 + totalTd.textContent = formatAggregateValue(result.rowTotals[ri]?.value ?? 0, config.aggregation); 196 + tr.appendChild(totalTd); 197 + tbody.appendChild(tr); 198 + } 199 + table.appendChild(tbody); 200 + 201 + // Footer (column totals) 202 + const tfoot = document.createElement('tfoot'); 203 + const footRow = document.createElement('tr'); 204 + const footLabel = document.createElement('td'); 205 + footLabel.colSpan = config.rowFields.length; 206 + footLabel.textContent = 'Total'; 207 + footRow.appendChild(footLabel); 208 + for (let ci = 0; ci < (result.colKeys.length || 1); ci++) { 209 + const td = document.createElement('td'); 210 + td.textContent = formatAggregateValue(result.colTotals[ci]?.value ?? 0, config.aggregation); 211 + footRow.appendChild(td); 212 + } 213 + const grandTd = document.createElement('td'); 214 + grandTd.className = 'pivot-total'; 215 + grandTd.textContent = formatAggregateValue(result.grandTotal.value, config.aggregation); 216 + footRow.appendChild(grandTd); 217 + tfoot.appendChild(footRow); 218 + table.appendChild(tfoot); 219 + 220 + container.appendChild(table); 221 + deps.pivotSection.appendChild(container); 222 + 223 + actions.querySelector('.pivot-edit')!.addEventListener('click', () => showPivotDialog(deps, id, config)); 224 + actions.querySelector('.pivot-delete')!.addEventListener('click', () => { 225 + deps.ydoc.transact(() => deps.getPivots().delete(id)); 226 + renderPivots(deps); 227 + }); 228 + }); 229 + }
+356
src/sheets/sheet-dialogs.ts
··· 1 + /** 2 + * Sheet dialog functions — Sort, Conditional Formatting, Data Validation. 3 + * 4 + * Extracted from main.ts for decomposition. Each function receives its 5 + * dependencies as parameters to stay decoupled from module-level state. 6 + */ 7 + 8 + import { colToLetter, cellId } from './formulas.js'; 9 + import { normalizeRange } from './selection-utils.js'; 10 + import { formatRuleLabel } from './conditional-format.js'; 11 + import { escapeHtml } from '../lib/escape-html.js'; 12 + 13 + // ── Types for dependency injection ────────────────────────── 14 + 15 + export interface SortDialogDeps { 16 + getActiveSheet: () => any; 17 + selectedCell: { col: number; row: number }; 18 + selectionRange: { startCol: number; startRow: number; endCol: number; endRow: number } | null; 19 + getCellData: (id: string) => { v?: any; f?: string; s?: any } | null; 20 + getCells: () => any; 21 + setCellData: (id: string, data: any) => void; 22 + ydoc: { transact: (fn: () => void) => void }; 23 + DEFAULT_COLS: number; 24 + DEFAULT_ROWS: number; 25 + evalCache: { clear: () => void }; 26 + clearSpillMaps: () => void; 27 + invalidateRecalcEngine: () => void; 28 + refreshVisibleCells: () => void; 29 + } 30 + 31 + export interface CfDialogDeps { 32 + getCfRulesArray: () => any[]; 33 + getCfRules: () => any; 34 + ydoc: { transact: (fn: () => void) => void }; 35 + evalCache: { clear: () => void }; 36 + clearSpillMaps: () => void; 37 + invalidateRecalcEngine: () => void; 38 + refreshVisibleCells: () => void; 39 + } 40 + 41 + export interface ValidationDialogDeps { 42 + selectedCell: { col: number; row: number }; 43 + selectionRange: { startCol: number; startRow: number; endCol: number; endRow: number } | null; 44 + getValidationForCell: (id: string) => any | null; 45 + getValidations: () => any; 46 + ydoc: { transact: (fn: () => void) => void }; 47 + applyToSelectedCells: (fn: (cellId: string) => void) => void; 48 + refreshVisibleCells: () => void; 49 + renderGrid: () => void; 50 + } 51 + 52 + // ── Sort Dialog ───────────────────────────────────────────── 53 + 54 + export function showSortDialog(deps: SortDialogDeps): void { 55 + const sheet = deps.getActiveSheet(); 56 + const colCount = sheet.get('colCount') || deps.DEFAULT_COLS; 57 + 58 + const overlay = document.createElement('div'); 59 + overlay.className = 'sheet-dialog-overlay'; 60 + 61 + let colOptions = ''; 62 + for (let c = 1; c <= colCount; c++) { 63 + colOptions += `<option value="${c}">Column ${colToLetter(c)}</option>`; 64 + } 65 + 66 + overlay.innerHTML = ` 67 + <div class="sheet-dialog"> 68 + <h3>Sort</h3> 69 + <div class="sort-level"> 70 + <span class="sort-level-label">Sort by</span> 71 + <select id="sort-col-1">${colOptions}</select> 72 + <select id="sort-order-1"><option value="asc">A to Z</option><option value="desc">Z to A</option></select> 73 + </div> 74 + <div class="sort-level"> 75 + <span class="sort-level-label">Then by</span> 76 + <select id="sort-col-2"><option value="">(none)</option>${colOptions}</select> 77 + <select id="sort-order-2"><option value="asc">A to Z</option><option value="desc">Z to A</option></select> 78 + </div> 79 + <div class="sort-level"> 80 + <span class="sort-level-label">Then by</span> 81 + <select id="sort-col-3"><option value="">(none)</option>${colOptions}</select> 82 + <select id="sort-order-3"><option value="asc">A to Z</option><option value="desc">Z to A</option></select> 83 + </div> 84 + <div class="sheet-dialog-actions"> 85 + <button id="sort-cancel">Cancel</button> 86 + <button id="sort-ok" class="btn-primary">Sort</button> 87 + </div> 88 + </div> 89 + `; 90 + document.body.appendChild(overlay); 91 + 92 + overlay.querySelector('#sort-col-1').value = String(deps.selectedCell.col); 93 + 94 + overlay.querySelector('#sort-cancel').addEventListener('click', () => overlay.remove()); 95 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 96 + 97 + overlay.querySelector('#sort-ok').addEventListener('click', () => { 98 + const sortKeys = []; 99 + for (let i = 1; i <= 3; i++) { 100 + const colVal = overlay.querySelector('#sort-col-' + i).value; 101 + if (!colVal) continue; 102 + sortKeys.push({ 103 + col: parseInt(colVal), 104 + order: overlay.querySelector('#sort-order-' + i).value, 105 + }); 106 + } 107 + if (sortKeys.length === 0) { overlay.remove(); return; } 108 + 109 + const sht = deps.getActiveSheet(); 110 + const colCt = sht.get('colCount') || deps.DEFAULT_COLS; 111 + let startRow = 1, endRow = sht.get('rowCount') || deps.DEFAULT_ROWS; 112 + if (deps.selectionRange) { 113 + const norm = normalizeRange(deps.selectionRange); 114 + startRow = norm.startRow; 115 + endRow = norm.endRow; 116 + } 117 + 118 + // Build row objects 119 + const rows = []; 120 + for (let r = startRow; r <= endRow; r++) { 121 + const obj: any = { _row: r }; 122 + for (let c = 1; c <= colCt; c++) { 123 + const data = deps.getCellData(cellId(c, r)); 124 + obj[c] = data ? data : null; 125 + } 126 + for (const key of sortKeys) { 127 + const data = obj[key.col]; 128 + obj['_sort_' + key.col] = data?.v ?? ''; 129 + } 130 + rows.push(obj); 131 + } 132 + 133 + const sorted = [...rows]; 134 + sorted.sort((a, b) => { 135 + for (const key of sortKeys) { 136 + const aVal = a['_sort_' + key.col]; 137 + const bVal = b['_sort_' + key.col]; 138 + let cmp = 0; 139 + const aEmpty = aVal === '' || aVal === null || aVal === undefined; 140 + const bEmpty = bVal === '' || bVal === null || bVal === undefined; 141 + if (aEmpty && bEmpty) cmp = 0; 142 + else if (aEmpty) cmp = -1; 143 + else if (bEmpty) cmp = 1; 144 + else if (typeof aVal === 'number' && typeof bVal === 'number') cmp = aVal - bVal; 145 + else cmp = String(aVal).localeCompare(String(bVal)); 146 + if (key.order === 'desc') cmp = -cmp; 147 + if (cmp !== 0) return cmp; 148 + } 149 + return 0; 150 + }); 151 + 152 + // Write sorted data back 153 + deps.ydoc.transact(() => { 154 + const cells = deps.getCells(); 155 + sorted.forEach((row, idx) => { 156 + const targetRow = startRow + idx; 157 + for (let c = 1; c <= colCt; c++) { 158 + const id = cellId(c, targetRow); 159 + const data = row[c]; 160 + if (data) { deps.setCellData(id, data); } 161 + else if (cells.has(id)) { cells.delete(id); } 162 + } 163 + }); 164 + }); 165 + 166 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 167 + deps.refreshVisibleCells(); 168 + overlay.remove(); 169 + }); 170 + } 171 + 172 + // ── Conditional Formatting Dialog ─────────────────────────── 173 + 174 + export function showCfModal(deps: CfDialogDeps): void { 175 + if (document.querySelector('.cf-modal')) return; 176 + const overlay = document.createElement('div'); 177 + overlay.className = 'modal-overlay'; 178 + const modal = document.createElement('div'); 179 + modal.className = 'modal cf-modal'; 180 + 181 + function renderCfModal() { 182 + const rules = deps.getCfRulesArray(); 183 + let html = '<h2>Conditional Formatting <button class="shortcuts-modal-close cf-close" title="Close">\u2715</button></h2>'; 184 + 185 + html += '<ul class="cf-rule-list">'; 186 + if (rules.length === 0) { 187 + html += '<li style="color:var(--color-text-faint); font-size:0.8rem;">No rules yet</li>'; 188 + } 189 + rules.forEach((rule, idx) => { 190 + const bgPreview = rule.bgColor || 'transparent'; 191 + const label = formatRuleLabel(rule); 192 + html += '<li class="cf-rule-item">'; 193 + html += '<span class="cf-rule-preview" style="background:' + bgPreview + '"></span>'; 194 + html += '<span class="cf-rule-text">' + escapeHtml(label) + '</span>'; 195 + html += '<button class="cf-delete-rule" data-cf-idx="' + idx + '" title="Delete rule">\u2715</button>'; 196 + html += '</li>'; 197 + }); 198 + html += '</ul>'; 199 + 200 + html += '<div class="cf-add-form">'; 201 + html += '<label>Rule type</label>'; 202 + html += '<select id="cf-type">'; 203 + html += '<option value="greaterThan">Greater than</option>'; 204 + html += '<option value="lessThan">Less than</option>'; 205 + html += '<option value="equalTo">Equal to</option>'; 206 + html += '<option value="between">Between</option>'; 207 + html += '<option value="textContains">Text contains</option>'; 208 + html += '<option value="isEmpty">Is empty</option>'; 209 + html += '<option value="isNotEmpty">Is not empty</option>'; 210 + html += '</select>'; 211 + html += '<div class="cf-value-row">'; 212 + html += '<input id="cf-value1" type="text" placeholder="Value">'; 213 + html += '<input id="cf-value2" type="text" placeholder="Value 2 (for between)" style="display:none">'; 214 + html += '</div>'; 215 + html += '<div class="cf-color-row">'; 216 + html += '<label>Background</label><input type="color" id="cf-bg" value="#fce4e4">'; 217 + html += '<label>Text</label><input type="color" id="cf-text" value="#9b1c1c">'; 218 + html += '</div>'; 219 + html += '<div class="cf-btn-row">'; 220 + html += '<button class="cf-btn-add cf-btn-primary">Add rule</button>'; 221 + html += '</div>'; 222 + html += '</div>'; 223 + 224 + modal.innerHTML = html; 225 + 226 + const typeSelect = modal.querySelector('#cf-type') as HTMLSelectElement; 227 + const value1Input = modal.querySelector('#cf-value1') as HTMLInputElement; 228 + const value2Input = modal.querySelector('#cf-value2') as HTMLInputElement; 229 + typeSelect.addEventListener('change', () => { 230 + const t = typeSelect.value; 231 + value2Input.style.display = t === 'between' ? '' : 'none'; 232 + const needsValue = !['isEmpty', 'isNotEmpty'].includes(t); 233 + value1Input.style.display = needsValue ? '' : 'none'; 234 + }); 235 + 236 + modal.querySelectorAll('.cf-delete-rule').forEach(btn => { 237 + btn.addEventListener('click', () => { 238 + const idx = parseInt((btn as HTMLElement).dataset.cfIdx!); 239 + const yArr = deps.getCfRules(); 240 + deps.ydoc.transact(() => { yArr.delete(idx, 1); }); 241 + renderCfModal(); 242 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 243 + deps.refreshVisibleCells(); 244 + }); 245 + }); 246 + 247 + modal.querySelector('.cf-btn-add')!.addEventListener('click', () => { 248 + const type = typeSelect.value; 249 + const value = value1Input.value; 250 + const value2 = value2Input.value; 251 + const bgColor = (modal.querySelector('#cf-bg') as HTMLInputElement).value; 252 + const textColor = (modal.querySelector('#cf-text') as HTMLInputElement).value; 253 + const rule: any = { type, bgColor, textColor }; 254 + if (!['isEmpty', 'isNotEmpty'].includes(type)) rule.value = value; 255 + if (type === 'between') rule.value2 = value2; 256 + const yArr = deps.getCfRules(); 257 + deps.ydoc.transact(() => { yArr.push([JSON.stringify(rule)]); }); 258 + renderCfModal(); 259 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 260 + deps.refreshVisibleCells(); 261 + }); 262 + 263 + modal.querySelector('.cf-close')!.addEventListener('click', () => overlay.remove()); 264 + } 265 + 266 + renderCfModal(); 267 + overlay.appendChild(modal); 268 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 269 + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', handler); } }; 270 + document.addEventListener('keydown', handler); 271 + document.body.appendChild(overlay); 272 + } 273 + 274 + // ── Data Validation Dialog ────────────────────────────────── 275 + 276 + export function showValidationModal(deps: ValidationDialogDeps): void { 277 + if (document.querySelector('.dv-modal')) return; 278 + const id = cellId(deps.selectedCell.col, deps.selectedCell.row); 279 + const existing = deps.getValidationForCell(id); 280 + 281 + const overlay = document.createElement('div'); 282 + overlay.className = 'modal-overlay'; 283 + const modal = document.createElement('div'); 284 + modal.className = 'modal dv-modal'; 285 + 286 + let html = '<h2>Data Validation <button class="shortcuts-modal-close dv-close" title="Close">\u2715</button></h2>'; 287 + html += '<div class="dv-form">'; 288 + html += '<label>Cell: ' + id + (deps.selectionRange ? ' (applied to selected range)' : '') + '</label>'; 289 + html += '<label>Validation type</label>'; 290 + html += '<select id="dv-type">'; 291 + html += '<option value="">None</option>'; 292 + html += '<option value="list"' + (existing?.type === 'list' ? ' selected' : '') + '>List of items</option>'; 293 + html += '<option value="numberBetween"' + (existing?.type === 'numberBetween' ? ' selected' : '') + '>Number between</option>'; 294 + html += '<option value="textLength"' + (existing?.type === 'textLength' ? ' selected' : '') + '>Text length</option>'; 295 + html += '</select>'; 296 + html += '<input id="dv-value1" type="text" placeholder="Items (comma-separated) or min" value="' + escapeHtml(existing?.value || '') + '">'; 297 + html += '<input id="dv-value2" type="text" placeholder="Max value" value="' + escapeHtml(existing?.value2 || '') + '" style="display:' + (existing?.type === 'numberBetween' || existing?.type === 'textLength' ? 'block' : 'none') + '">'; 298 + html += '<div class="dv-btn-row">'; 299 + if (existing) html += '<button class="dv-btn-danger dv-remove">Remove</button>'; 300 + html += '<button class="dv-btn-primary dv-apply">Apply</button>'; 301 + html += '</div>'; 302 + html += '</div>'; 303 + 304 + modal.innerHTML = html; 305 + 306 + const typeSelect = modal.querySelector('#dv-type') as HTMLSelectElement; 307 + const value1Input = modal.querySelector('#dv-value1') as HTMLInputElement; 308 + const value2Input = modal.querySelector('#dv-value2') as HTMLInputElement; 309 + 310 + typeSelect.addEventListener('change', () => { 311 + const t = typeSelect.value; 312 + value1Input.style.display = t ? '' : 'none'; 313 + value2Input.style.display = (t === 'numberBetween' || t === 'textLength') ? '' : 'none'; 314 + if (t === 'list') { value1Input.placeholder = 'Items (comma-separated)'; } 315 + else if (t === 'numberBetween') { value1Input.placeholder = 'Min'; value2Input.placeholder = 'Max'; } 316 + else if (t === 'textLength') { value1Input.placeholder = 'Min length'; value2Input.placeholder = 'Max length'; } 317 + }); 318 + 319 + modal.querySelector('.dv-apply')!.addEventListener('click', () => { 320 + const type = typeSelect.value; 321 + const validations = deps.getValidations(); 322 + if (!type) { 323 + deps.applyToSelectedCells((cellId) => { 324 + deps.ydoc.transact(() => { if (validations.has(cellId)) validations.delete(cellId); }); 325 + }); 326 + } else { 327 + const rule: { type: string; value: string; value2?: string } = { type, value: value1Input.value }; 328 + if (type === 'numberBetween' || type === 'textLength') rule.value2 = value2Input.value; 329 + deps.applyToSelectedCells((cellId) => { 330 + deps.ydoc.transact(() => { validations.set(cellId, JSON.stringify(rule)); }); 331 + }); 332 + } 333 + overlay.remove(); 334 + deps.refreshVisibleCells(); 335 + }); 336 + 337 + const removeBtn = modal.querySelector('.dv-remove'); 338 + if (removeBtn) { 339 + removeBtn.addEventListener('click', () => { 340 + const validations = deps.getValidations(); 341 + deps.applyToSelectedCells((cellId) => { 342 + deps.ydoc.transact(() => { if (validations.has(cellId)) validations.delete(cellId); }); 343 + }); 344 + overlay.remove(); 345 + deps.renderGrid(); 346 + }); 347 + } 348 + 349 + modal.querySelector('.dv-close')!.addEventListener('click', () => overlay.remove()); 350 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 351 + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', handler); } }; 352 + document.addEventListener('keydown', handler); 353 + 354 + overlay.appendChild(modal); 355 + document.body.appendChild(overlay); 356 + }
+397
src/sheets/sheet-tabs-ui.ts
··· 1 + /** 2 + * Sheet tabs UI — rendering, reordering, inline rename, context menu, color picker. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { renameSheet, duplicateSheet, deleteSheet, sheetHasData, countSheets, setTabColor, getTabColor, canMoveLeft, canMoveRight, TAB_COLORS } from './sheet-tab-management.js'; 9 + import { createContextMenu, SEPARATOR } from '../lib/context-menu.js'; 10 + import type { MenuItem } from '../lib/context-menu.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface SheetTabsDeps { 15 + ySheets: any; 16 + ydoc: { transact: (fn: () => void) => void }; 17 + getActiveSheetIdx: () => number; 18 + setActiveSheetIdx: (idx: number) => void; 19 + ensureSheet: (idx: number) => any; 20 + evalCache: { clear: () => void }; 21 + clearSpillMaps: () => void; 22 + invalidateRecalcEngine: () => void; 23 + renderGrid: () => void; 24 + hideActiveContextMenu: () => void; 25 + setActiveContextMenu: (menu: any) => void; 26 + sheetTabsContainer: HTMLElement; 27 + } 28 + 29 + // ── Swap sheet data (pure Yjs operation) ──────────────────── 30 + 31 + export function swapSheetData(ySheets: any, idxA: number, idxB: number): void { 32 + const sheetA = ySheets.get('sheet_' + idxA); 33 + const sheetB = ySheets.get('sheet_' + idxB); 34 + if (!sheetA || !sheetB) return; 35 + 36 + // Swap names 37 + const nameA = sheetA.get('name'); 38 + const nameB = sheetB.get('name'); 39 + sheetA.set('name', nameB); 40 + sheetB.set('name', nameA); 41 + 42 + // Swap cells 43 + const cellsA = sheetA.get('cells'); 44 + const cellsB = sheetB.get('cells'); 45 + 46 + const dataA = new Map<string, { v?: any; f?: string; s?: any }>(); 47 + const dataB = new Map<string, { v?: any; f?: string; s?: any }>(); 48 + 49 + if (cellsA) cellsA.forEach((val: any, key: string) => { 50 + dataA.set(key, { v: val.get('v'), f: val.get('f'), s: val.get('s') }); 51 + }); 52 + 53 + if (cellsB) cellsB.forEach((val: any, key: string) => { 54 + dataB.set(key, { v: val.get('v'), f: val.get('f'), s: val.get('s') }); 55 + }); 56 + 57 + if (cellsA) { 58 + const keysA: string[] = []; 59 + cellsA.forEach((_: any, key: string) => keysA.push(key)); 60 + keysA.forEach((k: string) => cellsA.delete(k)); 61 + } 62 + 63 + if (cellsB) { 64 + const keysB: string[] = []; 65 + cellsB.forEach((_: any, key: string) => keysB.push(key)); 66 + keysB.forEach((k: string) => cellsB.delete(k)); 67 + } 68 + 69 + dataB.forEach((data, key) => { 70 + const cell = new Y.Map(); 71 + if (data.v !== undefined) cell.set('v', data.v); 72 + if (data.f) cell.set('f', data.f); 73 + if (data.s) cell.set('s', data.s); 74 + cellsA.set(key, cell); 75 + }); 76 + 77 + dataA.forEach((data, key) => { 78 + const cell = new Y.Map(); 79 + if (data.v !== undefined) cell.set('v', data.v); 80 + if (data.f) cell.set('f', data.f); 81 + if (data.s) cell.set('s', data.s); 82 + cellsB.set(key, cell); 83 + }); 84 + 85 + // Swap other properties 86 + const propsToSwap = ['colWidths', 'rowCount', 'colCount', 'freezeRows', 'freezeCols', 'stripedRows', 'merges', 'cfRules', 'validations', 'notes', 'tabColor']; 87 + for (const prop of propsToSwap) { 88 + const valA = sheetA.get(prop); 89 + const valB = sheetB.get(prop); 90 + if (typeof valA !== 'object' && typeof valB !== 'object') { 91 + sheetA.set(prop, valB !== undefined ? valB : null); 92 + sheetB.set(prop, valA !== undefined ? valA : null); 93 + } 94 + } 95 + } 96 + 97 + // ── Reorder sheets ────────────────────────────────────────── 98 + 99 + export function reorderSheets(deps: SheetTabsDeps, fromIdx: number, toIdx: number): void { 100 + let sheetCount = 0; 101 + deps.ySheets.forEach((_: any, key: string) => { if (key.startsWith('sheet_')) sheetCount++; }); 102 + 103 + deps.ydoc.transact(() => { 104 + const direction = fromIdx < toIdx ? 1 : -1; 105 + let current = fromIdx; 106 + while (current !== toIdx) { 107 + const next = current + direction; 108 + swapSheetData(deps.ySheets, current, next); 109 + current = next; 110 + } 111 + }); 112 + 113 + const activeSheetIdx = deps.getActiveSheetIdx(); 114 + if (activeSheetIdx === fromIdx) { 115 + deps.setActiveSheetIdx(toIdx); 116 + } else if (fromIdx < toIdx && activeSheetIdx > fromIdx && activeSheetIdx <= toIdx) { 117 + deps.setActiveSheetIdx(activeSheetIdx - 1); 118 + } else if (fromIdx > toIdx && activeSheetIdx >= toIdx && activeSheetIdx < fromIdx) { 119 + deps.setActiveSheetIdx(activeSheetIdx + 1); 120 + } 121 + 122 + deps.evalCache.clear(); deps.clearSpillMaps(); 123 + deps.invalidateRecalcEngine(); 124 + renderSheetTabs(deps); 125 + deps.renderGrid(); 126 + } 127 + 128 + // ── Inline rename ─────────────────────────────────────────── 129 + 130 + export function beginInlineRename(deps: SheetTabsDeps, tab: HTMLElement, sheet: any, sheetIdx: number): void { 131 + if (tab.querySelector('.sheet-tab-rename')) return; 132 + 133 + const currentName = sheet.get('name') || 'Sheet ' + (sheetIdx + 1); 134 + 135 + const input = document.createElement('span'); 136 + input.className = 'sheet-tab-rename'; 137 + input.contentEditable = 'true'; 138 + input.spellcheck = false; 139 + input.textContent = currentName; 140 + 141 + const colorBar = tab.querySelector('.sheet-tab-color-bar'); 142 + tab.textContent = ''; 143 + if (colorBar) tab.appendChild(colorBar); 144 + tab.appendChild(input); 145 + tab.draggable = false; 146 + 147 + const range = document.createRange(); 148 + range.selectNodeContents(input); 149 + const sel = window.getSelection()!; 150 + sel.removeAllRanges(); 151 + sel.addRange(range); 152 + input.focus(); 153 + 154 + let committed = false; 155 + 156 + function commit() { 157 + if (committed) return; 158 + committed = true; 159 + const newName = (input.textContent || '').trim(); 160 + if (newName && newName !== currentName) { 161 + renameSheet(sheet, newName); 162 + } 163 + renderSheetTabs(deps); 164 + } 165 + 166 + function cancel() { 167 + if (committed) return; 168 + committed = true; 169 + renderSheetTabs(deps); 170 + } 171 + 172 + input.addEventListener('keydown', (e) => { 173 + if (e.key === 'Enter') { e.preventDefault(); commit(); } 174 + else if (e.key === 'Escape') { e.preventDefault(); cancel(); } 175 + }); 176 + 177 + input.addEventListener('blur', () => { 178 + setTimeout(() => { if (!committed) commit(); }, 0); 179 + }); 180 + } 181 + 182 + // ── Tab color picker ──────────────────────────────────────── 183 + 184 + export function showTabColorPicker(deps: SheetTabsDeps, tab: HTMLElement, sheet: any, _sheetIdx: number): void { 185 + document.querySelectorAll('.sheet-tab-color-picker').forEach(el => el.remove()); 186 + 187 + const picker = document.createElement('div'); 188 + picker.className = 'sheet-tab-color-picker'; 189 + 190 + const currentColor = getTabColor(sheet); 191 + 192 + const noneBtn = document.createElement('button'); 193 + noneBtn.className = 'sheet-tab-color-swatch sheet-tab-color-none'; 194 + noneBtn.title = 'No color'; 195 + noneBtn.textContent = '\u2715'; 196 + if (!currentColor) noneBtn.classList.add('selected'); 197 + noneBtn.addEventListener('click', () => { 198 + setTabColor(sheet, null); 199 + renderSheetTabs(deps); 200 + picker.remove(); 201 + }); 202 + picker.appendChild(noneBtn); 203 + 204 + for (const color of TAB_COLORS) { 205 + const swatch = document.createElement('button'); 206 + swatch.className = 'sheet-tab-color-swatch'; 207 + swatch.style.backgroundColor = color.value; 208 + swatch.title = color.name; 209 + if (currentColor === color.value) swatch.classList.add('selected'); 210 + swatch.addEventListener('click', () => { 211 + setTabColor(sheet, color.value); 212 + renderSheetTabs(deps); 213 + picker.remove(); 214 + }); 215 + picker.appendChild(swatch); 216 + } 217 + 218 + const rect = tab.getBoundingClientRect(); 219 + picker.style.position = 'fixed'; 220 + picker.style.left = rect.left + 'px'; 221 + picker.style.top = (rect.top - 40) + 'px'; 222 + picker.style.zIndex = '10000'; 223 + 224 + document.body.appendChild(picker); 225 + 226 + const closeHandler = (ev: MouseEvent) => { 227 + if (!picker.contains(ev.target as Node)) { 228 + picker.remove(); 229 + document.removeEventListener('mousedown', closeHandler); 230 + } 231 + }; 232 + setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 233 + } 234 + 235 + // ── Confirm and delete sheet ──────────────────────────────── 236 + 237 + export function confirmAndDeleteSheet(deps: SheetTabsDeps, sheetIdx: number): void { 238 + const total = countSheets(deps.ySheets); 239 + if (total <= 1) return; 240 + 241 + const sheet = deps.ySheets.get('sheet_' + sheetIdx); 242 + if (!sheet) return; 243 + 244 + const hasData = sheetHasData(sheet); 245 + if (hasData) { 246 + if (!confirm('This sheet contains data. Are you sure you want to delete it?')) return; 247 + } 248 + 249 + const newActive = deleteSheet(deps.ydoc as any, deps.ySheets, sheetIdx, deps.getActiveSheetIdx()); 250 + if (newActive >= 0) { 251 + deps.setActiveSheetIdx(newActive); 252 + deps.evalCache.clear(); deps.clearSpillMaps(); 253 + deps.invalidateRecalcEngine(); 254 + renderSheetTabs(deps); 255 + deps.renderGrid(); 256 + } 257 + } 258 + 259 + // ── Duplicate sheet ───────────────────────────────────────── 260 + 261 + export function doDuplicateSheet(deps: SheetTabsDeps, sheetIdx: number): void { 262 + const total = countSheets(deps.ySheets); 263 + const targetIdx = total; 264 + const newSheet = duplicateSheet(deps.ydoc as any, deps.ySheets, sheetIdx, targetIdx); 265 + if (newSheet) { 266 + deps.setActiveSheetIdx(targetIdx); 267 + deps.evalCache.clear(); deps.clearSpillMaps(); 268 + deps.invalidateRecalcEngine(); 269 + renderSheetTabs(deps); 270 + deps.renderGrid(); 271 + } 272 + } 273 + 274 + // ── Sheet tab context menu ────────────────────────────────── 275 + 276 + export function showSheetTabContextMenu(deps: SheetTabsDeps, e: MouseEvent, tab: HTMLElement, sheetIdx: number): void { 277 + e.preventDefault(); 278 + deps.hideActiveContextMenu(); 279 + 280 + const sheet = deps.ensureSheet(sheetIdx); 281 + const total = countSheets(deps.ySheets); 282 + 283 + const items: MenuItem[] = [ 284 + { label: 'Rename', icon: '\u270F', action: () => beginInlineRename(deps, tab, sheet, sheetIdx) }, 285 + { label: 'Duplicate', icon: '\u29C9', action: () => doDuplicateSheet(deps, sheetIdx) }, 286 + { label: 'Delete', icon: '\u2715', action: () => confirmAndDeleteSheet(deps, sheetIdx), disabled: total <= 1 }, 287 + SEPARATOR, 288 + { label: 'Move Left', icon: '\u2190', action: () => { reorderSheets(deps, sheetIdx, sheetIdx - 1); }, disabled: !canMoveLeft(sheetIdx) }, 289 + { label: 'Move Right', icon: '\u2192', action: () => { reorderSheets(deps, sheetIdx, sheetIdx + 1); }, disabled: !canMoveRight(sheetIdx, total) }, 290 + SEPARATOR, 291 + { label: 'Tab Color', icon: '\u25CF', action: () => showTabColorPicker(deps, tab, sheet, sheetIdx) }, 292 + ]; 293 + 294 + const ctxMenu = createContextMenu(items); 295 + document.body.appendChild(ctxMenu.el); 296 + ctxMenu.show(e.clientX, e.clientY); 297 + deps.setActiveContextMenu(ctxMenu); 298 + 299 + const closeHandler = (ev: MouseEvent) => { 300 + if (!ctxMenu.el.contains(ev.target as Node)) { 301 + deps.hideActiveContextMenu(); 302 + document.removeEventListener('mousedown', closeHandler); 303 + } 304 + }; 305 + setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 306 + } 307 + 308 + // ── Render sheet tabs ─────────────────────────────────────── 309 + 310 + let dragSourceSheetIdx = -1; 311 + 312 + export function renderSheetTabs(deps: SheetTabsDeps): void { 313 + deps.sheetTabsContainer.querySelectorAll('.sheet-tab').forEach(t => t.remove()); 314 + let sheetCount = 0; 315 + deps.ySheets.forEach((_: any, key: string) => { if (key.startsWith('sheet_')) sheetCount++; }); 316 + if (sheetCount === 0) deps.ensureSheet(0); 317 + sheetCount = Math.max(sheetCount, 1); 318 + const addBtn = document.getElementById('add-sheet'); 319 + const activeSheetIdx = deps.getActiveSheetIdx(); 320 + 321 + for (let i = 0; i < sheetCount; i++) { 322 + const sheet = deps.ensureSheet(i); 323 + const tab = document.createElement('button'); 324 + tab.className = 'sheet-tab' + (i === activeSheetIdx ? ' active' : ''); 325 + tab.dataset.sheet = String(i); 326 + 327 + const tabColor = getTabColor(sheet); 328 + if (tabColor) { 329 + const colorBar = document.createElement('span'); 330 + colorBar.className = 'sheet-tab-color-bar'; 331 + colorBar.style.backgroundColor = tabColor; 332 + tab.appendChild(colorBar); 333 + } 334 + 335 + const label = document.createElement('span'); 336 + label.className = 'sheet-tab-label'; 337 + label.textContent = sheet.get('name') || 'Sheet ' + (i + 1); 338 + tab.appendChild(label); 339 + 340 + tab.draggable = true; 341 + tab.addEventListener('click', () => { 342 + deps.setActiveSheetIdx(i); 343 + renderSheetTabs(deps); 344 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 345 + deps.renderGrid(); 346 + }); 347 + 348 + tab.addEventListener('dblclick', (e) => { 349 + e.preventDefault(); 350 + beginInlineRename(deps, tab, sheet, i); 351 + }); 352 + 353 + tab.addEventListener('contextmenu', (e) => { 354 + showSheetTabContextMenu(deps, e, tab, i); 355 + }); 356 + 357 + tab.addEventListener('dragstart', (e) => { 358 + dragSourceSheetIdx = i; 359 + tab.classList.add('dragging'); 360 + e.dataTransfer!.effectAllowed = 'move'; 361 + e.dataTransfer!.setData('text/plain', String(i)); 362 + }); 363 + tab.addEventListener('dragend', () => { 364 + tab.classList.remove('dragging'); 365 + dragSourceSheetIdx = -1; 366 + deps.sheetTabsContainer.querySelectorAll('.sheet-tab').forEach(t => { 367 + t.classList.remove('drag-over-left', 'drag-over-right'); 368 + }); 369 + }); 370 + tab.addEventListener('dragover', (e) => { 371 + e.preventDefault(); 372 + e.dataTransfer!.dropEffect = 'move'; 373 + const targetIdx = parseInt(tab.dataset.sheet!); 374 + if (targetIdx === dragSourceSheetIdx) return; 375 + deps.sheetTabsContainer.querySelectorAll('.sheet-tab').forEach(t => { 376 + t.classList.remove('drag-over-left', 'drag-over-right'); 377 + }); 378 + if (targetIdx < dragSourceSheetIdx) { 379 + tab.classList.add('drag-over-left'); 380 + } else { 381 + tab.classList.add('drag-over-right'); 382 + } 383 + }); 384 + tab.addEventListener('dragleave', () => { 385 + tab.classList.remove('drag-over-left', 'drag-over-right'); 386 + }); 387 + tab.addEventListener('drop', (e) => { 388 + e.preventDefault(); 389 + const fromIdx = dragSourceSheetIdx; 390 + const toIdx = parseInt(tab.dataset.sheet!); 391 + if (fromIdx === toIdx || fromIdx < 0) return; 392 + reorderSheets(deps, fromIdx, toIdx); 393 + }); 394 + 395 + deps.sheetTabsContainer.insertBefore(tab, addBtn); 396 + } 397 + }
+77
tests/ai-chat-panel.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import * as Y from 'yjs'; 3 + import { getSheetContextText } from '../src/sheets/ai-chat-panel.js'; 4 + 5 + /** Create a Y.Doc-backed cells map (Y.Map instances need a doc to work properly) */ 6 + function buildCells(data: Record<string, { v?: any; f?: string }>) { 7 + const ydoc = new Y.Doc(); 8 + const cells = ydoc.getMap('cells'); 9 + for (const [id, cell] of Object.entries(data)) { 10 + const ymap = new Y.Map(); 11 + if (cell.v !== undefined) ymap.set('v', cell.v); 12 + if (cell.f) ymap.set('f', cell.f); 13 + cells.set(id, ymap); 14 + } 15 + return cells; 16 + } 17 + 18 + describe('getSheetContextText', () => { 19 + it('returns empty string for empty cells', () => { 20 + const ydoc = new Y.Doc(); 21 + const cells = ydoc.getMap('cells'); 22 + expect(getSheetContextText(() => cells)).toBe(''); 23 + }); 24 + 25 + it('returns empty string for null cells', () => { 26 + expect(getSheetContextText(() => null)).toBe(''); 27 + }); 28 + 29 + it('formats single cell with value', () => { 30 + const cells = buildCells({ 'A1': { v: 'Hello' } }); 31 + const result = getSheetContextText(() => cells); 32 + const lines = result.split('\n'); 33 + expect(lines.length).toBe(2); // header + 1 data row 34 + expect(lines[0]).toContain('A'); // column header 35 + expect(lines[1]).toContain('Hello'); 36 + }); 37 + 38 + it('formats formula cells with = prefix', () => { 39 + const cells = buildCells({ 'A1': { v: 10, f: 'SUM(B1:B5)' } }); 40 + const result = getSheetContextText(() => cells); 41 + expect(result).toContain('=SUM(B1:B5)'); 42 + }); 43 + 44 + it('formats multiple cells across rows and columns', () => { 45 + const cells = buildCells({ 46 + 'A1': { v: 'Name' }, 47 + 'B1': { v: 'Age' }, 48 + 'A2': { v: 'Alice' }, 49 + 'B2': { v: 30 }, 50 + }); 51 + const result = getSheetContextText(() => cells); 52 + const lines = result.split('\n'); 53 + expect(lines.length).toBe(3); // header + 2 data rows 54 + expect(result).toContain('Name'); 55 + expect(result).toContain('Alice'); 56 + expect(result).toContain('30'); 57 + }); 58 + 59 + it('skips completely empty rows', () => { 60 + const cells = buildCells({ 61 + 'A1': { v: 'Top' }, 62 + 'A3': { v: 'Bottom' }, 63 + }); 64 + const result = getSheetContextText(() => cells); 65 + expect(result).toContain('Top'); 66 + expect(result).toContain('Bottom'); 67 + const lines = result.split('\n'); 68 + // header + row 1 + row 3 = 3 lines (row 2 skipped) 69 + expect(lines.length).toBe(3); 70 + }); 71 + 72 + it('includes value cells as strings', () => { 73 + const cells = buildCells({ 'B2': { v: 42 } }); 74 + const result = getSheetContextText(() => cells); 75 + expect(result).toContain('42'); 76 + }); 77 + });
+142
tests/sheet-tabs-ui.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import * as Y from 'yjs'; 3 + import { swapSheetData } from '../src/sheets/sheet-tabs-ui.js'; 4 + 5 + /** Create a minimal Y.Doc with two sheets for swap testing */ 6 + function createTestSheets() { 7 + const ydoc = new Y.Doc(); 8 + const ySheets = ydoc.getMap('sheets'); 9 + 10 + // Sheet 0 11 + const sheet0 = new Y.Map(); 12 + sheet0.set('name', 'Sales'); 13 + sheet0.set('rowCount', 50); 14 + sheet0.set('colCount', 10); 15 + const cells0 = new Y.Map(); 16 + const cellA1 = new Y.Map(); 17 + cellA1.set('v', 'Revenue'); 18 + cellA1.set('f', ''); 19 + cells0.set('A1', cellA1); 20 + const cellB1 = new Y.Map(); 21 + cellB1.set('v', 100); 22 + cells0.set('B1', cellB1); 23 + sheet0.set('cells', cells0); 24 + ySheets.set('sheet_0', sheet0); 25 + 26 + // Sheet 1 27 + const sheet1 = new Y.Map(); 28 + sheet1.set('name', 'Expenses'); 29 + sheet1.set('rowCount', 30); 30 + sheet1.set('colCount', 5); 31 + const cells1 = new Y.Map(); 32 + const cellC1 = new Y.Map(); 33 + cellC1.set('v', 'Cost'); 34 + cellC1.set('f', 'SUM(C2:C10)'); 35 + cells1.set('C1', cellC1); 36 + sheet1.set('cells', cells1); 37 + ySheets.set('sheet_1', sheet1); 38 + 39 + return { ydoc, ySheets }; 40 + } 41 + 42 + describe('swapSheetData', () => { 43 + it('swaps sheet names', () => { 44 + const { ySheets } = createTestSheets(); 45 + 46 + swapSheetData(ySheets, 0, 1); 47 + 48 + expect(ySheets.get('sheet_0').get('name')).toBe('Expenses'); 49 + expect(ySheets.get('sheet_1').get('name')).toBe('Sales'); 50 + }); 51 + 52 + it('swaps cell data between sheets', () => { 53 + const { ySheets } = createTestSheets(); 54 + 55 + swapSheetData(ySheets, 0, 1); 56 + 57 + // Sheet 0 should now have sheet 1's cells (C1 with formula) 58 + const cells0 = ySheets.get('sheet_0').get('cells'); 59 + expect(cells0.has('C1')).toBe(true); 60 + expect(cells0.get('C1').get('v')).toBe('Cost'); 61 + expect(cells0.get('C1').get('f')).toBe('SUM(C2:C10)'); 62 + expect(cells0.has('A1')).toBe(false); 63 + expect(cells0.has('B1')).toBe(false); 64 + 65 + // Sheet 1 should now have sheet 0's cells 66 + const cells1 = ySheets.get('sheet_1').get('cells'); 67 + expect(cells1.has('A1')).toBe(true); 68 + expect(cells1.get('A1').get('v')).toBe('Revenue'); 69 + expect(cells1.has('B1')).toBe(true); 70 + expect(cells1.get('B1').get('v')).toBe(100); 71 + expect(cells1.has('C1')).toBe(false); 72 + }); 73 + 74 + it('swaps simple properties (rowCount, colCount)', () => { 75 + const { ySheets } = createTestSheets(); 76 + 77 + swapSheetData(ySheets, 0, 1); 78 + 79 + expect(ySheets.get('sheet_0').get('rowCount')).toBe(30); 80 + expect(ySheets.get('sheet_0').get('colCount')).toBe(5); 81 + expect(ySheets.get('sheet_1').get('rowCount')).toBe(50); 82 + expect(ySheets.get('sheet_1').get('colCount')).toBe(10); 83 + }); 84 + 85 + it('handles missing sheets gracefully', () => { 86 + const { ySheets } = createTestSheets(); 87 + 88 + // Should not throw when one sheet doesn't exist 89 + expect(() => swapSheetData(ySheets, 0, 5)).not.toThrow(); 90 + 91 + // Original data should be unchanged 92 + expect(ySheets.get('sheet_0').get('name')).toBe('Sales'); 93 + }); 94 + 95 + it('double swap restores original state', () => { 96 + const { ySheets } = createTestSheets(); 97 + 98 + swapSheetData(ySheets, 0, 1); 99 + swapSheetData(ySheets, 0, 1); 100 + 101 + expect(ySheets.get('sheet_0').get('name')).toBe('Sales'); 102 + expect(ySheets.get('sheet_1').get('name')).toBe('Expenses'); 103 + 104 + const cells0 = ySheets.get('sheet_0').get('cells'); 105 + expect(cells0.has('A1')).toBe(true); 106 + expect(cells0.get('A1').get('v')).toBe('Revenue'); 107 + 108 + const cells1 = ySheets.get('sheet_1').get('cells'); 109 + expect(cells1.has('C1')).toBe(true); 110 + expect(cells1.get('C1').get('v')).toBe('Cost'); 111 + }); 112 + 113 + it('swaps sheets with empty cells', () => { 114 + const ydoc = new Y.Doc(); 115 + const ySheets = ydoc.getMap('sheets'); 116 + 117 + ydoc.transact(() => { 118 + const sheet0 = new Y.Map(); 119 + sheet0.set('name', 'Full'); 120 + sheet0.set('cells', new Y.Map()); 121 + ySheets.set('sheet_0', sheet0); 122 + 123 + // Must add cell after sheet0 is in the doc 124 + const cell = new Y.Map(); 125 + cell.set('v', 'data'); 126 + ySheets.get('sheet_0').get('cells').set('A1', cell); 127 + 128 + const sheet1 = new Y.Map(); 129 + sheet1.set('name', 'Empty'); 130 + sheet1.set('cells', new Y.Map()); 131 + ySheets.set('sheet_1', sheet1); 132 + }); 133 + 134 + swapSheetData(ySheets, 0, 1); 135 + 136 + expect(ySheets.get('sheet_0').get('name')).toBe('Empty'); 137 + expect(ySheets.get('sheet_0').get('cells').size).toBe(0); 138 + expect(ySheets.get('sheet_1').get('name')).toBe('Full'); 139 + expect(ySheets.get('sheet_1').get('cells').size).toBe(1); 140 + expect(ySheets.get('sheet_1').get('cells').get('A1').get('v')).toBe('data'); 141 + }); 142 + });