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 394 lines 16 kB view raw
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 8import { 9 currentSlide, goToSlide, removeSlide, duplicateSlide, moveSlide, slideCount, 10 SLIDE_WIDTH, SLIDE_HEIGHT, 11} from './canvas-engine.js'; 12import { getTheme, themeToCSS } from './layouts-themes.js'; 13import { escapeHtml } from '../lib/ai-chat.js'; 14import type { DOMRefs, AppActions } from './types.js'; 15 16/** 17 * Render the thumbnail sidebar for slide navigation. 18 */ 19/** 20 * Pick the effective slide background: prefer the theme's background color 21 * unless the slide carries an explicit non-default override. See #718. 22 * Default-new slides are created with '#ffffff'; only treat that exact value 23 * as "use the theme". 24 */ 25function slideBackground(slideBg: string, theme: ReturnType<typeof getTheme>): string { 26 if (!theme) return slideBg; 27 if (slideBg === '#ffffff' || slideBg === '#fff' || !slideBg) return theme.palette.background; 28 return slideBg; 29} 30 31export function renderThumbnails(refs: DOMRefs, actions: AppActions): void { 32 const { deck, themedDeck } = actions.getState(); 33 refs.thumbnailList.innerHTML = ''; 34 35 const theme = getTheme(themedDeck.themeId); 36 deck.slides.forEach((slide, i) => { 37 const thumb = document.createElement('div'); 38 thumb.className = 'slides-thumbnail' + (i === deck.currentSlide ? ' active' : ''); 39 thumb.dataset.index = String(i); 40 41 const num = document.createElement('span'); 42 num.className = 'slides-thumb-num'; 43 num.textContent = String(i + 1); 44 45 const preview = document.createElement('div'); 46 preview.className = 'slides-thumb-preview'; 47 preview.style.background = slideBackground(slide.background, theme); 48 preview.style.aspectRatio = '16/9'; 49 50 // Element count as a corner badge — previously this rendered as bare 51 // `<span>{N}</span>` which rendered visually flush against the slide- 52 // number above ("slide 1 with 1 element" read as "11", slide 2 with 2 53 // elements as "22", etc). See #727. Prefix with the element glyph so 54 // the badge reads as "▦ N" and is clearly separate from the slide 55 // number. 56 if (slide.elements.length > 0) { 57 preview.innerHTML = `<span class="slides-thumb-count" aria-label="${slide.elements.length} element${slide.elements.length === 1 ? '' : 's'}">\u25a6 ${slide.elements.length}</span>`; 58 } 59 60 thumb.appendChild(num); 61 thumb.appendChild(preview); 62 63 thumb.addEventListener('click', () => { 64 const s = actions.getState(); 65 actions.setState({ deck: goToSlide(s.deck, i) }); 66 actions.syncDeckToYjs(); 67 actions.render(); 68 }); 69 70 thumb.addEventListener('contextmenu', (e) => { 71 e.preventDefault(); 72 const s = actions.getState(); 73 if (s.deck.slides.length <= 1) return; 74 (async () => { 75 const { createContextMenu } = await import('../lib/context-menu.js'); 76 const menu = createContextMenu([ 77 { 78 label: 'Duplicate slide', 79 icon: '\u29C9', 80 action: () => { 81 const cur = actions.getState(); 82 actions.setState({ deck: duplicateSlide(cur.deck, i) }); 83 actions.syncDeckToYjs(); 84 actions.render(); 85 }, 86 }, 87 { 88 label: 'Delete slide', 89 icon: '\u2715', 90 action: () => { 91 const cur = actions.getState(); 92 actions.setState({ 93 deck: removeSlide(cur.deck, i), 94 themedDeck: { ...cur.themedDeck, layouts: cur.themedDeck.layouts.filter((_, idx) => idx !== i) }, 95 }); 96 actions.syncDeckToYjs(); 97 actions.render(); 98 }, 99 }, 100 ]); 101 document.body.appendChild(menu.el); 102 menu.show(e.clientX, e.clientY); 103 const closeHandler = (ev: MouseEvent): void => { 104 if (!menu.el.contains(ev.target as Node)) { 105 menu.destroy(); 106 document.removeEventListener('mousedown', closeHandler); 107 } 108 }; 109 setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 110 })(); 111 }); 112 113 // Drag-to-reorder 114 thumb.draggable = true; 115 thumb.addEventListener('dragstart', (e) => { 116 thumb.classList.add('dragging'); 117 e.dataTransfer!.effectAllowed = 'move'; 118 e.dataTransfer!.setData('text/plain', String(i)); 119 }); 120 thumb.addEventListener('dragend', () => { 121 thumb.classList.remove('dragging'); 122 refs.thumbnailList.querySelectorAll('.slides-thumbnail').forEach(t => { 123 (t as HTMLElement).classList.remove('drag-over-top', 'drag-over-bottom'); 124 }); 125 }); 126 thumb.addEventListener('dragover', (e) => { 127 e.preventDefault(); 128 e.dataTransfer!.dropEffect = 'move'; 129 refs.thumbnailList.querySelectorAll('.slides-thumbnail').forEach(t => { 130 (t as HTMLElement).classList.remove('drag-over-top', 'drag-over-bottom'); 131 }); 132 const rect = thumb.getBoundingClientRect(); 133 const midY = rect.top + rect.height / 2; 134 if (e.clientY < midY) { 135 thumb.classList.add('drag-over-top'); 136 } else { 137 thumb.classList.add('drag-over-bottom'); 138 } 139 }); 140 thumb.addEventListener('dragleave', () => { 141 thumb.classList.remove('drag-over-top', 'drag-over-bottom'); 142 }); 143 thumb.addEventListener('drop', (e) => { 144 e.preventDefault(); 145 const fromIdx = parseInt(e.dataTransfer!.getData('text/plain')); 146 if (isNaN(fromIdx) || fromIdx === i) return; 147 const s = actions.getState(); 148 const deck = moveSlide(s.deck, fromIdx, i); 149 const layouts = [...s.themedDeck.layouts]; 150 const [movedLayout] = layouts.splice(fromIdx, 1); 151 layouts.splice(i, 0, movedLayout); 152 actions.setState({ 153 deck: goToSlide(deck, i), 154 themedDeck: { ...s.themedDeck, layouts }, 155 }); 156 actions.syncDeckToYjs(); 157 actions.render(); 158 }); 159 160 refs.thumbnailList.appendChild(thumb); 161 }); 162} 163 164/** 165 * Render an SVG for a shape element. 166 */ 167export function renderShapeSVG(shapeType: string, w: number, h: number, fill: string): string { 168 switch (shapeType) { 169 case 'ellipse': 170 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>`; 171 case 'triangle': 172 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>`; 173 case 'line': 174 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>`; 175 case 'arrow': 176 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>`; 177 default: // rectangle 178 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>`; 179 } 180} 181 182/** 183 * Render a table element as a styled HTML table. 184 * Content format: JSON array of arrays, or markdown-style pipe-delimited text. 185 */ 186function renderTableElement(content: string, width: number, height: number, theme: ReturnType<typeof getTheme>): HTMLElement { 187 const wrapper = document.createElement('div'); 188 wrapper.className = 'slide-el-table'; 189 wrapper.style.cssText = `width:100%;height:100%;overflow:hidden;`; 190 191 let rows: string[][] = []; 192 try { 193 const parsed = JSON.parse(content); 194 if (Array.isArray(parsed) && parsed.length > 0) { 195 rows = parsed.map((r: unknown) => Array.isArray(r) ? r.map(String) : [String(r)]); 196 } 197 } catch { 198 // Fall back to pipe-delimited text parsing 199 const lines = content.split('\n').filter(l => l.trim()); 200 for (const line of lines) { 201 // Skip separator lines (---) 202 if (/^[\s|:-]+$/.test(line.replace(/-/g, ''))) continue; 203 if (/^-+(\s*\|\s*-+)*$/.test(line.trim())) continue; 204 const cells = line.split(/\s*\|\s*/).map(c => c.trim()).filter((_, i, arr) => i > 0 || arr[0] !== ''); 205 if (cells.length > 0) rows.push(cells); 206 } 207 } 208 209 if (rows.length === 0) { 210 wrapper.textContent = content || 'Empty table'; 211 return wrapper; 212 } 213 214 const table = document.createElement('table'); 215 table.className = 'slide-table'; 216 const textColor = theme?.palette.text || '#1a1815'; 217 const borderColor = theme?.palette.text ? theme.palette.text + '33' : '#d0d0d0'; 218 table.style.cssText = `width:100%;border-collapse:collapse;font-family:${theme?.fonts.body || 'system-ui'};font-size:12px;color:${textColor};`; 219 220 // First row as header 221 const thead = document.createElement('thead'); 222 const headerRow = document.createElement('tr'); 223 for (const cell of rows[0]) { 224 const th = document.createElement('th'); 225 th.textContent = cell; 226 th.style.cssText = `padding:4px 8px;border:1px solid ${borderColor};font-weight:600;text-align:left;background:${borderColor};`; 227 headerRow.appendChild(th); 228 } 229 thead.appendChild(headerRow); 230 table.appendChild(thead); 231 232 // Data rows 233 if (rows.length > 1) { 234 const tbody = document.createElement('tbody'); 235 for (let i = 1; i < rows.length; i++) { 236 const tr = document.createElement('tr'); 237 const maxCols = rows[0].length; 238 for (let j = 0; j < maxCols; j++) { 239 const td = document.createElement('td'); 240 td.textContent = rows[i][j] || ''; 241 td.style.cssText = `padding:4px 8px;border:1px solid ${borderColor};text-align:left;`; 242 tr.appendChild(td); 243 } 244 tbody.appendChild(tr); 245 } 246 table.appendChild(tbody); 247 } 248 249 wrapper.appendChild(table); 250 return wrapper; 251} 252 253/** 254 * Render the main slide canvas with all elements. 255 */ 256export function renderCanvas(refs: DOMRefs, actions: AppActions): void { 257 const { deck, themedDeck, selectedElementId } = actions.getState(); 258 const slide = currentSlide(deck); 259 const theme = getTheme(themedDeck.themeId); 260 const cssVars = theme ? themeToCSS(theme) : {}; 261 262 let style = `width:${SLIDE_WIDTH}px;height:${SLIDE_HEIGHT}px;position:relative;overflow:hidden;background:${slideBackground(slide.background, theme)};`; 263 for (const [k, v] of Object.entries(cssVars)) { 264 style += `${k}:${v};`; 265 } 266 refs.slideCanvas.setAttribute('style', style); 267 refs.slideCanvas.innerHTML = ''; 268 269 const sorted = [...slide.elements].sort((a, b) => a.zIndex - b.zIndex); 270 271 for (const el of sorted) { 272 const div = document.createElement('div'); 273 div.className = 'slide-element' + (el.id === selectedElementId ? ' selected' : ''); 274 div.dataset.elementId = el.id; 275 const isMasterPlaceholder = !!el.style._masterId; 276 div.style.cssText = `position:absolute;left:${el.x}px;top:${el.y}px;width:${el.width}px;height:${el.height}px;` 277 + (el.rotation ? `transform:rotate(${el.rotation}deg);` : '') 278 + (isMasterPlaceholder ? 'border:1.5px dashed var(--slide-accent, #3a8a7a);border-radius:3px;' : ''); 279 280 if (el.type === 'text') { 281 const textDiv = document.createElement('div'); 282 textDiv.className = 'slide-el-text'; 283 textDiv.contentEditable = 'true'; 284 textDiv.style.cssText = `width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;`; 285 textDiv.textContent = el.content || 'Text'; 286 div.appendChild(textDiv); 287 } else if (el.type === 'shape') { 288 const fill = el.style?.fill || theme?.palette.primary || '#3a8a7a'; 289 div.innerHTML = renderShapeSVG(el.shapeType || 'rectangle', el.width, el.height, fill); 290 } else if (el.type === 'image') { 291 const img = document.createElement('img'); 292 const imgUrl = el.content || ''; 293 const isSafe = /^(https?:|data:|blob:)/i.test(imgUrl) || imgUrl === ''; 294 img.src = isSafe ? imgUrl : ''; 295 img.style.cssText = 'width:100%;height:100%;object-fit:contain;'; 296 img.alt = ''; 297 div.appendChild(img); 298 } else if (el.type === 'table') { 299 div.appendChild(renderTableElement(el.content, el.width, el.height, theme)); 300 } else if (el.type === 'video') { 301 const videoUrl = el.content || ''; 302 const isSafe = /^(https?:|data:video\/|blob:)/i.test(videoUrl) || videoUrl === ''; 303 const video = document.createElement('video'); 304 video.src = isSafe ? videoUrl : ''; 305 video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000;'; 306 if (el.style.controls === 'true') video.controls = true; 307 if (el.style.loop === 'true') video.loop = true; 308 if (el.style.muted === 'true') video.muted = true; 309 // Don't autoplay in edit mode 310 video.preload = 'metadata'; 311 div.appendChild(video); 312 } else if (el.type === 'audio') { 313 const audioUrl = el.content || ''; 314 const isSafe = /^(https?:|data:audio\/|blob:)/i.test(audioUrl) || audioUrl === ''; 315 const wrapper = document.createElement('div'); 316 wrapper.className = 'slide-el-audio'; 317 wrapper.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--color-bg-muted, #f0f0f0);border-radius:8px;'; 318 const audio = document.createElement('audio'); 319 audio.src = isSafe ? audioUrl : ''; 320 audio.style.cssText = 'width:90%;'; 321 if (el.style.controls === 'true') audio.controls = true; 322 if (el.style.loop === 'true') audio.loop = true; 323 if (el.style.muted === 'true') audio.muted = true; 324 audio.preload = 'metadata'; 325 wrapper.appendChild(audio); 326 div.appendChild(wrapper); 327 } else if (el.type === 'embed') { 328 const embedUrl = el.content || ''; 329 const isSafe = /^https?:/i.test(embedUrl); 330 const iframe = document.createElement('iframe'); 331 iframe.src = isSafe ? embedUrl : ''; 332 iframe.style.cssText = 'width:100%;height:100%;border:none;'; 333 iframe.setAttribute('allowfullscreen', ''); 334 iframe.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); 335 iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); 336 iframe.loading = 'lazy'; 337 div.appendChild(iframe); 338 } 339 340 // Click to select + start drag 341 div.addEventListener('mousedown', (e) => { 342 e.stopPropagation(); 343 actions.setState({ 344 selectedElementId: el.id, 345 isDragging: true, 346 dragStartX: e.clientX, 347 dragStartY: e.clientY, 348 dragElStartX: el.x, 349 dragElStartY: el.y, 350 }); 351 renderCanvas(refs, actions); 352 }); 353 354 // Inline text editing — commit on blur 355 const textEl = div.querySelector('[contenteditable]'); 356 if (textEl) { 357 textEl.addEventListener('blur', () => { 358 const s = actions.getState(); 359 const current = currentSlide(s.deck); 360 const elIdx = current.elements.findIndex(e => e.id === el.id); 361 if (elIdx >= 0) { 362 current.elements[elIdx] = { ...current.elements[elIdx]!, content: (textEl as HTMLElement).textContent || '' }; 363 actions.syncDeckToYjs(); 364 } 365 }); 366 } 367 368 refs.slideCanvas.appendChild(div); 369 } 370} 371 372/** 373 * Full render pass: thumbnails, canvas, notes, dropdowns. 374 */ 375export function render(refs: DOMRefs, actions: AppActions): void { 376 renderThumbnails(refs, actions); 377 renderCanvas(refs, actions); 378 379 const state = actions.getState(); 380 const slide = currentSlide(state.deck); 381 refs.notesInput.value = slide.notes || ''; 382 383 if (state.themedDeck.layouts[state.deck.currentSlide]) { 384 refs.layoutSelect.value = state.themedDeck.layouts[state.deck.currentSlide]!; 385 } 386 // Sync master select dropdown to current slide's assignment 387 const currentMasterId = state.deck.masterAssignments?.[slide.id] || ''; 388 refs.masterSelect.value = currentMasterId; 389 refs.themeSelect.value = state.themedDeck.themeId; 390 391 actions.setState({ 392 presenter: { ...state.presenter, totalSlides: slideCount(state.deck) }, 393 }); 394}