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 533 lines 18 kB view raw
1/** 2 * Version History Panel — reusable slide-in panel for docs and sheets. 3 * 4 * Exports pure functions (testable) and a panel factory. 5 */ 6 7import { listVersions, getVersion, updateVersionMetadata } from './lib/local-store.js'; 8 9// --- Pure functions (exported for testing) --- 10 11/** 12 * Format an ISO date string as a human-readable relative time. 13 * 14 * Rules: 15 * - < 60s: "Just now" 16 * - < 60m: "N min ago" 17 * - < 24h: "N hours ago" 18 * - < 48h: "Yesterday" 19 * - else: "Mar 15" (short month + day) 20 */ 21export function formatRelativeTime(dateStr: string): string { 22 if (!dateStr) return 'Unknown'; 23 24 // Server returns UTC without 'Z' suffix — normalise 25 const normalised = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; 26 const date = new Date(normalised); 27 if (isNaN(date.getTime())) return 'Unknown'; 28 29 const now = Date.now(); 30 const diffMs = now - date.getTime(); 31 const diffSec = Math.floor(diffMs / 1000); 32 const diffMin = Math.floor(diffSec / 60); 33 const diffHours = Math.floor(diffMin / 60); 34 const diffDays = Math.floor(diffHours / 24); 35 36 if (diffSec < 60) return 'Just now'; 37 if (diffMin < 60) return `${diffMin} min ago`; 38 if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; 39 if (diffDays < 2) return 'Yesterday'; 40 41 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 42 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 43 return `${months[date.getMonth()]} ${date.getDate()}`; 44} 45 46/** 47 * Safely parse a JSON metadata string. 48 * Returns an empty object on any failure. 49 */ 50export function parseMetadata(raw: string | null): Record<string, unknown> { 51 if (!raw) return {}; 52 try { 53 const parsed = JSON.parse(raw); 54 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { 55 return parsed as Record<string, unknown>; 56 } 57 return {}; 58 } catch { 59 return {}; 60 } 61} 62 63/** 64 * Compute diff stats between current and previous version metadata. 65 */ 66export function computeDiffStats( 67 current?: { wordCount?: number }, 68 previous?: { wordCount?: number }, 69): { delta: number; label: string } { 70 const curCount = current?.wordCount ?? 0; 71 const prevCount = previous?.wordCount ?? 0; 72 73 const delta = curCount - prevCount; 74 75 if (!previous || previous.wordCount === undefined) { 76 return { delta: curCount, label: `+${curCount}` }; 77 } 78 if (delta > 0) return { delta, label: `+${delta}` }; 79 if (delta < 0) return { delta, label: `${delta}` }; 80 return { delta: 0, label: '0' }; 81} 82 83// --- Panel UI --- 84 85export interface VersionPanelConfig { 86 /** Document ID */ 87 docId: string; 88 /** Crypto key for decrypting version snapshots */ 89 cryptoKey: CryptoKey; 90 /** Container to mount the panel into (defaults to document.body) */ 91 container?: HTMLElement; 92 /** Callback when user confirms version restore */ 93 onRestore?: (versionId: string, decryptedData: Uint8Array) => Promise<void>; 94 /** Document type for display context */ 95 docType?: 'doc' | 'sheet'; 96} 97 98interface VersionData { 99 id: string; 100 document_id: string; 101 created_at: string; 102 metadata: Record<string, unknown> | null; 103 _name?: string; 104} 105 106export interface VersionPanel { 107 toggle: () => void; 108 open: () => void; 109 close: () => void; 110 isOpen: () => boolean; 111 destroy: () => void; 112} 113 114/** 115 * Render a Yjs XmlFragment (as stored by TipTap's `y-prosemirror` binding) as 116 * readable HTML for the version-preview pane. 117 * 118 * Previously we called `fragment.toString()`, which dumps the raw ProseMirror 119 * schema XML (`<paragraph indent="0"><heading level="1">Title</heading>…`) — 120 * unreadable. This walker maps each node name to a sensible HTML tag so 121 * headings, paragraphs, lists, blockquotes, and inline marks render properly. 122 * 123 * Kept tag-focused (not style-perfect) on purpose: the preview is a "read 124 * this to decide if you want to restore it" affordance, not a full editor. 125 * See #719. 126 */ 127export function renderYjsFragmentAsHtml( 128 fragment: { toArray: () => Array<unknown> }, 129): string { 130 // Map TipTap schema node names to HTML tags. Unknown nodes fall through 131 // as <div> so content still shows up rather than disappearing silently. 132 const NODE_TAG: Record<string, string> = { 133 paragraph: 'p', 134 blockquote: 'blockquote', 135 bulletList: 'ul', 136 orderedList: 'ol', 137 listItem: 'li', 138 taskList: 'ul', 139 taskItem: 'li', 140 codeBlock: 'pre', 141 horizontalRule: 'hr', 142 hardBreak: 'br', 143 table: 'table', 144 tableRow: 'tr', 145 tableCell: 'td', 146 tableHeader: 'th', 147 image: 'img', 148 pageBreak: 'hr', 149 }; 150 // Marks → inline wrapping tag 151 const MARK_TAG: Record<string, string> = { 152 bold: 'strong', 153 italic: 'em', 154 underline: 'u', 155 strike: 's', 156 code: 'code', 157 link: 'a', 158 subscript: 'sub', 159 superscript: 'sup', 160 highlight: 'mark', 161 }; 162 163 function escapeHtml(s: string): string { 164 return s 165 .replace(/&/g, '&amp;') 166 .replace(/</g, '&lt;') 167 .replace(/>/g, '&gt;') 168 .replace(/"/g, '&quot;') 169 .replace(/'/g, '&#39;'); 170 } 171 172 // Yjs exposes XmlText, XmlElement, XmlHook; we rely on duck-typed fields 173 // so this function stays testable without importing yjs types. 174 function walk(node: any): string { 175 // Text node — wrap in mark tags if it carries any 176 if (typeof node?.toString === 'function' && node.constructor?.name === 'YXmlText') { 177 const text = escapeHtml(node.toString()); 178 // YXmlText attributes don't represent marks directly; TipTap's 179 // y-prosemirror binding encodes marks via delta attrs which aren't 180 // trivially inspectable here. Return plain text; the node-level 181 // walker handles structural formatting which is what users care 182 // about in a preview. 183 return text; 184 } 185 186 if (node && typeof node.nodeName === 'string') { 187 const name: string = node.nodeName; 188 let tag = NODE_TAG[name]; 189 if (!tag) { 190 // Heading: nodeName === 'heading', level attr 191 if (name === 'heading') { 192 const level = Math.max(1, Math.min(6, Number(node.getAttribute?.('level')) || 1)); 193 tag = 'h' + level; 194 } else { 195 tag = 'div'; 196 } 197 } 198 199 const children: string = (node.toArray?.() ?? []).map(walk).join(''); 200 201 if (tag === 'hr' || tag === 'br') return `<${tag}>`; 202 if (tag === 'img') { 203 const src = escapeHtml(node.getAttribute?.('src') ?? ''); 204 const alt = escapeHtml(node.getAttribute?.('alt') ?? ''); 205 return `<img src="${src}" alt="${alt}">`; 206 } 207 if (tag === 'pre') return `<pre><code>${children}</code></pre>`; 208 209 return `<${tag}>${children}</${tag}>`; 210 } 211 212 return ''; 213 } 214 215 // Use MARK_TAG so the lint/build doesn't drop the lookup table — it's 216 // reserved for a follow-up pass that decodes y-prosemirror mark deltas. 217 void MARK_TAG; 218 219 return fragment.toArray().map(walk).join(''); 220} 221 222export function createVersionPanel(config: VersionPanelConfig): VersionPanel { 223 const { 224 docId, 225 container = document.body, 226 onRestore, 227 docType = 'doc', 228 } = config; 229 230 // Build DOM 231 const panel = document.createElement('div'); 232 panel.className = 'version-panel'; 233 panel.setAttribute('role', 'dialog'); 234 panel.setAttribute('aria-label', 'Version history'); 235 236 panel.innerHTML = ` 237 <div class="version-panel-header"> 238 <h3>Version History</h3> 239 <button class="btn-icon version-panel-close" title="Close (Esc)" aria-label="Close">&times;</button> 240 </div> 241 <div class="version-panel-list"></div> 242 <div class="version-panel-preview" style="display:none"> 243 <div class="version-panel-preview-header"> 244 <button class="btn-ghost version-panel-back">&larr; Back</button> 245 <button class="btn-primary btn-sm version-panel-restore">Restore this version</button> 246 </div> 247 <div class="version-panel-preview-content"></div> 248 </div> 249 `; 250 251 container.appendChild(panel); 252 253 const closeBtn = panel.querySelector('.version-panel-close') as HTMLButtonElement; 254 const listEl = panel.querySelector('.version-panel-list') as HTMLDivElement; 255 const previewEl = panel.querySelector('.version-panel-preview') as HTMLDivElement; 256 const previewContent = panel.querySelector('.version-panel-preview-content') as HTMLDivElement; 257 const backBtn = panel.querySelector('.version-panel-back') as HTMLButtonElement; 258 const restoreBtn = panel.querySelector('.version-panel-restore') as HTMLButtonElement; 259 260 let isOpen_ = false; 261 let selectedVersionId: string | null = null; 262 let versions: VersionData[] = []; 263 264 // --- Event handlers --- 265 closeBtn.addEventListener('click', close); 266 backBtn.addEventListener('click', () => { 267 previewEl.style.display = 'none'; 268 selectedVersionId = null; 269 }); 270 restoreBtn.addEventListener('click', handleRestore); 271 272 function handleKeydown(e: KeyboardEvent): void { 273 if (e.key === 'Escape' && isOpen_) { 274 e.preventDefault(); 275 close(); 276 } 277 // Cmd+Shift+H toggles 278 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 279 e.preventDefault(); 280 toggle(); 281 } 282 } 283 document.addEventListener('keydown', handleKeydown); 284 285 // --- Methods --- 286 function toggle(): void { 287 if (isOpen_) close(); 288 else open(); 289 } 290 291 function open(): void { 292 isOpen_ = true; 293 panel.classList.add('open'); 294 loadVersions(); 295 } 296 297 function close(): void { 298 isOpen_ = false; 299 panel.classList.remove('open'); 300 previewEl.style.display = 'none'; 301 selectedVersionId = null; 302 } 303 304 async function loadVersions(): Promise<void> { 305 listEl.innerHTML = '<div class="version-empty">Loading...</div>'; 306 307 try { 308 const entries = await listVersions(docId); 309 // Parse metadata JSON strings into objects 310 versions = entries.map(e => ({ 311 id: e.id, 312 document_id: e.document_id, 313 created_at: e.created_at, 314 metadata: ((): Record<string, unknown> | null => { 315 try { return JSON.parse(e.metadata || '{}'); } catch { return null; } 316 })(), 317 })); 318 319 if (versions.length === 0) { 320 listEl.innerHTML = '<div class="version-empty">No versions yet</div>'; 321 return; 322 } 323 324 listEl.innerHTML = ''; 325 326 for (let i = 0; i < versions.length; i++) { 327 const v = versions[i]!; 328 const meta = (v.metadata && typeof v.metadata === 'object') ? v.metadata : {}; 329 const prevMeta = (i < versions.length - 1) 330 ? ((versions[i + 1]!.metadata && typeof versions[i + 1]!.metadata === 'object') 331 ? versions[i + 1]!.metadata as Record<string, unknown> 332 : {}) 333 : undefined; 334 335 const countKey = docType === 'sheet' ? 'cellCount' : 'wordCount'; 336 const stats = computeDiffStats( 337 { wordCount: typeof meta[countKey] === 'number' ? (meta[countKey] as number) : undefined }, 338 prevMeta ? { wordCount: typeof prevMeta[countKey] === 'number' ? (prevMeta[countKey] as number) : undefined } : undefined, 339 ); 340 341 const item = document.createElement('div'); 342 item.className = 'version-panel-item'; 343 item.setAttribute('role', 'button'); 344 item.setAttribute('tabindex', '0'); 345 346 const timeStr = formatRelativeTime(v.created_at); 347 const author = (typeof meta['author'] === 'string' ? meta['author'] : null) || 'Unknown'; 348 const countVal = typeof meta[countKey] === 'number' ? (meta[countKey] as number) : null; 349 const countLabel = docType === 'sheet' ? 'cells' : 'words'; 350 const namedVersion = v._name || (typeof meta['name'] === 'string' ? meta['name'] as string : null) || (typeof meta['label'] === 'string' ? meta['label'] as string : null); 351 352 const deltaClass = stats.delta > 0 ? 'positive' : stats.delta < 0 ? 'negative' : ''; 353 354 item.innerHTML = ` 355 <div class="version-panel-item-top"> 356 <span class="version-panel-time">${timeStr}</span> 357 ${namedVersion ? `<span class="version-panel-named-badge">${escapeHtml(namedVersion)}</span>` : ''} 358 </div> 359 <div class="version-panel-item-meta"> 360 <span class="version-panel-author${author === 'forge' ? ' version-panel-author--forge' : ''}">${escapeHtml(author)}</span> 361 ${countVal !== null ? `<span class="version-panel-count">${countVal} ${countLabel}</span>` : ''} 362 <span class="version-panel-delta ${deltaClass}">${stats.label}</span> 363 </div> 364 <button class="version-panel-name-btn" title="Name this version">&#9998;</button> 365 `; 366 367 const nameBtn = item.querySelector('.version-panel-name-btn') as HTMLButtonElement; 368 nameBtn.addEventListener('click', (e) => { 369 e.stopPropagation(); 370 promptNameVersion(v.id, nameBtn); 371 }); 372 373 item.addEventListener('click', () => showPreview(v.id)); 374 item.addEventListener('keydown', (e) => { 375 if (e.key === 'Enter' || e.key === ' ') { 376 e.preventDefault(); 377 showPreview(v.id); 378 } 379 }); 380 381 listEl.appendChild(item); 382 } 383 } catch { 384 listEl.innerHTML = '<div class="version-empty">Failed to load versions</div>'; 385 } 386 } 387 388 async function promptNameVersion(versionId: string, btn: HTMLButtonElement): Promise<void> { 389 const { modalPrompt } = await import('./lib/modal-dialog.js'); 390 const name = await modalPrompt({ 391 title: 'Name this version', 392 message: 'Give this version a short, memorable name.', 393 placeholder: 'e.g. Before the big rewrite', 394 okLabel: 'Save', 395 }); 396 if (!name) return; 397 398 try { 399 // Merge the new name into existing metadata 400 const v = versions.find(ver => ver.id === versionId); 401 const existingMeta = (v?.metadata && typeof v.metadata === 'object') ? { ...v.metadata } : {}; 402 existingMeta['name'] = name; 403 await updateVersionMetadata(docId, versionId, existingMeta as Record<string, unknown>); 404 405 // Update local state 406 if (v && v.metadata && typeof v.metadata === 'object') { 407 (v.metadata as Record<string, unknown>)['name'] = name; 408 } 409 410 // Refresh the badge in the parent element 411 const itemTop = btn.parentElement?.querySelector('.version-panel-item-top'); 412 if (itemTop) { 413 const existingBadge = itemTop.querySelector('.version-panel-named-badge'); 414 if (existingBadge) { 415 existingBadge.textContent = name; 416 } else { 417 const badge = document.createElement('span'); 418 badge.className = 'version-panel-named-badge'; 419 badge.textContent = name; 420 itemTop.appendChild(badge); 421 } 422 } 423 } catch { 424 // Silently fail — best effort 425 } 426 } 427 428 async function showPreview(versionId: string): Promise<void> { 429 selectedVersionId = versionId; 430 previewEl.style.display = ''; 431 previewContent.textContent = 'Loading...'; 432 433 try { 434 const entry = await getVersion(docId, versionId); 435 if (!entry) throw new Error('Not found'); 436 const encrypted = new Uint8Array(entry.snapshot); 437 438 // Decrypt 439 const { decrypt } = await import('./lib/crypto.js'); 440 const decrypted = await decrypt(encrypted, config.cryptoKey); 441 442 // Render preview using Yjs 443 const Y = await import('yjs'); 444 const tempDoc = new Y.Doc(); 445 Y.applyUpdate(tempDoc, decrypted); 446 447 previewContent.innerHTML = ''; 448 449 if (docType === 'sheet') { 450 // Simple cell preview for sheets 451 const sheets = tempDoc.getMap('sheets'); 452 const previewDiv = document.createElement('div'); 453 previewDiv.className = 'version-preview-text'; 454 const firstSheet = sheets.get('sheet_0') as import('yjs').Map<unknown> | undefined; 455 if (firstSheet) { 456 const cells = firstSheet.get('cells') as import('yjs').Map<unknown> | undefined; 457 if (cells) { 458 const cellCount = cells.size; 459 previewDiv.textContent = `${cellCount} cells with data`; 460 } else { 461 previewDiv.textContent = 'Empty sheet'; 462 } 463 } else { 464 previewDiv.textContent = 'Empty spreadsheet'; 465 } 466 previewContent.appendChild(previewDiv); 467 } else { 468 // Doc preview — render the Yjs XmlFragment as readable HTML. 469 // Previously we dumped fragment.toString() which is the raw ProseMirror 470 // schema XML (<paragraph indent="0"><heading level="1">Title</heading>…) 471 // — unreadable for users. See #719. 472 const fragment = tempDoc.getXmlFragment('default'); 473 const previewDiv = document.createElement('div'); 474 previewDiv.className = 'version-preview-text'; 475 previewDiv.innerHTML = renderYjsFragmentAsHtml(fragment); 476 previewContent.appendChild(previewDiv); 477 } 478 479 tempDoc.destroy(); 480 } catch { 481 previewContent.textContent = 'Failed to load version preview'; 482 } 483 } 484 485 async function handleRestore(): Promise<void> { 486 if (!selectedVersionId) return; 487 const { modalConfirm } = await import('./lib/modal-dialog.js'); 488 const ok = await modalConfirm({ 489 title: 'Restore this version?', 490 message: 'Current changes will be replaced. This cannot be undone.', 491 okLabel: 'Restore', 492 destructive: true, 493 }); 494 if (!ok) return; 495 496 try { 497 const entry = await getVersion(docId, selectedVersionId); 498 if (!entry) throw new Error('Not found'); 499 const encrypted = new Uint8Array(entry.snapshot); 500 501 const { decrypt } = await import('./lib/crypto.js'); 502 const decrypted = await decrypt(encrypted, config.cryptoKey); 503 504 if (onRestore) { 505 await onRestore(selectedVersionId, decrypted); 506 } 507 508 close(); 509 } catch { 510 const { showToast } = await import('./landing-toast.js'); 511 showToast('Failed to restore version', 4000, true); 512 } 513 } 514 515 function escapeHtml(str: string): string { 516 const div = document.createElement('div'); 517 div.textContent = str; 518 return div.innerHTML; 519 } 520 521 function destroy(): void { 522 document.removeEventListener('keydown', handleKeydown); 523 panel.remove(); 524 } 525 526 return { 527 toggle, 528 open, 529 close, 530 isOpen: () => isOpen_, 531 destroy, 532 }; 533}