/** * Version History UI — version capture, list rendering, preview, restore, * sidebar toggle, and auto-capture triggers. * * Extracted from main.ts for decomposition. */ import * as Y from 'yjs'; import { VersionManager, computeWordCount } from '../lib/version-history.js'; import { encrypt, decrypt } from '../lib/crypto.js'; import { saveVersion, listVersions, getVersion } from '../lib/local-store.js'; // ── Types ─────────────────────────────────────────────────── export interface VersionHistoryDeps { editor: any; ydoc: any; provider: any; docId: string; cryptoKey: CryptoKey; userName: string; $: (id: string) => HTMLElement; } // ── Version History ───────────────────────────────────────── export function wireVersionHistory(deps: VersionHistoryDeps): void { const { editor, ydoc, provider, docId, cryptoKey, userName, $ } = deps; const versionManager = new VersionManager({ maxVersions: 50, editThreshold: 50, timeThresholdMs: 5 * 60 * 1000, }); const versionSidebar = $('version-sidebar'); const versionList = $('version-list'); const versionPreview = $('version-preview'); const versionPreviewContent = $('version-preview-content'); let selectedVersionId: string | null = null; // Track edits for version capture triggers editor.on('update', () => { versionManager.recordEdit(); if (versionManager.shouldCapture()) { captureVersion(); } }); // Check every minute for time-based capture setInterval(() => { if (versionManager.shouldCapture()) { captureVersion(); } }, 60_000); async function captureVersion() { try { const state = Y.encodeStateAsUpdate(ydoc); const encrypted = await encrypt(state, cryptoKey); const text = editor.getText(); const wordCount = computeWordCount(text); await saveVersion(docId, encrypted.buffer as ArrayBuffer, { author: userName, wordCount, timestamp: Date.now(), }); versionManager.addVersion(encrypted, { author: userName, wordCount }); } catch (err) { console.warn('Failed to capture version', err); } } // History button toggles sidebar $('btn-history').addEventListener('click', () => { const isOpen = versionSidebar.style.display !== 'none'; if (isOpen) { versionSidebar.style.display = 'none'; } else { versionSidebar.style.display = ''; loadVersionList(); } }); $('version-sidebar-close').addEventListener('click', () => { versionSidebar.style.display = 'none'; versionPreview.style.display = 'none'; }); async function loadVersionList() { try { const entries = await listVersions(docId); if (entries.length === 0) { versionList.innerHTML = '
No versions yet
'; return; } // Parse metadata from JSON strings and compute deltas const versions = entries.map(e => ({ ...e, _meta: JSON.parse(e.metadata || '{}') as Record, _delta: 0, })); versionList.innerHTML = ''; let prevWordCount: number | null = null; // versions are newest-first from listVersions for (let i = versions.length - 1; i >= 0; i--) { const v = versions[i]!; const wc = (v._meta.wordCount as number) ?? 0; if (prevWordCount !== null) { v._delta = wc - prevWordCount; } else { v._delta = wc; } prevWordCount = wc; } for (const v of versions) { const item = document.createElement('button'); item.className = 'version-item'; const ts = v.created_at ? new Date(v.created_at).toLocaleString() : 'Unknown'; const author = (v._meta.author as string) || 'Unknown'; const wc = v._meta.wordCount ?? '?'; const delta = v._delta; const deltaStr = delta > 0 ? `+${delta}` : `${delta}`; const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : ''; item.innerHTML = ` ${ts} ${author} ${wc} words ${deltaStr} `; item.addEventListener('click', () => showVersionPreview(v.id)); versionList.appendChild(item); } } catch (err) { versionList.innerHTML = '
Failed to load versions
'; } } async function showVersionPreview(versionId: string): Promise { selectedVersionId = versionId; versionPreview.style.display = ''; versionPreviewContent.textContent = 'Loading...'; try { const entry = await getVersion(docId, versionId); if (!entry) throw new Error('Not found'); const encrypted = new Uint8Array(entry.snapshot); const decrypted = await decrypt(encrypted, cryptoKey); const tempDoc = new Y.Doc(); Y.applyUpdate(tempDoc, decrypted); const fragment = tempDoc.getXmlFragment('default'); versionPreviewContent.innerHTML = ''; const previewDiv = document.createElement('div'); previewDiv.className = 'version-preview-text'; previewDiv.textContent = fragment.toString(); versionPreviewContent.appendChild(previewDiv); tempDoc.destroy(); } catch (err) { versionPreviewContent.textContent = 'Failed to load version preview'; } } $('version-back').addEventListener('click', () => { versionPreview.style.display = 'none'; selectedVersionId = null; }); $('version-restore').addEventListener('click', async () => { if (!selectedVersionId) return; const { modalConfirm } = await import('../lib/modal-dialog.js'); const ok = await modalConfirm({ title: 'Restore this version?', message: 'Current changes will be replaced. This cannot be undone.', okLabel: 'Restore', destructive: true, }); if (!ok) return; try { const entry = await getVersion(docId, selectedVersionId); if (!entry) throw new Error('Not found'); const encrypted = new Uint8Array(entry.snapshot); const decrypted = await decrypt(encrypted, cryptoKey); Y.applyUpdate(ydoc, decrypted); await provider._saveSnapshot(); versionPreview.style.display = 'none'; versionSidebar.style.display = 'none'; selectedVersionId = null; } catch { const { showToast } = await import('../landing-toast.js'); showToast('Failed to restore version', 4000, true); } }); } // ── Share Dialog ──────────────────────────────────────────── export interface ShareDialogDeps { $: (id: string) => HTMLElement; } export function wireShareDialog(deps: ShareDialogDeps): void { const { $ } = deps; const shareDialog = $('share-dialog'); const shareLinkInput = $('share-link-input') as HTMLInputElement; $('btn-share').addEventListener('click', () => { shareLinkInput.value = window.location.href; shareDialog.style.display = ''; }); $('share-dialog-close').addEventListener('click', () => { shareDialog.style.display = 'none'; }); shareDialog.addEventListener('click', (e: Event) => { if (e.target === shareDialog) shareDialog.style.display = 'none'; }); $('share-copy-link').addEventListener('click', async () => { try { await navigator.clipboard.writeText(shareLinkInput.value); const btn = $('share-copy-link'); btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); } catch { /* clipboard not available */ } }); }