/** * Version History Panel — reusable slide-in panel for docs and sheets. * * Exports pure functions (testable) and a panel factory. */ import { listVersions, getVersion, updateVersionMetadata } from './lib/local-store.js'; // --- Pure functions (exported for testing) --- /** * Format an ISO date string as a human-readable relative time. * * Rules: * - < 60s: "Just now" * - < 60m: "N min ago" * - < 24h: "N hours ago" * - < 48h: "Yesterday" * - else: "Mar 15" (short month + day) */ export function formatRelativeTime(dateStr: string): string { if (!dateStr) return 'Unknown'; // Server returns UTC without 'Z' suffix — normalise const normalised = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; const date = new Date(normalised); if (isNaN(date.getTime())) return 'Unknown'; const now = Date.now(); const diffMs = now - date.getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHours = Math.floor(diffMin / 60); const diffDays = Math.floor(diffHours / 24); if (diffSec < 60) return 'Just now'; if (diffMin < 60) return `${diffMin} min ago`; if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; if (diffDays < 2) return 'Yesterday'; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return `${months[date.getMonth()]} ${date.getDate()}`; } /** * Safely parse a JSON metadata string. * Returns an empty object on any failure. */ export function parseMetadata(raw: string | null): Record { if (!raw) return {}; try { const parsed = JSON.parse(raw); if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { return parsed as Record; } return {}; } catch { return {}; } } /** * Compute diff stats between current and previous version metadata. */ export function computeDiffStats( current?: { wordCount?: number }, previous?: { wordCount?: number }, ): { delta: number; label: string } { const curCount = current?.wordCount ?? 0; const prevCount = previous?.wordCount ?? 0; const delta = curCount - prevCount; if (!previous || previous.wordCount === undefined) { return { delta: curCount, label: `+${curCount}` }; } if (delta > 0) return { delta, label: `+${delta}` }; if (delta < 0) return { delta, label: `${delta}` }; return { delta: 0, label: '0' }; } // --- Panel UI --- export interface VersionPanelConfig { /** Document ID */ docId: string; /** Crypto key for decrypting version snapshots */ cryptoKey: CryptoKey; /** Container to mount the panel into (defaults to document.body) */ container?: HTMLElement; /** Callback when user confirms version restore */ onRestore?: (versionId: string, decryptedData: Uint8Array) => Promise; /** Document type for display context */ docType?: 'doc' | 'sheet'; } interface VersionData { id: string; document_id: string; created_at: string; metadata: Record | null; _name?: string; } export interface VersionPanel { toggle: () => void; open: () => void; close: () => void; isOpen: () => boolean; destroy: () => void; } /** * Render a Yjs XmlFragment (as stored by TipTap's `y-prosemirror` binding) as * readable HTML for the version-preview pane. * * Previously we called `fragment.toString()`, which dumps the raw ProseMirror * schema XML (`Title…`) — * unreadable. This walker maps each node name to a sensible HTML tag so * headings, paragraphs, lists, blockquotes, and inline marks render properly. * * Kept tag-focused (not style-perfect) on purpose: the preview is a "read * this to decide if you want to restore it" affordance, not a full editor. * See #719. */ export function renderYjsFragmentAsHtml( fragment: { toArray: () => Array }, ): string { // Map TipTap schema node names to HTML tags. Unknown nodes fall through // as
so content still shows up rather than disappearing silently. const NODE_TAG: Record = { paragraph: 'p', blockquote: 'blockquote', bulletList: 'ul', orderedList: 'ol', listItem: 'li', taskList: 'ul', taskItem: 'li', codeBlock: 'pre', horizontalRule: 'hr', hardBreak: 'br', table: 'table', tableRow: 'tr', tableCell: 'td', tableHeader: 'th', image: 'img', pageBreak: 'hr', }; // Marks → inline wrapping tag const MARK_TAG: Record = { bold: 'strong', italic: 'em', underline: 'u', strike: 's', code: 'code', link: 'a', subscript: 'sub', superscript: 'sup', highlight: 'mark', }; function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Yjs exposes XmlText, XmlElement, XmlHook; we rely on duck-typed fields // so this function stays testable without importing yjs types. function walk(node: any): string { // Text node — wrap in mark tags if it carries any if (typeof node?.toString === 'function' && node.constructor?.name === 'YXmlText') { const text = escapeHtml(node.toString()); // YXmlText attributes don't represent marks directly; TipTap's // y-prosemirror binding encodes marks via delta attrs which aren't // trivially inspectable here. Return plain text; the node-level // walker handles structural formatting which is what users care // about in a preview. return text; } if (node && typeof node.nodeName === 'string') { const name: string = node.nodeName; let tag = NODE_TAG[name]; if (!tag) { // Heading: nodeName === 'heading', level attr if (name === 'heading') { const level = Math.max(1, Math.min(6, Number(node.getAttribute?.('level')) || 1)); tag = 'h' + level; } else { tag = 'div'; } } const children: string = (node.toArray?.() ?? []).map(walk).join(''); if (tag === 'hr' || tag === 'br') return `<${tag}>`; if (tag === 'img') { const src = escapeHtml(node.getAttribute?.('src') ?? ''); const alt = escapeHtml(node.getAttribute?.('alt') ?? ''); return `${alt}`; } if (tag === 'pre') return `
${children}
`; return `<${tag}>${children}`; } return ''; } // Use MARK_TAG so the lint/build doesn't drop the lookup table — it's // reserved for a follow-up pass that decodes y-prosemirror mark deltas. void MARK_TAG; return fragment.toArray().map(walk).join(''); } export function createVersionPanel(config: VersionPanelConfig): VersionPanel { const { docId, container = document.body, onRestore, docType = 'doc', } = config; // Build DOM const panel = document.createElement('div'); panel.className = 'version-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Version history'); panel.innerHTML = `

Version History

`; container.appendChild(panel); const closeBtn = panel.querySelector('.version-panel-close') as HTMLButtonElement; const listEl = panel.querySelector('.version-panel-list') as HTMLDivElement; const previewEl = panel.querySelector('.version-panel-preview') as HTMLDivElement; const previewContent = panel.querySelector('.version-panel-preview-content') as HTMLDivElement; const backBtn = panel.querySelector('.version-panel-back') as HTMLButtonElement; const restoreBtn = panel.querySelector('.version-panel-restore') as HTMLButtonElement; let isOpen_ = false; let selectedVersionId: string | null = null; let versions: VersionData[] = []; // --- Event handlers --- closeBtn.addEventListener('click', close); backBtn.addEventListener('click', () => { previewEl.style.display = 'none'; selectedVersionId = null; }); restoreBtn.addEventListener('click', handleRestore); function handleKeydown(e: KeyboardEvent): void { if (e.key === 'Escape' && isOpen_) { e.preventDefault(); close(); } // Cmd+Shift+H toggles if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { e.preventDefault(); toggle(); } } document.addEventListener('keydown', handleKeydown); // --- Methods --- function toggle(): void { if (isOpen_) close(); else open(); } function open(): void { isOpen_ = true; panel.classList.add('open'); loadVersions(); } function close(): void { isOpen_ = false; panel.classList.remove('open'); previewEl.style.display = 'none'; selectedVersionId = null; } async function loadVersions(): Promise { listEl.innerHTML = '
Loading...
'; try { const entries = await listVersions(docId); // Parse metadata JSON strings into objects versions = entries.map(e => ({ id: e.id, document_id: e.document_id, created_at: e.created_at, metadata: ((): Record | null => { try { return JSON.parse(e.metadata || '{}'); } catch { return null; } })(), })); if (versions.length === 0) { listEl.innerHTML = '
No versions yet
'; return; } listEl.innerHTML = ''; for (let i = 0; i < versions.length; i++) { const v = versions[i]!; const meta = (v.metadata && typeof v.metadata === 'object') ? v.metadata : {}; const prevMeta = (i < versions.length - 1) ? ((versions[i + 1]!.metadata && typeof versions[i + 1]!.metadata === 'object') ? versions[i + 1]!.metadata as Record : {}) : undefined; const countKey = docType === 'sheet' ? 'cellCount' : 'wordCount'; const stats = computeDiffStats( { wordCount: typeof meta[countKey] === 'number' ? (meta[countKey] as number) : undefined }, prevMeta ? { wordCount: typeof prevMeta[countKey] === 'number' ? (prevMeta[countKey] as number) : undefined } : undefined, ); const item = document.createElement('div'); item.className = 'version-panel-item'; item.setAttribute('role', 'button'); item.setAttribute('tabindex', '0'); const timeStr = formatRelativeTime(v.created_at); const author = (typeof meta['author'] === 'string' ? meta['author'] : null) || 'Unknown'; const countVal = typeof meta[countKey] === 'number' ? (meta[countKey] as number) : null; const countLabel = docType === 'sheet' ? 'cells' : 'words'; const namedVersion = v._name || (typeof meta['name'] === 'string' ? meta['name'] as string : null) || (typeof meta['label'] === 'string' ? meta['label'] as string : null); const deltaClass = stats.delta > 0 ? 'positive' : stats.delta < 0 ? 'negative' : ''; item.innerHTML = `
${timeStr} ${namedVersion ? `${escapeHtml(namedVersion)}` : ''}
${escapeHtml(author)} ${countVal !== null ? `${countVal} ${countLabel}` : ''} ${stats.label}
`; const nameBtn = item.querySelector('.version-panel-name-btn') as HTMLButtonElement; nameBtn.addEventListener('click', (e) => { e.stopPropagation(); promptNameVersion(v.id, nameBtn); }); item.addEventListener('click', () => showPreview(v.id)); item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showPreview(v.id); } }); listEl.appendChild(item); } } catch { listEl.innerHTML = '
Failed to load versions
'; } } async function promptNameVersion(versionId: string, btn: HTMLButtonElement): Promise { const { modalPrompt } = await import('./lib/modal-dialog.js'); const name = await modalPrompt({ title: 'Name this version', message: 'Give this version a short, memorable name.', placeholder: 'e.g. Before the big rewrite', okLabel: 'Save', }); if (!name) return; try { // Merge the new name into existing metadata const v = versions.find(ver => ver.id === versionId); const existingMeta = (v?.metadata && typeof v.metadata === 'object') ? { ...v.metadata } : {}; existingMeta['name'] = name; await updateVersionMetadata(docId, versionId, existingMeta as Record); // Update local state if (v && v.metadata && typeof v.metadata === 'object') { (v.metadata as Record)['name'] = name; } // Refresh the badge in the parent element const itemTop = btn.parentElement?.querySelector('.version-panel-item-top'); if (itemTop) { const existingBadge = itemTop.querySelector('.version-panel-named-badge'); if (existingBadge) { existingBadge.textContent = name; } else { const badge = document.createElement('span'); badge.className = 'version-panel-named-badge'; badge.textContent = name; itemTop.appendChild(badge); } } } catch { // Silently fail — best effort } } async function showPreview(versionId: string): Promise { selectedVersionId = versionId; previewEl.style.display = ''; previewContent.textContent = 'Loading...'; try { const entry = await getVersion(docId, versionId); if (!entry) throw new Error('Not found'); const encrypted = new Uint8Array(entry.snapshot); // Decrypt const { decrypt } = await import('./lib/crypto.js'); const decrypted = await decrypt(encrypted, config.cryptoKey); // Render preview using Yjs const Y = await import('yjs'); const tempDoc = new Y.Doc(); Y.applyUpdate(tempDoc, decrypted); previewContent.innerHTML = ''; if (docType === 'sheet') { // Simple cell preview for sheets const sheets = tempDoc.getMap('sheets'); const previewDiv = document.createElement('div'); previewDiv.className = 'version-preview-text'; const firstSheet = sheets.get('sheet_0') as import('yjs').Map | undefined; if (firstSheet) { const cells = firstSheet.get('cells') as import('yjs').Map | undefined; if (cells) { const cellCount = cells.size; previewDiv.textContent = `${cellCount} cells with data`; } else { previewDiv.textContent = 'Empty sheet'; } } else { previewDiv.textContent = 'Empty spreadsheet'; } previewContent.appendChild(previewDiv); } else { // Doc preview — render the Yjs XmlFragment as readable HTML. // Previously we dumped fragment.toString() which is the raw ProseMirror // schema XML (Title…) // — unreadable for users. See #719. const fragment = tempDoc.getXmlFragment('default'); const previewDiv = document.createElement('div'); previewDiv.className = 'version-preview-text'; previewDiv.innerHTML = renderYjsFragmentAsHtml(fragment); previewContent.appendChild(previewDiv); } tempDoc.destroy(); } catch { previewContent.textContent = 'Failed to load version preview'; } } async function handleRestore(): Promise { 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 { decrypt } = await import('./lib/crypto.js'); const decrypted = await decrypt(encrypted, config.cryptoKey); if (onRestore) { await onRestore(selectedVersionId, decrypted); } close(); } catch { const { showToast } = await import('./landing-toast.js'); showToast('Failed to restore version', 4000, true); } } function escapeHtml(str: string): string { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function destroy(): void { document.removeEventListener('keydown', handleKeydown); panel.remove(); } return { toggle, open, close, isOpen: () => isOpen_, destroy, }; }