Full document, spreadsheet, slideshow, and diagram tooling
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();