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