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

Configure Feed

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

at main 302 lines 12 kB view raw
1/** 2 * Atmosphere Slides — E2EE collaborative presentations. 3 * Backed by Yjs for real-time collaboration. 4 * 5 * Orchestrator: state, Yjs sync, init, and module wiring. 6 * Rendering, events, presenter UI, and AI chat live in dedicated modules. 7 */ 8 9import { applyFeatureGates } from '../lib/feature-gate.js'; 10import * as Y from 'yjs'; 11import { importKey } from '../lib/crypto.js'; 12import { getDocument, updateDocument } from '../lib/local-store.js'; 13import { EncryptedProvider } from '../lib/provider.js'; 14 15applyFeatureGates(); 16import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 17import { wireKeyWarningForSession } from '../lib/key-warning.js'; 18import { wireSaveStatus } from '../lib/save-status-ui.js'; 19import { wireStatusChips } from '../lib/status-chips.js'; 20import { setupTooltips } from '../lib/tooltips.js'; 21import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 22import { createDeck, slideCount } from './canvas-engine.js'; 23import type { DeckState } from './canvas-engine.js'; 24import { getLayouts, getThemes, createThemedDeck } from './layouts-themes.js'; 25import { getDefaultMasters } from './master-slides.js'; 26import { 27 createSlideTransitions, getTransitionTypes, 28} from './transitions.js'; 29import type { SlideTransitions } from './transitions.js'; 30import { createPresenterState } from './presenter-mode.js'; 31import type { PresenterState } from './presenter-mode.js'; 32import { createSlideAnimations } from './element-animations.js'; 33import type { SlideAnimations } from './element-animations.js'; 34import { createCommandPalette } from '../command-palette.js'; 35 36import type { AppState, DOMRefs, AppActions } from './types.js'; 37import { render as doRender, renderCanvas as doRenderCanvas } from './rendering.js'; 38import { setupEventHandlers } from './event-handlers.js'; 39import { setupAIChatPanel } from './ai-chat-panel.js'; 40 41// --- DOM refs --- 42const $ = (id: string) => document.getElementById(id)!; 43const refs: DOMRefs = { 44 deckTitle: $('deck-title') as HTMLInputElement, 45 thumbnailList: $('thumbnail-list'), 46 slideCanvas: $('slide-canvas'), 47 layoutSelect: $('layout-select') as HTMLSelectElement, 48 masterSelect: $('master-select') as HTMLSelectElement, 49 themeSelect: $('theme-select') as HTMLSelectElement, 50 transitionSelect: $('transition-select') as HTMLSelectElement, 51 notesInput: $('notes-input') as HTMLTextAreaElement, 52 presenterOverlay: $('presenter-overlay'), 53 presenterCurrent: $('presenter-current'), 54 presenterNextPreview: $('presenter-next-preview'), 55 presenterNotesEl: $('presenter-notes'), 56 presenterTimerEl: $('presenter-timer'), 57 presenterProgressEl: $('presenter-progress'), 58}; 59 60// --- Mutable state --- 61const state: AppState = { 62 deck: createDeck(), 63 themedDeck: createThemedDeck(1), 64 transitions: createSlideTransitions(), 65 presenter: createPresenterState(1), 66 animations: createSlideAnimations(), 67 selectedElementId: null, 68 isDragging: false, 69 dragStartX: 0, 70 dragStartY: 0, 71 dragElStartX: 0, 72 dragElStartY: 0, 73 cryptoKey: null, 74 docId: window.location.pathname.split('/').pop() || '', 75}; 76 77// --- Yjs setup --- 78const ydoc = new Y.Doc(); 79const yDeck = ydoc.getMap('deck'); 80 81function syncDeckToYjs() { 82 yDeck.set('slides', JSON.stringify(state.deck.slides)); 83 yDeck.set('currentSlide', state.deck.currentSlide); 84 yDeck.set('themed', JSON.stringify(state.themedDeck)); 85 yDeck.set('transitions', JSON.stringify(state.transitions)); 86 yDeck.set('animations', JSON.stringify({ 87 animations: Object.fromEntries( 88 [...state.animations.animations].map(([k, v]) => [k, v]) 89 ), 90 })); 91 if (state.deck.masterAssignments) { 92 yDeck.set('masterAssignments', JSON.stringify(state.deck.masterAssignments)); 93 } 94} 95 96function loadDeckFromYjs() { 97 try { 98 const slidesJson = yDeck.get('slides') as string; 99 if (slidesJson) { 100 const slides = JSON.parse(slidesJson); 101 state.deck = { ...state.deck, slides, currentSlide: (yDeck.get('currentSlide') as number) || 0 }; 102 } 103 const themedJson = yDeck.get('themed') as string; 104 if (themedJson) state.themedDeck = JSON.parse(themedJson); 105 const transJson = yDeck.get('transitions') as string; 106 if (transJson) { 107 const parsed = JSON.parse(transJson); 108 state.transitions = { ...parsed, overrides: new Map(Object.entries(parsed.overrides || {})) }; 109 } 110 const animJson = yDeck.get('animations') as string; 111 if (animJson) { 112 const parsed = JSON.parse(animJson); 113 state.animations = { 114 animations: new Map(Object.entries(parsed.animations || {}).map( 115 ([k, v]) => [Number(k), v as any[]] 116 )), 117 }; 118 } 119 const masterJson = yDeck.get('masterAssignments') as string; 120 if (masterJson) { 121 state.deck = { ...state.deck, masterAssignments: JSON.parse(masterJson) }; 122 } 123 } catch { /* use defaults */ } 124} 125 126// --- Actions (dependency injection for modules) --- 127const actions: AppActions = { 128 getState: () => state, 129 setState: (patch) => { Object.assign(state, patch); }, 130 syncDeckToYjs, 131 render: () => doRender(refs, actions), 132 renderCanvas: () => doRenderCanvas(refs, actions), 133}; 134 135// --- Populate dropdowns --- 136function initDropdowns() { 137 getLayouts().forEach(l => { 138 const opt = document.createElement('option'); 139 opt.value = l.type; 140 opt.textContent = l.label; 141 refs.layoutSelect.appendChild(opt); 142 }); 143 // Master slides dropdown: "None" + all default masters 144 const noneOpt = document.createElement('option'); 145 noneOpt.value = ''; 146 noneOpt.textContent = 'No Master'; 147 refs.masterSelect.appendChild(noneOpt); 148 getDefaultMasters().forEach(m => { 149 const opt = document.createElement('option'); 150 opt.value = m.id; 151 opt.textContent = m.name; 152 refs.masterSelect.appendChild(opt); 153 }); 154 getThemes().forEach(t => { 155 const opt = document.createElement('option'); 156 opt.value = t.id; 157 opt.textContent = t.name; 158 refs.themeSelect.appendChild(opt); 159 }); 160 getTransitionTypes().forEach(t => { 161 const opt = document.createElement('option'); 162 opt.value = t.type; 163 opt.textContent = t.label; 164 refs.transitionSelect.appendChild(opt); 165 }); 166} 167 168// --- Initialize --- 169async function init() { 170 const keyFragment = window.location.hash.slice(1); 171 if (keyFragment) { 172 try { state.cryptoKey = await importKey(keyFragment); } catch { /* anon */ } 173 } 174 175 initDropdowns(); 176 setupTooltips(); 177 mountOfflineIndicator(); 178 setupEventHandlers(refs, actions); 179 setupAIChatPanel(refs, actions); 180 181 if (state.cryptoKey) { 182 const provider = new EncryptedProvider(ydoc, state.docId, state.cryptoKey); 183 installDocGoneHandler(provider); 184 wireKeyWarningForSession(state.docId, document.querySelector<HTMLElement>('.app-topbar')); 185 wireSaveStatus({ provider, ydoc }); 186 wireStatusChips({ provider }); 187 provider.on('sync', () => { 188 loadDeckFromYjs(); 189 actions.render(); 190 191 // Check for pending PPTX import from landing page drag-and-drop 192 const pendingKey = `pending-import-${state.docId}`; 193 const pendingRaw = sessionStorage.getItem(pendingKey); 194 if (pendingRaw) { 195 sessionStorage.removeItem(pendingKey); 196 (async () => { 197 try { 198 const pending = JSON.parse(pendingRaw) as { name: string; type: string; data: string }; 199 if (pending.type !== 'pptx') return; 200 const { convertPptxToDeck } = await import('./pptx-import.js'); 201 const resp = await fetch(pending.data); 202 const arrayBuffer = await resp.arrayBuffer(); 203 const deck = await convertPptxToDeck(arrayBuffer); 204 state.deck = deck; 205 state.themedDeck = (await import('./layouts-themes.js')).createThemedDeck(deck.slides.length); 206 syncDeckToYjs(); 207 await provider._saveSnapshot(); 208 actions.render(); 209 } catch (err) { 210 console.error('PPTX import error:', err); 211 } 212 })(); 213 } 214 }); 215 } 216 217 // Load title 218 try { 219 const doc = await getDocument(state.docId); 220 if (doc?.name) { 221 refs.deckTitle.value = doc.name; 222 } 223 } catch { /* ignore */ } 224 225 actions.render(); 226} 227 228// --- Mobile: toggle slide-panel as overlay on small screens --- 229const slidePanel = document.getElementById('slide-panel'); 230const panelToggle = document.getElementById('btn-toggle-slide-panel'); 231if (slidePanel && panelToggle) { 232 const closePanel = () => { 233 slidePanel.classList.remove('slides-panel--mobile-open'); 234 panelToggle.setAttribute('aria-expanded', 'false'); 235 }; 236 panelToggle.addEventListener('click', () => { 237 const wasOpen = slidePanel.classList.contains('slides-panel--mobile-open'); 238 slidePanel.classList.toggle('slides-panel--mobile-open'); 239 panelToggle.setAttribute('aria-expanded', wasOpen ? 'false' : 'true'); 240 }); 241 // Tap a thumbnail auto-closes the overlay (tiny screens: content focus) 242 slidePanel.addEventListener('click', (e) => { 243 const target = e.target as HTMLElement | null; 244 if (target?.closest('.slides-thumbnail') && slidePanel.classList.contains('slides-panel--mobile-open')) { 245 closePanel(); 246 } 247 }); 248 // Escape closes the overlay 249 document.addEventListener('keydown', (e) => { 250 if (e.key === 'Escape' && slidePanel.classList.contains('slides-panel--mobile-open')) closePanel(); 251 }); 252} 253 254// --- Export (JSON deck backup) --- 255function exportDeckAsJson(): number { 256 const title = refs.deckTitle.value.trim() || 'Untitled Presentation'; 257 const payload = { 258 format: 'atmos-slides-deck', 259 version: 1, 260 title, 261 deck: state.deck, 262 themedDeck: state.themedDeck, 263 transitions: state.transitions, 264 animations: state.animations, 265 exportedAt: new Date().toISOString(), 266 }; 267 const content = JSON.stringify(payload, null, 2); 268 const blob = new Blob([content], { type: 'application/json' }); 269 const url = URL.createObjectURL(blob); 270 const a = document.createElement('a'); 271 a.href = url; 272 a.download = title.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_') + '.deck.json'; 273 document.body.appendChild(a); a.click(); document.body.removeChild(a); 274 URL.revokeObjectURL(url); 275 return slideCount(state.deck); 276} 277 278$('btn-export').addEventListener('click', async () => { 279 const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 280 try { 281 const count = exportDeckAsJson(); 282 exportSuccess({ count, noun: 'slide', format: 'JSON' }); 283 } catch (err) { 284 exportError({ format: 'JSON', error: err }); 285 } 286}); 287 288// --- Command Palette --- 289createCommandPalette({ 290 actions: [ 291 { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } }, 292 { id: 'new-slide-deck', label: 'New Presentation', category: 'action', icon: '\u25eb', action: () => { window.open('/', '_blank'); } }, 293 { id: 'present', label: 'Present', category: 'action', icon: '\u25b7', shortcut: 'F5', action: () => { document.getElementById('btn-present')?.click(); } }, 294 { id: 'add-slide', label: 'Add Slide', category: 'action', icon: '+', action: () => { document.getElementById('btn-add-slide')?.click(); } }, 295 { id: 'add-text', label: 'Add Text Element', category: 'action', icon: 'T', action: () => { document.getElementById('btn-add-text')?.click(); } }, 296 { id: 'add-shape', label: 'Add Shape Element', category: 'action', icon: '\u25a0', action: () => { document.getElementById('btn-add-shape')?.click(); } }, 297 { id: 'add-image', label: 'Add Image Element', category: 'action', icon: '\u25a3', action: () => { document.getElementById('btn-add-image')?.click(); } }, 298 { id: 'export', label: 'Export Presentation (JSON)', category: 'action', icon: '\u2193', action: () => { document.getElementById('btn-export')?.click(); } }, 299 ], 300}); 301 302init();