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 230 lines 8.0 kB view raw
1/** 2 * Version History UI — version capture, list rendering, preview, restore, 3 * sidebar toggle, and auto-capture triggers. 4 * 5 * Extracted from main.ts for decomposition. 6 */ 7 8import * as Y from 'yjs'; 9import { VersionManager, computeWordCount } from '../lib/version-history.js'; 10import { encrypt, decrypt } from '../lib/crypto.js'; 11import { saveVersion, listVersions, getVersion } from '../lib/local-store.js'; 12 13// ── Types ─────────────────────────────────────────────────── 14 15export interface VersionHistoryDeps { 16 editor: any; 17 ydoc: any; 18 provider: any; 19 docId: string; 20 cryptoKey: CryptoKey; 21 userName: string; 22 $: (id: string) => HTMLElement; 23} 24 25// ── Version History ───────────────────────────────────────── 26 27export function wireVersionHistory(deps: VersionHistoryDeps): void { 28 const { editor, ydoc, provider, docId, cryptoKey, userName, $ } = deps; 29 30 const versionManager = new VersionManager({ 31 maxVersions: 50, 32 editThreshold: 50, 33 timeThresholdMs: 5 * 60 * 1000, 34 }); 35 36 const versionSidebar = $('version-sidebar'); 37 const versionList = $('version-list'); 38 const versionPreview = $('version-preview'); 39 const versionPreviewContent = $('version-preview-content'); 40 let selectedVersionId: string | null = null; 41 42 // Track edits for version capture triggers 43 editor.on('update', () => { 44 versionManager.recordEdit(); 45 if (versionManager.shouldCapture()) { 46 captureVersion(); 47 } 48 }); 49 50 // Check every minute for time-based capture 51 setInterval(() => { 52 if (versionManager.shouldCapture()) { 53 captureVersion(); 54 } 55 }, 60_000); 56 57 async function captureVersion() { 58 try { 59 const state = Y.encodeStateAsUpdate(ydoc); 60 const encrypted = await encrypt(state, cryptoKey); 61 const text = editor.getText(); 62 const wordCount = computeWordCount(text); 63 64 await saveVersion(docId, encrypted.buffer as ArrayBuffer, { 65 author: userName, 66 wordCount, 67 timestamp: Date.now(), 68 }); 69 70 versionManager.addVersion(encrypted, { author: userName, wordCount }); 71 } catch (err) { 72 console.warn('Failed to capture version', err); 73 } 74 } 75 76 // History button toggles sidebar 77 $('btn-history').addEventListener('click', () => { 78 const isOpen = versionSidebar.style.display !== 'none'; 79 if (isOpen) { 80 versionSidebar.style.display = 'none'; 81 } else { 82 versionSidebar.style.display = ''; 83 loadVersionList(); 84 } 85 }); 86 87 $('version-sidebar-close').addEventListener('click', () => { 88 versionSidebar.style.display = 'none'; 89 versionPreview.style.display = 'none'; 90 }); 91 92 async function loadVersionList() { 93 try { 94 const entries = await listVersions(docId); 95 if (entries.length === 0) { 96 versionList.innerHTML = '<div class="version-empty">No versions yet</div>'; 97 return; 98 } 99 // Parse metadata from JSON strings and compute deltas 100 const versions = entries.map(e => ({ 101 ...e, 102 _meta: JSON.parse(e.metadata || '{}') as Record<string, unknown>, 103 _delta: 0, 104 })); 105 versionList.innerHTML = ''; 106 let prevWordCount: number | null = null; 107 // versions are newest-first from listVersions 108 for (let i = versions.length - 1; i >= 0; i--) { 109 const v = versions[i]!; 110 const wc = (v._meta.wordCount as number) ?? 0; 111 if (prevWordCount !== null) { 112 v._delta = wc - prevWordCount; 113 } else { 114 v._delta = wc; 115 } 116 prevWordCount = wc; 117 } 118 for (const v of versions) { 119 const item = document.createElement('button'); 120 item.className = 'version-item'; 121 const ts = v.created_at ? new Date(v.created_at).toLocaleString() : 'Unknown'; 122 const author = (v._meta.author as string) || 'Unknown'; 123 const wc = v._meta.wordCount ?? '?'; 124 const delta = v._delta; 125 const deltaStr = delta > 0 ? `+${delta}` : `${delta}`; 126 const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : ''; 127 item.innerHTML = ` 128 <span class="version-time">${ts}</span> 129 <span class="version-meta"> 130 <span class="version-author">${author}</span> 131 <span class="version-wc">${wc} words</span> 132 <span class="version-delta ${deltaClass}">${deltaStr}</span> 133 </span> 134 `; 135 item.addEventListener('click', () => showVersionPreview(v.id)); 136 versionList.appendChild(item); 137 } 138 } catch (err) { 139 versionList.innerHTML = '<div class="version-empty">Failed to load versions</div>'; 140 } 141 } 142 143 async function showVersionPreview(versionId: string): Promise<void> { 144 selectedVersionId = versionId; 145 versionPreview.style.display = ''; 146 versionPreviewContent.textContent = 'Loading...'; 147 try { 148 const entry = await getVersion(docId, versionId); 149 if (!entry) throw new Error('Not found'); 150 const encrypted = new Uint8Array(entry.snapshot); 151 const decrypted = await decrypt(encrypted, cryptoKey); 152 const tempDoc = new Y.Doc(); 153 Y.applyUpdate(tempDoc, decrypted); 154 const fragment = tempDoc.getXmlFragment('default'); 155 versionPreviewContent.innerHTML = ''; 156 const previewDiv = document.createElement('div'); 157 previewDiv.className = 'version-preview-text'; 158 previewDiv.textContent = fragment.toString(); 159 versionPreviewContent.appendChild(previewDiv); 160 tempDoc.destroy(); 161 } catch (err) { 162 versionPreviewContent.textContent = 'Failed to load version preview'; 163 } 164 } 165 166 $('version-back').addEventListener('click', () => { 167 versionPreview.style.display = 'none'; 168 selectedVersionId = null; 169 }); 170 171 $('version-restore').addEventListener('click', async () => { 172 if (!selectedVersionId) return; 173 const { modalConfirm } = await import('../lib/modal-dialog.js'); 174 const ok = await modalConfirm({ 175 title: 'Restore this version?', 176 message: 'Current changes will be replaced. This cannot be undone.', 177 okLabel: 'Restore', 178 destructive: true, 179 }); 180 if (!ok) return; 181 try { 182 const entry = await getVersion(docId, selectedVersionId); 183 if (!entry) throw new Error('Not found'); 184 const encrypted = new Uint8Array(entry.snapshot); 185 const decrypted = await decrypt(encrypted, cryptoKey); 186 Y.applyUpdate(ydoc, decrypted); 187 await provider._saveSnapshot(); 188 versionPreview.style.display = 'none'; 189 versionSidebar.style.display = 'none'; 190 selectedVersionId = null; 191 } catch { 192 const { showToast } = await import('../landing-toast.js'); 193 showToast('Failed to restore version', 4000, true); 194 } 195 }); 196} 197 198// ── Share Dialog ──────────────────────────────────────────── 199 200export interface ShareDialogDeps { 201 $: (id: string) => HTMLElement; 202} 203 204export function wireShareDialog(deps: ShareDialogDeps): void { 205 const { $ } = deps; 206 const shareDialog = $('share-dialog'); 207 const shareLinkInput = $('share-link-input') as HTMLInputElement; 208 209 $('btn-share').addEventListener('click', () => { 210 shareLinkInput.value = window.location.href; 211 shareDialog.style.display = ''; 212 }); 213 214 $('share-dialog-close').addEventListener('click', () => { 215 shareDialog.style.display = 'none'; 216 }); 217 218 shareDialog.addEventListener('click', (e: Event) => { 219 if (e.target === shareDialog) shareDialog.style.display = 'none'; 220 }); 221 222 $('share-copy-link').addEventListener('click', async () => { 223 try { 224 await navigator.clipboard.writeText(shareLinkInput.value); 225 const btn = $('share-copy-link'); 226 btn.textContent = 'Copied!'; 227 setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); 228 } catch { /* clipboard not available */ } 229 }); 230}