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

Configure Feed

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

feat: shortcuts settings, print dialog, slide reorder, presence sidebar (0.39.0)

Add reusable keyboard shortcuts settings panel UI with click-to-record
rebinding, conflict detection, localStorage persistence, and reset.

Sheets print: replace direct-print with options dialog. Users can now
choose page size, orientation, margins, scaling mode, grid lines, and
header repeat before printing or previewing.

Slides: add drag-to-reorder on thumbnail panel. Dragging a slide
thumbnail repositions it in the deck with visual drop indicators.

Collaboration: add presence sidebar showing who's online with
active/stale status dots, auto-refresh every 5s, and user avatars.

Closes #618, #619, #620, #622

+821 -3
+10
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.39.0] — 2026-04-14 11 + 12 + ### Added 13 + - Keyboard shortcuts settings panel — reusable UI for viewing and rebinding shortcuts with conflict detection (#618) 14 + - Sheets: print options dialog with page size, orientation, margins, grid lines, repeat headers, scaling, and preview (#619) 15 + - Slides: drag-to-reorder thumbnails in the slide panel (#620) 16 + - Collaboration: who's online presence sidebar with active/stale indicators, auto-refresh (#622) 17 + - CSS: slide thumbnail drag indicators, presence sidebar styles, print dialog styles 18 + 10 19 ## [0.38.0] — 2026-04-14 11 20 12 21 ### Added 22 + - Sheets: drag-to-reorder tab bar (#621) 13 23 - Sheets: 5 new chart types — area, doughnut, radar, stacked bar, stacked line (#613) 14 24 - Sheets: data bars conditional formatting with bidirectional support for negative values (#614) 15 25 - Sheets: icon sets conditional formatting with 3/4/5-icon set support (#614)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.38.0", 3 + "version": "0.39.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+82
src/css/app.css
··· 11663 11663 } 11664 11664 .slides-anim-controls select { font-size: 0.78rem; flex: 1; min-width: 80px; } 11665 11665 .slides-anim-hint { font-size: 0.78rem; color: var(--color-text-muted); margin: var(--space-xs) 0; } 11666 + 11667 + /* ── Slide Thumbnail Drag-to-Reorder ──────────────────────────── */ 11668 + 11669 + .slides-thumbnail.dragging { opacity: 0.4; } 11670 + .slides-thumbnail.drag-over-top { border-top: 3px solid var(--color-accent); } 11671 + .slides-thumbnail.drag-over-bottom { border-bottom: 3px solid var(--color-accent); } 11672 + .slides-thumbnail { cursor: grab; transition: opacity 0.15s; } 11673 + .slides-thumbnail:active { cursor: grabbing; } 11674 + 11675 + /* ── Presence / Who's Online Sidebar ─────────────────────────── */ 11676 + 11677 + .presence-sidebar { 11678 + position: fixed; 11679 + right: 0; 11680 + top: var(--topbar-h, 48px); 11681 + width: 240px; 11682 + max-height: calc(100vh - var(--topbar-h, 48px)); 11683 + background: var(--color-surface); 11684 + border-left: 1px solid var(--color-border); 11685 + overflow-y: auto; 11686 + z-index: 30; 11687 + padding: var(--space-sm); 11688 + display: flex; 11689 + flex-direction: column; 11690 + gap: var(--space-xs); 11691 + box-shadow: -2px 0 8px rgba(0,0,0,0.05); 11692 + } 11693 + 11694 + .presence-sidebar h3 { 11695 + margin: 0; 11696 + font-size: 0.85rem; 11697 + font-weight: 600; 11698 + color: var(--color-text-muted); 11699 + text-transform: uppercase; 11700 + letter-spacing: 0.04em; 11701 + } 11702 + 11703 + .presence-user { 11704 + display: flex; 11705 + align-items: center; 11706 + gap: var(--space-xs); 11707 + padding: 6px 8px; 11708 + border-radius: var(--radius-sm, 4px); 11709 + } 11710 + .presence-user:hover { background: var(--color-surface-alt); } 11711 + 11712 + .presence-avatar { 11713 + width: 28px; 11714 + height: 28px; 11715 + border-radius: 50%; 11716 + display: flex; 11717 + align-items: center; 11718 + justify-content: center; 11719 + color: #fff; 11720 + font-weight: 600; 11721 + font-size: 0.75rem; 11722 + flex-shrink: 0; 11723 + } 11724 + 11725 + .presence-info { 11726 + flex: 1; 11727 + min-width: 0; 11728 + } 11729 + .presence-name { 11730 + font-size: 0.82rem; 11731 + font-weight: 500; 11732 + overflow: hidden; 11733 + text-overflow: ellipsis; 11734 + white-space: nowrap; 11735 + } 11736 + .presence-status { 11737 + font-size: 0.7rem; 11738 + color: var(--color-text-muted); 11739 + } 11740 + .presence-dot { 11741 + width: 8px; 11742 + height: 8px; 11743 + border-radius: 50%; 11744 + flex-shrink: 0; 11745 + } 11746 + .presence-dot--active { background: #4caf50; } 11747 + .presence-dot--stale { background: #ff9800; }
+124
src/lib/presence-sidebar.ts
··· 1 + /** 2 + * Presence Sidebar — shows who's online in the current document. 3 + * 4 + * Renders a toggleable sidebar with user avatars, names, and 5 + * active/stale status indicators. Works with any Yjs awareness instance. 6 + */ 7 + 8 + import { 9 + getActiveCursors, 10 + getStaleCursors, 11 + } from './cursor-presence.js'; 12 + import type { PresenceState, UserPresence } from './cursor-presence.js'; 13 + 14 + export interface PresenceSidebarConfig { 15 + /** Where to mount the sidebar */ 16 + anchorEl: HTMLElement; 17 + /** Get current presence state */ 18 + getPresenceState: () => PresenceState; 19 + /** Local user name */ 20 + getLocalUserName: () => string; 21 + } 22 + 23 + /** 24 + * Mount the presence sidebar. Returns toggle/destroy controls. 25 + */ 26 + export function mountPresenceSidebar(config: PresenceSidebarConfig): { 27 + toggle: () => void; 28 + refresh: () => void; 29 + destroy: () => void; 30 + isOpen: () => boolean; 31 + } { 32 + let sidebar: HTMLElement | null = null; 33 + let refreshTimer: ReturnType<typeof setInterval> | null = null; 34 + 35 + function isOpen() { return sidebar !== null && sidebar.style.display !== 'none'; } 36 + 37 + function render() { 38 + if (!sidebar || sidebar.style.display === 'none') return; 39 + 40 + const state = config.getPresenceState(); 41 + const now = Date.now(); 42 + const active = getActiveCursors(state, now); 43 + const stale = getStaleCursors(state, now); 44 + const localName = config.getLocalUserName(); 45 + 46 + let html = '<h3>Online</h3>'; 47 + 48 + // Local user (always shown first) 49 + html += renderUser(localName, '#3498db', 'You', true); 50 + 51 + // Active remote users 52 + for (const user of active) { 53 + html += renderUser(user.userName, user.color, 'Active', true); 54 + } 55 + 56 + // Stale remote users 57 + for (const user of stale) { 58 + html += renderUser(user.userName, user.color, 'Away', false); 59 + } 60 + 61 + if (active.length === 0 && stale.length === 0) { 62 + html += '<p style="font-size:0.78rem;color:var(--color-text-muted);margin:8px 0">No other collaborators</p>'; 63 + } 64 + 65 + sidebar.innerHTML = html; 66 + } 67 + 68 + function renderUser(name: string, color: string, status: string, isActive: boolean): string { 69 + const initial = name.charAt(0).toUpperCase() || '?'; 70 + const dotClass = isActive ? 'presence-dot--active' : 'presence-dot--stale'; 71 + return `<div class="presence-user"> 72 + <div class="presence-avatar" style="background:${escapeAttr(color)}">${escapeHtml(initial)}</div> 73 + <div class="presence-info"> 74 + <div class="presence-name">${escapeHtml(name)}</div> 75 + <div class="presence-status">${escapeHtml(status)}</div> 76 + </div> 77 + <div class="presence-dot ${dotClass}"></div> 78 + </div>`; 79 + } 80 + 81 + function toggle() { 82 + if (!sidebar) { 83 + sidebar = document.createElement('div'); 84 + sidebar.className = 'presence-sidebar'; 85 + config.anchorEl.appendChild(sidebar); 86 + } 87 + 88 + if (sidebar.style.display === 'none') { 89 + sidebar.style.display = ''; 90 + render(); 91 + // Auto-refresh every 5 seconds while open 92 + if (!refreshTimer) { 93 + refreshTimer = setInterval(render, 5000); 94 + } 95 + } else { 96 + sidebar.style.display = 'none'; 97 + if (refreshTimer) { 98 + clearInterval(refreshTimer); 99 + refreshTimer = null; 100 + } 101 + } 102 + } 103 + 104 + function destroy() { 105 + if (refreshTimer) { 106 + clearInterval(refreshTimer); 107 + refreshTimer = null; 108 + } 109 + if (sidebar) { 110 + sidebar.remove(); 111 + sidebar = null; 112 + } 113 + } 114 + 115 + return { toggle, refresh: render, destroy, isOpen }; 116 + } 117 + 118 + function escapeHtml(s: string): string { 119 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 120 + } 121 + 122 + function escapeAttr(s: string): string { 123 + return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 124 + }
+186
src/lib/shortcuts-settings-ui.ts
··· 1 + /** 2 + * Keyboard Shortcuts Settings Panel — reusable UI for viewing and 3 + * customizing key bindings. Mounts into any container element. 4 + * 5 + * Uses the pure logic from keyboard-shortcuts.ts for state management. 6 + * Renders a grouped list of shortcuts with click-to-record rebinding. 7 + */ 8 + 9 + import { 10 + shortcutsByCategory, 11 + formatCombo, 12 + activeCombo, 13 + setCustomBinding, 14 + resetBinding, 15 + resetAllBindings, 16 + wouldConflict, 17 + serializeBindings, 18 + deserializeBindings, 19 + keyCombo, 20 + } from './keyboard-shortcuts.js'; 21 + import type { ShortcutState, KeyCombo, Shortcut } from './keyboard-shortcuts.js'; 22 + 23 + const STORAGE_KEY = 'tools-shortcut-bindings'; 24 + 25 + export interface ShortcutsSettingsConfig { 26 + container: HTMLElement; 27 + getState: () => ShortcutState; 28 + setState: (state: ShortcutState) => void; 29 + } 30 + 31 + /** 32 + * Mount the shortcuts settings panel into a container. 33 + * Returns a destroy function to clean up. 34 + */ 35 + export function mountShortcutsSettings(config: ShortcutsSettingsConfig): { destroy: () => void; refresh: () => void } { 36 + const { container, getState, setState } = config; 37 + let recordingId: string | null = null; 38 + let keydownHandler: ((e: KeyboardEvent) => void) | null = null; 39 + 40 + function render() { 41 + const state = getState(); 42 + const categories = shortcutsByCategory(state); 43 + let html = '<div class="shortcuts-settings-panel">'; 44 + html += '<div class="shortcuts-settings-header">'; 45 + html += '<h3>Keyboard Shortcuts</h3>'; 46 + html += '<button class="shortcut-reset-all-btn" id="btn-reset-all-shortcuts">Reset All</button>'; 47 + html += '</div>'; 48 + 49 + for (const [category, shortcuts] of categories) { 50 + html += `<div class="shortcuts-category">`; 51 + html += `<h4 class="shortcuts-category-title">${escapeHtml(category)}</h4>`; 52 + for (const shortcut of shortcuts) { 53 + const combo = activeCombo(shortcut); 54 + const isCustom = shortcut.customCombo !== null; 55 + const isRecording = recordingId === shortcut.id; 56 + const conflictId = wouldConflict(state, combo, shortcut.id); 57 + const conflictClass = conflictId ? ' shortcut-combo--conflict' : ''; 58 + const recordingClass = isRecording ? ' shortcut-combo--recording' : ''; 59 + 60 + html += `<div class="shortcut-row" data-shortcut-id="${shortcut.id}">`; 61 + html += `<span class="shortcut-label">${escapeHtml(shortcut.label)}`; 62 + if (shortcut.description) { 63 + html += `<span class="shortcut-desc">${escapeHtml(shortcut.description)}</span>`; 64 + } 65 + html += '</span>'; 66 + html += `<span class="shortcut-combo${conflictClass}${recordingClass}" data-action="record" title="Click to rebind">${isRecording ? 'Press keys...' : formatCombo(combo)}</span>`; 67 + if (isCustom) { 68 + html += `<button class="shortcut-reset-btn" data-action="reset" title="Reset to default">Reset</button>`; 69 + } 70 + html += '</div>'; 71 + } 72 + html += '</div>'; 73 + } 74 + html += '</div>'; 75 + container.innerHTML = html; 76 + 77 + // Wire events 78 + container.querySelector('#btn-reset-all-shortcuts')?.addEventListener('click', () => { 79 + setState(resetAllBindings(getState())); 80 + persistBindings(getState()); 81 + recordingId = null; 82 + render(); 83 + }); 84 + 85 + container.querySelectorAll('[data-action="record"]').forEach(el => { 86 + el.addEventListener('click', (e) => { 87 + const row = (e.target as HTMLElement).closest('.shortcut-row') as HTMLElement; 88 + const id = row?.dataset.shortcutId; 89 + if (!id) return; 90 + startRecording(id); 91 + }); 92 + }); 93 + 94 + container.querySelectorAll('[data-action="reset"]').forEach(el => { 95 + el.addEventListener('click', (e) => { 96 + const row = (e.target as HTMLElement).closest('.shortcut-row') as HTMLElement; 97 + const id = row?.dataset.shortcutId; 98 + if (!id) return; 99 + setState(resetBinding(getState(), id)); 100 + persistBindings(getState()); 101 + render(); 102 + }); 103 + }); 104 + } 105 + 106 + function startRecording(id: string) { 107 + recordingId = id; 108 + render(); 109 + 110 + // Remove old handler if any 111 + if (keydownHandler) { 112 + document.removeEventListener('keydown', keydownHandler, true); 113 + } 114 + 115 + keydownHandler = (e: KeyboardEvent) => { 116 + e.preventDefault(); 117 + e.stopPropagation(); 118 + 119 + // Escape cancels recording 120 + if (e.key === 'Escape') { 121 + recordingId = null; 122 + document.removeEventListener('keydown', keydownHandler!, true); 123 + keydownHandler = null; 124 + render(); 125 + return; 126 + } 127 + 128 + // Ignore modifier-only keypresses 129 + if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return; 130 + 131 + const combo = keyCombo(e.key, { 132 + ctrl: e.ctrlKey, 133 + shift: e.shiftKey, 134 + alt: e.altKey, 135 + meta: e.metaKey, 136 + }); 137 + 138 + setState(setCustomBinding(getState(), id, combo)); 139 + persistBindings(getState()); 140 + recordingId = null; 141 + document.removeEventListener('keydown', keydownHandler!, true); 142 + keydownHandler = null; 143 + render(); 144 + }; 145 + 146 + document.addEventListener('keydown', keydownHandler, true); 147 + } 148 + 149 + function persistBindings(state: ShortcutState) { 150 + try { 151 + const data = serializeBindings(state); 152 + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); 153 + } catch { /* ignore */ } 154 + } 155 + 156 + function destroy() { 157 + if (keydownHandler) { 158 + document.removeEventListener('keydown', keydownHandler, true); 159 + keydownHandler = null; 160 + } 161 + container.innerHTML = ''; 162 + } 163 + 164 + // Initial render 165 + render(); 166 + 167 + return { destroy, refresh: render }; 168 + } 169 + 170 + /** 171 + * Load saved bindings from localStorage and apply them to a shortcut state. 172 + */ 173 + export function loadSavedBindings(state: ShortcutState): ShortcutState { 174 + try { 175 + const raw = localStorage.getItem(STORAGE_KEY); 176 + if (!raw) return state; 177 + const data = JSON.parse(raw); 178 + return deserializeBindings(state, data); 179 + } catch { 180 + return state; 181 + } 182 + } 183 + 184 + function escapeHtml(s: string): string { 185 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 186 + }
+9 -1
src/sheets/import-export.ts
··· 316 316 }); 317 317 document.getElementById('tb-export-pdf')?.addEventListener('click', () => { exportSheetPdf(deps); closeAllDropdowns(); }); 318 318 document.getElementById('tb-import')!.addEventListener('click', () => { importCSV(deps); closeAllDropdowns(); }); 319 - document.getElementById('tb-print')!.addEventListener('click', () => { printSheet(deps); closeAllDropdowns(); }); 319 + document.getElementById('tb-print')!.addEventListener('click', () => { 320 + closeAllDropdowns(); 321 + import('./print-dialog.js').then(({ showPrintDialog }) => { 322 + showPrintDialog({ 323 + buildPrintData: () => buildPrintData(deps), 324 + getSheetName: () => deps.getActiveSheet().get('name') || 'Sheet 1', 325 + }); 326 + }); 327 + }); 320 328 321 329 // Drag-and-drop import 322 330 deps.sheetContainer.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer!.dropEffect = 'copy'; deps.sheetContainer.classList.add('drag-over'); });
+110
src/sheets/print-dialog.ts
··· 1 + /** 2 + * Print Options Dialog for Sheets. 3 + * 4 + * Shows a modal with page size, orientation, margins, grid lines, 5 + * repeat headers, and scaling options before printing. 6 + */ 7 + 8 + import { PAGE_SIZES, MARGIN_PRESETS, scalingModes, buildSheetsPrintHtml } from '../lib/print-layout.js'; 9 + import type { SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 10 + 11 + export interface PrintDialogDeps { 12 + buildPrintData: () => SheetsPrintData; 13 + getSheetName: () => string; 14 + } 15 + 16 + export function showPrintDialog(deps: PrintDialogDeps): void { 17 + const overlay = document.createElement('div'); 18 + overlay.className = 'sheet-dialog-overlay'; 19 + 20 + const defaults: SheetsPrintOptions = { 21 + pageSize: 'letter', 22 + orientation: 'landscape', 23 + margins: 'normal', 24 + gridLines: true, 25 + repeatHeaders: true, 26 + scaling: 'fit-to-width', 27 + title: deps.getSheetName(), 28 + }; 29 + 30 + overlay.innerHTML = ` 31 + <div class="sheet-dialog" style="max-width:420px"> 32 + <h3>Print Options</h3> 33 + <label>Page Size 34 + <select id="print-page-size"> 35 + ${Object.keys(PAGE_SIZES).map(k => `<option value="${k}" ${k === defaults.pageSize ? 'selected' : ''}>${k.charAt(0).toUpperCase() + k.slice(1)}</option>`).join('')} 36 + </select> 37 + </label> 38 + <label>Orientation 39 + <select id="print-orientation"> 40 + <option value="portrait">Portrait</option> 41 + <option value="landscape" selected>Landscape</option> 42 + </select> 43 + </label> 44 + <label>Margins 45 + <select id="print-margins"> 46 + ${Object.keys(MARGIN_PRESETS).map(k => `<option value="${k}" ${k === defaults.margins ? 'selected' : ''}>${k.charAt(0).toUpperCase() + k.slice(1)}</option>`).join('')} 47 + </select> 48 + </label> 49 + <label>Scaling 50 + <select id="print-scaling"> 51 + ${scalingModes.map(m => `<option value="${m}" ${m === defaults.scaling ? 'selected' : ''}>${m.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</option>`).join('')} 52 + </select> 53 + </label> 54 + <div class="print-checkbox-row" style="display:flex;gap:16px;margin:8px 0"> 55 + <label style="display:flex;align-items:center;gap:4px;font-size:0.85rem"> 56 + <input type="checkbox" id="print-grid-lines" ${defaults.gridLines ? 'checked' : ''}> Grid lines 57 + </label> 58 + <label style="display:flex;align-items:center;gap:4px;font-size:0.85rem"> 59 + <input type="checkbox" id="print-repeat-headers" ${defaults.repeatHeaders ? 'checked' : ''}> Repeat headers 60 + </label> 61 + </div> 62 + <div class="sheet-dialog-actions"> 63 + <button id="print-cancel">Cancel</button> 64 + <button id="print-preview" class="btn-secondary">Preview</button> 65 + <button id="print-ok" class="btn-primary">Print</button> 66 + </div> 67 + </div> 68 + `; 69 + 70 + document.body.appendChild(overlay); 71 + overlay.querySelector('#print-cancel')!.addEventListener('click', () => overlay.remove()); 72 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 73 + 74 + function getOptions(): SheetsPrintOptions { 75 + return { 76 + pageSize: (overlay.querySelector('#print-page-size') as HTMLSelectElement).value, 77 + orientation: (overlay.querySelector('#print-orientation') as HTMLSelectElement).value as 'portrait' | 'landscape', 78 + margins: (overlay.querySelector('#print-margins') as HTMLSelectElement).value, 79 + scaling: (overlay.querySelector('#print-scaling') as HTMLSelectElement).value as any, 80 + gridLines: (overlay.querySelector('#print-grid-lines') as HTMLInputElement).checked, 81 + repeatHeaders: (overlay.querySelector('#print-repeat-headers') as HTMLInputElement).checked, 82 + title: deps.getSheetName(), 83 + }; 84 + } 85 + 86 + function doPrint() { 87 + const html = buildSheetsPrintHtml(deps.buildPrintData(), getOptions()); 88 + const printWindow = window.open('', '_blank'); 89 + if (printWindow) { 90 + printWindow.document.write(html); 91 + printWindow.document.close(); 92 + printWindow.addEventListener('load', () => { printWindow.print(); }); 93 + } 94 + overlay.remove(); 95 + } 96 + 97 + function doPreview() { 98 + const html = buildSheetsPrintHtml(deps.buildPrintData(), getOptions()); 99 + const previewWindow = window.open('', '_blank'); 100 + if (previewWindow) { 101 + previewWindow.document.write(html); 102 + previewWindow.document.close(); 103 + } 104 + } 105 + 106 + overlay.querySelector('#print-ok')!.addEventListener('click', doPrint); 107 + overlay.querySelector('#print-preview')!.addEventListener('click', doPreview); 108 + 109 + setTimeout(() => (overlay.querySelector('#print-page-size') as HTMLSelectElement)?.focus(), 50); 110 + }
+49 -1
src/slides/rendering.ts
··· 6 6 */ 7 7 8 8 import { 9 - currentSlide, goToSlide, removeSlide, duplicateSlide, slideCount, 9 + currentSlide, goToSlide, removeSlide, duplicateSlide, moveSlide, slideCount, 10 10 SLIDE_WIDTH, SLIDE_HEIGHT, 11 11 } from './canvas-engine.js'; 12 12 import { getTheme, themeToCSS } from './layouts-themes.js'; ··· 64 64 actions.syncDeckToYjs(); 65 65 actions.render(); 66 66 } 67 + }); 68 + 69 + // Drag-to-reorder 70 + thumb.draggable = true; 71 + thumb.addEventListener('dragstart', (e) => { 72 + thumb.classList.add('dragging'); 73 + e.dataTransfer!.effectAllowed = 'move'; 74 + e.dataTransfer!.setData('text/plain', String(i)); 75 + }); 76 + thumb.addEventListener('dragend', () => { 77 + thumb.classList.remove('dragging'); 78 + refs.thumbnailList.querySelectorAll('.slides-thumbnail').forEach(t => { 79 + (t as HTMLElement).classList.remove('drag-over-top', 'drag-over-bottom'); 80 + }); 81 + }); 82 + thumb.addEventListener('dragover', (e) => { 83 + e.preventDefault(); 84 + e.dataTransfer!.dropEffect = 'move'; 85 + const fromIdx = parseInt(e.dataTransfer!.getData('text/plain') || '-1'); 86 + refs.thumbnailList.querySelectorAll('.slides-thumbnail').forEach(t => { 87 + (t as HTMLElement).classList.remove('drag-over-top', 'drag-over-bottom'); 88 + }); 89 + const rect = thumb.getBoundingClientRect(); 90 + const midY = rect.top + rect.height / 2; 91 + if (e.clientY < midY) { 92 + thumb.classList.add('drag-over-top'); 93 + } else { 94 + thumb.classList.add('drag-over-bottom'); 95 + } 96 + }); 97 + thumb.addEventListener('dragleave', () => { 98 + thumb.classList.remove('drag-over-top', 'drag-over-bottom'); 99 + }); 100 + thumb.addEventListener('drop', (e) => { 101 + e.preventDefault(); 102 + const fromIdx = parseInt(e.dataTransfer!.getData('text/plain')); 103 + if (isNaN(fromIdx) || fromIdx === i) return; 104 + const s = actions.getState(); 105 + const deck = moveSlide(s.deck, fromIdx, i); 106 + const layouts = [...s.themedDeck.layouts]; 107 + const [movedLayout] = layouts.splice(fromIdx, 1); 108 + layouts.splice(i, 0, movedLayout); 109 + actions.setState({ 110 + deck: goToSlide(deck, i), 111 + themedDeck: { ...s.themedDeck, layouts }, 112 + }); 113 + actions.syncDeckToYjs(); 114 + actions.render(); 67 115 }); 68 116 69 117 refs.thumbnailList.appendChild(thumb);
+69
tests/presence-sidebar.test.ts
··· 1 + /** 2 + * Tests for presence sidebar logic integration. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + createPresenceState, 7 + updateCursor, 8 + getActiveCursors, 9 + getStaleCursors, 10 + activeUserCount, 11 + removeUser, 12 + pruneStaleUsers, 13 + } from '../src/lib/cursor-presence.js'; 14 + 15 + describe('presence sidebar state', () => { 16 + it('tracks active users', () => { 17 + let state = createPresenceState('local-1'); 18 + state = updateCursor(state, 'user-2', 'Alice', { anchor: 'A1', head: 'A1' }); 19 + state = updateCursor(state, 'user-3', 'Bob', { anchor: 'B2', head: 'B2' }); 20 + expect(activeUserCount(state)).toBe(2); 21 + }); 22 + 23 + it('excludes local user from active cursors', () => { 24 + let state = createPresenceState('local-1'); 25 + state = updateCursor(state, 'local-1', 'Me', { anchor: 'A1', head: 'A1' }); 26 + expect(activeUserCount(state)).toBe(0); 27 + }); 28 + 29 + it('detects stale users', () => { 30 + let state = createPresenceState('local-1', 1000); 31 + state = updateCursor(state, 'user-2', 'Alice', { anchor: 'A1', head: 'A1' }); 32 + // Fake timestamp: user was seen 2000ms ago 33 + state.users.get('user-2')!.lastSeen = Date.now() - 2000; 34 + const stale = getStaleCursors(state); 35 + expect(stale).toHaveLength(1); 36 + expect(stale[0].userName).toBe('Alice'); 37 + expect(getActiveCursors(state)).toHaveLength(0); 38 + }); 39 + 40 + it('removes users', () => { 41 + let state = createPresenceState('local-1'); 42 + state = updateCursor(state, 'user-2', 'Alice', { anchor: 'A1', head: 'A1' }); 43 + expect(state.users.size).toBe(1); 44 + state = removeUser(state, 'user-2'); 45 + expect(state.users.size).toBe(0); 46 + }); 47 + 48 + it('prunes stale users', () => { 49 + let state = createPresenceState('local-1', 1000); 50 + state = updateCursor(state, 'user-2', 'Alice', { anchor: 'A1', head: 'A1' }); 51 + state = updateCursor(state, 'user-3', 'Bob', { anchor: 'B2', head: 'B2' }); 52 + state.users.get('user-2')!.lastSeen = Date.now() - 2000; 53 + state = pruneStaleUsers(state); 54 + expect(state.users.size).toBe(1); 55 + expect(state.users.has('user-3')).toBe(true); 56 + }); 57 + 58 + it('assigns distinct colors to users', () => { 59 + let state = createPresenceState('local-1'); 60 + state = updateCursor(state, 'user-2', 'Alice', { anchor: 'A1', head: 'A1' }); 61 + state = updateCursor(state, 'user-3', 'Bob', { anchor: 'B2', head: 'B2' }); 62 + const alice = state.users.get('user-2')!; 63 + const bob = state.users.get('user-3')!; 64 + expect(alice.color).toBeTruthy(); 65 + expect(bob.color).toBeTruthy(); 66 + // Colors should be different for different users 67 + expect(alice.color).not.toBe(bob.color); 68 + }); 69 + });
+130
tests/print-dialog.test.ts
··· 1 + /** 2 + * Tests for sheets print dialog — verifies print HTML generation 3 + * with various option combinations. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + buildSheetsPrintHtml, 8 + PAGE_SIZES, 9 + MARGIN_PRESETS, 10 + formatPrintDate, 11 + } from '../src/lib/print-layout.js'; 12 + import { createDeck, addSlide, moveSlide } from '../src/slides/canvas-engine.js'; 13 + 14 + describe('buildSheetsPrintHtml with options', () => { 15 + const sampleData = { 16 + headers: ['A', 'B', 'C'], 17 + rows: [ 18 + [{ value: '1' }, { value: '2' }, { value: '3' }], 19 + [{ value: '4' }, { value: '5' }, { value: '6' }], 20 + ], 21 + }; 22 + 23 + it('generates valid HTML with default options', () => { 24 + const html = buildSheetsPrintHtml(sampleData); 25 + expect(html).toContain('<!DOCTYPE html>'); 26 + expect(html).toContain('<table'); 27 + expect(html).toContain('Spreadsheet'); 28 + }); 29 + 30 + it('applies landscape orientation', () => { 31 + const html = buildSheetsPrintHtml(sampleData, { orientation: 'landscape' }); 32 + expect(html).toContain('landscape'); 33 + }); 34 + 35 + it('applies portrait orientation', () => { 36 + const html = buildSheetsPrintHtml(sampleData, { orientation: 'portrait' }); 37 + expect(html).toContain(PAGE_SIZES.letter.width); 38 + }); 39 + 40 + it('uses A4 page size', () => { 41 + const html = buildSheetsPrintHtml(sampleData, { pageSize: 'a4' }); 42 + expect(html).toContain('210mm'); 43 + }); 44 + 45 + it('uses narrow margins', () => { 46 + const html = buildSheetsPrintHtml(sampleData, { margins: 'narrow' }); 47 + expect(html).toContain('0.5in'); 48 + }); 49 + 50 + it('hides grid lines when disabled', () => { 51 + const html = buildSheetsPrintHtml(sampleData, { gridLines: false }); 52 + expect(html).toContain('border: none'); 53 + }); 54 + 55 + it('shows grid lines by default', () => { 56 + const html = buildSheetsPrintHtml(sampleData); 57 + expect(html).toContain('border: 1px solid #ccc'); 58 + }); 59 + 60 + it('repeats headers with thead when enabled', () => { 61 + const html = buildSheetsPrintHtml(sampleData, { repeatHeaders: true }); 62 + expect(html).toContain('<thead>'); 63 + expect(html).toContain('table-header-group'); 64 + }); 65 + 66 + it('uses fit-to-width scaling', () => { 67 + const html = buildSheetsPrintHtml(sampleData, { scaling: 'fit-to-width' }); 68 + expect(html).toContain('width: 100%'); 69 + }); 70 + 71 + it('sets custom title', () => { 72 + const html = buildSheetsPrintHtml(sampleData, { title: 'My Sheet' }); 73 + expect(html).toContain('My Sheet'); 74 + }); 75 + 76 + it('renders cell values', () => { 77 + const html = buildSheetsPrintHtml(sampleData); 78 + expect(html).toContain('>1<'); 79 + expect(html).toContain('>6<'); 80 + }); 81 + 82 + it('renders column headers', () => { 83 + const html = buildSheetsPrintHtml(sampleData); 84 + expect(html).toContain('>A<'); 85 + expect(html).toContain('>B<'); 86 + expect(html).toContain('>C<'); 87 + }); 88 + }); 89 + 90 + describe('slide drag-to-reorder via moveSlide', () => { 91 + 92 + it('moves a slide from index 0 to index 2', () => { 93 + let deck = createDeck(); 94 + deck = addSlide(deck); // now 2 slides 95 + deck = addSlide(deck); // now 3 slides 96 + const id0 = deck.slides[0].id; 97 + const id1 = deck.slides[1].id; 98 + const id2 = deck.slides[2].id; 99 + deck = moveSlide(deck, 0, 2); 100 + expect(deck.slides[0].id).toBe(id1); 101 + expect(deck.slides[1].id).toBe(id2); 102 + expect(deck.slides[2].id).toBe(id0); 103 + }); 104 + 105 + it('moves a slide from index 2 to index 0', () => { 106 + let deck = createDeck(); 107 + deck = addSlide(deck); 108 + deck = addSlide(deck); 109 + const id2 = deck.slides[2].id; 110 + deck = moveSlide(deck, 2, 0); 111 + expect(deck.slides[0].id).toBe(id2); 112 + }); 113 + 114 + it('no-op when from === to', () => { 115 + let deck = createDeck(); 116 + deck = addSlide(deck); 117 + const before = deck.slides.map(s => s.id); 118 + deck = moveSlide(deck, 1, 1); 119 + expect(deck.slides.map(s => s.id)).toEqual(before); 120 + }); 121 + }); 122 + 123 + describe('formatPrintDate', () => { 124 + it('formats a specific date', () => { 125 + const result = formatPrintDate(new Date(2026, 3, 14)); 126 + expect(result).toContain('April'); 127 + expect(result).toContain('14'); 128 + expect(result).toContain('2026'); 129 + }); 130 + });
+51
tests/shortcuts-settings-ui.test.ts
··· 1 + /** 2 + * Tests for keyboard shortcuts settings panel UI. 3 + */ 4 + import { describe, it, expect, beforeEach } from 'vitest'; 5 + import { JSDOM } from 'jsdom'; 6 + import { 7 + createShortcutState, 8 + registerShortcut, 9 + keyCombo, 10 + activeCombo, 11 + formatCombo, 12 + } from '../src/lib/keyboard-shortcuts.js'; 13 + import type { ShortcutState } from '../src/lib/keyboard-shortcuts.js'; 14 + 15 + // We test the pure logic integration since the UI module depends on DOM + localStorage. 16 + // The mountShortcutsSettings function is tested via e2e; here we verify the state flow. 17 + 18 + describe('shortcuts settings panel state flow', () => { 19 + let state: ShortcutState; 20 + 21 + beforeEach(() => { 22 + state = createShortcutState(); 23 + state = registerShortcut(state, 'bold', 'Bold', 'Make text bold', keyCombo('b', { meta: true }), 'Formatting'); 24 + state = registerShortcut(state, 'italic', 'Italic', 'Make text italic', keyCombo('i', { meta: true }), 'Formatting'); 25 + state = registerShortcut(state, 'undo', 'Undo', 'Undo last action', keyCombo('z', { meta: true }), 'General'); 26 + }); 27 + 28 + it('registers shortcuts with categories', () => { 29 + expect(state.shortcuts.size).toBe(3); 30 + expect(state.shortcuts.get('bold')?.category).toBe('Formatting'); 31 + expect(state.shortcuts.get('undo')?.category).toBe('General'); 32 + }); 33 + 34 + it('activeCombo returns default when no custom binding', () => { 35 + const bold = state.shortcuts.get('bold')!; 36 + const combo = activeCombo(bold); 37 + expect(combo.key).toBe('b'); 38 + expect(combo.meta).toBe(true); 39 + }); 40 + 41 + it('formatCombo produces readable output', () => { 42 + const bold = state.shortcuts.get('bold')!; 43 + const formatted = formatCombo(activeCombo(bold)); 44 + expect(formatted).toBe('Cmd+B'); 45 + }); 46 + 47 + it('formatCombo handles multi-modifier combos', () => { 48 + const combo = keyCombo('s', { ctrl: true, shift: true }); 49 + expect(formatCombo(combo)).toBe('Ctrl+Shift+S'); 50 + }); 51 + });