Version history: new document_versions table (migration 0003) holding full-body snapshots that cascade with the doc. Throttled auto-snapshots on save (>=3min apart, max 40/doc, pruned), explicit manual restore points, and a pre_restore safety copy taken before each restore so restoring is itself undoable. Endpoints under /api/docs/:id/versions, all owner-scoped. Empties and bare renames never snapshot. Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx (no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987 filenames. docx is a hand-built OOXML zip. PDF is handled client-side via the browser print dialog + an @media print stylesheet so CJK renders with the reader's own fonts. Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel (snapshot list, preview, restore) wired into the title row; bilingual zh-first to match chrome. Restore remounts the editor via editorEpoch. Stop saving empty docs: blank Untitled drafts now reuse-on-create and self-discard when navigated away from (refs avoid stale closures). Tests: versions_test.go, export_test.go (incl. valid-zip docx). go build/vet/test, tsc, vite build all clean; live end-to-end smoke verified snapshot/throttle/restore/export. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { api, type ExportFormat } from '../../api/client'
|
||
|
||
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
||
// plain <a download> links to the server's export endpoint (which sets the
|
||
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
|
||
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
|
||
// no server-side font embedding. Bilingual zh·en labels match Petal's chrome.
|
||
|
||
interface FormatOption {
|
||
format: ExportFormat
|
||
emoji: string
|
||
zh: string
|
||
en: string
|
||
}
|
||
|
||
const FORMATS: FormatOption[] = [
|
||
{ format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' },
|
||
{ format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' },
|
||
{ format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' },
|
||
{ format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' },
|
||
]
|
||
|
||
interface Props {
|
||
docId: string
|
||
}
|
||
|
||
export function ExportMenu({ docId }: Props) {
|
||
const [open, setOpen] = useState(false)
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const onDown = (e: MouseEvent) => {
|
||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', onDown)
|
||
return () => document.removeEventListener('mousedown', onDown)
|
||
}, [open])
|
||
|
||
return (
|
||
<div ref={ref} className="relative shrink-0">
|
||
<button
|
||
type="button"
|
||
aria-label="Export document"
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((o) => !o)}
|
||
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||
style={{
|
||
borderRadius: 'var(--radius-pill)',
|
||
background: 'var(--color-surface)',
|
||
color: 'var(--color-plum)',
|
||
boxShadow: 'var(--shadow-soft)',
|
||
}}
|
||
title="Save or print your writing"
|
||
>
|
||
<span aria-hidden>⬇</span>
|
||
<span>导出</span>
|
||
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div
|
||
role="menu"
|
||
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
||
style={{
|
||
width: 220,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-card)',
|
||
boxShadow: 'var(--shadow-soft)',
|
||
}}
|
||
>
|
||
{FORMATS.map((f) => (
|
||
<a
|
||
key={f.format}
|
||
role="menuitem"
|
||
href={api.exportUrl(docId, f.format)}
|
||
download
|
||
onClick={() => setOpen(false)}
|
||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold no-underline"
|
||
style={{ color: 'var(--color-plum)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||
>
|
||
<span aria-hidden>{f.emoji}</span>
|
||
<span>{f.zh}</span>
|
||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||
{f.en}
|
||
</span>
|
||
</a>
|
||
))}
|
||
|
||
<div className="my-1 h-px" style={{ background: 'var(--color-border)' }} />
|
||
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setOpen(false)
|
||
// Defer so the menu unmounts before the print dialog snapshots.
|
||
setTimeout(() => window.print(), 50)
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
|
||
style={{ background: 'transparent', color: 'var(--color-plum)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||
>
|
||
<span aria-hidden>🖨️</span>
|
||
<span>打印 / PDF</span>
|
||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||
Print / PDF
|
||
</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|