✨ revisions
This commit is contained in:
190
src/renderer/components/Revisions/RevisionPanel.css
Normal file
190
src/renderer/components/Revisions/RevisionPanel.css
Normal file
@@ -0,0 +1,190 @@
|
||||
/* ─── Revision panel — overlays the editor area ─────────────────────────── */
|
||||
.revision-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
background: var(--editor-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─── Header ─────────────────────────────────────────────────────────────── */
|
||||
.revision-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
height: 38px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--toolbar-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.revision-back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.revision-back-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.revision-panel-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Body (two-column layout) ───────────────────────────────────────────── */
|
||||
.revision-panel-body {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Revision list (left column) ───────────────────────────────────────── */
|
||||
.revision-list {
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.revision-empty {
|
||||
padding: 20px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.revision-entry {
|
||||
position: relative;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.revision-entry:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.revision-entry.selected {
|
||||
background: var(--active-bg);
|
||||
}
|
||||
|
||||
.revision-entry-date {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.revision-entry-words {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.revision-entry-delete {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.revision-entry:hover .revision-entry-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.revision-entry-delete:hover {
|
||||
color: #c05050;
|
||||
}
|
||||
|
||||
/* ─── Preview (right column) ─────────────────────────────────────────────── */
|
||||
.revision-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.revision-preview-empty {
|
||||
margin: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-sans);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.revision-preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--text-primary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.revision-preview-content h1,
|
||||
.revision-preview-content h2,
|
||||
.revision-preview-content h3 {
|
||||
color: var(--heading-color);
|
||||
font-weight: 700;
|
||||
margin: 1em 0 0.4em;
|
||||
}
|
||||
|
||||
.revision-preview-content p {
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.revision-preview-content em { font-style: italic; }
|
||||
.revision-preview-content strong { font-weight: 700; }
|
||||
|
||||
.revision-preview-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--toolbar-bg);
|
||||
}
|
||||
|
||||
.revision-restore-btn {
|
||||
background: var(--accent);
|
||||
color: var(--bg-base);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.revision-restore-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
131
src/renderer/components/Revisions/RevisionPanel.tsx
Normal file
131
src/renderer/components/Revisions/RevisionPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import { currentEditorView } from '../Editor/MarkdownEditor'
|
||||
import type { RevisionMeta } from '../../types/editor'
|
||||
import './RevisionPanel.css'
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const isToday = d.toDateString() === now.toDateString()
|
||||
const isYesterday = d.toDateString() === new Date(now.getTime() - 86400000).toDateString()
|
||||
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
if (isToday) return `Today, ${time}`
|
||||
if (isYesterday) return `Yesterday, ${time}`
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + time
|
||||
}
|
||||
|
||||
export function RevisionPanel(): JSX.Element {
|
||||
const { activeFilePath, toggleRevisionPanel, revisions, setRevisions } = useEditorStore()
|
||||
const [selected, setSelected] = useState<RevisionMeta | null>(null)
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||
const [previewRaw, setPreviewRaw] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeFilePath) return
|
||||
window.api.listRevisions(activeFilePath).then(setRevisions)
|
||||
setSelected(null)
|
||||
setPreviewHtml(null)
|
||||
setPreviewRaw(null)
|
||||
}, [activeFilePath])
|
||||
|
||||
const selectRevision = async (rev: RevisionMeta): Promise<void> => {
|
||||
if (selected?.id === rev.id) return
|
||||
setSelected(rev)
|
||||
setPreviewHtml(null)
|
||||
setPreviewRaw(null)
|
||||
if (!activeFilePath) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const content = await window.api.loadRevision(activeFilePath, rev.id)
|
||||
setPreviewRaw(content)
|
||||
const html = await marked.parse(content)
|
||||
setPreviewHtml(DOMPurify.sanitize(html))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const restore = (): void => {
|
||||
if (previewRaw === null) return
|
||||
const view = currentEditorView
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: previewRaw }
|
||||
})
|
||||
}
|
||||
toggleRevisionPanel()
|
||||
}
|
||||
|
||||
const deleteRev = async (e: React.MouseEvent, rev: RevisionMeta): Promise<void> => {
|
||||
e.stopPropagation()
|
||||
if (!activeFilePath) return
|
||||
await window.api.deleteRevision(activeFilePath, rev.id)
|
||||
const updated = revisions.filter((r) => r.id !== rev.id)
|
||||
setRevisions(updated)
|
||||
if (selected?.id === rev.id) {
|
||||
setSelected(null)
|
||||
setPreviewHtml(null)
|
||||
setPreviewRaw(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="revision-panel">
|
||||
<div className="revision-panel-header">
|
||||
<button className="revision-back-btn" onClick={toggleRevisionPanel} title="Close">
|
||||
←
|
||||
</button>
|
||||
<span className="revision-panel-title">Revision History</span>
|
||||
</div>
|
||||
<div className="revision-panel-body">
|
||||
<div className="revision-list">
|
||||
{revisions.length === 0 ? (
|
||||
<p className="revision-empty">No revisions yet.<br />Save with ⌘S to create one.</p>
|
||||
) : (
|
||||
revisions.map((rev) => (
|
||||
<div
|
||||
key={rev.id}
|
||||
className={`revision-entry${selected?.id === rev.id ? ' selected' : ''}`}
|
||||
onClick={() => selectRevision(rev)}
|
||||
>
|
||||
<div className="revision-entry-date">{formatDate(rev.timestamp)}</div>
|
||||
<div className="revision-entry-words">{rev.wordCount.toLocaleString()} words</div>
|
||||
<button
|
||||
className="revision-entry-delete"
|
||||
onClick={(e) => deleteRev(e, rev)}
|
||||
title="Delete revision"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="revision-preview">
|
||||
{selected === null ? (
|
||||
<p className="revision-preview-empty">Select a revision to preview</p>
|
||||
) : loading ? (
|
||||
<p className="revision-preview-empty">Loading…</p>
|
||||
) : previewHtml !== null ? (
|
||||
<>
|
||||
<div
|
||||
className="revision-preview-content"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
<div className="revision-preview-footer">
|
||||
<button className="revision-restore-btn" onClick={restore}>
|
||||
Restore this version
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user