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