/**
* 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 */ }
});
}