Files
petal/web/src/components/Export/ExportMenu.tsx
prosolis 8e1111d768 Phase 8: version history + export (trust foundation)
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
2026-06-25 23:44:15 -07:00

121 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}