Full document, spreadsheet, slideshow, and diagram tooling
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, '&')
166 .replace(/</g, '<')
167 .replace(/>/g, '>')
168 .replace(/"/g, '"')
169 .replace(/'/g, ''');
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">×</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">← 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">✎</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}