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

Configure Feed

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

refactor(slides): decompose main.ts into focused modules

Extract 645-line monolith into 5 focused modules via dependency injection:
- types.ts: shared AppState, DOMRefs, AppActions interfaces
- rendering.ts: thumbnails, canvas, shape SVG, full render
- presenter-ui.ts: enter/exit/render presenter overlay
- event-handlers.ts: toolbar, drag, touch, keyboard, title
- ai-chat-panel.ts: AI chat sidebar setup and messaging

main.ts reduced to 173 lines: state, Yjs sync, init, command palette.
All modules receive state through injected AppActions interface.

+834 -551
+147
src/slides/ai-chat-panel.ts
··· 1 + /** 2 + * Slides AI Chat Panel — chat sidebar setup and message handling. 3 + * 4 + * Extracts the AI chat integration from main.ts into a self-contained 5 + * module. Receives deck state through AppActions for context building. 6 + */ 7 + 8 + import type { DeckState } from './canvas-engine.js'; 9 + import { 10 + createChatSidebar, createChatState, loadConfig, isConfigured, 11 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 12 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 13 + type ChatMessage, 14 + } from '../lib/ai-chat.js'; 15 + import { splitResponse, isSlideAction } from '../lib/ai-actions.js'; 16 + import { executeSlideAction } from './ai-slide-actions.js'; 17 + import type { DOMRefs, AppActions } from './types.js'; 18 + 19 + /** 20 + * Initialize the AI chat panel and return the teardown-free wiring. 21 + */ 22 + export function setupAIChatPanel(refs: DOMRefs, actions: AppActions): void { 23 + const $ = (id: string) => document.getElementById(id)!; 24 + 25 + const chatUI = createChatSidebar(); 26 + $('main-content').appendChild(chatUI.container); 27 + 28 + const chatState = createChatState(); 29 + 30 + const chatWiring = initChatWiring({ 31 + chatUI, 32 + chatState, 33 + chatConfig: loadConfig(), 34 + toggleBtn: $('btn-ai-chat'), 35 + editorType: 'slide', 36 + onSend: sendChatMessage, 37 + }); 38 + 39 + function getSlideContextText(): string { 40 + const { deck } = actions.getState(); 41 + const lines: string[] = []; 42 + deck.slides.forEach((slide, i) => { 43 + lines.push(`Slide ${i + 1}${i === deck.currentSlide ? ' (current)' : ''}:`); 44 + if (slide.notes) lines.push(` Notes: ${slide.notes}`); 45 + slide.elements.forEach(el => { 46 + if (el.content) lines.push(` ${el.type}: "${el.content}"`); 47 + else lines.push(` ${el.type} (${Math.round(el.width)}x${Math.round(el.height)})`); 48 + }); 49 + }); 50 + return lines.join('\n'); 51 + } 52 + 53 + async function sendChatMessage(): Promise<void> { 54 + const text = chatUI.input.value.trim(); 55 + if (!text || chatState.loading) return; 56 + 57 + const cfg = chatWiring.getConfig(); 58 + if (!isConfigured(cfg)) { 59 + chatUI.settingsPanel.style.display = ''; 60 + chatUI.endpointInput.focus(); 61 + return; 62 + } 63 + 64 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 65 + chatState.messages.push(userMsg); 66 + appendMessage(chatUI.messageList, userMsg); 67 + 68 + chatUI.input.value = ''; 69 + chatUI.input.style.height = ''; 70 + chatUI.sendBtn.style.display = 'none'; 71 + chatUI.stopBtn.style.display = ''; 72 + chatState.loading = true; 73 + chatState.error = null; 74 + 75 + const title = refs.deckTitle.value.trim() || 'Untitled Presentation'; 76 + const includeContext = chatUI.contextToggle.checked; 77 + const actionsEnabled = chatUI.actionsToggle.checked; 78 + const contextText = includeContext ? getSlideContextText() : ''; 79 + 80 + const systemPrompt = buildSystemMessage(title, contextText, { 81 + editorType: 'slide', 82 + actionsEnabled, 83 + }); 84 + 85 + const { deck } = actions.getState(); 86 + const slideDeps = { 87 + getState: () => actions.getState().deck, 88 + setState: (s: DeckState) => { 89 + actions.setState({ deck: s }); 90 + actions.syncDeckToYjs(); 91 + }, 92 + render: () => actions.render(), 93 + }; 94 + 95 + const abortController = new AbortController(); 96 + chatState.abortController = abortController; 97 + const bubble = appendStreamingBubble(chatUI.messageList); 98 + let fullText = ''; 99 + 100 + await streamChat( 101 + cfg, 102 + chatState.messages, 103 + systemPrompt, 104 + { 105 + onChunk(chunk) { 106 + fullText += chunk; 107 + bubble.update(renderMarkdown(fullText)); 108 + }, 109 + onDone(doneText) { 110 + if (doneText) { 111 + chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 112 + 113 + if (actionsEnabled) { 114 + const { displayText, actions: parsedActions } = splitResponse(doneText); 115 + if (parsedActions.length > 0) { 116 + bubble.update(renderMarkdown(displayText)); 117 + for (const action of parsedActions) { 118 + if (!isSlideAction(action)) continue; 119 + appendActionCard(chatUI.messageList, action, { 120 + onApply: (a) => { 121 + const result = executeSlideAction(a as Parameters<typeof executeSlideAction>[0], slideDeps); 122 + if (!result.success && result.error) { 123 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 124 + } 125 + }, 126 + onDismiss: () => {}, 127 + }); 128 + } 129 + } 130 + } 131 + } 132 + }, 133 + onError(err) { 134 + chatState.error = err; 135 + bubble.el.classList.add('ai-chat-bubble--error'); 136 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 137 + }, 138 + }, 139 + abortController.signal, 140 + ); 141 + 142 + chatState.loading = false; 143 + chatState.abortController = null; 144 + chatUI.sendBtn.style.display = ''; 145 + chatUI.stopBtn.style.display = 'none'; 146 + } 147 + }
+277
src/slides/event-handlers.ts
··· 1 + /** 2 + * Slides Event Handlers — button clicks, drag/touch, keyboard shortcuts. 3 + * 4 + * All DOM event wiring extracted from main.ts. Each handler reads/writes 5 + * state through the injected AppActions interface. 6 + */ 7 + 8 + import { 9 + addSlide, goToSlide, addElement, removeElement, moveElement, currentSlide, 10 + } from './canvas-engine.js'; 11 + import { setSlideLayout, setDeckTheme, getTheme } from './layouts-themes.js'; 12 + import type { LayoutType } from './layouts-themes.js'; 13 + import { createTransition, setDefaultTransition } from './transitions.js'; 14 + import { setNotes, nextSlide as presenterNext, prevSlide as presenterPrev } from './presenter-mode.js'; 15 + import { enterPresenter, exitPresenter, renderPresenter } from './presenter-ui.js'; 16 + import type { DOMRefs, AppActions } from './types.js'; 17 + 18 + const $ = (id: string) => document.getElementById(id)!; 19 + 20 + /** 21 + * Wire up all event listeners. Call once during init. 22 + */ 23 + export function setupEventHandlers(refs: DOMRefs, actions: AppActions): void { 24 + setupToolbarButtons(refs, actions); 25 + setupPresenterButtons(refs, actions); 26 + setupDropdownHandlers(refs, actions); 27 + setupNotesInput(refs, actions); 28 + setupCanvasInteraction(refs, actions); 29 + setupDragHandlers(refs, actions); 30 + setupTouchHandlers(refs, actions); 31 + setupKeyboardShortcuts(refs, actions); 32 + setupTitleEditing(refs, actions); 33 + } 34 + 35 + // --- Toolbar buttons --- 36 + 37 + function setupToolbarButtons(refs: DOMRefs, actions: AppActions): void { 38 + $('btn-add-slide').addEventListener('click', () => { 39 + const s = actions.getState(); 40 + let deck = addSlide(s.deck, s.deck.currentSlide + 1); 41 + deck = goToSlide(deck, deck.currentSlide + 1); 42 + actions.setState({ 43 + deck, 44 + themedDeck: { ...s.themedDeck, layouts: [...s.themedDeck.layouts, 'titleContent'] }, 45 + }); 46 + actions.syncDeckToYjs(); 47 + actions.render(); 48 + }); 49 + 50 + $('btn-add-text').addEventListener('click', () => { 51 + const s = actions.getState(); 52 + actions.setState({ deck: addElement(s.deck, 'text', 100, 100, 300, 60, 'Click to edit') }); 53 + actions.syncDeckToYjs(); 54 + actions.renderCanvas(); 55 + }); 56 + 57 + $('btn-add-shape').addEventListener('click', () => { 58 + const s = actions.getState(); 59 + const fill = getTheme(s.themedDeck.themeId)?.palette.primary ?? ''; 60 + actions.setState({ deck: addElement(s.deck, 'shape', 200, 150, 150, 150, '', { fill }) }); 61 + actions.syncDeckToYjs(); 62 + actions.renderCanvas(); 63 + }); 64 + 65 + $('btn-add-image').addEventListener('click', () => { 66 + const url = prompt('Image URL:'); 67 + if (url) { 68 + const s = actions.getState(); 69 + actions.setState({ deck: addElement(s.deck, 'image', 150, 100, 300, 200, url) }); 70 + actions.syncDeckToYjs(); 71 + actions.renderCanvas(); 72 + } 73 + }); 74 + 75 + $('btn-delete-element').addEventListener('click', () => { 76 + const s = actions.getState(); 77 + if (s.selectedElementId) { 78 + actions.setState({ 79 + deck: removeElement(s.deck, s.selectedElementId), 80 + selectedElementId: null, 81 + }); 82 + actions.syncDeckToYjs(); 83 + actions.renderCanvas(); 84 + } 85 + }); 86 + } 87 + 88 + // --- Presenter buttons --- 89 + 90 + function setupPresenterButtons(refs: DOMRefs, actions: AppActions): void { 91 + $('btn-present').addEventListener('click', () => enterPresenter(refs, actions)); 92 + $('btn-presenter-exit').addEventListener('click', () => exitPresenter(refs, actions)); 93 + 94 + $('btn-presenter-next').addEventListener('click', () => { 95 + const s = actions.getState(); 96 + actions.setState({ presenter: presenterNext(s.presenter) }); 97 + renderPresenter(refs, actions); 98 + }); 99 + 100 + $('btn-presenter-prev').addEventListener('click', () => { 101 + const s = actions.getState(); 102 + actions.setState({ presenter: presenterPrev(s.presenter) }); 103 + renderPresenter(refs, actions); 104 + }); 105 + } 106 + 107 + // --- Dropdowns --- 108 + 109 + function setupDropdownHandlers(refs: DOMRefs, actions: AppActions): void { 110 + refs.layoutSelect.addEventListener('change', () => { 111 + const s = actions.getState(); 112 + actions.setState({ 113 + themedDeck: setSlideLayout(s.themedDeck, s.deck.currentSlide, refs.layoutSelect.value as LayoutType), 114 + }); 115 + actions.syncDeckToYjs(); 116 + actions.render(); 117 + }); 118 + 119 + refs.themeSelect.addEventListener('change', () => { 120 + const s = actions.getState(); 121 + actions.setState({ themedDeck: setDeckTheme(s.themedDeck, refs.themeSelect.value) }); 122 + actions.syncDeckToYjs(); 123 + actions.render(); 124 + }); 125 + 126 + refs.transitionSelect.addEventListener('change', () => { 127 + const s = actions.getState(); 128 + actions.setState({ 129 + transitions: setDefaultTransition(s.transitions, createTransition(refs.transitionSelect.value as 'none')), 130 + }); 131 + actions.syncDeckToYjs(); 132 + }); 133 + } 134 + 135 + // --- Notes --- 136 + 137 + function setupNotesInput(refs: DOMRefs, actions: AppActions): void { 138 + refs.notesInput.addEventListener('input', () => { 139 + const s = actions.getState(); 140 + const presenter = setNotes(s.presenter, s.deck.currentSlide, refs.notesInput.value); 141 + const slide = currentSlide(s.deck); 142 + slide.notes = refs.notesInput.value; 143 + actions.setState({ presenter }); 144 + actions.syncDeckToYjs(); 145 + }); 146 + } 147 + 148 + // --- Canvas click-to-deselect --- 149 + 150 + function setupCanvasInteraction(refs: DOMRefs, actions: AppActions): void { 151 + refs.slideCanvas.addEventListener('mousedown', (e) => { 152 + if (e.target === refs.slideCanvas) { 153 + actions.setState({ selectedElementId: null }); 154 + actions.renderCanvas(); 155 + } 156 + }); 157 + } 158 + 159 + // --- Mouse drag --- 160 + 161 + function setupDragHandlers(refs: DOMRefs, actions: AppActions): void { 162 + document.addEventListener('mousemove', (e) => { 163 + const s = actions.getState(); 164 + if (!s.isDragging || !s.selectedElementId) return; 165 + const dx = e.clientX - s.dragStartX; 166 + const dy = e.clientY - s.dragStartY; 167 + actions.setState({ deck: moveElement(s.deck, s.selectedElementId, s.dragElStartX + dx, s.dragElStartY + dy) }); 168 + actions.renderCanvas(); 169 + }); 170 + 171 + document.addEventListener('mouseup', () => { 172 + const s = actions.getState(); 173 + if (s.isDragging) { 174 + actions.setState({ isDragging: false }); 175 + actions.syncDeckToYjs(); 176 + } 177 + }); 178 + } 179 + 180 + // --- Touch drag --- 181 + 182 + function setupTouchHandlers(refs: DOMRefs, actions: AppActions): void { 183 + refs.slideCanvas.addEventListener('touchstart', (e) => { 184 + if (e.touches.length !== 1) return; 185 + const touch = e.touches[0]!; 186 + const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest('[data-element-id]') as HTMLElement | null; 187 + if (!target) return; 188 + const s = actions.getState(); 189 + const elId = target.dataset.elementId!; 190 + const el = currentSlide(s.deck).elements.find(el => el.id === elId); 191 + if (!el) return; 192 + e.preventDefault(); 193 + actions.setState({ 194 + selectedElementId: elId, 195 + isDragging: true, 196 + dragStartX: touch.clientX, 197 + dragStartY: touch.clientY, 198 + dragElStartX: el.x, 199 + dragElStartY: el.y, 200 + }); 201 + actions.renderCanvas(); 202 + }, { passive: false }); 203 + 204 + document.addEventListener('touchmove', (e) => { 205 + const s = actions.getState(); 206 + if (!s.isDragging || !s.selectedElementId) return; 207 + const touch = e.touches[0]!; 208 + const dx = touch.clientX - s.dragStartX; 209 + const dy = touch.clientY - s.dragStartY; 210 + actions.setState({ deck: moveElement(s.deck, s.selectedElementId, s.dragElStartX + dx, s.dragElStartY + dy) }); 211 + actions.renderCanvas(); 212 + e.preventDefault(); 213 + }, { passive: false }); 214 + 215 + document.addEventListener('touchend', () => { 216 + const s = actions.getState(); 217 + if (s.isDragging) { 218 + actions.setState({ isDragging: false }); 219 + actions.syncDeckToYjs(); 220 + } 221 + }); 222 + } 223 + 224 + // --- Keyboard shortcuts --- 225 + 226 + function setupKeyboardShortcuts(refs: DOMRefs, actions: AppActions): void { 227 + document.addEventListener('keydown', (e) => { 228 + // Presenter mode shortcuts 229 + if (refs.presenterOverlay.style.display !== 'none') { 230 + const s = actions.getState(); 231 + if (e.key === 'ArrowRight' || e.key === ' ') { 232 + actions.setState({ presenter: presenterNext(s.presenter) }); 233 + renderPresenter(refs, actions); 234 + } else if (e.key === 'ArrowLeft') { 235 + actions.setState({ presenter: presenterPrev(s.presenter) }); 236 + renderPresenter(refs, actions); 237 + } else if (e.key === 'Escape') { 238 + exitPresenter(refs, actions); 239 + } 240 + return; 241 + } 242 + 243 + if (e.key === 'F5') { 244 + e.preventDefault(); 245 + enterPresenter(refs, actions); 246 + } 247 + 248 + if (e.key === 'Delete' && actions.getState().selectedElementId 249 + && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { 250 + const s = actions.getState(); 251 + actions.setState({ 252 + deck: removeElement(s.deck, s.selectedElementId!), 253 + selectedElementId: null, 254 + }); 255 + actions.syncDeckToYjs(); 256 + actions.renderCanvas(); 257 + } 258 + }); 259 + } 260 + 261 + // --- Title editing --- 262 + 263 + function setupTitleEditing(refs: DOMRefs, actions: AppActions): void { 264 + refs.deckTitle.addEventListener('change', async () => { 265 + const s = actions.getState(); 266 + if (!s.cryptoKey) return; 267 + const { encrypt } = await import('../lib/crypto.js'); 268 + const nameBytes = new TextEncoder().encode(refs.deckTitle.value); 269 + const encrypted = await encrypt(nameBytes, s.cryptoKey); 270 + const b64 = btoa(String.fromCharCode(...new Uint8Array(encrypted))); 271 + fetch(`/api/documents/${s.docId}/name`, { 272 + method: 'PUT', 273 + headers: { 'Content-Type': 'application/json' }, 274 + body: JSON.stringify({ name_encrypted: b64 }), 275 + }); 276 + }); 277 + }
+79 -551
src/slides/main.ts
··· 1 1 /** 2 2 * Tools Slides — E2EE collaborative presentations. 3 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. 4 7 */ 5 8 6 9 import * as Y from 'yjs'; 7 - import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 10 + import { importKey } from '../lib/crypto.js'; 8 11 import { EncryptedProvider } from '../lib/provider.js'; 9 12 import { setupTooltips } from '../lib/tooltips.js'; 10 - import { 11 - createDeck, addSlide, removeSlide, moveSlide, duplicateSlide, goToSlide, 12 - addElement, removeElement, moveElement, resizeElement, bringToFront, sendToBack, 13 - currentSlide, slideCount, elementCount, 14 - SLIDE_WIDTH, SLIDE_HEIGHT, 15 - } from './canvas-engine.js'; 16 - import type { DeckState, SlideElement, ElementType } from './canvas-engine.js'; 17 - import { 18 - getLayouts, getLayout, getThemes, getTheme, createThemedDeck, 19 - setSlideLayout, setDeckTheme, themeToCSS, 20 - } from './layouts-themes.js'; 21 - import type { LayoutType, Theme } from './layouts-themes.js'; 13 + import { createDeck, slideCount } from './canvas-engine.js'; 14 + import type { DeckState } from './canvas-engine.js'; 15 + import { getLayouts, getThemes, createThemedDeck } from './layouts-themes.js'; 22 16 import { 23 - createTransition, createSlideTransitions, setDefaultTransition, 24 - getTransitionForSlide, getTransitionTypes, transitionCSS, generateDeckCSS, 17 + createSlideTransitions, getTransitionTypes, 25 18 } from './transitions.js'; 26 19 import type { SlideTransitions } from './transitions.js'; 27 - import { 28 - createPresenterState, startPresentation, stopPresentation, 29 - nextSlide as presenterNext, prevSlide as presenterPrev, 30 - tickTimer, toggleTimer, setNotes, currentNotes, formatTime, 31 - progressPercent, isOverTime, 32 - } from './presenter-mode.js'; 20 + import { createPresenterState } from './presenter-mode.js'; 33 21 import type { PresenterState } from './presenter-mode.js'; 34 - import { 35 - createChatSidebar, createChatState, loadConfig, isConfigured, 36 - buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 37 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 38 - type ChatMessage, 39 - } from '../lib/ai-chat.js'; 40 - import { splitResponse, isSlideAction } from '../lib/ai-actions.js'; 41 - import { executeSlideAction } from './ai-slide-actions.js'; 22 + import { createCommandPalette } from '../command-palette.js'; 23 + 24 + import type { AppState, DOMRefs, AppActions } from './types.js'; 25 + import { render as doRender, renderCanvas as doRenderCanvas } from './rendering.js'; 26 + import { setupEventHandlers } from './event-handlers.js'; 27 + import { setupAIChatPanel } from './ai-chat-panel.js'; 42 28 43 29 // --- DOM refs --- 44 30 const $ = (id: string) => document.getElementById(id)!; 45 - const deckTitle = $('deck-title') as HTMLInputElement; 46 - const thumbnailList = $('thumbnail-list'); 47 - const slideCanvas = $('slide-canvas'); 48 - const layoutSelect = $('layout-select') as HTMLSelectElement; 49 - const themeSelect = $('theme-select') as HTMLSelectElement; 50 - const transitionSelect = $('transition-select') as HTMLSelectElement; 51 - const notesInput = $('notes-input') as HTMLTextAreaElement; 52 - const presenterOverlay = $('presenter-overlay'); 53 - const presenterCurrent = $('presenter-current'); 54 - const presenterNextPreview = $('presenter-next-preview'); 55 - const presenterNotesEl = $('presenter-notes'); 56 - const presenterTimerEl = $('presenter-timer'); 57 - const presenterProgressEl = $('presenter-progress'); 31 + const refs: DOMRefs = { 32 + deckTitle: $('deck-title') as HTMLInputElement, 33 + thumbnailList: $('thumbnail-list'), 34 + slideCanvas: $('slide-canvas'), 35 + layoutSelect: $('layout-select') as HTMLSelectElement, 36 + themeSelect: $('theme-select') as HTMLSelectElement, 37 + transitionSelect: $('transition-select') as HTMLSelectElement, 38 + notesInput: $('notes-input') as HTMLTextAreaElement, 39 + presenterOverlay: $('presenter-overlay'), 40 + presenterCurrent: $('presenter-current'), 41 + presenterNextPreview: $('presenter-next-preview'), 42 + presenterNotesEl: $('presenter-notes'), 43 + presenterTimerEl: $('presenter-timer'), 44 + presenterProgressEl: $('presenter-progress'), 45 + }; 58 46 59 - // --- State --- 60 - let deck: DeckState = createDeck(); 61 - let themedDeck = createThemedDeck(1); 62 - let transitions: SlideTransitions = createSlideTransitions(); 63 - let presenter: PresenterState = createPresenterState(1); 64 - let selectedElementId: string | null = null; 65 - let isDragging = false; 66 - let dragStartX = 0; 67 - let dragStartY = 0; 68 - let dragElStartX = 0; 69 - let dragElStartY = 0; 47 + // --- Mutable state --- 48 + const state: AppState = { 49 + deck: createDeck(), 50 + themedDeck: createThemedDeck(1), 51 + transitions: createSlideTransitions(), 52 + presenter: createPresenterState(1), 53 + selectedElementId: null, 54 + isDragging: false, 55 + dragStartX: 0, 56 + dragStartY: 0, 57 + dragElStartX: 0, 58 + dragElStartY: 0, 59 + cryptoKey: null, 60 + docId: window.location.pathname.split('/').pop() || '', 61 + }; 70 62 71 63 // --- Yjs setup --- 72 - const docId = window.location.pathname.split('/').pop() || ''; 73 - const keyFragment = window.location.hash.slice(1); 74 - let cryptoKey: CryptoKey | null = null; 75 64 const ydoc = new Y.Doc(); 76 65 const yDeck = ydoc.getMap('deck'); 77 66 78 - async function initCrypto() { 79 - if (keyFragment) { 80 - try { cryptoKey = await importKey(keyFragment); } catch { /* anon */ } 81 - } 82 - } 83 - 84 67 function syncDeckToYjs() { 85 - yDeck.set('slides', JSON.stringify(deck.slides)); 86 - yDeck.set('currentSlide', deck.currentSlide); 87 - yDeck.set('themed', JSON.stringify(themedDeck)); 88 - yDeck.set('transitions', JSON.stringify(transitions)); 68 + yDeck.set('slides', JSON.stringify(state.deck.slides)); 69 + yDeck.set('currentSlide', state.deck.currentSlide); 70 + yDeck.set('themed', JSON.stringify(state.themedDeck)); 71 + yDeck.set('transitions', JSON.stringify(state.transitions)); 89 72 } 90 73 91 74 function loadDeckFromYjs() { ··· 93 76 const slidesJson = yDeck.get('slides') as string; 94 77 if (slidesJson) { 95 78 const slides = JSON.parse(slidesJson); 96 - deck = { ...deck, slides, currentSlide: (yDeck.get('currentSlide') as number) || 0 }; 79 + state.deck = { ...state.deck, slides, currentSlide: (yDeck.get('currentSlide') as number) || 0 }; 97 80 } 98 81 const themedJson = yDeck.get('themed') as string; 99 - if (themedJson) themedDeck = JSON.parse(themedJson); 82 + if (themedJson) state.themedDeck = JSON.parse(themedJson); 100 83 const transJson = yDeck.get('transitions') as string; 101 84 if (transJson) { 102 85 const parsed = JSON.parse(transJson); 103 - transitions = { ...parsed, overrides: new Map(Object.entries(parsed.overrides || {})) }; 86 + state.transitions = { ...parsed, overrides: new Map(Object.entries(parsed.overrides || {})) }; 104 87 } 105 88 } catch { /* use defaults */ } 106 89 } 107 90 91 + // --- Actions (dependency injection for modules) --- 92 + const actions: AppActions = { 93 + getState: () => state, 94 + setState: (patch) => { Object.assign(state, patch); }, 95 + syncDeckToYjs, 96 + render: () => doRender(refs, actions), 97 + renderCanvas: () => doRenderCanvas(refs, actions), 98 + }; 99 + 108 100 // --- Populate dropdowns --- 109 101 function initDropdowns() { 110 102 getLayouts().forEach(l => { 111 103 const opt = document.createElement('option'); 112 104 opt.value = l.type; 113 105 opt.textContent = l.label; 114 - layoutSelect.appendChild(opt); 106 + refs.layoutSelect.appendChild(opt); 115 107 }); 116 108 getThemes().forEach(t => { 117 109 const opt = document.createElement('option'); 118 110 opt.value = t.id; 119 111 opt.textContent = t.name; 120 - themeSelect.appendChild(opt); 112 + refs.themeSelect.appendChild(opt); 121 113 }); 122 114 getTransitionTypes().forEach(t => { 123 115 const opt = document.createElement('option'); 124 116 opt.value = t.type; 125 117 opt.textContent = t.label; 126 - transitionSelect.appendChild(opt); 127 - }); 128 - } 129 - 130 - // --- Rendering --- 131 - function renderThumbnails() { 132 - thumbnailList.innerHTML = ''; 133 - deck.slides.forEach((slide, i) => { 134 - const thumb = document.createElement('div'); 135 - thumb.className = 'slides-thumbnail' + (i === deck.currentSlide ? ' active' : ''); 136 - thumb.dataset.index = String(i); 137 - const num = document.createElement('span'); 138 - num.className = 'slides-thumb-num'; 139 - num.textContent = String(i + 1); 140 - const preview = document.createElement('div'); 141 - preview.className = 'slides-thumb-preview'; 142 - preview.style.background = slide.background; 143 - preview.style.aspectRatio = '16/9'; 144 - // Mini element indicators 145 - if (slide.elements.length > 0) { 146 - preview.innerHTML = `<span class="slides-thumb-count">${slide.elements.length}</span>`; 147 - } 148 - thumb.appendChild(num); 149 - thumb.appendChild(preview); 150 - thumb.addEventListener('click', () => { 151 - deck = goToSlide(deck, i); 152 - syncDeckToYjs(); 153 - render(); 154 - }); 155 - // Context menu for delete/duplicate 156 - thumb.addEventListener('contextmenu', (e) => { 157 - e.preventDefault(); 158 - if (deck.slides.length > 1) { 159 - const action = confirm('Delete this slide? (Cancel to duplicate)'); 160 - if (action) { 161 - deck = removeSlide(deck, i); 162 - themedDeck = { ...themedDeck, layouts: themedDeck.layouts.filter((_, idx) => idx !== i) }; 163 - } else { 164 - deck = duplicateSlide(deck, i); 165 - } 166 - syncDeckToYjs(); 167 - render(); 168 - } 169 - }); 170 - thumbnailList.appendChild(thumb); 171 - }); 172 - } 173 - 174 - function renderCanvas() { 175 - const slide = currentSlide(deck); 176 - const theme = getTheme(themedDeck.themeId); 177 - const cssVars = theme ? themeToCSS(theme) : {}; 178 - 179 - let style = `width:${SLIDE_WIDTH}px;height:${SLIDE_HEIGHT}px;position:relative;overflow:hidden;background:${slide.background};`; 180 - for (const [k, v] of Object.entries(cssVars)) { 181 - style += `${k}:${v};`; 182 - } 183 - slideCanvas.setAttribute('style', style); 184 - 185 - slideCanvas.innerHTML = ''; 186 - const sorted = [...slide.elements].sort((a, b) => a.zIndex - b.zIndex); 187 - 188 - for (const el of sorted) { 189 - const div = document.createElement('div'); 190 - div.className = 'slide-element' + (el.id === selectedElementId ? ' selected' : ''); 191 - div.dataset.elementId = el.id; 192 - div.style.cssText = `position:absolute;left:${el.x}px;top:${el.y}px;width:${el.width}px;height:${el.height}px;` 193 - + (el.rotation ? `transform:rotate(${el.rotation}deg);` : ''); 194 - 195 - if (el.type === 'text') { 196 - const textDiv = document.createElement('div'); 197 - textDiv.className = 'slide-el-text'; 198 - textDiv.contentEditable = 'true'; 199 - textDiv.style.cssText = `width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;`; 200 - textDiv.textContent = el.content || 'Text'; 201 - div.appendChild(textDiv); 202 - } else if (el.type === 'shape') { 203 - const fill = el.style?.fill || theme?.palette.primary || '#3a8a7a'; 204 - div.innerHTML = renderShapeSVG(el.shapeType || 'rectangle', el.width, el.height, fill); 205 - } else if (el.type === 'image') { 206 - const img = document.createElement('img'); 207 - const imgUrl = el.content || ''; 208 - // Only allow safe URL schemes to prevent javascript: XSS 209 - const isSafe = /^(https?:|data:|blob:)/i.test(imgUrl) || imgUrl === ''; 210 - img.src = isSafe ? imgUrl : ''; 211 - img.style.cssText = 'width:100%;height:100%;object-fit:contain;'; 212 - img.alt = ''; 213 - div.appendChild(img); 214 - } 215 - 216 - // Click to select 217 - div.addEventListener('mousedown', (e) => { 218 - e.stopPropagation(); 219 - selectedElementId = el.id; 220 - isDragging = true; 221 - dragStartX = e.clientX; 222 - dragStartY = e.clientY; 223 - dragElStartX = el.x; 224 - dragElStartY = el.y; 225 - renderCanvas(); 226 - }); 227 - 228 - // Inline text editing 229 - const textEl = div.querySelector('[contenteditable]'); 230 - if (textEl) { 231 - textEl.addEventListener('blur', () => { 232 - const slide = currentSlide(deck); 233 - const elIdx = slide.elements.findIndex(e => e.id === el.id); 234 - if (elIdx >= 0) { 235 - slide.elements[elIdx] = { ...slide.elements[elIdx]!, content: (textEl as HTMLElement).textContent || '' }; 236 - syncDeckToYjs(); 237 - } 238 - }); 239 - } 240 - 241 - slideCanvas.appendChild(div); 242 - } 243 - } 244 - 245 - function renderShapeSVG(shapeType: string, w: number, h: number, fill: string): string { 246 - switch (shapeType) { 247 - case 'ellipse': 248 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-2}" ry="${h/2-2}" fill="${fill}" stroke="${fill}" stroke-width="2" opacity="0.8"/></svg>`; 249 - case 'triangle': 250 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polygon points="${w/2},2 ${w-2},${h-2} 2,${h-2}" fill="${fill}" opacity="0.8"/></svg>`; 251 - case 'line': 252 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w}" y2="${h/2}" stroke="${fill}" stroke-width="3"/></svg>`; 253 - case 'arrow': 254 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w-10}" y2="${h/2}" stroke="${fill}" stroke-width="3"/><polygon points="${w},${h/2} ${w-12},${h/2-6} ${w-12},${h/2+6}" fill="${fill}"/></svg>`; 255 - default: // rectangle 256 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect x="2" y="2" width="${w-4}" height="${h-4}" rx="4" fill="${fill}" opacity="0.8"/></svg>`; 257 - } 258 - } 259 - 260 - function render() { 261 - renderThumbnails(); 262 - renderCanvas(); 263 - // Sync notes 264 - const slide = currentSlide(deck); 265 - notesInput.value = slide.notes || ''; 266 - // Update dropdown selections 267 - if (themedDeck.layouts[deck.currentSlide]) { 268 - layoutSelect.value = themedDeck.layouts[deck.currentSlide]!; 269 - } 270 - themeSelect.value = themedDeck.themeId; 271 - presenter = { ...presenter, totalSlides: slideCount(deck) }; 272 - } 273 - 274 - // --- Presenter mode --- 275 - let timerInterval: ReturnType<typeof setInterval> | null = null; 276 - 277 - function enterPresenter() { 278 - presenter = startPresentation({ ...presenter, totalSlides: slideCount(deck) }); 279 - presenterOverlay.style.display = ''; 280 - document.body.classList.add('presenting'); 281 - renderPresenter(); 282 - timerInterval = setInterval(() => { 283 - presenter = tickTimer(presenter); 284 - renderPresenter(); 285 - }, 1000); 286 - } 287 - 288 - function exitPresenter() { 289 - presenter = stopPresentation(presenter); 290 - presenterOverlay.style.display = 'none'; 291 - document.body.classList.remove('presenting'); 292 - if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } 293 - } 294 - 295 - function renderPresenter() { 296 - // Current slide 297 - const slide = deck.slides[presenter.currentSlide]; 298 - if (slide) { 299 - presenterCurrent.style.background = slide.background; 300 - presenterCurrent.innerHTML = `<div style="padding:40px;font-size:24px;color:${getTheme(themedDeck.themeId)?.palette.text || '#1a1815'}">${slide.elements.filter(e => e.type === 'text').map(e => escapeHtml(e.content)).join('<br>') || ''}</div>`; 301 - } 302 - // Next slide preview 303 - const next = deck.slides[presenter.currentSlide + 1]; 304 - if (next) { 305 - presenterNextPreview.style.background = next.background; 306 - presenterNextPreview.innerHTML = `<div style="padding:8px;font-size:10px;">${next.elements.length} elements</div>`; 307 - } else { 308 - presenterNextPreview.innerHTML = '<div style="padding:8px;opacity:0.5;">End</div>'; 309 - } 310 - // Notes 311 - presenterNotesEl.textContent = currentNotes(presenter); 312 - // Timer 313 - presenterTimerEl.textContent = formatTime(presenter.elapsedSeconds); 314 - if (isOverTime(presenter)) presenterTimerEl.classList.add('over-time'); 315 - else presenterTimerEl.classList.remove('over-time'); 316 - // Progress 317 - presenterProgressEl.textContent = `${presenter.currentSlide + 1} / ${presenter.totalSlides}`; 318 - } 319 - 320 - // --- Event handlers --- 321 - $('btn-add-slide').addEventListener('click', () => { 322 - deck = addSlide(deck, deck.currentSlide + 1); 323 - deck = goToSlide(deck, deck.currentSlide + 1); 324 - themedDeck = { ...themedDeck, layouts: [...themedDeck.layouts, 'titleContent'] }; 325 - syncDeckToYjs(); 326 - render(); 327 - }); 328 - 329 - $('btn-present').addEventListener('click', () => enterPresenter()); 330 - $('btn-presenter-exit').addEventListener('click', () => exitPresenter()); 331 - $('btn-presenter-next').addEventListener('click', () => { presenter = presenterNext(presenter); renderPresenter(); }); 332 - $('btn-presenter-prev').addEventListener('click', () => { presenter = presenterPrev(presenter); renderPresenter(); }); 333 - 334 - $('btn-add-text').addEventListener('click', () => { 335 - deck = addElement(deck, 'text', 100, 100, 300, 60, 'Click to edit'); 336 - syncDeckToYjs(); 337 - renderCanvas(); 338 - }); 339 - 340 - $('btn-add-shape').addEventListener('click', () => { 341 - deck = addElement(deck, 'shape', 200, 150, 150, 150, '', { fill: getTheme(themedDeck.themeId)?.palette.primary ?? '' }); 342 - syncDeckToYjs(); 343 - renderCanvas(); 344 - }); 345 - 346 - $('btn-add-image').addEventListener('click', () => { 347 - const url = prompt('Image URL:'); 348 - if (url) { 349 - deck = addElement(deck, 'image', 150, 100, 300, 200, url); 350 - syncDeckToYjs(); 351 - renderCanvas(); 352 - } 353 - }); 354 - 355 - $('btn-delete-element').addEventListener('click', () => { 356 - if (selectedElementId) { 357 - deck = removeElement(deck, selectedElementId); 358 - selectedElementId = null; 359 - syncDeckToYjs(); 360 - renderCanvas(); 361 - } 362 - }); 363 - 364 - layoutSelect.addEventListener('change', () => { 365 - themedDeck = setSlideLayout(themedDeck, deck.currentSlide, layoutSelect.value as LayoutType); 366 - syncDeckToYjs(); 367 - render(); 368 - }); 369 - 370 - themeSelect.addEventListener('change', () => { 371 - themedDeck = setDeckTheme(themedDeck, themeSelect.value); 372 - syncDeckToYjs(); 373 - render(); 374 - }); 375 - 376 - transitionSelect.addEventListener('change', () => { 377 - transitions = setDefaultTransition(transitions, createTransition(transitionSelect.value as any)); 378 - syncDeckToYjs(); 379 - }); 380 - 381 - notesInput.addEventListener('input', () => { 382 - presenter = setNotes(presenter, deck.currentSlide, notesInput.value); 383 - const slide = currentSlide(deck); 384 - slide.notes = notesInput.value; 385 - syncDeckToYjs(); 386 - }); 387 - 388 - // Canvas click to deselect 389 - slideCanvas.addEventListener('mousedown', (e) => { 390 - if (e.target === slideCanvas) { 391 - selectedElementId = null; 392 - renderCanvas(); 393 - } 394 - }); 395 - 396 - // Drag handling (mouse) 397 - document.addEventListener('mousemove', (e) => { 398 - if (!isDragging || !selectedElementId) return; 399 - const dx = e.clientX - dragStartX; 400 - const dy = e.clientY - dragStartY; 401 - deck = moveElement(deck, selectedElementId, dragElStartX + dx, dragElStartY + dy); 402 - renderCanvas(); 403 - }); 404 - 405 - document.addEventListener('mouseup', () => { 406 - if (isDragging) { 407 - isDragging = false; 408 - syncDeckToYjs(); 409 - } 410 - }); 411 - 412 - // Drag handling (touch) 413 - slideCanvas.addEventListener('touchstart', (e) => { 414 - if (e.touches.length !== 1) return; 415 - const touch = e.touches[0]!; 416 - const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest('[data-element-id]') as HTMLElement | null; 417 - if (!target) return; 418 - const elId = target.dataset.elementId!; 419 - const el = currentSlide(deck).elements.find(el => el.id === elId); 420 - if (!el) return; 421 - e.preventDefault(); 422 - selectedElementId = elId; 423 - isDragging = true; 424 - dragStartX = touch.clientX; 425 - dragStartY = touch.clientY; 426 - dragElStartX = el.x; 427 - dragElStartY = el.y; 428 - renderCanvas(); 429 - }, { passive: false }); 430 - 431 - document.addEventListener('touchmove', (e) => { 432 - if (!isDragging || !selectedElementId) return; 433 - const touch = e.touches[0]!; 434 - const dx = touch.clientX - dragStartX; 435 - const dy = touch.clientY - dragStartY; 436 - deck = moveElement(deck, selectedElementId, dragElStartX + dx, dragElStartY + dy); 437 - renderCanvas(); 438 - e.preventDefault(); 439 - }, { passive: false }); 440 - 441 - document.addEventListener('touchend', () => { 442 - if (isDragging) { 443 - isDragging = false; 444 - syncDeckToYjs(); 445 - } 446 - }); 447 - 448 - // Keyboard shortcuts 449 - document.addEventListener('keydown', (e) => { 450 - if (presenterOverlay.style.display !== 'none') { 451 - if (e.key === 'ArrowRight' || e.key === ' ') { presenter = presenterNext(presenter); renderPresenter(); } 452 - else if (e.key === 'ArrowLeft') { presenter = presenterPrev(presenter); renderPresenter(); } 453 - else if (e.key === 'Escape') exitPresenter(); 454 - return; 455 - } 456 - if (e.key === 'F5') { e.preventDefault(); enterPresenter(); } 457 - if (e.key === 'Delete' && selectedElementId && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { 458 - deck = removeElement(deck, selectedElementId); 459 - selectedElementId = null; 460 - syncDeckToYjs(); 461 - renderCanvas(); 462 - } 463 - }); 464 - 465 - // Title editing 466 - deckTitle.addEventListener('change', async () => { 467 - if (!cryptoKey) return; 468 - const { encrypt } = await import('../lib/crypto.js'); 469 - const nameBytes = new TextEncoder().encode(deckTitle.value); 470 - const encrypted = await encrypt(nameBytes, cryptoKey); 471 - const b64 = btoa(String.fromCharCode(...new Uint8Array(encrypted))); 472 - fetch(`/api/documents/${docId}/name`, { 473 - method: 'PUT', 474 - headers: { 'Content-Type': 'application/json' }, 475 - body: JSON.stringify({ name_encrypted: b64 }), 476 - }); 477 - }); 478 - 479 - // ── AI Chat Panel ──────────────────────────────────────────────────────── 480 - 481 - const chatUI = createChatSidebar(); 482 - $('main-content').appendChild(chatUI.container); 483 - 484 - const chatState = createChatState(); 485 - 486 - const chatWiring = initChatWiring({ 487 - chatUI, 488 - chatState, 489 - chatConfig: loadConfig(), 490 - toggleBtn: $('btn-ai-chat'), 491 - editorType: 'slide', 492 - onSend: sendChatMessage, 493 - }); 494 - 495 - function getSlideContextText(): string { 496 - const lines: string[] = []; 497 - deck.slides.forEach((slide, i) => { 498 - lines.push(`Slide ${i + 1}${i === deck.currentSlide ? ' (current)' : ''}:`); 499 - if (slide.notes) lines.push(` Notes: ${slide.notes}`); 500 - slide.elements.forEach(el => { 501 - if (el.content) lines.push(` ${el.type}: "${el.content}"`); 502 - else lines.push(` ${el.type} (${Math.round(el.width)}x${Math.round(el.height)})`); 503 - }); 118 + refs.transitionSelect.appendChild(opt); 504 119 }); 505 - return lines.join('\n'); 506 120 } 507 121 508 - async function sendChatMessage(): Promise<void> { 509 - const text = chatUI.input.value.trim(); 510 - if (!text || chatState.loading) return; 511 - 512 - const cfg = chatWiring.getConfig(); 513 - if (!isConfigured(cfg)) { 514 - chatUI.settingsPanel.style.display = ''; 515 - chatUI.endpointInput.focus(); 516 - return; 122 + // --- Initialize --- 123 + async function init() { 124 + const keyFragment = window.location.hash.slice(1); 125 + if (keyFragment) { 126 + try { state.cryptoKey = await importKey(keyFragment); } catch { /* anon */ } 517 127 } 518 128 519 - const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 520 - chatState.messages.push(userMsg); 521 - appendMessage(chatUI.messageList, userMsg); 522 - 523 - chatUI.input.value = ''; 524 - chatUI.input.style.height = ''; 525 - chatUI.sendBtn.style.display = 'none'; 526 - chatUI.stopBtn.style.display = ''; 527 - chatState.loading = true; 528 - chatState.error = null; 529 - 530 - const title = deckTitle.value.trim() || 'Untitled Presentation'; 531 - const includeContext = chatUI.contextToggle.checked; 532 - const actionsEnabled = chatUI.actionsToggle.checked; 533 - const contextText = includeContext ? getSlideContextText() : ''; 534 - 535 - const systemPrompt = buildSystemMessage(title, contextText, { 536 - editorType: 'slide', 537 - actionsEnabled, 538 - }); 539 - 540 - const slideDeps = { 541 - getState: () => deck, 542 - setState: (s: DeckState) => { deck = s; syncDeckToYjs(); }, 543 - render, 544 - }; 545 - 546 - const abortController = new AbortController(); 547 - chatState.abortController = abortController; 548 - const bubble = appendStreamingBubble(chatUI.messageList); 549 - let fullText = ''; 550 - 551 - await streamChat( 552 - cfg, 553 - chatState.messages, 554 - systemPrompt, 555 - { 556 - onChunk(chunk) { 557 - fullText += chunk; 558 - bubble.update(renderMarkdown(fullText)); 559 - }, 560 - onDone(doneText) { 561 - if (doneText) { 562 - chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 563 - 564 - if (actionsEnabled) { 565 - const { displayText, actions } = splitResponse(doneText); 566 - if (actions.length > 0) { 567 - bubble.update(renderMarkdown(displayText)); 568 - for (const action of actions) { 569 - if (!isSlideAction(action)) continue; 570 - appendActionCard(chatUI.messageList, action, { 571 - onApply: (a) => { 572 - const result = executeSlideAction(a as Parameters<typeof executeSlideAction>[0], slideDeps); 573 - if (!result.success && result.error) { 574 - appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 575 - } 576 - }, 577 - onDismiss: () => {}, 578 - }); 579 - } 580 - } 581 - } 582 - } 583 - }, 584 - onError(err) { 585 - chatState.error = err; 586 - bubble.el.classList.add('ai-chat-bubble--error'); 587 - bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 588 - }, 589 - }, 590 - abortController.signal, 591 - ); 592 - 593 - chatState.loading = false; 594 - chatState.abortController = null; 595 - chatUI.sendBtn.style.display = ''; 596 - chatUI.stopBtn.style.display = 'none'; 597 - } 598 - 599 - // --- Initialize --- 600 - async function init() { 601 - await initCrypto(); 602 129 initDropdowns(); 603 130 setupTooltips(); 131 + setupEventHandlers(refs, actions); 132 + setupAIChatPanel(refs, actions); 604 133 605 - if (cryptoKey) { 606 - const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 134 + if (state.cryptoKey) { 135 + const provider = new EncryptedProvider(ydoc, state.docId, state.cryptoKey); 607 136 provider.on('sync', () => { 608 137 loadDeckFromYjs(); 609 - render(); 138 + actions.render(); 610 139 }); 611 140 } 612 141 613 142 // Load title 614 143 try { 615 - const res = await fetch(`/api/documents/${docId}`); 144 + const res = await fetch(`/api/documents/${state.docId}`); 616 145 if (res.ok) { 617 146 const doc = await res.json(); 618 - if (doc.name_encrypted && cryptoKey) { 147 + if (doc.name_encrypted && state.cryptoKey) { 619 148 const bytes = Uint8Array.from(atob(doc.name_encrypted), c => c.charCodeAt(0)); 620 149 const { decrypt } = await import('../lib/crypto.js'); 621 - const plain = await decrypt(new Uint8Array(bytes.buffer), cryptoKey); 622 - deckTitle.value = new TextDecoder().decode(plain); 150 + const plain = await decrypt(new Uint8Array(bytes.buffer), state.cryptoKey); 151 + refs.deckTitle.value = new TextDecoder().decode(plain); 623 152 } 624 153 } 625 154 } catch { /* ignore */ } 626 155 627 - render(); 156 + actions.render(); 628 157 } 629 158 630 159 // --- Command Palette --- 631 - import { createCommandPalette } from '../command-palette.js'; 632 160 createCommandPalette({ 633 161 actions: [ 634 162 { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } },
+91
src/slides/presenter-ui.ts
··· 1 + /** 2 + * Presenter UI — enter/exit/render the presenter overlay. 3 + * 4 + * Bridges the pure presenter-mode.ts logic with DOM rendering. 5 + * Timer interval management lives here since it is UI-specific. 6 + */ 7 + 8 + import { 9 + startPresentation, stopPresentation, tickTimer, 10 + currentNotes, formatTime, isOverTime, 11 + } from './presenter-mode.js'; 12 + import { slideCount } from './canvas-engine.js'; 13 + import { getTheme } from './layouts-themes.js'; 14 + import { escapeHtml } from '../lib/ai-chat.js'; 15 + import type { DOMRefs, AppActions } from './types.js'; 16 + 17 + let timerInterval: ReturnType<typeof setInterval> | null = null; 18 + 19 + /** 20 + * Render the presenter overlay contents (current slide, next preview, notes, timer). 21 + */ 22 + export function renderPresenter(refs: DOMRefs, actions: AppActions): void { 23 + const { deck, themedDeck, presenter } = actions.getState(); 24 + 25 + // Current slide 26 + const slide = deck.slides[presenter.currentSlide]; 27 + if (slide) { 28 + refs.presenterCurrent.style.background = slide.background; 29 + const textColor = getTheme(themedDeck.themeId)?.palette.text || '#1a1815'; 30 + const textContent = slide.elements 31 + .filter(e => e.type === 'text') 32 + .map(e => escapeHtml(e.content)) 33 + .join('<br>'); 34 + refs.presenterCurrent.innerHTML = `<div style="padding:40px;font-size:24px;color:${textColor}">${textContent || ''}</div>`; 35 + } 36 + 37 + // Next slide preview 38 + const next = deck.slides[presenter.currentSlide + 1]; 39 + if (next) { 40 + refs.presenterNextPreview.style.background = next.background; 41 + refs.presenterNextPreview.innerHTML = `<div style="padding:8px;font-size:10px;">${next.elements.length} elements</div>`; 42 + } else { 43 + refs.presenterNextPreview.innerHTML = '<div style="padding:8px;opacity:0.5;">End</div>'; 44 + } 45 + 46 + // Notes 47 + refs.presenterNotesEl.textContent = currentNotes(presenter); 48 + 49 + // Timer 50 + refs.presenterTimerEl.textContent = formatTime(presenter.elapsedSeconds); 51 + if (isOverTime(presenter)) refs.presenterTimerEl.classList.add('over-time'); 52 + else refs.presenterTimerEl.classList.remove('over-time'); 53 + 54 + // Progress 55 + refs.presenterProgressEl.textContent = `${presenter.currentSlide + 1} / ${presenter.totalSlides}`; 56 + } 57 + 58 + /** 59 + * Enter presenter mode: activate state, show overlay, start timer. 60 + */ 61 + export function enterPresenter(refs: DOMRefs, actions: AppActions): void { 62 + const state = actions.getState(); 63 + const presenter = startPresentation({ ...state.presenter, totalSlides: slideCount(state.deck) }); 64 + actions.setState({ presenter }); 65 + 66 + refs.presenterOverlay.style.display = ''; 67 + document.body.classList.add('presenting'); 68 + renderPresenter(refs, actions); 69 + 70 + timerInterval = setInterval(() => { 71 + const s = actions.getState(); 72 + actions.setState({ presenter: tickTimer(s.presenter) }); 73 + renderPresenter(refs, actions); 74 + }, 1000); 75 + } 76 + 77 + /** 78 + * Exit presenter mode: deactivate state, hide overlay, stop timer. 79 + */ 80 + export function exitPresenter(refs: DOMRefs, actions: AppActions): void { 81 + const state = actions.getState(); 82 + actions.setState({ presenter: stopPresentation(state.presenter) }); 83 + 84 + refs.presenterOverlay.style.display = 'none'; 85 + document.body.classList.remove('presenting'); 86 + 87 + if (timerInterval) { 88 + clearInterval(timerInterval); 89 + timerInterval = null; 90 + } 91 + }
+187
src/slides/rendering.ts
··· 1 + /** 2 + * Slides Rendering — thumbnail panel, canvas rendering, shape SVG generation. 3 + * 4 + * Pure rendering functions that read state and write to the DOM. 5 + * No event handling or state mutation — those live in event-handlers.ts and main.ts. 6 + */ 7 + 8 + import { 9 + currentSlide, goToSlide, removeSlide, duplicateSlide, slideCount, 10 + SLIDE_WIDTH, SLIDE_HEIGHT, 11 + } from './canvas-engine.js'; 12 + import { getTheme, themeToCSS } from './layouts-themes.js'; 13 + import { escapeHtml } from '../lib/ai-chat.js'; 14 + import type { DOMRefs, AppActions } from './types.js'; 15 + 16 + /** 17 + * Render the thumbnail sidebar for slide navigation. 18 + */ 19 + export function renderThumbnails(refs: DOMRefs, actions: AppActions): void { 20 + const { deck, themedDeck } = actions.getState(); 21 + refs.thumbnailList.innerHTML = ''; 22 + 23 + deck.slides.forEach((slide, i) => { 24 + const thumb = document.createElement('div'); 25 + thumb.className = 'slides-thumbnail' + (i === deck.currentSlide ? ' active' : ''); 26 + thumb.dataset.index = String(i); 27 + 28 + const num = document.createElement('span'); 29 + num.className = 'slides-thumb-num'; 30 + num.textContent = String(i + 1); 31 + 32 + const preview = document.createElement('div'); 33 + preview.className = 'slides-thumb-preview'; 34 + preview.style.background = slide.background; 35 + preview.style.aspectRatio = '16/9'; 36 + 37 + if (slide.elements.length > 0) { 38 + preview.innerHTML = `<span class="slides-thumb-count">${slide.elements.length}</span>`; 39 + } 40 + 41 + thumb.appendChild(num); 42 + thumb.appendChild(preview); 43 + 44 + thumb.addEventListener('click', () => { 45 + const s = actions.getState(); 46 + actions.setState({ deck: goToSlide(s.deck, i) }); 47 + actions.syncDeckToYjs(); 48 + actions.render(); 49 + }); 50 + 51 + thumb.addEventListener('contextmenu', (e) => { 52 + e.preventDefault(); 53 + const s = actions.getState(); 54 + if (s.deck.slides.length > 1) { 55 + const shouldDelete = confirm('Delete this slide? (Cancel to duplicate)'); 56 + if (shouldDelete) { 57 + actions.setState({ 58 + deck: removeSlide(s.deck, i), 59 + themedDeck: { ...s.themedDeck, layouts: s.themedDeck.layouts.filter((_, idx) => idx !== i) }, 60 + }); 61 + } else { 62 + actions.setState({ deck: duplicateSlide(s.deck, i) }); 63 + } 64 + actions.syncDeckToYjs(); 65 + actions.render(); 66 + } 67 + }); 68 + 69 + refs.thumbnailList.appendChild(thumb); 70 + }); 71 + } 72 + 73 + /** 74 + * Render an SVG for a shape element. 75 + */ 76 + export function renderShapeSVG(shapeType: string, w: number, h: number, fill: string): string { 77 + switch (shapeType) { 78 + case 'ellipse': 79 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-2}" ry="${h/2-2}" fill="${fill}" stroke="${fill}" stroke-width="2" opacity="0.8"/></svg>`; 80 + case 'triangle': 81 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polygon points="${w/2},2 ${w-2},${h-2} 2,${h-2}" fill="${fill}" opacity="0.8"/></svg>`; 82 + case 'line': 83 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w}" y2="${h/2}" stroke="${fill}" stroke-width="3"/></svg>`; 84 + case 'arrow': 85 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w-10}" y2="${h/2}" stroke="${fill}" stroke-width="3"/><polygon points="${w},${h/2} ${w-12},${h/2-6} ${w-12},${h/2+6}" fill="${fill}"/></svg>`; 86 + default: // rectangle 87 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect x="2" y="2" width="${w-4}" height="${h-4}" rx="4" fill="${fill}" opacity="0.8"/></svg>`; 88 + } 89 + } 90 + 91 + /** 92 + * Render the main slide canvas with all elements. 93 + */ 94 + export function renderCanvas(refs: DOMRefs, actions: AppActions): void { 95 + const { deck, themedDeck, selectedElementId } = actions.getState(); 96 + const slide = currentSlide(deck); 97 + const theme = getTheme(themedDeck.themeId); 98 + const cssVars = theme ? themeToCSS(theme) : {}; 99 + 100 + let style = `width:${SLIDE_WIDTH}px;height:${SLIDE_HEIGHT}px;position:relative;overflow:hidden;background:${slide.background};`; 101 + for (const [k, v] of Object.entries(cssVars)) { 102 + style += `${k}:${v};`; 103 + } 104 + refs.slideCanvas.setAttribute('style', style); 105 + refs.slideCanvas.innerHTML = ''; 106 + 107 + const sorted = [...slide.elements].sort((a, b) => a.zIndex - b.zIndex); 108 + 109 + for (const el of sorted) { 110 + const div = document.createElement('div'); 111 + div.className = 'slide-element' + (el.id === selectedElementId ? ' selected' : ''); 112 + div.dataset.elementId = el.id; 113 + div.style.cssText = `position:absolute;left:${el.x}px;top:${el.y}px;width:${el.width}px;height:${el.height}px;` 114 + + (el.rotation ? `transform:rotate(${el.rotation}deg);` : ''); 115 + 116 + if (el.type === 'text') { 117 + const textDiv = document.createElement('div'); 118 + textDiv.className = 'slide-el-text'; 119 + textDiv.contentEditable = 'true'; 120 + textDiv.style.cssText = `width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;`; 121 + textDiv.textContent = el.content || 'Text'; 122 + div.appendChild(textDiv); 123 + } else if (el.type === 'shape') { 124 + const fill = el.style?.fill || theme?.palette.primary || '#3a8a7a'; 125 + div.innerHTML = renderShapeSVG(el.shapeType || 'rectangle', el.width, el.height, fill); 126 + } else if (el.type === 'image') { 127 + const img = document.createElement('img'); 128 + const imgUrl = el.content || ''; 129 + const isSafe = /^(https?:|data:|blob:)/i.test(imgUrl) || imgUrl === ''; 130 + img.src = isSafe ? imgUrl : ''; 131 + img.style.cssText = 'width:100%;height:100%;object-fit:contain;'; 132 + img.alt = ''; 133 + div.appendChild(img); 134 + } 135 + 136 + // Click to select + start drag 137 + div.addEventListener('mousedown', (e) => { 138 + e.stopPropagation(); 139 + actions.setState({ 140 + selectedElementId: el.id, 141 + isDragging: true, 142 + dragStartX: e.clientX, 143 + dragStartY: e.clientY, 144 + dragElStartX: el.x, 145 + dragElStartY: el.y, 146 + }); 147 + renderCanvas(refs, actions); 148 + }); 149 + 150 + // Inline text editing — commit on blur 151 + const textEl = div.querySelector('[contenteditable]'); 152 + if (textEl) { 153 + textEl.addEventListener('blur', () => { 154 + const s = actions.getState(); 155 + const current = currentSlide(s.deck); 156 + const elIdx = current.elements.findIndex(e => e.id === el.id); 157 + if (elIdx >= 0) { 158 + current.elements[elIdx] = { ...current.elements[elIdx]!, content: (textEl as HTMLElement).textContent || '' }; 159 + actions.syncDeckToYjs(); 160 + } 161 + }); 162 + } 163 + 164 + refs.slideCanvas.appendChild(div); 165 + } 166 + } 167 + 168 + /** 169 + * Full render pass: thumbnails, canvas, notes, dropdowns. 170 + */ 171 + export function render(refs: DOMRefs, actions: AppActions): void { 172 + renderThumbnails(refs, actions); 173 + renderCanvas(refs, actions); 174 + 175 + const state = actions.getState(); 176 + const slide = currentSlide(state.deck); 177 + refs.notesInput.value = slide.notes || ''; 178 + 179 + if (state.themedDeck.layouts[state.deck.currentSlide]) { 180 + refs.layoutSelect.value = state.themedDeck.layouts[state.deck.currentSlide]!; 181 + } 182 + refs.themeSelect.value = state.themedDeck.themeId; 183 + 184 + actions.setState({ 185 + presenter: { ...state.presenter, totalSlides: slideCount(state.deck) }, 186 + }); 187 + }
+53
src/slides/types.ts
··· 1 + /** 2 + * Slides App Types — shared interfaces for the slides editor modules. 3 + * 4 + * Dependency-injection context that wires modules together without 5 + * circular imports. Each module receives the parts of AppContext it needs. 6 + */ 7 + 8 + import type { DeckState } from './canvas-engine.js'; 9 + import type { ThemedDeck } from './layouts-themes.js'; 10 + import type { SlideTransitions } from './transitions.js'; 11 + import type { PresenterState } from './presenter-mode.js'; 12 + 13 + /** Mutable application state — single source of truth in main.ts. */ 14 + export interface AppState { 15 + deck: DeckState; 16 + themedDeck: ThemedDeck; 17 + transitions: SlideTransitions; 18 + presenter: PresenterState; 19 + selectedElementId: string | null; 20 + isDragging: boolean; 21 + dragStartX: number; 22 + dragStartY: number; 23 + dragElStartX: number; 24 + dragElStartY: number; 25 + cryptoKey: CryptoKey | null; 26 + docId: string; 27 + } 28 + 29 + /** DOM element references used across modules. */ 30 + export interface DOMRefs { 31 + deckTitle: HTMLInputElement; 32 + thumbnailList: HTMLElement; 33 + slideCanvas: HTMLElement; 34 + layoutSelect: HTMLSelectElement; 35 + themeSelect: HTMLSelectElement; 36 + transitionSelect: HTMLSelectElement; 37 + notesInput: HTMLTextAreaElement; 38 + presenterOverlay: HTMLElement; 39 + presenterCurrent: HTMLElement; 40 + presenterNextPreview: HTMLElement; 41 + presenterNotesEl: HTMLElement; 42 + presenterTimerEl: HTMLElement; 43 + presenterProgressEl: HTMLElement; 44 + } 45 + 46 + /** Operations that modules call back into the main orchestrator. */ 47 + export interface AppActions { 48 + getState: () => AppState; 49 + setState: (patch: Partial<AppState>) => void; 50 + syncDeckToYjs: () => void; 51 + render: () => void; 52 + renderCanvas: () => void; 53 + }