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

Configure Feed

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

refactor: decompose sheets/main.ts and landing-events.ts (#300)

scott 03f1a0bf 9eeb485a

+1302 -1158
+133
src/landing-events-doclist.ts
··· 1 + /** 2 + * Delegated click handler for the document list: star toggle, trash, 3 + * move-to-folder, duplicate, tag editing, and recent-doc tracking. 4 + */ 5 + 6 + import type { EventDeps } from './landing-events.js'; 7 + import { toggleStar, moveToFolder, trackRecentDoc } from './landing-utils.js'; 8 + import { parseTags, saveDocumentTags } from './tags.js'; 9 + import { showToast } from './landing-toast.js'; 10 + import { showMoveModal as renderMoveModal } from './landing-render.js'; 11 + 12 + export function attachDocListListener(deps: EventDeps): void { 13 + deps.docListEl.addEventListener('click', async (e) => { 14 + const target = e.target as HTMLElement; 15 + 16 + // Star toggle 17 + const starBtn = target.closest('.doc-star') as HTMLElement | null; 18 + if (starBtn) { 19 + e.preventDefault(); 20 + e.stopPropagation(); 21 + const updated = toggleStar(deps.getStars(), starBtn.dataset.id!); 22 + deps.setStars(updated); 23 + localStorage.setItem('tools-stars', JSON.stringify(updated)); 24 + deps.renderDocuments(); 25 + return; 26 + } 27 + 28 + // Delete (trash) 29 + const deleteBtn = target.closest('.doc-item-delete') as HTMLElement | null; 30 + if (deleteBtn) { 31 + e.preventDefault(); 32 + e.stopPropagation(); 33 + const id = deleteBtn.dataset.id!; 34 + const trashRes = await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 35 + if (!trashRes.ok) { showToast('Failed to trash document', 4000, true); return; } 36 + const doc = deps.getAllDocs().find(d => d.id === id); 37 + if (doc) { 38 + doc.deleted_at = new Date().toISOString(); 39 + deps.setAllDocs(deps.getAllDocs().filter(d => d.id !== id)); 40 + deps.setTrashedDocs([doc, ...deps.getTrashedDocs()]); 41 + } 42 + deps.renderDocuments(); 43 + showToast('Document moved to trash', 5000, false, async () => { 44 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 45 + if (doc) { 46 + doc.deleted_at = null; 47 + deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 48 + deps.setAllDocs([...deps.getAllDocs(), doc]); 49 + } 50 + deps.renderDocuments(); 51 + }); 52 + return; 53 + } 54 + 55 + // Move to folder 56 + const moveBtn = target.closest('.doc-item-move') as HTMLElement | null; 57 + if (moveBtn) { 58 + e.preventDefault(); 59 + e.stopPropagation(); 60 + renderMoveModal(deps.getRenderDeps(), moveBtn.dataset.id!); 61 + return; 62 + } 63 + 64 + // Duplicate 65 + const dupBtn = target.closest('.doc-item-duplicate') as HTMLElement | null; 66 + if (dupBtn) { 67 + e.preventDefault(); 68 + e.stopPropagation(); 69 + const id = dupBtn.dataset.id!; 70 + const originalDoc = deps.getAllDocs().find(d => d.id === id); 71 + if (!originalDoc) return; 72 + try { 73 + const res = await fetch('/api/documents', { 74 + method: 'POST', 75 + headers: { 'Content-Type': 'application/json' }, 76 + body: JSON.stringify({ type: originalDoc.type, name_encrypted: originalDoc.name_encrypted }), 77 + }); 78 + if (!res.ok) throw new Error('Create failed'); 79 + const { id: newId } = await res.json(); 80 + const snapRes = await fetch(`/api/documents/${id}/snapshot`); 81 + if (snapRes.ok) { 82 + const blob = await snapRes.blob(); 83 + await fetch(`/api/documents/${newId}/snapshot`, { method: 'PUT', body: blob }); 84 + } 85 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 86 + if (keys[id]) { 87 + keys[newId] = keys[id]; 88 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 89 + } 90 + const currentFolderId = deps.getCurrentFolderId(); 91 + if (currentFolderId) { 92 + const updated = moveToFolder(deps.getFolderAssignments(), newId, currentFolderId); 93 + deps.setFolderAssignments(updated); 94 + localStorage.setItem('tools-folder-assignments', JSON.stringify(updated)); 95 + } 96 + await deps.loadDocuments(); 97 + showToast('Document duplicated'); 98 + } catch { 99 + showToast('Failed to duplicate document', 4000, true); 100 + } 101 + return; 102 + } 103 + 104 + // Tag edit 105 + const tagBtn = target.closest('.doc-item-tag-edit') as HTMLElement | null; 106 + if (tagBtn) { 107 + e.preventDefault(); 108 + e.stopPropagation(); 109 + const id = tagBtn.dataset.id!; 110 + const doc = deps.getAllDocs().find(d => d.id === id); 111 + if (!doc) return; 112 + const current = parseTags(doc.tags); 113 + const input = prompt('Tags (comma-separated):', current.join(', ')); 114 + if (input === null) return; 115 + const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 116 + doc.tags = JSON.stringify(newTags); 117 + if (id) saveDocumentTags(id, newTags); 118 + deps.renderDocuments(); 119 + return; 120 + } 121 + 122 + // Track recent docs on click (links that navigate away) 123 + const docLink = target.closest('a[data-doc-id]') as HTMLElement | null; 124 + if (docLink) { 125 + const docId = docLink.dataset.docId; 126 + if (docId) { 127 + const updated = trackRecentDoc(deps.getRecentIds(), docId); 128 + deps.setRecentIds(updated); 129 + localStorage.setItem('tools-recent', JSON.stringify(updated)); 130 + } 131 + } 132 + }); 133 + }
+108
src/landing-events-folders.ts
··· 1 + /** 2 + * Delegated folder-list click handler (navigate, rename, delete) and 3 + * the folder create/rename modal listeners. 4 + */ 5 + 6 + import type { EventDeps } from './landing-events.js'; 7 + import { 8 + createFolder, 9 + renameFolder, 10 + deleteFolder, 11 + clearFolderAssignments, 12 + } from './landing-utils.js'; 13 + 14 + // ── Folder modal state (module-scoped) ─────────────────────── 15 + 16 + const folderModalState = { 17 + mode: 'create' as 'create' | 'rename', 18 + targetId: null as string | null, 19 + }; 20 + 21 + /** Expose modal state so the folder-list rename handler can set it. */ 22 + export function getFolderModalState() { return folderModalState; } 23 + 24 + // ── Delegated folder-list listener ─────────────────────────── 25 + 26 + export function attachFolderListListener(deps: EventDeps): void { 27 + deps.folderListEl.addEventListener('click', (e) => { 28 + const target = e.target as HTMLElement; 29 + 30 + // Rename folder 31 + const renameBtn = target.closest('.folder-rename') as HTMLElement | null; 32 + if (renameBtn) { 33 + e.stopPropagation(); 34 + const folder = deps.getFolders().find(f => f.id === renameBtn.dataset.id); 35 + if (!folder) return; 36 + folderModalState.mode = 'rename'; 37 + folderModalState.targetId = folder.id; 38 + deps.folderModalTitle.textContent = 'Rename Folder'; 39 + deps.folderNameInput.value = folder.name; 40 + deps.folderConfirm.textContent = 'Rename'; 41 + deps.folderModal.style.display = ''; 42 + deps.folderNameInput.focus(); 43 + deps.folderNameInput.select(); 44 + return; 45 + } 46 + 47 + // Delete folder 48 + const deleteBtn = target.closest('.folder-delete') as HTMLElement | null; 49 + if (deleteBtn) { 50 + e.stopPropagation(); 51 + const folder = deps.getFolders().find(f => f.id === deleteBtn.dataset.id); 52 + if (!folder) return; 53 + if (!confirm(`Delete folder "${folder.name}"? Documents inside will be moved to the root.`)) return; 54 + const clearedAssignments = clearFolderAssignments(deps.getFolderAssignments(), folder.id); 55 + deps.setFolderAssignments(clearedAssignments); 56 + const updatedFolders = deleteFolder(deps.getFolders(), folder.id); 57 + deps.setFolders(updatedFolders); 58 + localStorage.setItem('tools-folders', JSON.stringify(updatedFolders)); 59 + localStorage.setItem('tools-folder-assignments', JSON.stringify(clearedAssignments)); 60 + deps.renderDocuments(); 61 + return; 62 + } 63 + 64 + // Navigate into folder (but not if clicking actions) 65 + const card = target.closest('.folder-card') as HTMLElement | null; 66 + if (card && !target.closest('.folder-card-actions')) { 67 + deps.setCurrentFolderId(card.dataset.folderId!); 68 + deps.renderDocuments(); 69 + } 70 + }); 71 + } 72 + 73 + // ── Folder modal one-off listeners ─────────────────────────── 74 + 75 + export function attachFolderModalListeners(deps: EventDeps): void { 76 + deps.newFolderBtn.addEventListener('click', () => { 77 + folderModalState.mode = 'create'; 78 + folderModalState.targetId = null; 79 + deps.folderModalTitle.textContent = 'New Folder'; 80 + deps.folderNameInput.value = ''; 81 + deps.folderConfirm.textContent = 'Create'; 82 + deps.folderModal.style.display = ''; 83 + deps.folderNameInput.focus(); 84 + }); 85 + 86 + deps.folderCancel.addEventListener('click', () => { 87 + deps.folderModal.style.display = 'none'; 88 + }); 89 + 90 + deps.folderConfirm.addEventListener('click', () => { 91 + const name = deps.folderNameInput.value.trim(); 92 + if (!name) return; 93 + 94 + if (folderModalState.mode === 'create') { 95 + deps.setFolders(createFolder(deps.getFolders(), name)); 96 + } else if (folderModalState.mode === 'rename') { 97 + deps.setFolders(renameFolder(deps.getFolders(), folderModalState.targetId!, name)); 98 + } 99 + localStorage.setItem('tools-folders', JSON.stringify(deps.getFolders())); 100 + deps.folderModal.style.display = 'none'; 101 + deps.renderDocuments(); 102 + }); 103 + 104 + deps.folderNameInput.addEventListener('keydown', (e) => { 105 + if (e.key === 'Enter') deps.folderConfirm.click(); 106 + if (e.key === 'Escape') deps.folderCancel.click(); 107 + }); 108 + }
+93
src/landing-events-identity.ts
··· 1 + /** 2 + * Username / Tailscale identity: detection, badge display, 3 + * modal listeners for manual name entry, and badge click handler. 4 + */ 5 + 6 + import type { EventDeps } from './landing-events.js'; 7 + import { generateRandomUsername, validateUsername } from './landing-utils.js'; 8 + 9 + // ── Tailscale identity (module-scoped) ─────────────────────── 10 + 11 + interface TsIdentity { login: string; name: string; profilePic: string | null; } 12 + let tsIdentity: TsIdentity | null = null; 13 + 14 + function showUserBadge(deps: EventDeps, name: string, profilePic?: string | null): void { 15 + if (profilePic) { 16 + deps.userBadge.innerHTML = `<img src="${profilePic}" alt="" class="user-avatar" />${name}`; 17 + } else { 18 + deps.userBadge.textContent = name; 19 + } 20 + deps.userBadge.title = tsIdentity ? `${tsIdentity.name} (${tsIdentity.login})` : 'Click to change name'; 21 + deps.userBadge.style.display = ''; 22 + } 23 + 24 + function saveUsername(deps: EventDeps, name: string): void { 25 + localStorage.setItem('tools-username', name); 26 + deps.usernameModal.style.display = 'none'; 27 + showUserBadge(deps, name, null); 28 + } 29 + 30 + // ── Init (detect Tailscale identity or prompt for name) ────── 31 + 32 + export async function initUsername(deps: EventDeps): Promise<void> { 33 + // Try Tailscale identity first (injected by Tailscale Serve) 34 + try { 35 + const res = await fetch('/api/me'); 36 + const data = await res.json(); 37 + if (data.login) { 38 + tsIdentity = data as TsIdentity; 39 + localStorage.setItem('tools-username', tsIdentity.name); 40 + showUserBadge(deps, tsIdentity.name, tsIdentity.profilePic); 41 + return; 42 + } 43 + } catch { /* anonymous/local access -- fall through */ } 44 + 45 + // Fall back to localStorage username 46 + const existing = localStorage.getItem('tools-username'); 47 + if (existing) { 48 + showUserBadge(deps, existing, null); 49 + return; 50 + } 51 + deps.usernameModal.style.display = ''; 52 + deps.usernameInput.focus(); 53 + } 54 + 55 + // ── One-off listeners for username modal and badge ─────────── 56 + 57 + export function attachUsernameListeners(deps: EventDeps): void { 58 + deps.usernameConfirm.addEventListener('click', () => { 59 + const val = deps.usernameInput.value.trim(); 60 + const result = validateUsername(val); 61 + if (result.valid) { 62 + saveUsername(deps, val); 63 + } else { 64 + deps.usernameInput.setCustomValidity(result.error!); 65 + deps.usernameInput.reportValidity(); 66 + } 67 + }); 68 + 69 + deps.usernameSkip.addEventListener('click', () => { 70 + saveUsername(deps, generateRandomUsername()); 71 + }); 72 + 73 + deps.usernameInput.addEventListener('keydown', (e) => { 74 + if (e.key === 'Enter') deps.usernameConfirm.click(); 75 + }); 76 + 77 + deps.userBadge.addEventListener('click', () => { 78 + if (tsIdentity) { 79 + alert(`Signed in as ${tsIdentity.name}\n${tsIdentity.login}`); 80 + return; 81 + } 82 + const current = localStorage.getItem('tools-username') || ''; 83 + const newName = prompt('Change your display name:', current); 84 + if (newName !== null) { 85 + const trimmed = newName.trim(); 86 + const result = validateUsername(trimmed); 87 + if (result.valid) { 88 + localStorage.setItem('tools-username', trimmed); 89 + showUserBadge(deps, trimmed, null); 90 + } 91 + } 92 + }); 93 + }
+72
src/landing-events-trash.ts
··· 1 + /** 2 + * Delegated trash-list click handler (restore, permanent delete, 3 + * empty all) and the trash-section toggle listener. 4 + */ 5 + 6 + import type { EventDeps } from './landing-events.js'; 7 + import { showToast } from './landing-toast.js'; 8 + 9 + // ── Delegated trash-list listener ──────────────────────────── 10 + 11 + export function attachTrashListListener(deps: EventDeps): void { 12 + deps.trashListEl.addEventListener('click', async (e) => { 13 + const target = e.target as HTMLElement; 14 + 15 + // Restore 16 + const restoreBtn = target.closest('.trash-restore') as HTMLElement | null; 17 + if (restoreBtn) { 18 + const id = restoreBtn.dataset.id!; 19 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 20 + const doc = deps.getTrashedDocs().find(d => d.id === id); 21 + if (doc) { 22 + doc.deleted_at = null; 23 + deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 24 + deps.setAllDocs([...deps.getAllDocs(), doc]); 25 + } 26 + deps.renderDocuments(); 27 + return; 28 + } 29 + 30 + // Permanent delete 31 + const permBtn = target.closest('.trash-permanent') as HTMLElement | null; 32 + if (permBtn) { 33 + const doc = deps.getTrashedDocs().find(d => d.id === permBtn.dataset.id); 34 + const name = doc?._decryptedName || 'this document'; 35 + if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 36 + const id = permBtn.dataset.id!; 37 + await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 38 + const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 39 + delete k[id]; 40 + localStorage.setItem('tools-keys', JSON.stringify(k)); 41 + deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 42 + deps.renderDocuments(); 43 + return; 44 + } 45 + 46 + // Empty all trash 47 + const emptyBtn = target.closest('.trash-empty-all') as HTMLElement | null; 48 + if (emptyBtn) { 49 + const docs = deps.getTrashedDocs(); 50 + if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 51 + const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 52 + for (const doc of docs) { 53 + await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 54 + delete k[doc.id]; 55 + } 56 + localStorage.setItem('tools-keys', JSON.stringify(k)); 57 + deps.setTrashedDocs([]); 58 + deps.renderDocuments(); 59 + } 60 + }); 61 + } 62 + 63 + // ── Trash toggle one-off listener ──────────────────────────── 64 + 65 + export function attachTrashToggleListener(deps: EventDeps): void { 66 + deps.trashToggle.addEventListener('click', () => { 67 + deps.setTrashExpanded(!deps.getTrashExpanded()); 68 + const icon = deps.trashToggle.querySelector('.trash-toggle-icon'); 69 + if (icon) icon.textContent = deps.getTrashExpanded() ? '\u25BC' : '\u25B6'; 70 + deps.renderDocuments(); 71 + }); 72 + }
+19 -361
src/landing-events.ts
··· 1 1 /** 2 - * Event listeners for the landing page: delegated click handlers on 3 - * document list, folder list, trash, breadcrumbs, move modal, tag filter, 4 - * recent section, plus one-off listeners for sort, search, folders, 5 - * modals, username, view toggle, and trash toggle. 2 + * Event listeners for the landing page — orchestrator that delegates 3 + * to focused sub-modules for document list, folders, trash, and identity. 6 4 * 7 - * Extracted from landing.ts for decomposition. 5 + * Keeps small/shared handlers inline: breadcrumbs, move modal, tag filter, 6 + * recent section, sort, search, view toggle, backup, and modal backdrop. 8 7 */ 9 8 10 9 import type { DocumentMeta, Folder, FolderAssignments, StarMap, SortLabels } from './landing-types.js'; 11 - import { 12 - toggleStar, 13 - createFolder, 14 - renameFolder, 15 - deleteFolder, 16 - moveToFolder, 17 - clearFolderAssignments, 18 - generateRandomUsername, 19 - validateUsername, 20 - trackRecentDoc, 21 - } from './landing-utils.js'; 22 - import { parseTags, saveDocumentTags } from './tags.js'; 10 + import { moveToFolder, trackRecentDoc } from './landing-utils.js'; 23 11 import { showToast } from './landing-toast.js'; 24 - import { showMoveModal as renderMoveModal } from './landing-render.js'; 25 12 import type { RenderDeps } from './landing-render.js'; 13 + 14 + import { attachDocListListener } from './landing-events-doclist.js'; 15 + import { attachFolderListListener, attachFolderModalListeners } from './landing-events-folders.js'; 16 + import { attachTrashListListener, attachTrashToggleListener } from './landing-events-trash.js'; 17 + import { initUsername as doInitUsername, attachUsernameListeners } from './landing-events-identity.js'; 26 18 27 19 // ── Deps interface ─────────────────────────────────────────── 28 20 ··· 100 92 type: 'Type', 101 93 }; 102 94 103 - // ── Username / identity ────────────────────────────────────── 104 - 105 - interface TsIdentity { login: string; name: string; profilePic: string | null; } 106 - let tsIdentity: TsIdentity | null = null; 107 - 108 - function showUserBadge(deps: EventDeps, name: string, profilePic?: string | null): void { 109 - if (profilePic) { 110 - deps.userBadge.innerHTML = `<img src="${profilePic}" alt="" class="user-avatar" />${name}`; 111 - } else { 112 - deps.userBadge.textContent = name; 113 - } 114 - deps.userBadge.title = tsIdentity ? `${tsIdentity.name} (${tsIdentity.login})` : 'Click to change name'; 115 - deps.userBadge.style.display = ''; 116 - } 117 - 118 - function saveUsername(deps: EventDeps, name: string): void { 119 - localStorage.setItem('tools-username', name); 120 - deps.usernameModal.style.display = 'none'; 121 - showUserBadge(deps, name, null); 122 - } 95 + // ── Public API (unchanged signatures) ──────────────────────── 123 96 124 97 export async function initUsername(deps: EventDeps): Promise<void> { 125 - // Try Tailscale identity first (injected by Tailscale Serve) 126 - try { 127 - const res = await fetch('/api/me'); 128 - const data = await res.json(); 129 - if (data.login) { 130 - tsIdentity = data as TsIdentity; 131 - localStorage.setItem('tools-username', tsIdentity.name); 132 - showUserBadge(deps, tsIdentity.name, tsIdentity.profilePic); 133 - return; 134 - } 135 - } catch { /* anonymous/local access — fall through */ } 136 - 137 - // Fall back to localStorage username 138 - const existing = localStorage.getItem('tools-username'); 139 - if (existing) { 140 - showUserBadge(deps, existing, null); 141 - return; 142 - } 143 - deps.usernameModal.style.display = ''; 144 - deps.usernameInput.focus(); 98 + return doInitUsername(deps); 145 99 } 146 - 147 - // ── Delegated listeners (attached once) ────────────────────── 148 100 149 101 export function setupDelegatedListeners(deps: EventDeps): void { 150 - // --- docListEl: star, delete, move, duplicate, tag-edit, recent tracking --- 151 - deps.docListEl.addEventListener('click', async (e) => { 152 - const target = e.target as HTMLElement; 153 - 154 - // Star toggle 155 - const starBtn = target.closest('.doc-star') as HTMLElement | null; 156 - if (starBtn) { 157 - e.preventDefault(); 158 - e.stopPropagation(); 159 - const updated = toggleStar(deps.getStars(), starBtn.dataset.id!); 160 - deps.setStars(updated); 161 - localStorage.setItem('tools-stars', JSON.stringify(updated)); 162 - deps.renderDocuments(); 163 - return; 164 - } 165 - 166 - // Delete (trash) 167 - const deleteBtn = target.closest('.doc-item-delete') as HTMLElement | null; 168 - if (deleteBtn) { 169 - e.preventDefault(); 170 - e.stopPropagation(); 171 - const id = deleteBtn.dataset.id!; 172 - const trashRes = await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 173 - if (!trashRes.ok) { showToast('Failed to trash document', 4000, true); return; } 174 - const doc = deps.getAllDocs().find(d => d.id === id); 175 - if (doc) { 176 - doc.deleted_at = new Date().toISOString(); 177 - deps.setAllDocs(deps.getAllDocs().filter(d => d.id !== id)); 178 - deps.setTrashedDocs([doc, ...deps.getTrashedDocs()]); 179 - } 180 - deps.renderDocuments(); 181 - showToast('Document moved to trash', 5000, false, async () => { 182 - await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 183 - if (doc) { 184 - doc.deleted_at = null; 185 - deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 186 - deps.setAllDocs([...deps.getAllDocs(), doc]); 187 - } 188 - deps.renderDocuments(); 189 - }); 190 - return; 191 - } 192 - 193 - // Move to folder 194 - const moveBtn = target.closest('.doc-item-move') as HTMLElement | null; 195 - if (moveBtn) { 196 - e.preventDefault(); 197 - e.stopPropagation(); 198 - renderMoveModal(deps.getRenderDeps(), moveBtn.dataset.id!); 199 - return; 200 - } 201 - 202 - // Duplicate 203 - const dupBtn = target.closest('.doc-item-duplicate') as HTMLElement | null; 204 - if (dupBtn) { 205 - e.preventDefault(); 206 - e.stopPropagation(); 207 - const id = dupBtn.dataset.id!; 208 - const originalDoc = deps.getAllDocs().find(d => d.id === id); 209 - if (!originalDoc) return; 210 - try { 211 - const res = await fetch('/api/documents', { 212 - method: 'POST', 213 - headers: { 'Content-Type': 'application/json' }, 214 - body: JSON.stringify({ type: originalDoc.type, name_encrypted: originalDoc.name_encrypted }), 215 - }); 216 - if (!res.ok) throw new Error('Create failed'); 217 - const { id: newId } = await res.json(); 218 - const snapRes = await fetch(`/api/documents/${id}/snapshot`); 219 - if (snapRes.ok) { 220 - const blob = await snapRes.blob(); 221 - await fetch(`/api/documents/${newId}/snapshot`, { method: 'PUT', body: blob }); 222 - } 223 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 224 - if (keys[id]) { 225 - keys[newId] = keys[id]; 226 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 227 - } 228 - const currentFolderId = deps.getCurrentFolderId(); 229 - if (currentFolderId) { 230 - const updated = moveToFolder(deps.getFolderAssignments(), newId, currentFolderId); 231 - deps.setFolderAssignments(updated); 232 - localStorage.setItem('tools-folder-assignments', JSON.stringify(updated)); 233 - } 234 - await deps.loadDocuments(); 235 - showToast('Document duplicated'); 236 - } catch { 237 - showToast('Failed to duplicate document', 4000, true); 238 - } 239 - return; 240 - } 241 - 242 - // Tag edit 243 - const tagBtn = target.closest('.doc-item-tag-edit') as HTMLElement | null; 244 - if (tagBtn) { 245 - e.preventDefault(); 246 - e.stopPropagation(); 247 - const id = tagBtn.dataset.id!; 248 - const doc = deps.getAllDocs().find(d => d.id === id); 249 - if (!doc) return; 250 - const current = parseTags(doc.tags); 251 - const input = prompt('Tags (comma-separated):', current.join(', ')); 252 - if (input === null) return; 253 - const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 254 - doc.tags = JSON.stringify(newTags); 255 - if (id) saveDocumentTags(id, newTags); 256 - deps.renderDocuments(); 257 - return; 258 - } 259 - 260 - // Track recent docs on click (links that navigate away) 261 - const docLink = target.closest('a[data-doc-id]') as HTMLElement | null; 262 - if (docLink) { 263 - const docId = docLink.dataset.docId; 264 - if (docId) { 265 - const updated = trackRecentDoc(deps.getRecentIds(), docId); 266 - deps.setRecentIds(updated); 267 - localStorage.setItem('tools-recent', JSON.stringify(updated)); 268 - } 269 - } 270 - }); 271 - 272 - // --- folderListEl: navigate, rename, delete --- 273 - deps.folderListEl.addEventListener('click', (e) => { 274 - const target = e.target as HTMLElement; 275 - 276 - // Rename folder 277 - const renameBtn = target.closest('.folder-rename') as HTMLElement | null; 278 - if (renameBtn) { 279 - e.stopPropagation(); 280 - const folder = deps.getFolders().find(f => f.id === renameBtn.dataset.id); 281 - if (!folder) return; 282 - folderModalState.mode = 'rename'; 283 - folderModalState.targetId = folder.id; 284 - deps.folderModalTitle.textContent = 'Rename Folder'; 285 - deps.folderNameInput.value = folder.name; 286 - deps.folderConfirm.textContent = 'Rename'; 287 - deps.folderModal.style.display = ''; 288 - deps.folderNameInput.focus(); 289 - deps.folderNameInput.select(); 290 - return; 291 - } 292 - 293 - // Delete folder 294 - const deleteBtn = target.closest('.folder-delete') as HTMLElement | null; 295 - if (deleteBtn) { 296 - e.stopPropagation(); 297 - const folder = deps.getFolders().find(f => f.id === deleteBtn.dataset.id); 298 - if (!folder) return; 299 - if (!confirm(`Delete folder "${folder.name}"? Documents inside will be moved to the root.`)) return; 300 - const clearedAssignments = clearFolderAssignments(deps.getFolderAssignments(), folder.id); 301 - deps.setFolderAssignments(clearedAssignments); 302 - const updatedFolders = deleteFolder(deps.getFolders(), folder.id); 303 - deps.setFolders(updatedFolders); 304 - localStorage.setItem('tools-folders', JSON.stringify(updatedFolders)); 305 - localStorage.setItem('tools-folder-assignments', JSON.stringify(clearedAssignments)); 306 - deps.renderDocuments(); 307 - return; 308 - } 309 - 310 - // Navigate into folder (but not if clicking actions) 311 - const card = target.closest('.folder-card') as HTMLElement | null; 312 - if (card && !target.closest('.folder-card-actions')) { 313 - deps.setCurrentFolderId(card.dataset.folderId!); 314 - deps.renderDocuments(); 315 - } 316 - }); 317 - 318 - // --- trashListEl: restore, permanent delete, empty all --- 319 - deps.trashListEl.addEventListener('click', async (e) => { 320 - const target = e.target as HTMLElement; 321 - 322 - // Restore 323 - const restoreBtn = target.closest('.trash-restore') as HTMLElement | null; 324 - if (restoreBtn) { 325 - const id = restoreBtn.dataset.id!; 326 - await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 327 - const doc = deps.getTrashedDocs().find(d => d.id === id); 328 - if (doc) { 329 - doc.deleted_at = null; 330 - deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 331 - deps.setAllDocs([...deps.getAllDocs(), doc]); 332 - } 333 - deps.renderDocuments(); 334 - return; 335 - } 336 - 337 - // Permanent delete 338 - const permBtn = target.closest('.trash-permanent') as HTMLElement | null; 339 - if (permBtn) { 340 - const doc = deps.getTrashedDocs().find(d => d.id === permBtn.dataset.id); 341 - const name = doc?._decryptedName || 'this document'; 342 - if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 343 - const id = permBtn.dataset.id!; 344 - await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 345 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 346 - delete k[id]; 347 - localStorage.setItem('tools-keys', JSON.stringify(k)); 348 - deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 349 - deps.renderDocuments(); 350 - return; 351 - } 352 - 353 - // Empty all trash 354 - const emptyBtn = target.closest('.trash-empty-all') as HTMLElement | null; 355 - if (emptyBtn) { 356 - const docs = deps.getTrashedDocs(); 357 - if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 358 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 359 - for (const doc of docs) { 360 - await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 361 - delete k[doc.id]; 362 - } 363 - localStorage.setItem('tools-keys', JSON.stringify(k)); 364 - deps.setTrashedDocs([]); 365 - deps.renderDocuments(); 366 - } 367 - }); 102 + attachDocListListener(deps); 103 + attachFolderListListener(deps); 104 + attachTrashListListener(deps); 368 105 369 106 // --- breadcrumbsEl: navigate --- 370 107 deps.breadcrumbsEl.addEventListener('click', (e) => { ··· 419 156 } 420 157 } 421 158 422 - // ── Folder modal state (module-scoped) ─────────────────────── 423 - 424 - const folderModalState = { 425 - mode: 'create' as 'create' | 'rename', 426 - targetId: null as string | null, 427 - }; 428 - 429 - // ── One-off listeners ──────────────────────────────────────── 430 - 431 159 export function setupOneOffListeners(deps: EventDeps): void { 432 160 // --- Sort --- 433 161 deps.sortLabel.textContent = SORT_LABELS[deps.getCurrentSort()] || SORT_LABELS.updated; ··· 469 197 // Init clear button visibility 470 198 deps.searchClear.style.display = 'none'; 471 199 472 - // --- New folder --- 473 - deps.newFolderBtn.addEventListener('click', () => { 474 - folderModalState.mode = 'create'; 475 - folderModalState.targetId = null; 476 - deps.folderModalTitle.textContent = 'New Folder'; 477 - deps.folderNameInput.value = ''; 478 - deps.folderConfirm.textContent = 'Create'; 479 - deps.folderModal.style.display = ''; 480 - deps.folderNameInput.focus(); 481 - }); 482 - 483 - deps.folderCancel.addEventListener('click', () => { 484 - deps.folderModal.style.display = 'none'; 485 - }); 486 - 487 - deps.folderConfirm.addEventListener('click', () => { 488 - const name = deps.folderNameInput.value.trim(); 489 - if (!name) return; 490 - 491 - if (folderModalState.mode === 'create') { 492 - deps.setFolders(createFolder(deps.getFolders(), name)); 493 - } else if (folderModalState.mode === 'rename') { 494 - deps.setFolders(renameFolder(deps.getFolders(), folderModalState.targetId!, name)); 495 - } 496 - localStorage.setItem('tools-folders', JSON.stringify(deps.getFolders())); 497 - deps.folderModal.style.display = 'none'; 498 - deps.renderDocuments(); 499 - }); 500 - 501 - deps.folderNameInput.addEventListener('keydown', (e) => { 502 - if (e.key === 'Enter') deps.folderConfirm.click(); 503 - if (e.key === 'Escape') deps.folderCancel.click(); 504 - }); 200 + // --- Folders --- 201 + attachFolderModalListeners(deps); 505 202 506 203 deps.moveCancel.addEventListener('click', () => { 507 204 deps.moveModal.style.display = 'none'; ··· 509 206 }); 510 207 511 208 // --- Trash toggle --- 512 - deps.trashToggle.addEventListener('click', () => { 513 - deps.setTrashExpanded(!deps.getTrashExpanded()); 514 - const icon = deps.trashToggle.querySelector('.trash-toggle-icon'); 515 - if (icon) icon.textContent = deps.getTrashExpanded() ? '\u25BC' : '\u25B6'; 516 - deps.renderDocuments(); 517 - }); 209 + attachTrashToggleListener(deps); 518 210 519 211 // --- View toggle --- 520 212 if (deps.viewToggleBtn) { ··· 527 219 } 528 220 529 221 // --- Username --- 530 - deps.usernameConfirm.addEventListener('click', () => { 531 - const val = deps.usernameInput.value.trim(); 532 - const result = validateUsername(val); 533 - if (result.valid) { 534 - saveUsername(deps, val); 535 - } else { 536 - deps.usernameInput.setCustomValidity(result.error!); 537 - deps.usernameInput.reportValidity(); 538 - } 539 - }); 540 - 541 - deps.usernameSkip.addEventListener('click', () => { 542 - saveUsername(deps, generateRandomUsername()); 543 - }); 544 - 545 - deps.usernameInput.addEventListener('keydown', (e) => { 546 - if (e.key === 'Enter') deps.usernameConfirm.click(); 547 - }); 548 - 549 - deps.userBadge.addEventListener('click', () => { 550 - if (tsIdentity) { 551 - alert(`Signed in as ${tsIdentity.name}\n${tsIdentity.login}`); 552 - return; 553 - } 554 - const current = localStorage.getItem('tools-username') || ''; 555 - const newName = prompt('Change your display name:', current); 556 - if (newName !== null) { 557 - const trimmed = newName.trim(); 558 - const result = validateUsername(trimmed); 559 - if (result.valid) { 560 - localStorage.setItem('tools-username', trimmed); 561 - showUserBadge(deps, trimmed, null); 562 - } 563 - } 564 - }); 222 + attachUsernameListeners(deps); 565 223 566 224 // --- Close modals on backdrop click --- 567 225 [deps.usernameModal, deps.folderModal, deps.moveModal].forEach(modal => {
+100
src/sheets/grid-click-handlers.ts
··· 1 + /** 2 + * Grid Click Handlers — rich cell interaction and validation dropdown. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { handleRichCellClick } from './rich-cells.js'; 8 + import { getDropdownItems } from './data-validation.js'; 9 + 10 + // ── Types ─────────────────────────────────────────────────── 11 + 12 + export interface GridClickHandlersDeps { 13 + grid: HTMLElement; 14 + getCellData: (id: string) => any; 15 + setCellData: (id: string, data: any) => void; 16 + getValidationForCell: (id: string) => any; 17 + evalCache: { clear: () => void }; 18 + clearSpillMaps: () => void; 19 + invalidateRecalcEngine: () => void; 20 + refreshVisibleCells: () => void; 21 + } 22 + 23 + // ── Functions ─────────────────────────────────────────────── 24 + 25 + /** Wire rich cell click handler (checkbox toggle, star rating) */ 26 + export function wireRichCellClick(deps: GridClickHandlersDeps): void { 27 + const { grid, getCellData, setCellData, refreshVisibleCells } = deps; 28 + 29 + grid.addEventListener('click', (e) => { 30 + const target = e.target as HTMLElement; 31 + const richEl = target.closest('[data-rich]') as HTMLElement | null; 32 + if (!richEl) return; 33 + const td = richEl.closest('td'); 34 + if (!td) return; 35 + const cellId = td.dataset.id; 36 + if (!cellId) return; 37 + const cellData = getCellData(cellId); 38 + const result = handleRichCellClick(target, cellData?.v); 39 + if (result) { 40 + e.stopPropagation(); 41 + setCellData(cellId, { v: result.value, f: '', s: cellData?.s ?? {} }); 42 + refreshVisibleCells(); 43 + } 44 + }); 45 + } 46 + 47 + /** Wire validation dropdown click handler */ 48 + export function wireValidationDropdown(deps: GridClickHandlersDeps): void { 49 + const { grid, getValidationForCell, setCellData, evalCache, clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells } = deps; 50 + 51 + grid.addEventListener('click', (e) => { 52 + const arrow = (e.target as HTMLElement).closest('.cell-dropdown-arrow'); 53 + if (!arrow) return; 54 + e.stopPropagation(); 55 + const cellIdStr = (arrow as HTMLElement).dataset.dropdownCell; 56 + const validation = getValidationForCell(cellIdStr); 57 + if (!validation || validation.type !== 'list') return; 58 + 59 + // Remove any existing dropdown 60 + document.querySelectorAll('.validation-dropdown').forEach(d => d.remove()); 61 + 62 + const items = getDropdownItems(validation); 63 + if (items.length === 0) return; 64 + 65 + const td = arrow.closest('td'); 66 + const rect = td!.getBoundingClientRect(); 67 + 68 + const dropdown = document.createElement('div'); 69 + dropdown.className = 'validation-dropdown'; 70 + dropdown.style.left = rect.left + 'px'; 71 + dropdown.style.top = rect.bottom + 'px'; 72 + dropdown.style.minWidth = rect.width + 'px'; 73 + 74 + items.forEach((item: string) => { 75 + const btn = document.createElement('button'); 76 + btn.className = 'validation-dropdown-item'; 77 + btn.textContent = item; 78 + btn.addEventListener('click', () => { 79 + const numVal = Number(item); 80 + const value = item === '' ? '' : (!isNaN(numVal) && item !== '' ? numVal : item); 81 + setCellData(cellIdStr!, { v: value, f: '' }); 82 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 83 + refreshVisibleCells(); 84 + dropdown.remove(); 85 + }); 86 + dropdown.appendChild(btn); 87 + }); 88 + 89 + document.body.appendChild(dropdown); 90 + 91 + // Close on click outside 92 + const closeDropdown = (ev: MouseEvent) => { 93 + if (!dropdown.contains(ev.target as Node)) { 94 + dropdown.remove(); 95 + document.removeEventListener('click', closeDropdown); 96 + } 97 + }; 98 + setTimeout(() => document.addEventListener('click', closeDropdown), 0); 99 + }); 100 + }
+109
src/sheets/hidden-rows-cols-ui.ts
··· 1 + /** 2 + * Hidden Rows/Cols UI — Yjs-backed hide/unhide state and selection actions. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { normalizeRange } from './selection-utils.js'; 9 + import { getAdjacentHiddenRows, getAdjacentHiddenCols } from './hidden-rows-cols.js'; 10 + 11 + // ── Types ────��────────────────────────────────────────────── 12 + 13 + export interface HiddenRowsColsDeps { 14 + ydoc: any; 15 + getActiveSheet: () => any; 16 + getSelectionRange: () => any; 17 + renderGrid: () => void; 18 + DEFAULT_ROWS: number; 19 + DEFAULT_COLS: number; 20 + } 21 + 22 + // ── State access ──────────────────────────────────────────── 23 + 24 + export function getHiddenRows(getActiveSheet: () => any): any { 25 + const sheet = getActiveSheet(); 26 + if (!sheet.has('hiddenRows')) sheet.set('hiddenRows', new Y.Map()); 27 + return sheet.get('hiddenRows'); 28 + } 29 + 30 + export function getHiddenCols(getActiveSheet: () => any): any { 31 + const sheet = getActiveSheet(); 32 + if (!sheet.has('hiddenCols')) sheet.set('hiddenCols', new Y.Map()); 33 + return sheet.get('hiddenCols'); 34 + } 35 + 36 + export function isRowHidden(getActiveSheet: () => any, row: number): boolean { 37 + return getHiddenRows(getActiveSheet).get(String(row)) === true; 38 + } 39 + 40 + export function isColHidden(getActiveSheet: () => any, col: number): boolean { 41 + return getHiddenCols(getActiveSheet).get(String(col)) === true; 42 + } 43 + 44 + export function setRowHidden(getActiveSheet: () => any, row: number, hidden: boolean): void { 45 + const yMap = getHiddenRows(getActiveSheet); 46 + if (hidden) yMap.set(String(row), true); 47 + else if (yMap.has(String(row))) yMap.delete(String(row)); 48 + } 49 + 50 + export function setColHidden(getActiveSheet: () => any, col: number, hidden: boolean): void { 51 + const yMap = getHiddenCols(getActiveSheet); 52 + if (hidden) yMap.set(String(col), true); 53 + else if (yMap.has(String(col))) yMap.delete(String(col)); 54 + } 55 + 56 + // ── Actions ───────────────────────────────────────────────── 57 + 58 + export function hideSelectedRows(deps: HiddenRowsColsDeps): void { 59 + const { ydoc, getActiveSheet, getSelectionRange, renderGrid } = deps; 60 + const selectionRange = getSelectionRange(); 61 + if (!selectionRange) return; 62 + const { startRow, endRow } = normalizeRange(selectionRange); 63 + ydoc.transact(() => { 64 + for (let r = startRow; r <= endRow; r++) setRowHidden(getActiveSheet, r, true); 65 + }); 66 + renderGrid(); 67 + } 68 + 69 + export function hideSelectedCols(deps: HiddenRowsColsDeps): void { 70 + const { ydoc, getActiveSheet, getSelectionRange, renderGrid } = deps; 71 + const selectionRange = getSelectionRange(); 72 + if (!selectionRange) return; 73 + const { startCol, endCol } = normalizeRange(selectionRange); 74 + ydoc.transact(() => { 75 + for (let c = startCol; c <= endCol; c++) setColHidden(getActiveSheet, c, true); 76 + }); 77 + renderGrid(); 78 + } 79 + 80 + export function unhideAdjacentRows(deps: HiddenRowsColsDeps, row: number): void { 81 + const { ydoc, getActiveSheet, renderGrid, DEFAULT_ROWS } = deps; 82 + const sheet = getActiveSheet(); 83 + const totalRows = sheet.get('rowCount') || DEFAULT_ROWS; 84 + const hiddenSet = { has: (r: number) => isRowHidden(getActiveSheet, r) }; 85 + const adjacent = getAdjacentHiddenRows(row, totalRows, hiddenSet); 86 + if (adjacent.length === 0) return; 87 + ydoc.transact(() => { for (const r of adjacent) setRowHidden(getActiveSheet, r, false); }); 88 + renderGrid(); 89 + } 90 + 91 + export function unhideAdjacentCols(deps: HiddenRowsColsDeps, col: number): void { 92 + const { ydoc, getActiveSheet, renderGrid, DEFAULT_COLS } = deps; 93 + const sheet = getActiveSheet(); 94 + const totalCols = sheet.get('colCount') || DEFAULT_COLS; 95 + const hiddenSet = { has: (c: number) => isColHidden(getActiveSheet, c) }; 96 + const adjacent = getAdjacentHiddenCols(col, totalCols, hiddenSet); 97 + if (adjacent.length === 0) return; 98 + ydoc.transact(() => { for (const c of adjacent) setColHidden(getActiveSheet, c, false); }); 99 + renderGrid(); 100 + } 101 + 102 + /** Build a HiddenSet adapter for the pure functions */ 103 + export function buildHiddenRowSet(getActiveSheet: () => any): { has: (r: number) => boolean } { 104 + return { has: (r) => isRowHidden(getActiveSheet, r) }; 105 + } 106 + 107 + export function buildHiddenColSet(getActiveSheet: () => any): { has: (c: number) => boolean } { 108 + return { has: (c) => isColHidden(getActiveSheet, c) }; 109 + }
+109
src/sheets/image-cells-ui.ts
··· 1 + /** 2 + * Image Cells UI — upload handler, cell rendering, and toolbar wiring. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId } from './formulas.js'; 8 + import { uploadBlob, downloadBlob, readFileAsBuffer, blobToObjectUrl } from '../lib/blob-upload.js'; 9 + import { createImageCellState, setCellImage } from './image-cells.js'; 10 + import type { ImageCellState } from './image-cells.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface ImageCellsUIDeps { 15 + grid: HTMLElement; 16 + getSelectedCell: () => { col: number; row: number }; 17 + getCellData: (id: string) => any; 18 + setCellData: (id: string, data: any) => void; 19 + renderGrid: () => void; 20 + closeAllDropdowns: () => void; 21 + } 22 + 23 + // ── State ─────────────────────────────────────────────────── 24 + 25 + let imageCellState: ImageCellState = createImageCellState(); 26 + const imageCache = new Map<string, string>(); // blobId → objectURL 27 + 28 + // ── Functions ─────────────────────────────────────────────── 29 + 30 + /** Post-render: populate image cell placeholders */ 31 + export function renderImageCells(grid: HTMLElement): void { 32 + grid.querySelectorAll('.cell-image-container[data-blob-id]').forEach((el: Element) => { 33 + const td = el as HTMLElement; 34 + const blobId = td.dataset.blobId; 35 + if (!blobId) return; 36 + renderCellImage(td, blobId); 37 + }); 38 + } 39 + 40 + /** Render image in cell from blob ID */ 41 + function renderCellImage(td: HTMLElement, blobId: string): void { 42 + // Check cache first 43 + if (imageCache.has(blobId)) { 44 + const img = document.createElement('img'); 45 + img.src = imageCache.get(blobId)!; 46 + img.alt = ''; 47 + img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 48 + td.textContent = ''; 49 + td.appendChild(img); 50 + return; 51 + } 52 + 53 + // Load async 54 + td.textContent = '...'; 55 + downloadBlob(blobId).then(({ data, mimeType }) => { 56 + const url = blobToObjectUrl(data, mimeType); 57 + imageCache.set(blobId, url); 58 + const img = document.createElement('img'); 59 + img.src = url; 60 + img.alt = ''; 61 + img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 62 + td.textContent = ''; 63 + td.appendChild(img); 64 + }).catch(() => { 65 + td.textContent = '[img]'; 66 + }); 67 + } 68 + 69 + /** Wire the image upload toolbar button and file input */ 70 + export function wireImageCells(deps: ImageCellsUIDeps): void { 71 + const { grid, getSelectedCell, getCellData, setCellData, renderGrid, closeAllDropdowns } = deps; 72 + 73 + const imageUploadInput = document.getElementById('image-upload-input') as HTMLInputElement; 74 + 75 + document.getElementById('tb-insert-image')!.addEventListener('click', () => { 76 + imageUploadInput.click(); 77 + closeAllDropdowns(); 78 + }); 79 + 80 + imageUploadInput.addEventListener('change', async () => { 81 + const file = imageUploadInput.files?.[0]; 82 + if (!file) return; 83 + imageUploadInput.value = ''; 84 + 85 + const selectedCell = getSelectedCell(); 86 + const targetCell = cellId(selectedCell.col, selectedCell.row); 87 + try { 88 + const buf = await readFileAsBuffer(file); 89 + const docId = window.location.pathname.split('/').pop() || ''; 90 + const result = await uploadBlob(docId, new Uint8Array(buf), file.name, file.type); 91 + 92 + // Get natural dimensions 93 + const img = new Image(); 94 + const url = blobToObjectUrl(buf, file.type); 95 + imageCache.set(result.id, url); 96 + 97 + img.onload = () => { 98 + imageCellState = setCellImage(imageCellState, targetCell, result.id, img.naturalWidth, img.naturalHeight, { alt: file.name }); 99 + // Store image ref in cell data so it persists via Yjs 100 + const existing = getCellData(targetCell) || {}; 101 + setCellData(targetCell, { ...existing, img: result.id }); 102 + renderGrid(); 103 + }; 104 + img.src = url; 105 + } catch (err) { 106 + console.error('Image upload failed:', err); 107 + } 108 + }); 109 + }
+225 -797
src/sheets/main.ts
··· 7 7 */ 8 8 9 9 import * as Y from 'yjs'; 10 - import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 10 + import { importKey } from '../lib/crypto.js'; 11 11 import { setupTooltips } from '../lib/tooltips.js'; 12 12 import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 13 13 import { EncryptedProvider } from '../lib/provider.js'; 14 - import { createVersionPanel } from '../version-panel.js'; 15 - import { evaluate, extractRefs, formatCell, parseRef, colToLetter, letterToCol, cellId } from './formulas.js'; 14 + import { evaluate, formatCell, cellId } from './formulas.js'; 16 15 import { RecalcEngine } from './recalc.js'; 17 - // xlsx-import, xlsx-export, charts, filter — now used via extracted UI modules 18 - // sort — now used via toolbar-wiring.ts 19 - import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 20 - import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 21 - import { renderInteractiveCell, handleRichCellClick } from './rich-cells.js'; 22 - import { parseDateValue, showDatePicker } from './date-picker.js'; 23 - import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 24 - import { buildBorderStyle, getWrapStyle, getStripedRowClass } from './cell-styles.js'; 16 + import { getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 25 17 import { normalizeRange, isInRange } from './selection-utils.js'; 26 - import { hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle } from './cell-style-utils.js'; 27 18 import { buildMergeMap, findCellMerge } from './merge-utils.js'; 28 - import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget, getSpillTargetValue } from './spill-tracking.js'; 29 - // save-indicator — now used via save-status-ui.ts 30 - // csv-utils — now used via import-export.ts 31 - import type { SpillState } from './spill-tracking.js'; 32 - // status-bar — now used via status-bar-ui.ts 33 - import { showSortDialog as _showSortDialog, showCfModal as _showCfModal, showValidationModal as _showValidationModal } from './sheet-dialogs.js'; 34 - import { getSheetContextText as _getSheetContextText, sendChatMessage as _sendChatMessage } from './ai-chat-panel.js'; 35 - import { renderSheetTabs as _renderSheetTabs, reorderSheets as _reorderSheets, swapSheetData as _swapSheetData, beginInlineRename as _beginInlineRename, showSheetTabContextMenu as _showSheetTabContextMenu, showTabColorPicker as _showTabColorPicker, confirmAndDeleteSheet as _confirmAndDeleteSheet, doDuplicateSheet as _doDuplicateSheet } from './sheet-tabs-ui.js'; 36 - import { showPivotDialog as _showPivotDialog, renderPivots as _renderPivots } from './pivot-ui.js'; 37 - // formula-autocomplete — now used via formula-autocomplete-ui.ts 38 - // cell-notes — now used via cell-notes-ui.ts 39 - // formula-highlighter, range-highlight, formula-tooltip — now used via formula-bar-ui.ts 40 - import { clearGridHighlights } from './range-highlight.js'; 41 - import { hideTooltip } from './formula-tooltip.js'; 42 - // format-painter, row-col-ops — now used via toolbar-wiring.ts 43 - // context-menu — now used via context-menu-handler.ts 44 - // Sheet tab management functions used via sheet-tabs-ui.ts (no longer directly imported here) 45 - // clipboard-copy, clipboard-paste — now used via clipboard-operations.ts 46 - // paste-special — now used via paste-special-ui.ts → clipboard-operations.ts 47 - import { computeVisibleRows, computeVisibleCols, hiddenRowsSpacerAdjustment, getAdjacentHiddenRows, getAdjacentHiddenCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 48 - import { isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 49 - import { isSparklineResult, drawSparkline } from './sparkline.js'; 50 - import { detectPattern, generateFillValues, adjustFormulaRef, PATTERN_TYPES } from './drag-fill.js'; 51 - // print-layout — now used via import-export.ts 19 + import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget } from './spill-tracking.js'; 20 + import { isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 21 + import { isSparklineResult } from './sparkline.js'; 52 22 import { 53 23 createChatSidebar, createChatState, loadConfig, initChatWiring, 54 24 } from '../lib/ai-chat.js'; 55 - import { escapeHtml } from '../lib/escape-html.js'; 56 - // splitResponse/isSheetAction/executeSheetAction used via ai-chat-panel.ts 57 - // computePivot/formatAggregateValue used via pivot-ui.ts 58 - import type { PivotConfig, AggregateFunction } from './pivot-table.js'; 59 - // database-views — now used via database-views-ui.ts 60 - import { uploadBlob, downloadBlob, readFileAsBuffer, blobToObjectUrl } from '../lib/blob-upload.js'; 61 - import { createImageCellState, setCellImage, getCellImage, hasImage, imageCellIds } from './image-cells.js'; 62 - import type { ImageCellState } from './image-cells.js'; 25 + import type { PivotConfig } from './pivot-table.js'; 63 26 // ── Extracted UI modules ──────────────────────────────────── 64 27 import { showToast, handleImportFile as _handleImportFile, printSheet as _printSheet, wireImportExportToolbar as _wireImportExportToolbar } from './import-export.js'; 65 28 import { showChartDialog as _showChartDialogUI, renderCharts as _renderChartsUI } from './charts-ui.js'; 66 29 import { showDbViewDialog as _showDbViewDialogUI, renderDbView as _renderDbViewUI } from './database-views-ui.js'; 67 - import { isFilterMode, getFilterState, toggleFilterMode as _toggleFilterMode, getFilterHiddenRows as _getFilterHiddenRows, showFilterDropdown as _showFilterDropdown, applyFilterToGrid as _applyFilterToGrid, setupFilterGridObserver as _setupFilterGridObserver, loadFilterStateFromYjs as _loadFilterStateFromYjs } from './filter-ui.js'; 30 + import { toggleFilterMode as _toggleFilterMode, getFilterHiddenRows as _getFilterHiddenRows, applyFilterToGrid as _applyFilterToGrid, setupFilterGridObserver as _setupFilterGridObserver, loadFilterStateFromYjs as _loadFilterStateFromYjs } from './filter-ui.js'; 68 31 import { createFindReplaceBar, showFindReplaceBar as _showFindReplaceBarUI, hideFindReplaceBar as _hideFindReplaceBarUI, wireFindReplaceBar as _wireFindReplaceBar, getSheetsFindState } from './find-replace-bar.js'; 69 - import { getNotesMap as _getNotesMap, getNotesObject as _getNotesObject, setNoteInYjs as _setNoteInYjs, showNoteDialog as _showNoteDialogUI, renderNoteIndicators as _renderNoteIndicatorsUI, wireNoteHover as _wireNoteHover, wireErrorTooltip as _wireErrorTooltip, hideNoteTooltip } from './cell-notes-ui.js'; 32 + import { getNotesMap as _getNotesMap, getNotesObject as _getNotesObject, setNoteInYjs as _setNoteInYjs, showNoteDialog as _showNoteDialogUI, renderNoteIndicators as _renderNoteIndicatorsUI, wireNoteHover as _wireNoteHover, wireErrorTooltip as _wireErrorTooltip } from './cell-notes-ui.js'; 70 33 import { wireConnectionStatus as _wireConnectionStatus, setupCollabAvatars as _setupCollabAvatars } from './collaboration-ui.js'; 71 34 import { updateStatusBar as _updateStatusBarUI, wireStatusBarFreezeClick as _wireStatusBarFreezeClick } from './status-bar-ui.js'; 72 35 import { hideActiveContextMenu, wireContextMenu as _wireContextMenu, setActiveContextMenu } from './context-menu-handler.js'; 73 - // paste-special-ui — now used via clipboard-operations.ts 74 36 import { updateFormulaHighlight as _updateFormulaHighlight, updateFormulaRangeHighlights as _updateFormulaRangeHighlights, updateFormulaTooltip, onFormulaInputUpdate as _onFormulaInputUpdate, commitFormulaBar as _commitFormulaBar, wireFormulaBarKeys as _wireFormulaBarKeys, refreshVisibleCells as _refreshVisibleCells } from './formula-bar-ui.js'; 75 37 import { hideAutocomplete as _hideAutocomplete, attachCellEditorAutocomplete as _attachCellEditorAutocomplete, wireAutocomplete as _wireAutocomplete } from './formula-autocomplete-ui.js'; 76 38 import { wireShortcutButton } from './shortcuts-modal.js'; ··· 87 49 closeAllDropdowns as _closeAllDropdowns, sortColumn as _sortColumn, 88 50 doInsertRow as _doInsertRow, doDeleteRow as _doDeleteRow, 89 51 doInsertColumn as _doInsertColumn, doDeleteColumn as _doDeleteColumn, 90 - updateUndoRedoState as _updateUndoRedoState, updateFreezeToolbarState as _updateFreezeToolbarState, 52 + updateFreezeToolbarState as _updateFreezeToolbarState, 91 53 updateWrapButtonState as _updateWrapButtonState, updateStripedButtonState as _updateStripedButtonState, 92 54 updateBoldButtonState as _updateBoldButtonState, updateItalicButtonState as _updateItalicButtonState, 93 55 updateUnderlineButtonState as _updateUnderlineButtonState, updateStrikethroughButtonState as _updateStrikethroughButtonState, ··· 96 58 getFormatPainterFormat as _getFormatPainterFormat, applyFormatPainterToCell as _applyFormatPainterToCell, 97 59 wireToolbar as _wireToolbar, 98 60 } from './toolbar-wiring.js'; 61 + import { showSortDialog as _showSortDialog, showCfModal as _showCfModal, showValidationModal as _showValidationModal } from './sheet-dialogs.js'; 62 + import { getSheetContextText as _getSheetContextText, sendChatMessage as _sendChatMessage } from './ai-chat-panel.js'; 63 + import { renderSheetTabs as _renderSheetTabs, reorderSheets as _reorderSheets } from './sheet-tabs-ui.js'; 64 + import { showPivotDialog as _showPivotDialog, renderPivots as _renderPivots } from './pivot-ui.js'; 65 + import { clearGridHighlights } from './range-highlight.js'; 66 + import { hideTooltip } from './formula-tooltip.js'; 67 + // ── Phase 8 extracted modules ─────────────────────────────── 68 + import { renderImageCells as _renderImageCellsUI, wireImageCells as _wireImageCells } from './image-cells-ui.js'; 69 + import { updateMergeButtonState as _updateMergeButtonStateUI, wireMergeButton as _wireMergeButton } from './merge-cells-ui.js'; 70 + import { wireVersionPanel as _wireVersionPanel, wireDocTitle as _wireDocTitle } from './version-history-ui.js'; 71 + import { wireRichCellClick as _wireRichCellClick, wireValidationDropdown as _wireValidationDropdown } from './grid-click-handlers.js'; 72 + import { 73 + isRowHidden as _isRowHidden, isColHidden as _isColHidden, 74 + hideSelectedRows as _hideSelectedRowsUI, hideSelectedCols as _hideSelectedColsUI, 75 + unhideAdjacentRows as _unhideAdjacentRowsUI, unhideAdjacentCols as _unhideAdjacentColsUI, 76 + buildHiddenRowSet as _buildHiddenRowSet, buildHiddenColSet as _buildHiddenColSet, 77 + } from './hidden-rows-cols-ui.js'; 78 + import { attachPreSyncObservers as _attachPreSyncObservers, wireSyncEvent as _wireSyncEvent } from './sync-observers.js'; 99 79 100 80 // --- Constants --- 101 81 const DEFAULT_ROWS = 100; 102 82 const DEFAULT_COLS = 26; 103 - const DEFAULT_COL_WIDTH = 96; // px (was 6rem) 104 - const MIN_COL_WIDTH = 40; // px minimum column width 105 - const ROW_HEADER_WIDTH = 48; // px (3rem) 83 + const DEFAULT_COL_WIDTH = 96; 84 + const MIN_COL_WIDTH = 40; 85 + const ROW_HEADER_WIDTH = 48; 106 86 107 87 // --- Clipboard buffer for paste-special operations --- 108 - // Stores the last copied grid data so paste-special can transform it 109 88 let _clipboardBuffer: Array<Array<{ value: any; formula: string; style: Record<string, any> }>> | null = null; 110 89 111 90 // --- Resolve document ID and encryption key --- 112 - // URL format: /sheets/{docId}#{base64urlKey} 113 91 const pathParts = location.pathname.split('/').filter(Boolean); 114 92 const docId = pathParts[1]; 115 93 const hash = location.hash.slice(1); ··· 124 102 localStorage.removeItem('crypt-username'); 125 103 } 126 104 127 - // Resolve key: URL hash > localStorage > server (cross-device sync) 128 105 const storedKeysInit = getLocalKeys(); 129 106 let keyString = hash || storedKeysInit[docId]; 130 107 131 108 if (!keyString) { 132 109 const serverKeys = await fetchServerKeys(); 133 - if (serverKeys?.[docId]) { 134 - keyString = serverKeys[docId]; 135 - } 110 + if (serverKeys?.[docId]) { keyString = serverKeys[docId]; } 136 111 } 137 112 138 113 if (!docId || !keyString) { ··· 148 123 // --- Yjs setup --- 149 124 const ydoc = new Y.Doc(); 150 125 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 151 - 152 - // Wait for snapshot to load before touching ydoc — prevents CRDT conflict 153 - // where ensureSheet() creates an empty sheet that overwrites loaded data 154 126 await provider.whenReady; 155 127 156 - // Yjs shared types for spreadsheet 157 128 const ySheets = ydoc.getMap('sheets'); 158 129 let activeSheetIdx = 0; 159 130 160 - // Initialize default sheet if empty 161 131 function ensureSheet(idx) { 162 132 const key = `sheet_${idx}`; 163 133 if (!ySheets.has(key)) { ··· 172 142 return ySheets.get(key); 173 143 } 174 144 175 - function getActiveSheet() { 176 - return ensureSheet(activeSheetIdx); 177 - } 178 - 179 - function getCells() { 180 - return getActiveSheet().get('cells'); 181 - } 145 + function getActiveSheet() { return ensureSheet(activeSheetIdx); } 146 + function getCells() { return getActiveSheet().get('cells'); } 182 147 183 - // --- Column width helpers (synced via Yjs) --- 148 + // --- Column width helpers --- 184 149 function getColWidths() { 185 150 const sheet = getActiveSheet(); 186 - if (!sheet.has('colWidths')) { 187 - sheet.set('colWidths', new Y.Map()); 188 - } 151 + if (!sheet.has('colWidths')) sheet.set('colWidths', new Y.Map()); 189 152 return sheet.get('colWidths'); 190 153 } 191 - 192 154 function getColWidth(col) { 193 - const widths = getColWidths(); 194 - const w = widths.get(String(col)); 155 + const w = getColWidths().get(String(col)); 195 156 return (typeof w === 'number' && w >= MIN_COL_WIDTH) ? w : DEFAULT_COL_WIDTH; 196 157 } 197 - 198 - function setColWidth(col, width) { 199 - const clamped = Math.max(MIN_COL_WIDTH, Math.round(width)); 200 - getColWidths().set(String(col), clamped); 201 - } 202 - 203 - // --- Freeze pane state (synced via Yjs) --- 204 - function getFreezeRows() { 205 - const sheet = getActiveSheet(); 206 - return sheet.get('freezeRows') || 0; 207 - } 208 - 209 - function getFreezeCols() { 210 - const sheet = getActiveSheet(); 211 - return sheet.get('freezeCols') || 0; 212 - } 213 - 214 - function setFreezeRows(n) { 215 - getActiveSheet().set('freezeRows', n); 216 - } 158 + function setColWidth(col, width) { getColWidths().set(String(col), Math.max(MIN_COL_WIDTH, Math.round(width))); } 217 159 218 - function setFreezeCols(n) { 219 - getActiveSheet().set('freezeCols', n); 220 - } 160 + // --- Freeze pane state --- 161 + function getFreezeRows() { return getActiveSheet().get('freezeRows') || 0; } 162 + function getFreezeCols() { return getActiveSheet().get('freezeCols') || 0; } 163 + function setFreezeRows(n) { getActiveSheet().set('freezeRows', n); } 164 + function setFreezeCols(n) { getActiveSheet().set('freezeCols', n); } 221 165 222 - // --- Conditional Formatting rules (synced via Yjs) --- 166 + // --- Conditional Formatting rules --- 223 167 function getCfRules() { 224 168 const sheet = getActiveSheet(); 225 169 if (!sheet.has('cfRules')) sheet.set('cfRules', new Y.Array()); 226 170 return sheet.get('cfRules'); 227 171 } 228 - 229 172 function getCfRulesArray() { 230 173 const yArr = getCfRules(); 231 174 const rules = []; 232 - for (let i = 0; i < yArr.length; i++) { 233 - try { rules.push(JSON.parse(yArr.get(i))); } catch {} 234 - } 175 + for (let i = 0; i < yArr.length; i++) { try { rules.push(JSON.parse(yArr.get(i))); } catch {} } 235 176 return rules; 236 177 } 237 178 238 - // --- Data Validation rules (synced via Yjs) --- 179 + // --- Data Validation rules --- 239 180 function getValidations() { 240 181 const sheet = getActiveSheet(); 241 182 if (!sheet.has('validations')) sheet.set('validations', new Y.Map()); 242 183 return sheet.get('validations'); 243 184 } 244 - 245 185 function getValidationForCell(id) { 246 186 const validations = getValidations(); 247 - if (validations.has(id)) { 248 - try { return JSON.parse(validations.get(id)); } catch {} 249 - } 187 + if (validations.has(id)) { try { return JSON.parse(validations.get(id)); } catch {} } 250 188 return null; 251 189 } 252 190 253 - // --- Striped rows setting (synced via Yjs) --- 254 - function getStripedRows() { 255 - return getActiveSheet().get('stripedRows') || false; 256 - } 257 - 258 - function setStripedRows(enabled) { 259 - getActiveSheet().set('stripedRows', !!enabled); 260 - } 261 - 262 - // --- Hidden rows/cols state (synced via Yjs) --- 263 - function getHiddenRows() { 264 - const sheet = getActiveSheet(); 265 - if (!sheet.has('hiddenRows')) sheet.set('hiddenRows', new Y.Map()); 266 - return sheet.get('hiddenRows'); 267 - } 268 - 269 - function getHiddenCols() { 270 - const sheet = getActiveSheet(); 271 - if (!sheet.has('hiddenCols')) sheet.set('hiddenCols', new Y.Map()); 272 - return sheet.get('hiddenCols'); 273 - } 274 - 275 - function isRowHidden(row) { 276 - return getHiddenRows().get(String(row)) === true; 277 - } 278 - 279 - function isColHidden(col) { 280 - return getHiddenCols().get(String(col)) === true; 281 - } 282 - 283 - function setRowHidden(row, hidden) { 284 - const yMap = getHiddenRows(); 285 - if (hidden) yMap.set(String(row), true); 286 - else if (yMap.has(String(row))) yMap.delete(String(row)); 287 - } 288 - 289 - function setColHidden(col, hidden) { 290 - const yMap = getHiddenCols(); 291 - if (hidden) yMap.set(String(col), true); 292 - else if (yMap.has(String(col))) yMap.delete(String(col)); 293 - } 294 - 295 - function hideSelectedRows() { 296 - if (!selectionRange) return; 297 - const { startRow, endRow } = normalizeRange(selectionRange); 298 - ydoc.transact(() => { 299 - for (let r = startRow; r <= endRow; r++) setRowHidden(r, true); 300 - }); 301 - renderGrid(); 302 - } 303 - 304 - function hideSelectedCols() { 305 - if (!selectionRange) return; 306 - const { startCol, endCol } = normalizeRange(selectionRange); 307 - ydoc.transact(() => { 308 - for (let c = startCol; c <= endCol; c++) setColHidden(c, true); 309 - }); 310 - renderGrid(); 311 - } 312 - 313 - function unhideAdjacentRows(row) { 314 - const sheet = getActiveSheet(); 315 - const totalRows = sheet.get('rowCount') || DEFAULT_ROWS; 316 - const hiddenSet = { has: (r) => isRowHidden(r) }; 317 - const adjacent = getAdjacentHiddenRows(row, totalRows, hiddenSet); 318 - if (adjacent.length === 0) return; 319 - ydoc.transact(() => { for (const r of adjacent) setRowHidden(r, false); }); 320 - renderGrid(); 321 - } 322 - 323 - function unhideAdjacentCols(col) { 324 - const sheet = getActiveSheet(); 325 - const totalCols = sheet.get('colCount') || DEFAULT_COLS; 326 - const hiddenSet = { has: (c) => isColHidden(c) }; 327 - const adjacent = getAdjacentHiddenCols(col, totalCols, hiddenSet); 328 - if (adjacent.length === 0) return; 329 - ydoc.transact(() => { for (const c of adjacent) setColHidden(c, false); }); 330 - renderGrid(); 331 - } 191 + // --- Striped rows --- 192 + function getStripedRows() { return getActiveSheet().get('stripedRows') || false; } 193 + function setStripedRows(enabled) { getActiveSheet().set('stripedRows', !!enabled); } 332 194 333 - /** Build a HiddenSet adapter for the pure functions */ 334 - function buildHiddenRowSet() { 335 - return { has: (r) => isRowHidden(r) }; 195 + // --- Hidden rows/cols (extracted to hidden-rows-cols-ui.ts) --- 196 + function isRowHidden(row) { return _isRowHidden(getActiveSheet, row); } 197 + function isColHidden(col) { return _isColHidden(getActiveSheet, col); } 198 + function _hiddenDeps() { 199 + return { ydoc, getActiveSheet, getSelectionRange: () => selectionRange, renderGrid, DEFAULT_ROWS, DEFAULT_COLS }; 336 200 } 201 + function hideSelectedRows() { _hideSelectedRowsUI(_hiddenDeps()); } 202 + function hideSelectedCols() { _hideSelectedColsUI(_hiddenDeps()); } 203 + function unhideAdjacentRows(row) { _unhideAdjacentRowsUI(_hiddenDeps(), row); } 204 + function unhideAdjacentCols(col) { _unhideAdjacentColsUI(_hiddenDeps(), col); } 205 + function buildHiddenRowSet() { return _buildHiddenRowSet(getActiveSheet); } 206 + function buildHiddenColSet() { return _buildHiddenColSet(getActiveSheet); } 337 207 338 - function buildHiddenColSet() { 339 - return { has: (c) => isColHidden(c) }; 340 - } 341 - 342 - // --- Row heights (synced via Yjs) --- 208 + // --- Row heights --- 343 209 function getRowHeights() { 344 210 const sheet = getActiveSheet(); 345 211 if (!sheet.has('rowHeights')) sheet.set('rowHeights', new Y.Map()); 346 212 return sheet.get('rowHeights'); 347 213 } 348 - 349 214 function getRowHeight(row) { 350 - const heights = getRowHeights(); 351 - const h = heights.get(String(row)); 352 - return (typeof h === 'number' && h >= 14) ? h : 26; // default 26px 353 - } 354 - 355 - function setRowHeight(row, height) { 356 - const clamped = Math.max(14, Math.round(height)); 357 - getRowHeights().set(String(row), clamped); 215 + const h = getRowHeights().get(String(row)); 216 + return (typeof h === 'number' && h >= 14) ? h : 26; 358 217 } 218 + function setRowHeight(row, height) { getRowHeights().set(String(row), Math.max(14, Math.round(height))); } 359 219 360 - // Yjs UndoManager for undo/redo 220 + // Yjs UndoManager 361 221 const undoManager = new Y.UndoManager(ySheets); 362 222 363 223 // --- State --- ··· 367 227 let isSelecting = false; 368 228 let isFillDragging = false; 369 229 let fillPreviewRange = null; 370 - 371 - // --- Find & Replace state (managed by find-replace-bar.ts) --- 372 230 const sheetsFindState = getSheetsFindState(); 373 231 let findReplaceBarVisible = false; 374 232 375 - // --- Merge helpers (#11) --- 233 + // --- Merge helpers --- 376 234 function getMerges() { 377 235 const sheet = getActiveSheet(); 378 236 if (!sheet.has('merges')) sheet.set('merges', new Y.Map()); 379 237 return sheet.get('merges'); 380 238 } 381 - 382 - // buildMergeMap and findCellMerge extracted to merge-utils.ts 383 - // Wrappers that pass Yjs data: 384 - function _buildMergeMap() { 385 - return buildMergeMap(getMerges().entries()); 386 - } 387 - function isCellMerged(col, row) { 388 - return findCellMerge(col, row, getMerges().entries()); 389 - } 239 + function _buildMergeMap() { return buildMergeMap(getMerges().entries()); } 240 + function isCellMerged(col, row) { return findCellMerge(col, row, getMerges().entries()); } 390 241 391 - // --- Hidden canvas for text measurement (auto-fit) --- 242 + // --- Measurement + DOM refs --- 392 243 const measureCanvas = document.createElement('canvas'); 393 244 const measureCtx = measureCanvas.getContext('2d'); 394 - 395 - // --- DOM refs --- 396 245 const grid = document.getElementById('sheet-grid'); 397 246 const cellAddressInput = document.getElementById('cell-address'); 398 247 const formulaInput = document.getElementById('formula-input') as HTMLInputElement; 399 248 const sheetContainer = document.getElementById('sheet-container'); 400 249 const sheetTabsContainer = document.getElementById('sheet-tabs'); 401 250 402 - // --- Grid rendering — extracted to grid-rendering.ts --- 251 + // --- Grid rendering (extracted to grid-rendering.ts) --- 403 252 function _gridRenderingDeps() { 404 253 return { 405 254 grid, getActiveSheet, getCellData, computeDisplayValue, ··· 434 283 function setCellData(id, data) { 435 284 const cells = getCells(); 436 285 let cell; 437 - if (cells.has(id)) { 438 - cell = cells.get(id); 439 - } else { 440 - cell = new Y.Map(); 441 - cells.set(id, cell); 442 - } 443 - // Ensure only primitives are stored — objects cause [object Object] display 286 + if (cells.has(id)) { cell = cells.get(id); } 287 + else { cell = new Y.Map(); cells.set(id, cell); } 444 288 let v = data.v; 445 289 if (v instanceof Date) v = v.getTime(); 446 290 else if (typeof v === 'object' && v !== null) { ··· 454 298 if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 455 299 } 456 300 457 - function getFormulaErrorTooltip(value: string): string | null { 458 - const info = getErrorInfo(value); 459 - return info ? formatErrorTooltip(info) : null; 460 - } 461 - 462 301 function computeDisplayValue(id, cellData) { 463 302 if (!cellData) { 464 - // Check if this cell is a spill target 465 303 const spillInfo = _spillState.targets.get(id); 466 304 if (spillInfo) return formatCell(spillInfo.value, undefined); 467 305 return ''; 468 306 } 469 307 if (cellData.f) { 470 308 const val = evaluateFormula(cellData.f); 471 - // Sparkline results pass through as objects for canvas rendering 472 309 if (isSparklineResult(val)) return val; 473 - // Array results: register spill and display first element 474 310 if (Array.isArray(val) && (val as any)._rangeRows) { 475 311 _registerSpill(id, val); 476 312 const spillInfo = _spillState.sources.get(id); ··· 479 315 } 480 316 return formatCell(val, cellData.s?.format); 481 317 } 482 - // Check if this cell is a spill target (cell exists but has no formula/value) 483 318 if (!cellData.v && !cellData.f) { 484 319 const spillInfo = _spillState.targets.get(id); 485 320 if (spillInfo) return formatCell(spillInfo.value, cellData.s?.format); ··· 489 324 490 325 const evalCache = new Map(); 491 326 492 - // --- Spill tracking extracted to spill-tracking.ts --- 327 + // --- Spill tracking --- 493 328 const _spillState = createSpillState(); 494 - 495 - // Wrappers that pass local state/deps: 496 329 function _clearSpillMaps() { clearSpillMaps(_spillState); } 497 330 function _registerSpill(sourceId: string, arr: unknown[]): void { 498 331 const sheet = getActiveSheet(); 499 - const maxRows = sheet.get('rowCount') || 100; 500 - const maxCols = sheet.get('colCount') || 26; 501 - registerSpill(_spillState, sourceId, arr as any, getCellData, maxRows, maxCols); 332 + registerSpill(_spillState, sourceId, arr as any, getCellData, sheet.get('rowCount') || 100, sheet.get('colCount') || 26); 502 333 } 503 334 function _isSpillSource(id: string): boolean { return isSpillSource(_spillState, id); } 504 335 function _isSpillTarget(id: string): boolean { return isSpillTarget(_spillState, id); } 505 336 506 - // --- Recalc engine integration --- 337 + // --- Recalc engine --- 507 338 function buildRecalcCellStore() { 508 339 return { 509 - get(id) { 510 - const data = getCellData(id); 511 - if (!data) return null; 512 - return { v: data.v ?? '', f: data.f || '' }; 513 - }, 514 - set(id, cell) { 515 - evalCache.set('__cell__' + id, cell.v); 516 - }, 517 - has(id) { 518 - return getCellData(id) !== null; 519 - }, 340 + get(id) { const data = getCellData(id); if (!data) return null; return { v: data.v ?? '', f: data.f || '' }; }, 341 + set(id, cell) { evalCache.set('__cell__' + id, cell.v); }, 342 + has(id) { return getCellData(id) !== null; }, 520 343 entries() { 521 - const cells = getCells(); 522 - const result = []; 523 - cells.forEach((yCell, id) => { 524 - const f = yCell.get('f') || ''; 525 - const v = yCell.get('v') ?? ''; 526 - result.push([id, { v, f }]); 527 - }); 344 + const cells = getCells(); const result = []; 345 + cells.forEach((yCell, id) => { result.push([id, { v: yCell.get('v') ?? '', f: yCell.get('f') || '' }]); }); 528 346 return result[Symbol.iterator](); 529 347 }, 530 348 getAllFormulaCells() { 531 - const cells = getCells(); 532 - const result = []; 533 - cells.forEach((yCell, id) => { 534 - const f = yCell.get('f') || ''; 535 - if (f) result.push([id, { v: yCell.get('v') ?? '', f }]); 536 - }); 349 + const cells = getCells(); const result = []; 350 + cells.forEach((yCell, id) => { const f = yCell.get('f') || ''; if (f) result.push([id, { v: yCell.get('v') ?? '', f }]); }); 537 351 return result; 538 352 }, 539 353 }; 540 354 } 541 355 542 356 let recalcEngine = null; 543 - 544 357 function getRecalcEngine() { 545 - if (!recalcEngine) { 546 - recalcEngine = new RecalcEngine(buildRecalcCellStore()); 547 - recalcEngine.buildFullGraph(); 548 - } 358 + if (!recalcEngine) { recalcEngine = new RecalcEngine(buildRecalcCellStore()); recalcEngine.buildFullGraph(); } 549 359 return recalcEngine; 550 360 } 551 - 552 - function invalidateRecalcEngine() { 553 - recalcEngine = null; 554 - } 361 + function invalidateRecalcEngine() { recalcEngine = null; } 555 362 556 363 function evaluateFormula(formula) { 557 364 if (evalCache.has(formula)) return evalCache.get(formula); ··· 567 374 return result; 568 375 } 569 376 570 - function getCellClasses(col, row, cellData) { 571 - const classes = []; 572 - if (selectedCell.col === col && selectedCell.row === row) classes.push('selected'); 573 - if (editingCell && editingCell.col === col && editingCell.row === row) classes.push('editing'); 574 - if (_isInRange(col, row)) classes.push('in-range'); 575 - return classes.join(' '); 576 - } 577 - 578 - // normalizeRange and isInRange extracted to selection-utils.ts 579 - // Wrapper that captures local selectionRange: 580 - function _isInRange(col, row) { 581 - return isInRange(col, row, selectionRange); 582 - } 583 - 584 - // hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle 585 - // extracted to cell-style-utils.ts (imported above) 377 + function _isInRange(col, row) { return isInRange(col, row, selectionRange); } 586 378 587 379 // --- Grid events --- 588 380 function attachGridEvents() { 589 381 grid.addEventListener('mousedown', onGridMouseDown); 590 382 grid.addEventListener('dblclick', onGridDblClick); 591 - // Touch support for mobile/tablet (#148) 592 383 grid.addEventListener('touchstart', onGridTouchStart, { passive: false }); 593 384 } 594 385 595 - // Touch events — extracted to touch-events.ts 386 + // --- Dep factories for extracted modules --- 387 + function _mouseEventsDeps() { 388 + return { 389 + grid, sheetContainer, measureCtx, getActiveSheet, 390 + getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 391 + getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 392 + setIsSelecting: (v) => { isSelecting = v; }, 393 + getEditingCell: () => editingCell, commitEdit, startEditing, 394 + getCellData, setCellData, computeDisplayValue, 395 + getColWidth, setColWidth, getRowHeight, setRowHeight, 396 + getCellEl, getFormatPainterFormat, applyFormatPainterToCell, 397 + updateSelectionVisuals, updateFormulaBar, updateMergeButtonState, 398 + unhideAdjacentRows, unhideAdjacentCols, renderGrid, refreshVisibleCells, 399 + autoFitColumn, autoFitRow, ydoc, evalCache: { clear: () => evalCache.clear() }, 400 + clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 401 + getFillPreviewRange: () => fillPreviewRange, setFillPreviewRange: (r) => { fillPreviewRange = r; }, 402 + setIsFillDragging: (v) => { isFillDragging = v; }, 403 + DEFAULT_ROWS, DEFAULT_COLS, 404 + }; 405 + } 406 + 407 + function _cellEditingDeps() { 408 + return { 409 + grid, formulaInput, cellAddressInput, 410 + getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 411 + getEditingCell: () => editingCell, setEditingCell: (c) => { editingCell = c; }, 412 + getCellData, setCellData, computeDisplayValue, 413 + evalCache: { clear: () => evalCache.clear() }, 414 + clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 415 + moveSelection, updateFormulaHighlight, updateFormulaRangeHighlights, 416 + updateFormulaTooltip, hideAutocomplete, attachCellEditorAutocomplete, 417 + }; 418 + } 419 + 420 + function _touchEventsDeps() { 421 + return { 422 + grid, getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 423 + getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 424 + setIsSelecting: (v) => { isSelecting = v; }, 425 + getEditingCell: () => editingCell, commitEdit, startEditing, 426 + getColWidth, setColWidth, getRowHeight, setRowHeight, 427 + updateSelectionVisuals, updateFormulaBar, updateMergeButtonState, 428 + renderGrid, MIN_COL_WIDTH, 429 + }; 430 + } 431 + 432 + // Touch/mouse/editing wrappers 596 433 function onGridTouchStart(e) { _onGridTouchStart(_touchEventsDeps(), e); } 597 434 _wireTouchDoubleTap(_touchEventsDeps()); 598 - 599 - // onGridMouseDown, onGridDblClick, column/row resize, auto-fit, cell mouse down, 600 - // drag-to-fill, onCellDblClick — extracted to mouse-events.ts 601 435 function onGridMouseDown(e) { _onGridMouseDown(_mouseEventsDeps(), e); } 602 436 function onGridDblClick(e) { _onGridDblClick(_mouseEventsDeps(), e); } 603 437 function autoFitColumn(col) { _autoFitColumn(_mouseEventsDeps(), col); } 604 438 function autoFitRow(row) { _autoFitRow(_mouseEventsDeps(), row); } 605 - 606 - // startEditing, commitEdit, onEditKeyDown — extracted to cell-editing.ts 607 439 function startEditing(col, row) { _startEditingCE(_cellEditingDeps(), col, row); } 608 440 function commitEdit() { _commitEditCE(_cellEditingDeps()); } 609 441 610 442 // --- Selection & navigation (extracted to selection-navigation.ts) --- 611 443 function _selNavDeps() { 612 444 return { 613 - grid, cellAddressInput, sheetContainer, 614 - getActiveSheet, getSelectedCell: () => selectedCell, 615 - setSelectedCell: (c) => { selectedCell = c; }, 616 - getSelectionRange: () => selectionRange, 617 - setSelectionRange: (r) => { selectionRange = r; }, 445 + grid, cellAddressInput, sheetContainer, getActiveSheet, 446 + getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 447 + getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 618 448 getCells, getColWidth, getRowHeight, 619 449 updateFormulaBar, updateStatusBar, updateMergeButtonState, 620 450 updateWrapButtonState, updateBoldButtonState, updateItalicButtonState, ··· 628 458 function moveSelectionTo(col, row) { _moveSelectionTo(_selNavDeps(), col, row); } 629 459 function getDataExtent() { return _getDataExtent(_selNavDeps()); } 630 460 function scrollCellIntoView(col, row) { _scrollCellIntoView(_selNavDeps(), col, row); } 631 - function getCellEl(col: number, row: number): Element | null { 632 - return _getCellEl(grid, col, row); 633 - } 461 + function getCellEl(col: number, row: number): Element | null { return _getCellEl(grid, col, row); } 634 462 function updateSelectionVisuals() { _updateSelectionVisuals(_selNavDeps()); } 635 463 function clearPrevSelection() { _clearPrevSelection(); } 636 464 637 465 // --- Clipboard operations (extracted to clipboard-operations.ts) --- 638 466 function _clipboardDeps() { 639 467 return { 640 - ydoc, grid, 641 - getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 642 - getCellData, setCellData, getCells, 643 - evalCache: { clear: () => evalCache.clear() }, 468 + ydoc, grid, getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 469 + getCellData, setCellData, getCells, evalCache: { clear: () => evalCache.clear() }, 644 470 clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 645 - getClipboardBuffer: () => _clipboardBuffer, 646 - setClipboardBuffer: (buf) => { _clipboardBuffer = buf; }, 471 + getClipboardBuffer: () => _clipboardBuffer, setClipboardBuffer: (buf) => { _clipboardBuffer = buf; }, 647 472 }; 648 473 } 649 474 function deleteSelectedCells() { _deleteSelectedCells(_clipboardDeps()); } ··· 652 477 function pasteAtSelection(text) { _pasteAtSelection(_clipboardDeps(), text); } 653 478 function showPasteSpecialDialog() { _showPasteSpecialDialogCO(_clipboardDeps()); } 654 479 655 - // --- Keyboard navigation (extracted to keyboard-handler.ts) --- 480 + // --- Keyboard handler --- 656 481 _wireKeyboardHandler({ 657 482 grid, formulaInput, sheetContainer, provider, 658 - getSelectedCell: () => selectedCell, 659 - setSelectedCell: (c) => { selectedCell = c; }, 660 - getSelectionRange: () => selectionRange, 661 - setSelectionRange: (r) => { selectionRange = r; }, 483 + getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 484 + getSelectionRange: () => selectionRange, setSelectionRange: (r) => { selectionRange = r; }, 662 485 getActiveSheet, getCellData, setCellData, 663 486 getEditingCell: () => editingCell, startEditing, 664 487 moveSelection, extendSelection, moveSelectionTo, getDataExtent, ··· 669 492 undoManager, evalCache: { clear: () => evalCache.clear() }, 670 493 clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, renderGrid, 671 494 hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 672 - toggleFilterMode, printSheet, showFindReplaceBar, 673 - DEFAULT_COLS, DEFAULT_ROWS, 495 + toggleFilterMode, printSheet, showFindReplaceBar, DEFAULT_COLS, DEFAULT_ROWS, 674 496 }); 675 - 676 - // Paste event listener (extracted to clipboard-operations.ts) 677 497 _wirePasteListener(_clipboardDeps(), { getEditingCell: () => editingCell, formulaInput }); 678 498 679 - // updateFormulaBar — extracted to cell-editing.ts 680 499 function updateFormulaBar() { _updateFormulaBarCE(_cellEditingDeps()); } 681 500 682 - // --- Formula bar + highlighting (extracted to formula-bar-ui.ts) --- 501 + // --- Formula bar + highlighting --- 683 502 const formulaHighlightLayer = document.getElementById('formula-highlight-layer'); 684 - 685 503 function _formulaBarDeps() { 686 504 return { 687 505 formulaInput, formulaHighlightLayer, grid, ··· 699 517 function commitFormulaBar() { _commitFormulaBar(_formulaBarDeps()); } 700 518 _wireFormulaBarKeys(_formulaBarDeps()); 701 519 702 - // --- Toolbar (extracted to toolbar-wiring.ts) --- 520 + // --- Toolbar --- 703 521 function _toolbarDeps() { 704 522 return { 705 523 ydoc, grid, getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, ··· 713 531 function applyStyleToSelection(styleProp, value) { _applyStyleToSelection(_toolbarDeps(), styleProp, value); } 714 532 function clearFormattingSelection() { _clearFormattingSelection(_toolbarDeps()); } 715 533 function closeAllDropdowns() { _closeAllDropdowns(); } 534 + function sortColumn(col, asc) { _sortColumn(_toolbarDeps(), col, asc); } 716 535 function doInsertRow(rowIndex) { _doInsertRow(_toolbarDeps(), rowIndex); } 717 536 function doDeleteRow(rowIndex) { _doDeleteRow(_toolbarDeps(), rowIndex); } 718 537 function doInsertColumn(colIndex) { _doInsertColumn(_toolbarDeps(), colIndex); } 719 538 function doDeleteColumn(colIndex) { _doDeleteColumn(_toolbarDeps(), colIndex); } 720 539 function updateFreezeToolbarState() { _updateFreezeToolbarState(_toolbarDeps()); } 540 + function getFormatPainterFormat() { return _getFormatPainterFormat(_toolbarDeps()); } 541 + function applyFormatPainterToCell(col, row) { _applyFormatPainterToCell(_toolbarDeps(), col, row); } 721 542 _wireToolbar(_toolbarDeps()); 722 543 723 - // --- Sheet tabs (extracted to sheet-tabs-ui.ts) --- 544 + // --- Sheet tabs --- 724 545 function _sheetTabsDeps() { 725 546 return { 726 547 ySheets, ydoc, getActiveSheetIdx: () => activeSheetIdx, 727 548 setActiveSheetIdx: (idx: number) => { activeSheetIdx = idx; }, 728 549 ensureSheet, evalCache, clearSpillMaps: _clearSpillMaps, 729 - invalidateRecalcEngine, renderGrid, hideActiveContextMenu, 730 - setActiveContextMenu, 731 - sheetTabsContainer, 550 + invalidateRecalcEngine, renderGrid, hideActiveContextMenu, setActiveContextMenu, sheetTabsContainer, 732 551 }; 733 552 } 734 553 function renderSheetTabs() { _renderSheetTabs(_sheetTabsDeps()); } 735 - function reorderSheets(fromIdx: number, toIdx: number) { _reorderSheets(_sheetTabsDeps(), fromIdx, toIdx); } 736 554 737 555 document.getElementById('add-sheet').addEventListener('click', () => { 738 556 let count = 0; ··· 740 558 ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 741 559 }); 742 560 743 - // --- Document title --- 744 - const titleInput = document.getElementById('doc-title'); 745 - async function loadTitle() { 746 - try { 747 - const res = await fetch('/api/documents/' + docId); 748 - if (!res.ok) return; 749 - const doc = await res.json(); 750 - if (doc.name_encrypted) { 751 - const encBytes = Uint8Array.from(atob(doc.name_encrypted), c => c.charCodeAt(0)); 752 - titleInput.value = await decryptString(encBytes, cryptoKey); 753 - } 754 - } catch {} 755 - } 756 - loadTitle(); 757 - 758 - titleInput.addEventListener('focus', () => { 759 - (titleInput as HTMLInputElement).select(); 760 - }); 761 - 762 - let titleSaveTimeout; 763 - titleInput.addEventListener('input', () => { 764 - clearTimeout(titleSaveTimeout); 765 - titleSaveTimeout = setTimeout(async () => { 766 - const encrypted = await encryptString(titleInput.value, cryptoKey); 767 - const b64 = btoa(String.fromCharCode(...encrypted)); 768 - fetch('/api/documents/' + docId + '/name', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name_encrypted: b64 }) }) 769 - .then(r => { if (!r.ok) throw new Error(); }) 770 - .catch(() => showToast('Failed to save title')); 771 - }, 500); 772 - }); 561 + // --- Document title + version panel (extracted to version-history-ui.ts) --- 562 + const titleInput = document.getElementById('doc-title') as HTMLInputElement; 563 + _wireDocTitle({ docId, cryptoKey, ydoc, provider }); 773 564 774 - // --- Connection status (extracted to collaboration-ui.ts) --- 565 + // --- Connection + collaboration --- 775 566 _wireConnectionStatus({ provider, ydoc }); 776 - provider.on('sync', () => { 777 - const _st = document.getElementById('status-text'); if (_st) _st.textContent = 'Synced'; 778 - // Re-attach ALL observers after sync — the snapshot may have replaced the Y.Map/Y.Array 779 - // objects that were observed during initial setup (before data loaded from peers) 780 - getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 781 - ySheets.observe(() => { renderSheetTabs(); }); 782 - ySheets.observeDeep((events) => { 783 - for (const event of events) { 784 - if (event.target && event.target === getColWidths()) { scheduleRenderGrid(); return; } 785 - const changed = event.changes?.keys; 786 - if (changed) { 787 - for (const [key] of changed) { 788 - if (key === 'freezeRows' || key === 'freezeCols' || key === 'stripedRows') { scheduleRenderGrid(); return; } 789 - } 790 - } 791 - if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 792 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 793 - } 794 - } 795 - }); 796 - getCharts().observeDeep(() => { renderCharts(); }); 797 - getNotesMap().observe(() => renderNoteIndicators()); 798 - renderGrid(); 799 567 800 - // Check for pending file import from landing page drag-and-drop 801 - const pendingKey = `pending-import-${docId}`; 802 - const pendingRaw = sessionStorage.getItem(pendingKey); 803 - if (pendingRaw) { 804 - sessionStorage.removeItem(pendingKey); 805 - try { 806 - const pending = JSON.parse(pendingRaw); 807 - // Set import-in-progress flag to prevent snapshot saves during async import 808 - window.__importInProgress = true; 809 - // Convert data URL back to a File object and await the full import 810 - fetch(pending.data) 811 - .then(r => r.blob()) 812 - .then(async blob => { 813 - const file = new File([blob], pending.name, { type: blob.type }); 814 - await handleImportFile(file); 815 - }) 816 - .finally(async () => { 817 - window.__importInProgress = false; 818 - // Force save now that import flag is cleared — handleImportFile's save 819 - // was blocked by __importInProgress, so we need to save here 820 - await provider._saveSnapshot(); 821 - }); 822 - } catch { 823 - window.__importInProgress = false; 824 - } 825 - } 826 - 827 - // Check for sheet template content 828 - const tmplKey = `template-content-${docId}`; 829 - const tmplContent = sessionStorage.getItem(tmplKey); 830 - const tmplType = sessionStorage.getItem(`template-type-${docId}`); 831 - if (tmplContent && tmplType === 'sheet') { 832 - sessionStorage.removeItem(tmplKey); 833 - sessionStorage.removeItem(`template-type-${docId}`); 834 - try { 835 - const cellMap = JSON.parse(tmplContent); 836 - const cells = getCells(); 837 - if (cells.size === 0) { 838 - ydoc.transact(() => { 839 - for (const [cellId, data] of Object.entries(cellMap)) { 840 - setCellData(cellId, data as any); 841 - } 842 - }); 843 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 844 - } 845 - } catch { /* ignore invalid template */ } 846 - } 847 - }); 568 + // --- Sync observers (extracted to sync-observers.ts) --- 569 + function _syncObsDeps() { 570 + return { 571 + docId, ydoc, provider, ySheets, getCells, getCellData, setCellData, getColWidths, getCfRules, getValidations, 572 + getCharts, getNotesMap, evalCache: { clear: () => evalCache.clear() }, 573 + clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 574 + scheduleRenderGrid, renderGrid, renderSheetTabs, renderCharts, renderNoteIndicators, 575 + refreshVisibleCells, updateFormulaBar, handleImportFile, 576 + }; 577 + } 578 + _wireSyncEvent(_syncObsDeps()); 579 + _attachPreSyncObservers(_syncObsDeps()); 848 580 849 - // --- Collaboration avatars (extracted to collaboration-ui.ts) --- 850 581 _setupCollabAvatars({ provider, ydoc }); 851 582 852 - // --- Export/Import/Print (extracted to import-export.ts) --- 583 + // --- Export/Import/Print --- 853 584 function _importExportDeps() { 854 585 return { 855 586 getActiveSheet, getCellData, setCellData, getCells, computeDisplayValue, ··· 863 594 function printSheet() { _printSheet(_importExportDeps()); } 864 595 _wireImportExportToolbar(_importExportDeps(), closeAllDropdowns); 865 596 866 - // --- React to Yjs changes --- 867 - getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 868 - ySheets.observe(() => { renderSheetTabs(); }); 869 - 870 - // Re-render when colWidths, freeze state, CF rules, validations, or stripedRows change from remote collaborators 871 - ySheets.observeDeep((events) => { 872 - for (const event of events) { 873 - if (event.target && event.target === getColWidths()) { scheduleRenderGrid(); return; } 874 - const changed = event.changes?.keys; 875 - if (changed) { 876 - for (const [key] of changed) { 877 - if (key === 'freezeRows' || key === 'freezeCols' || key === 'stripedRows') { scheduleRenderGrid(); return; } 878 - } 879 - } 880 - // CF rules or validations changed 881 - if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 882 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 883 - } 884 - } 885 - }); 886 - 887 - // --- Cell Merging (#11) --- 888 - function mergeCells() { 889 - if (!selectionRange) return; 890 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 891 - if (startCol === endCol && startRow === endRow) return; 892 - const merges = getMerges(); 893 - const mergeKey = cellId(startCol, startRow); 894 - if (merges.has(mergeKey)) { 895 - ydoc.transact(() => { merges.delete(mergeKey); }); 896 - updateMergeButtonState(); renderGrid(); return; 897 - } 898 - ydoc.transact(() => { 899 - const keysToDelete = []; 900 - merges.forEach((mergeData, key) => { 901 - const m = typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData; 902 - if (m.startCol <= endCol && m.endCol >= startCol && m.startRow <= endRow && m.endRow >= startRow) keysToDelete.push(key); 903 - }); 904 - keysToDelete.forEach(k => merges.delete(k)); 905 - const cells = getCells(); 906 - for (let r = startRow; r <= endRow; r++) { 907 - for (let c = startCol; c <= endCol; c++) { 908 - if (c === startCol && r === startRow) continue; 909 - const id = cellId(c, r); 910 - if (cells.has(id)) cells.delete(id); 911 - } 912 - } 913 - merges.set(mergeKey, JSON.stringify({ startCol, startRow, endCol, endRow })); 914 - }); 915 - updateMergeButtonState(); renderGrid(); 916 - } 917 - 918 - function updateMergeButtonState() { 919 - const mergeBtn = document.getElementById('tb-merge'); 920 - if (!mergeBtn) return; 921 - const merge = isCellMerged(selectedCell.col, selectedCell.row); 922 - if (merge) { 923 - mergeBtn.classList.add('merge-active'); 924 - mergeBtn.dataset.tooltip = 'Unmerge cells'; 925 - } else { 926 - mergeBtn.classList.remove('merge-active'); 927 - mergeBtn.dataset.tooltip = 'Merge/Unmerge cells'; 928 - } 597 + // --- Cell Merging (extracted to merge-cells-ui.ts) --- 598 + function _mergeDeps() { 599 + return { 600 + ydoc, grid, getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 601 + getMerges, getCells, getCellData, renderGrid, 602 + }; 929 603 } 930 - 931 - document.getElementById('tb-merge').addEventListener('click', () => { 932 - const merge = isCellMerged(selectedCell.col, selectedCell.row); 933 - if (merge) { 934 - const merges = getMerges(); 935 - ydoc.transact(() => { merges.delete(cellId(merge.startCol, merge.startRow)); }); 936 - updateMergeButtonState(); renderGrid(); showToast('Cells unmerged'); return; 937 - } 938 - mergeCells(); showToast('Cells merged'); 939 - }); 604 + function updateMergeButtonState() { _updateMergeButtonStateUI(_mergeDeps()); } 605 + _wireMergeButton(_mergeDeps()); 940 606 941 - // --- Autosave indicator (extracted to save-status-ui.ts) --- 607 + // --- Autosave --- 942 608 wireSaveStatus({ provider, ydoc }); 943 609 944 - 945 - // --- Version Panel (slide-in, Cmd+Shift+H) --- 946 - const sheetsVersionPanel = createVersionPanel({ 947 - docId, 948 - cryptoKey, 949 - docType: 'sheet', 950 - onRestore: async (_versionId, decryptedData) => { 951 - Y.applyUpdate(ydoc, decryptedData); 952 - await provider._saveSnapshot(); 953 - }, 954 - }); 955 - 956 - // Wire Cmd+Shift+H to toggle version panel 957 - document.addEventListener('keydown', (e) => { 958 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 959 - e.preventDefault(); 960 - sheetsVersionPanel.toggle(); 961 - } 962 - }); 963 - 964 - // Wire version history button if present 965 - const btnHistory = document.getElementById('btn-history'); 966 - if (btnHistory) { 967 - btnHistory.addEventListener('click', () => sheetsVersionPanel.toggle()); 968 - } 610 + // --- Version Panel --- 611 + _wireVersionPanel({ docId, cryptoKey, ydoc, provider }); 969 612 970 - // --- Keyboard shortcuts modal (extracted to shortcuts-modal.ts) --- 613 + // --- Shortcuts modal --- 971 614 wireShortcutButton(); 972 615 973 - // ── Charts Feature (extracted to charts-ui.ts) ── 616 + // ── Charts ── 974 617 const chartsSection = document.getElementById('charts-section'); 975 - 976 618 function getCharts() { 977 619 const sheet = getActiveSheet(); 978 620 if (!sheet.has('charts')) sheet.set('charts', new Y.Map()); 979 621 return sheet.get('charts'); 980 622 } 981 - 982 623 function _chartsDeps() { 983 - return { 984 - getActiveSheet, getCellData, evaluateFormula, getCharts, 985 - selectedCell, selectionRange, ydoc, chartsSection, 986 - }; 624 + return { getActiveSheet, getCellData, evaluateFormula, getCharts, selectedCell, selectionRange, ydoc, chartsSection }; 987 625 } 988 626 function showChartDialog(existingId?, existingConfig?) { _showChartDialogUI(_chartsDeps(), existingId, existingConfig); } 989 627 function renderCharts() { return _renderChartsUI(_chartsDeps()); } 990 - 991 628 document.getElementById('tb-chart').addEventListener('click', () => { showChartDialog(null, null); closeAllDropdowns(); }); 992 629 993 - // ======================================================== 994 - // Pivot Table Feature 995 - // ======================================================== 996 - 630 + // ── Pivots ── 997 631 const pivotSection = document.getElementById('pivot-section'); 998 - 999 632 function getPivots() { 1000 633 const sheet = getActiveSheet(); 1001 634 if (!sheet.has('pivots')) sheet.set('pivots', new Y.Map()); 1002 635 return sheet.get('pivots'); 1003 636 } 1004 - 1005 - // showPivotDialog and renderPivots extracted to pivot-ui.ts 1006 - function _pivotDeps() { 1007 - return { getActiveSheet, getCellData, getPivots, ydoc, DEFAULT_COLS, DEFAULT_ROWS, pivotSection }; 1008 - } 1009 - function showPivotDialog(existingId?: string, existingConfig?: PivotConfig & { title?: string }) { 1010 - _showPivotDialog(_pivotDeps(), existingId, existingConfig); 1011 - } 1012 - function renderPivots() { 1013 - _renderPivots(_pivotDeps()); 1014 - } 1015 - 1016 - document.getElementById('tb-pivot')!.addEventListener('click', () => { 1017 - showPivotDialog(); 1018 - closeAllDropdowns(); 1019 - }); 1020 - 1021 - // ======================================================== 1022 - // Image Cells Feature 1023 - // ======================================================== 637 + function _pivotDeps() { return { getActiveSheet, getCellData, getPivots, ydoc, DEFAULT_COLS, DEFAULT_ROWS, pivotSection }; } 638 + function showPivotDialog(existingId?: string, existingConfig?: PivotConfig & { title?: string }) { _showPivotDialog(_pivotDeps(), existingId, existingConfig); } 639 + function renderPivots() { _renderPivots(_pivotDeps()); } 640 + document.getElementById('tb-pivot')!.addEventListener('click', () => { showPivotDialog(); closeAllDropdowns(); }); 1024 641 1025 - let imageCellState: ImageCellState = createImageCellState(); 1026 - const imageUploadInput = document.getElementById('image-upload-input') as HTMLInputElement; 1027 - const imageCache = new Map<string, string>(); // blobId → objectURL 642 + // ── Image Cells (extracted to image-cells-ui.ts) ── 643 + function renderImageCells() { _renderImageCellsUI(grid); } 644 + _wireImageCells({ grid, getSelectedCell: () => selectedCell, getCellData, setCellData, renderGrid, closeAllDropdowns }); 1028 645 1029 - document.getElementById('tb-insert-image')!.addEventListener('click', () => { 1030 - imageUploadInput.click(); 1031 - closeAllDropdowns(); 1032 - }); 1033 - 1034 - imageUploadInput.addEventListener('change', async () => { 1035 - const file = imageUploadInput.files?.[0]; 1036 - if (!file) return; 1037 - imageUploadInput.value = ''; 1038 - 1039 - const targetCell = cellId(selectedCell.col, selectedCell.row); 1040 - try { 1041 - const buf = await readFileAsBuffer(file); 1042 - const docId = window.location.pathname.split('/').pop() || ''; 1043 - const result = await uploadBlob(docId, new Uint8Array(buf), file.name, file.type); 1044 - 1045 - // Get natural dimensions 1046 - const img = new Image(); 1047 - const url = blobToObjectUrl(buf, file.type); 1048 - imageCache.set(result.id, url); 1049 - 1050 - img.onload = () => { 1051 - imageCellState = setCellImage(imageCellState, targetCell, result.id, img.naturalWidth, img.naturalHeight, { alt: file.name }); 1052 - // Store image ref in cell data so it persists via Yjs 1053 - const existing = getCellData(targetCell) || {}; 1054 - setCellData(targetCell, { ...existing, img: result.id }); 1055 - renderGrid(); 1056 - }; 1057 - img.src = url; 1058 - } catch (err) { 1059 - console.error('Image upload failed:', err); 1060 - } 1061 - }); 1062 - 1063 - // Post-render: populate image cell placeholders 1064 - function renderImageCells() { 1065 - grid.querySelectorAll('.cell-image-container[data-blob-id]').forEach((el: Element) => { 1066 - const td = el as HTMLElement; 1067 - const blobId = td.dataset.blobId; 1068 - if (!blobId) return; 1069 - renderCellImage(td, blobId); 1070 - }); 1071 - } 1072 - 1073 - // Render image in cell from blob ID 1074 - function renderCellImage(td: HTMLElement, blobId: string): void { 1075 - // Check cache first 1076 - if (imageCache.has(blobId)) { 1077 - const img = document.createElement('img'); 1078 - img.src = imageCache.get(blobId)!; 1079 - img.alt = ''; 1080 - img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 1081 - td.textContent = ''; 1082 - td.appendChild(img); 1083 - return; 1084 - } 1085 - 1086 - // Load async 1087 - td.textContent = '...'; 1088 - downloadBlob(blobId).then(({ data, mimeType }) => { 1089 - const url = blobToObjectUrl(data, mimeType); 1090 - imageCache.set(blobId, url); 1091 - const img = document.createElement('img'); 1092 - img.src = url; 1093 - img.alt = ''; 1094 - img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 1095 - td.textContent = ''; 1096 - td.appendChild(img); 1097 - }).catch(() => { 1098 - td.textContent = '[img]'; 1099 - }); 1100 - } 1101 - 1102 - // ── Database Views (extracted to database-views-ui.ts) ── 646 + // ── Database Views ── 1103 647 const dbViewSection = document.getElementById('database-view-section'); 1104 - function _dbViewsDeps() { 1105 - return { getActiveSheet, getCellData, evaluateFormula, DEFAULT_ROWS, DEFAULT_COLS, dbViewSection }; 1106 - } 648 + function _dbViewsDeps() { return { getActiveSheet, getCellData, evaluateFormula, DEFAULT_ROWS, DEFAULT_COLS, dbViewSection }; } 1107 649 function showDbViewDialog() { _showDbViewDialogUI(_dbViewsDeps()); } 1108 650 function renderDbView() { _renderDbViewUI(_dbViewsDeps()); } 1109 - 1110 651 document.getElementById('tb-view-mode')!.addEventListener('click', () => { showDbViewDialog(); closeAllDropdowns(); }); 1111 652 1112 - // ── Filter UI (extracted to filter-ui.ts) ── 653 + // ── Filter ── 1113 654 function _filterDeps() { return { getActiveSheet, getCellData, grid, DEFAULT_ROWS, DEFAULT_COLS }; } 1114 655 function toggleFilterMode() { _toggleFilterMode(_filterDeps()); } 1115 656 function getFilterHiddenRows() { return _getFilterHiddenRows(_filterDeps()); } 1116 - function applyFilterToGrid() { _applyFilterToGrid(_filterDeps()); } 1117 - 1118 657 document.getElementById('tb-filter').addEventListener('click', () => { toggleFilterMode(); closeAllDropdowns(); }); 1119 658 1120 - // Sort dialog (extracted to sheet-dialogs.ts) 659 + // Sort dialog 1121 660 function showSortDialog() { 1122 661 _showSortDialog({ 1123 662 getActiveSheet, selectedCell, selectionRange, getCellData, getCells, setCellData, ··· 1129 668 1130 669 // Filter grid observer + load filter state on sync 1131 670 _setupFilterGridObserver(_filterDeps()); 1132 - provider.on('sync', () => { 1133 - _loadFilterStateFromYjs(_filterDeps()); 1134 - renderCharts(); 1135 - }); 671 + provider.on('sync', () => { _loadFilterStateFromYjs(_filterDeps()); renderCharts(); }); 1136 672 getCharts().observeDeep(() => { renderCharts(); }); 1137 673 1138 674 // --- Conditional Formatting modal --- 1139 - // showCfModal extracted to sheet-dialogs.ts 1140 675 function showCfModal() { 1141 - _showCfModal({ 1142 - getCfRulesArray, getCfRules, ydoc, evalCache, clearSpillMaps: _clearSpillMaps, 1143 - invalidateRecalcEngine, refreshVisibleCells, 1144 - }); 676 + _showCfModal({ getCfRulesArray, getCfRules, ydoc, evalCache, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells }); 1145 677 } 1146 - 1147 678 document.getElementById('tb-cf').addEventListener('click', () => { closeAllDropdowns(); showCfModal(); }); 1148 679 1149 - // showValidationModal extracted to sheet-dialogs.ts 680 + // --- Validation modal --- 1150 681 function showValidationModal() { 1151 - _showValidationModal({ 1152 - selectedCell, selectionRange, getValidationForCell, getValidations, 1153 - ydoc, applyToSelectedCells, refreshVisibleCells, renderGrid, 1154 - }); 682 + _showValidationModal({ selectedCell, selectionRange, getValidationForCell, getValidations, ydoc, applyToSelectedCells, refreshVisibleCells, renderGrid }); 1155 683 } 1156 - 1157 684 function applyToSelectedCells(fn) { 1158 - if (!selectionRange) { 1159 - fn(cellId(selectedCell.col, selectedCell.row)); 1160 - return; 1161 - } 685 + if (!selectionRange) { fn(cellId(selectedCell.col, selectedCell.row)); return; } 1162 686 const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 1163 - for (let r = startRow; r <= endRow; r++) { 1164 - for (let c = startCol; c <= endCol; c++) { 1165 - fn(cellId(c, r)); 1166 - } 1167 - } 687 + for (let r = startRow; r <= endRow; r++) for (let c = startCol; c <= endCol; c++) fn(cellId(c, r)); 1168 688 } 1169 - 1170 689 document.getElementById('tb-validation').addEventListener('click', () => { closeAllDropdowns(); showValidationModal(); }); 1171 690 1172 - // --- Rich cell click handler (checkbox toggle, star rating) --- 1173 - grid.addEventListener('click', (e) => { 1174 - const target = e.target as HTMLElement; 1175 - const richEl = target.closest('[data-rich]') as HTMLElement | null; 1176 - if (!richEl) return; 1177 - const td = richEl.closest('td'); 1178 - if (!td) return; 1179 - const cellId = td.dataset.id; 1180 - if (!cellId) return; 1181 - const cellData = getCellData(cellId); 1182 - const result = handleRichCellClick(target, cellData?.v); 1183 - if (result) { 1184 - e.stopPropagation(); 1185 - setCellData(cellId, { v: result.value, f: '', s: cellData?.s ?? {} }); 1186 - refreshVisibleCells(); 1187 - } 1188 - }); 1189 - 1190 - // --- Validation dropdown click handler --- 1191 - grid.addEventListener('click', (e) => { 1192 - const arrow = (e.target as HTMLElement).closest('.cell-dropdown-arrow'); 1193 - if (!arrow) return; 1194 - e.stopPropagation(); 1195 - const cellIdStr = arrow.dataset.dropdownCell; 1196 - const validation = getValidationForCell(cellIdStr); 1197 - if (!validation || validation.type !== 'list') return; 1198 - 1199 - // Remove any existing dropdown 1200 - document.querySelectorAll('.validation-dropdown').forEach(d => d.remove()); 1201 - 1202 - const items = getDropdownItems(validation); 1203 - if (items.length === 0) return; 1204 - 1205 - const td = arrow.closest('td'); 1206 - const rect = td.getBoundingClientRect(); 1207 - 1208 - const dropdown = document.createElement('div'); 1209 - dropdown.className = 'validation-dropdown'; 1210 - dropdown.style.left = rect.left + 'px'; 1211 - dropdown.style.top = rect.bottom + 'px'; 1212 - dropdown.style.minWidth = rect.width + 'px'; 1213 - 1214 - items.forEach(item => { 1215 - const btn = document.createElement('button'); 1216 - btn.className = 'validation-dropdown-item'; 1217 - btn.textContent = item; 1218 - btn.addEventListener('click', () => { 1219 - const numVal = Number(item); 1220 - const value = item === '' ? '' : (!isNaN(numVal) && item !== '' ? numVal : item); 1221 - setCellData(cellIdStr, { v: value, f: '' }); 1222 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1223 - refreshVisibleCells(); 1224 - dropdown.remove(); 1225 - }); 1226 - dropdown.appendChild(btn); 1227 - }); 1228 - 1229 - document.body.appendChild(dropdown); 1230 - 1231 - // Close on click outside 1232 - const closeDropdown = (ev) => { 1233 - if (!dropdown.contains(ev.target)) { 1234 - dropdown.remove(); 1235 - document.removeEventListener('click', closeDropdown); 1236 - } 691 + // --- Grid click handlers (extracted to grid-click-handlers.ts) --- 692 + function _gridClickDeps() { 693 + return { 694 + grid, getCellData, setCellData, getValidationForCell, 695 + evalCache: { clear: () => evalCache.clear() }, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 1237 696 }; 1238 - setTimeout(() => document.addEventListener('click', closeDropdown), 0); 1239 - }); 697 + } 698 + _wireRichCellClick(_gridClickDeps()); 699 + _wireValidationDropdown(_gridClickDeps()); 1240 700 1241 - // --- Toolbar state helpers (extracted to toolbar-wiring.ts) --- 701 + // --- Toolbar state helpers --- 1242 702 function updateWrapButtonState() { _updateWrapButtonState(_toolbarDeps()); } 1243 703 function updateStripedButtonState() { _updateStripedButtonState(_toolbarDeps()); } 1244 704 function updateBoldButtonState() { _updateBoldButtonState(_toolbarDeps()); } ··· 1249 709 function updateFontFamilySelect() { _updateFontFamilySelect(_toolbarDeps()); } 1250 710 function updateVerticalAlignButton() { _updateVerticalAlignButton(_toolbarDeps()); } 1251 711 1252 - // ── Status Bar (extracted to status-bar-ui.ts) ── 712 + // ── Status Bar ── 1253 713 function _statusBarDeps() { 1254 714 return { 1255 715 getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 1256 - getCellData, computeDisplayValue, getFreezeRows, getFreezeCols, 1257 - setFreezeRows, setFreezeCols, renderGrid, 716 + getCellData, computeDisplayValue, getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, renderGrid, 1258 717 }; 1259 718 } 1260 719 function updateStatusBar() { _updateStatusBarUI(_statusBarDeps()); } 1261 720 _wireStatusBarFreezeClick(_statusBarDeps()); 1262 721 1263 - // ── Formula Autocomplete (extracted to formula-autocomplete-ui.ts) ── 722 + // ── Formula Autocomplete ── 1264 723 const autocompleteEl = document.getElementById('formula-autocomplete'); 1265 724 function hideAutocomplete() { _hideAutocomplete(autocompleteEl); } 1266 725 function attachCellEditorAutocomplete(inputEl) { _attachCellEditorAutocomplete(autocompleteEl, inputEl); } 1267 726 _wireAutocomplete({ autocompleteEl, formulaInput }); 1268 727 1269 - // ── Formula UX wiring (highlighting, tooltips, range highlights) ── 728 + // ── Formula UX wiring ── 1270 729 formulaInput.addEventListener('input', onFormulaInputUpdate); 1271 730 formulaInput.addEventListener('click', () => { updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); }); 1272 731 formulaInput.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); }); 1273 732 formulaInput.addEventListener('scroll', () => { if (formulaHighlightLayer) formulaHighlightLayer.scrollLeft = formulaInput.scrollLeft; }); 1274 733 formulaInput.addEventListener('focus', () => { updateFormulaHighlight(formulaInput.value, true); updateFormulaRangeHighlights(formulaInput.value); }); 1275 734 formulaInput.addEventListener('blur', () => { hideTooltip(); clearGridHighlights(); updateFormulaHighlight(formulaInput.value); }); 1276 - 1277 - // attachCellEditorFormulaUX — extracted to cell-editing.ts 1278 735 function attachCellEditorFormulaUX(inputEl, anchorTd) { _attachCellEditorFormulaUXCE(_cellEditingDeps(), inputEl, anchorTd); } 1279 736 1280 - // ── Cell Notes (extracted to cell-notes-ui.ts) ── 737 + // ── Cell Notes ── 1281 738 function _cellNotesDeps() { return { getActiveSheet, grid }; } 1282 739 function getNotesMap() { return _getNotesMap(_cellNotesDeps()); } 1283 740 function getNotesObject() { return _getNotesObject(_cellNotesDeps()); } 1284 741 function setNoteInYjs(id, text) { _setNoteInYjs(_cellNotesDeps(), id, text); } 1285 742 function showNoteDialog(id) { _showNoteDialogUI(_cellNotesDeps(), id, renderNoteIndicators); } 1286 743 function renderNoteIndicators() { _renderNoteIndicatorsUI(_cellNotesDeps()); } 1287 - 1288 - // renderSparklines — extracted to grid-rendering.ts 1289 744 function renderSparklines() { _renderSparklines(_gridRenderingDeps()); } 1290 745 1291 - // ── Note hover + Error tooltip (extracted to cell-notes-ui.ts) ── 1292 746 _wireNoteHover(_cellNotesDeps()); 1293 747 _wireErrorTooltip(grid); 1294 748 1295 - // ── Context Menu (extracted to context-menu-handler.ts) ── 749 + // ── Context Menu ── 1296 750 function _contextMenuDeps() { 1297 751 return { 1298 752 grid, getActiveSheet, getCellData, ··· 1303 757 hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 1304 758 getFreezeRows, getFreezeCols, setFreezeRows, setFreezeCols, 1305 759 getColWidth, setColWidth, getRowHeight, setRowHeight, 1306 - isAtHiddenRowBoundary, isAtHiddenColBoundary, 1307 - buildHiddenRowSet, buildHiddenColSet, renderGrid, 1308 - showNoteDialog, setNoteInYjs, renderNoteIndicators, getNotesObject, 1309 - DEFAULT_ROWS, DEFAULT_COLS, 760 + isAtHiddenRowBoundary, isAtHiddenColBoundary, buildHiddenRowSet, buildHiddenColSet, renderGrid, 761 + showNoteDialog, setNoteInYjs, renderNoteIndicators, getNotesObject, DEFAULT_ROWS, DEFAULT_COLS, 1310 762 }; 1311 763 } 1312 764 _wireContextMenu(_contextMenuDeps()); 1313 - 1314 - // Observe notes changes from collaborators 1315 765 getNotesMap().observe(() => renderNoteIndicators()); 1316 766 1317 - // No scroll handler — all rows are rendered upfront and the browser 1318 - // handles scrolling natively. Zero JS during scroll = zero jank. 1319 - 1320 - // ── Find & Replace Bar (extracted to find-replace-bar.ts) ── 767 + // ── Find & Replace Bar ── 1321 768 const findBar = createFindReplaceBar(); 1322 769 sheetContainer.parentNode.insertBefore(findBar, sheetContainer); 1323 - 1324 770 function _findReplaceDeps() { 1325 771 return { 1326 772 getActiveSheet, getCellData, setCellData, computeDisplayValue, 1327 - evalCache, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, 1328 - renderGrid, scrollCellIntoView, 1329 - getSelectedCell: () => selectedCell, 1330 - setSelectedCell: (c) => { selectedCell = c; }, 773 + evalCache, clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, renderGrid, scrollCellIntoView, 774 + getSelectedCell: () => selectedCell, setSelectedCell: (c) => { selectedCell = c; }, 1331 775 setSelectionRange: (r) => { selectionRange = r; }, 1332 - getFindReplaceBarVisible: () => findReplaceBarVisible, 1333 - setFindReplaceBarVisible: (v) => { findReplaceBarVisible = v; }, 776 + getFindReplaceBarVisible: () => findReplaceBarVisible, setFindReplaceBarVisible: (v) => { findReplaceBarVisible = v; }, 1334 777 ydoc, sheetContainer, DEFAULT_ROWS, DEFAULT_COLS, 1335 778 }; 1336 779 } ··· 1338 781 function hideFindReplaceBar() { _hideFindReplaceBarUI(_findReplaceDeps(), findBar); } 1339 782 _wireFindReplaceBar(_findReplaceDeps(), findBar); 1340 783 1341 - // Row resize — extracted to mouse-events.ts 1342 784 function startRowResize(handle, e) { _startRowResize(_mouseEventsDeps(), handle, e); } 1343 785 1344 - // ── AI Chat Panel ──────────────────────────────────────────────────────── 1345 - 786 + // ── AI Chat Panel ── 1346 787 const chatUI = createChatSidebar(); 1347 788 document.getElementById('sheet-body')!.appendChild(chatUI.container); 1348 - 1349 789 const chatState = createChatState(); 1350 - 1351 790 const chatWiring = initChatWiring({ 1352 - chatUI, 1353 - chatState, 1354 - chatConfig: loadConfig(), 1355 - toggleBtn: document.getElementById('btn-ai-chat'), 1356 - editorType: 'sheet', 1357 - onSend: sendChatMessage, 791 + chatUI, chatState, chatConfig: loadConfig(), 792 + toggleBtn: document.getElementById('btn-ai-chat'), editorType: 'sheet', onSend: sendChatMessage, 1358 793 }); 1359 - 1360 - /** Extract spreadsheet content as text for AI context */ 1361 - // getSheetContextText and sendChatMessage extracted to ai-chat-panel.ts 1362 794 function getSheetContextText() { return _getSheetContextText(getCells); } 1363 795 async function sendChatMessage() { 1364 - return _sendChatMessage({ 1365 - getCells, getCellData, setCellData, renderGrid, chatUI, chatState, chatWiring, 1366 - titleInput: titleInput as HTMLInputElement, 1367 - }); 796 + return _sendChatMessage({ getCells, getCellData, setCellData, renderGrid, chatUI, chatState, chatWiring, titleInput: titleInput as HTMLInputElement }); 1368 797 } 1369 - 1370 798 1371 799 // --- Command Palette --- 1372 800 import { createCommandPalette } from '../command-palette.js';
+90
src/sheets/merge-cells-ui.ts
··· 1 + /** 2 + * Merge Cells UI — merge/unmerge logic, toolbar button state, click handler. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId } from './formulas.js'; 8 + import { normalizeRange } from './selection-utils.js'; 9 + import { findCellMerge } from './merge-utils.js'; 10 + import { showToast } from './import-export.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface MergeCellsUIDeps { 15 + ydoc: any; 16 + grid: HTMLElement; 17 + getSelectedCell: () => { col: number; row: number }; 18 + getSelectionRange: () => any; 19 + getMerges: () => any; 20 + getCells: () => any; 21 + getCellData: (id: string) => any; 22 + renderGrid: () => void; 23 + } 24 + 25 + // ── Functions ─────────────────────────────────────────────── 26 + 27 + function isCellMergedLocal(col: number, row: number, getMerges: () => any) { 28 + return findCellMerge(col, row, getMerges().entries()); 29 + } 30 + 31 + export function mergeCells(deps: MergeCellsUIDeps): void { 32 + const { ydoc, getSelectedCell, getSelectionRange, getMerges, getCells, renderGrid } = deps; 33 + const selectionRange = getSelectionRange(); 34 + if (!selectionRange) return; 35 + const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 36 + if (startCol === endCol && startRow === endRow) return; 37 + const merges = getMerges(); 38 + const mergeKey = cellId(startCol, startRow); 39 + if (merges.has(mergeKey)) { 40 + ydoc.transact(() => { merges.delete(mergeKey); }); 41 + updateMergeButtonState(deps); renderGrid(); return; 42 + } 43 + ydoc.transact(() => { 44 + const keysToDelete: string[] = []; 45 + merges.forEach((mergeData: any, key: string) => { 46 + const m = typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData; 47 + if (m.startCol <= endCol && m.endCol >= startCol && m.startRow <= endRow && m.endRow >= startRow) keysToDelete.push(key); 48 + }); 49 + keysToDelete.forEach((k: string) => merges.delete(k)); 50 + const cells = getCells(); 51 + for (let r = startRow; r <= endRow; r++) { 52 + for (let c = startCol; c <= endCol; c++) { 53 + if (c === startCol && r === startRow) continue; 54 + const id = cellId(c, r); 55 + if (cells.has(id)) cells.delete(id); 56 + } 57 + } 58 + merges.set(mergeKey, JSON.stringify({ startCol, startRow, endCol, endRow })); 59 + }); 60 + updateMergeButtonState(deps); renderGrid(); 61 + } 62 + 63 + export function updateMergeButtonState(deps: MergeCellsUIDeps): void { 64 + const { getSelectedCell, getMerges } = deps; 65 + const mergeBtn = document.getElementById('tb-merge'); 66 + if (!mergeBtn) return; 67 + const selectedCell = getSelectedCell(); 68 + const merge = isCellMergedLocal(selectedCell.col, selectedCell.row, getMerges); 69 + if (merge) { 70 + mergeBtn.classList.add('merge-active'); 71 + mergeBtn.dataset.tooltip = 'Unmerge cells'; 72 + } else { 73 + mergeBtn.classList.remove('merge-active'); 74 + mergeBtn.dataset.tooltip = 'Merge/Unmerge cells'; 75 + } 76 + } 77 + 78 + export function wireMergeButton(deps: MergeCellsUIDeps): void { 79 + const { ydoc, getSelectedCell, getMerges, renderGrid } = deps; 80 + document.getElementById('tb-merge')!.addEventListener('click', () => { 81 + const selectedCell = getSelectedCell(); 82 + const merge = isCellMergedLocal(selectedCell.col, selectedCell.row, getMerges); 83 + if (merge) { 84 + const merges = getMerges(); 85 + ydoc.transact(() => { merges.delete(cellId(merge.startCol, merge.startRow)); }); 86 + updateMergeButtonState(deps); renderGrid(); showToast('Cells unmerged'); return; 87 + } 88 + mergeCells(deps); showToast('Cells merged'); 89 + }); 90 + }
+159
src/sheets/sync-observers.ts
··· 1 + /** 2 + * Sync Observers — Yjs observer setup for cells, sheets, charts, notes, 3 + * plus pending import and template loading on initial sync. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + // ── Types ──────────���──────────────────────────────────────── 9 + 10 + export interface SyncObserversDeps { 11 + docId: string; 12 + ydoc: any; 13 + provider: any; 14 + ySheets: any; 15 + getCells: () => any; 16 + getCellData: (id: string) => any; 17 + setCellData: (id: string, data: any) => void; 18 + getColWidths: () => any; 19 + getCfRules: () => any; 20 + getValidations: () => any; 21 + getCharts: () => any; 22 + getNotesMap: () => any; 23 + evalCache: { clear: () => void }; 24 + clearSpillMaps: () => void; 25 + invalidateRecalcEngine: () => void; 26 + scheduleRenderGrid: () => void; 27 + renderGrid: () => void; 28 + renderSheetTabs: () => void; 29 + renderCharts: () => void; 30 + renderNoteIndicators: () => void; 31 + refreshVisibleCells: () => void; 32 + updateFormulaBar: () => void; 33 + handleImportFile: (file: File) => Promise<void>; 34 + } 35 + 36 + // ── Functions ─────────────────────────────────────────────── 37 + 38 + /** Attach the initial (pre-sync) Yjs observers */ 39 + export function attachPreSyncObservers(deps: SyncObserversDeps): void { 40 + const { 41 + ySheets, getCells, getColWidths, getCfRules, getValidations, 42 + evalCache, clearSpillMaps, invalidateRecalcEngine, 43 + scheduleRenderGrid, renderSheetTabs, refreshVisibleCells, updateFormulaBar, 44 + } = deps; 45 + 46 + getCells().observeDeep(() => { 47 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 48 + scheduleRenderGrid(); updateFormulaBar(); 49 + }); 50 + 51 + ySheets.observe(() => { renderSheetTabs(); }); 52 + 53 + // Re-render when colWidths, freeze state, CF rules, validations, or stripedRows change 54 + ySheets.observeDeep((events: any[]) => { 55 + for (const event of events) { 56 + if (event.target && event.target === getColWidths()) { scheduleRenderGrid(); return; } 57 + const changed = event.changes?.keys; 58 + if (changed) { 59 + for (const [key] of changed) { 60 + if (key === 'freezeRows' || key === 'freezeCols' || key === 'stripedRows') { scheduleRenderGrid(); return; } 61 + } 62 + } 63 + if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 64 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 65 + } 66 + } 67 + }); 68 + } 69 + 70 + /** Wire the provider 'sync' event: re-attach observers, load pending imports, apply templates */ 71 + export function wireSyncEvent(deps: SyncObserversDeps): void { 72 + const { 73 + docId, ydoc, provider, ySheets, 74 + getCells, setCellData, getColWidths, getCfRules, getValidations, getCharts, getNotesMap, 75 + evalCache, clearSpillMaps, invalidateRecalcEngine, 76 + scheduleRenderGrid, renderGrid, renderSheetTabs, renderCharts, renderNoteIndicators, 77 + refreshVisibleCells, updateFormulaBar, handleImportFile, 78 + } = deps; 79 + 80 + provider.on('sync', () => { 81 + const _st = document.getElementById('status-text'); 82 + if (_st) _st.textContent = 'Synced'; 83 + 84 + // Re-attach ALL observers after sync — the snapshot may have replaced the Y.Map/Y.Array 85 + // objects that were observed during initial setup (before data loaded from peers) 86 + getCells().observeDeep(() => { 87 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 88 + scheduleRenderGrid(); updateFormulaBar(); 89 + }); 90 + 91 + ySheets.observe(() => { renderSheetTabs(); }); 92 + 93 + ySheets.observeDeep((events: any[]) => { 94 + for (const event of events) { 95 + if (event.target && event.target === getColWidths()) { scheduleRenderGrid(); return; } 96 + const changed = event.changes?.keys; 97 + if (changed) { 98 + for (const [key] of changed) { 99 + if (key === 'freezeRows' || key === 'freezeCols' || key === 'stripedRows') { scheduleRenderGrid(); return; } 100 + } 101 + } 102 + if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 103 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 104 + } 105 + } 106 + }); 107 + 108 + getCharts().observeDeep(() => { renderCharts(); }); 109 + getNotesMap().observe(() => renderNoteIndicators()); 110 + renderGrid(); 111 + 112 + // Check for pending file import from landing page drag-and-drop 113 + const pendingKey = `pending-import-${docId}`; 114 + const pendingRaw = sessionStorage.getItem(pendingKey); 115 + if (pendingRaw) { 116 + sessionStorage.removeItem(pendingKey); 117 + try { 118 + const pending = JSON.parse(pendingRaw); 119 + // Set import-in-progress flag to prevent snapshot saves during async import 120 + (window as any).__importInProgress = true; 121 + // Convert data URL back to a File object and await the full import 122 + fetch(pending.data) 123 + .then(r => r.blob()) 124 + .then(async blob => { 125 + const file = new File([blob], pending.name, { type: blob.type }); 126 + await handleImportFile(file); 127 + }) 128 + .finally(async () => { 129 + (window as any).__importInProgress = false; 130 + // Force save now that import flag is cleared 131 + await provider._saveSnapshot(); 132 + }); 133 + } catch { 134 + (window as any).__importInProgress = false; 135 + } 136 + } 137 + 138 + // Check for sheet template content 139 + const tmplKey = `template-content-${docId}`; 140 + const tmplContent = sessionStorage.getItem(tmplKey); 141 + const tmplType = sessionStorage.getItem(`template-type-${docId}`); 142 + if (tmplContent && tmplType === 'sheet') { 143 + sessionStorage.removeItem(tmplKey); 144 + sessionStorage.removeItem(`template-type-${docId}`); 145 + try { 146 + const cellMap = JSON.parse(tmplContent); 147 + const cells = getCells(); 148 + if (cells.size === 0) { 149 + ydoc.transact(() => { 150 + for (const [cellId, data] of Object.entries(cellMap)) { 151 + setCellData(cellId, data as any); 152 + } 153 + }); 154 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 155 + } 156 + } catch { /* ignore invalid template */ } 157 + } 158 + }); 159 + }
+85
src/sheets/version-history-ui.ts
··· 1 + /** 2 + * Version History UI — version panel wiring and document title management. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { encryptString, decryptString } from '../lib/crypto.js'; 9 + import { createVersionPanel } from '../version-panel.js'; 10 + import { showToast } from './import-export.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface VersionHistoryUIDeps { 15 + docId: string; 16 + cryptoKey: CryptoKey; 17 + ydoc: any; 18 + provider: any; 19 + } 20 + 21 + // ── Functions ─────────────────────────────────────────────── 22 + 23 + /** Wire the version panel (slide-in, Cmd+Shift+H) and history button */ 24 + export function wireVersionPanel(deps: VersionHistoryUIDeps): void { 25 + const { docId, cryptoKey, ydoc, provider } = deps; 26 + 27 + const sheetsVersionPanel = createVersionPanel({ 28 + docId, 29 + cryptoKey, 30 + docType: 'sheet', 31 + onRestore: async (_versionId: string, decryptedData: Uint8Array) => { 32 + Y.applyUpdate(ydoc, decryptedData); 33 + await provider._saveSnapshot(); 34 + }, 35 + }); 36 + 37 + // Wire Cmd+Shift+H to toggle version panel 38 + document.addEventListener('keydown', (e) => { 39 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 40 + e.preventDefault(); 41 + sheetsVersionPanel.toggle(); 42 + } 43 + }); 44 + 45 + // Wire version history button if present 46 + const btnHistory = document.getElementById('btn-history'); 47 + if (btnHistory) { 48 + btnHistory.addEventListener('click', () => sheetsVersionPanel.toggle()); 49 + } 50 + } 51 + 52 + /** Wire the document title input (load, save on edit) */ 53 + export function wireDocTitle(deps: VersionHistoryUIDeps): void { 54 + const { docId, cryptoKey } = deps; 55 + const titleInput = document.getElementById('doc-title') as HTMLInputElement; 56 + 57 + async function loadTitle() { 58 + try { 59 + const res = await fetch('/api/documents/' + docId); 60 + if (!res.ok) return; 61 + const doc = await res.json(); 62 + if (doc.name_encrypted) { 63 + const encBytes = Uint8Array.from(atob(doc.name_encrypted), c => c.charCodeAt(0)); 64 + titleInput.value = await decryptString(encBytes, cryptoKey); 65 + } 66 + } catch {} 67 + } 68 + loadTitle(); 69 + 70 + titleInput.addEventListener('focus', () => { 71 + titleInput.select(); 72 + }); 73 + 74 + let titleSaveTimeout: ReturnType<typeof setTimeout>; 75 + titleInput.addEventListener('input', () => { 76 + clearTimeout(titleSaveTimeout); 77 + titleSaveTimeout = setTimeout(async () => { 78 + const encrypted = await encryptString(titleInput.value, cryptoKey); 79 + const b64 = btoa(String.fromCharCode(...encrypted)); 80 + fetch('/api/documents/' + docId + '/name', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name_encrypted: b64 }) }) 81 + .then(r => { if (!r.ok) throw new Error(); }) 82 + .catch(() => showToast('Failed to save title')); 83 + }, 500); 84 + }); 85 + }