feat(history): add undo/redo, keyboard shortcuts, and file operations

Implement history store with undo/redo stacks using structuredClone
snapshots. Add keyboard shortcuts for common operations (copy, paste,
delete, group, save, export). Implement save-as dialog (.pen file
download) and export dialog (PNG/SVG with scale options).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kayshen-X 2026-02-18 21:49:31 +08:00
parent a56577ea85
commit b425defdca
6 changed files with 764 additions and 0 deletions

View file

@ -0,0 +1,132 @@
import { useState, useRef, useEffect } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useCanvasStore } from '@/stores/canvas-store'
import { exportToPNG, exportToSVG } from '@/utils/export'
interface ExportDialogProps {
open: boolean
onClose: () => void
}
export default function ExportDialog({ open, onClose }: ExportDialogProps) {
const [format, setFormat] = useState<'png' | 'svg'>('png')
const [scale, setScale] = useState(2)
const [selectedOnly, setSelectedOnly] = useState(false)
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
const hasSelection = useCanvasStore(
(s) => s.selection.selectedIds.length > 0,
)
const dialogRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
const handleExport = () => {
if (!fabricCanvas) return
if (format === 'png') {
exportToPNG(fabricCanvas, {
multiplier: scale,
selectedOnly,
})
} else {
exportToSVG(fabricCanvas, { selectedOnly })
}
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-background/80" onClick={onClose} />
<div
ref={dialogRef}
className="relative bg-card rounded-lg border border-border p-4 w-72 shadow-xl"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Export</h3>
<Button variant="ghost" size="icon-sm" onClick={onClose}>
<X size={14} />
</Button>
</div>
{/* Format */}
<div className="mb-3">
<label className="text-xs text-muted-foreground block mb-1">Format</label>
<div className="flex gap-2">
{(['png', 'svg'] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
className={cn(
'flex-1 text-xs py-1.5 rounded transition-colors',
format === f
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
)}
>
{f.toUpperCase()}
</button>
))}
</div>
</div>
{/* Scale (PNG only) */}
{format === 'png' && (
<div className="mb-3">
<label className="text-xs text-muted-foreground block mb-1">Scale</label>
<div className="flex gap-2">
{[1, 2, 3].map((s) => (
<button
key={s}
type="button"
onClick={() => setScale(s)}
className={cn(
'flex-1 text-xs py-1.5 rounded transition-colors',
scale === s
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
)}
>
{s}x
</button>
))}
</div>
</div>
)}
{/* Selected only */}
{hasSelection && (
<label className="flex items-center gap-2 mb-4 cursor-pointer">
<input
type="checkbox"
checked={selectedOnly}
onChange={(e) => setSelectedOnly(e.target.checked)}
className="rounded border-input bg-secondary text-primary focus:ring-ring focus:ring-offset-0"
/>
<span className="text-xs text-foreground">Export selected only</span>
</label>
)}
{/* Export button */}
<Button
onClick={handleExport}
disabled={!fabricCanvas}
className="w-full"
size="sm"
>
Export {format.toUpperCase()}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,101 @@
import { useState, useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useDocumentStore } from '@/stores/document-store'
import { downloadDocument } from '@/utils/file-operations'
import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync'
interface SaveDialogProps {
open: boolean
onClose: () => void
}
export default function SaveDialog({ open, onClose }: SaveDialogProps) {
const [name, setName] = useState('untitled')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) return
// Pre-fill with existing name (without extension)
const fn = useDocumentStore.getState().fileName
if (fn) {
setName(fn.replace(/\.pen$|\.json$/, ''))
} else {
setName('untitled')
}
// Focus + select on open
requestAnimationFrame(() => inputRef.current?.select())
}, [open])
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
const handleSave = () => {
const trimmed = name.trim()
if (!trimmed) return
// Force-sync all Fabric object positions to the store before serializing
syncCanvasPositionsToStore()
const fileName = trimmed.endsWith('.pen') ? trimmed : `${trimmed}.pen`
const doc = useDocumentStore.getState().document
downloadDocument(doc, fileName)
useDocumentStore.setState({ fileName, isDirty: false })
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-background/80" onClick={onClose} />
<div className="relative bg-card rounded-lg border border-border p-4 w-72 shadow-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Save As</h3>
<Button variant="ghost" size="icon-sm" onClick={onClose}>
<X size={14} />
</Button>
</div>
<label className="text-xs text-muted-foreground block mb-1">File name</label>
<div className="flex items-center gap-1 mb-4">
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
}}
className="flex-1 bg-secondary border border-input rounded px-2 py-1.5 text-sm text-foreground focus:outline-none focus:border-ring"
autoFocus
/>
<span className="text-xs text-muted-foreground">.pen</span>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!name.trim()}
className="flex-1"
>
Save
</Button>
</div>
</div>
</div>
)
}

View file

@ -2,6 +2,17 @@ import { useEffect } from 'react'
import { ActiveSelection } from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import { cloneNodesWithNewIds } from '@/utils/node-clone'
import {
supportsFileSystemAccess,
writeToFileHandle,
saveDocumentAs,
downloadDocument,
openDocumentFS,
openDocument,
} from '@/utils/file-operations'
import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync'
import type { ToolType } from '@/types/canvas'
const TOOL_KEYS: Record<string, ToolType> = {
@ -30,6 +41,178 @@ export function useKeyboardShortcuts() {
const isMod = e.metaKey || e.ctrlKey
// Undo: Cmd/Ctrl+Z
if (isMod && e.key === 'z' && !e.shiftKey) {
e.preventDefault()
const currentDoc = useDocumentStore.getState().document
const prev = useHistoryStore.getState().undo(currentDoc)
if (prev) {
useDocumentStore.getState().applyHistoryState(prev)
}
return
}
// Redo: Cmd/Ctrl+Shift+Z
if (isMod && e.key === 'z' && e.shiftKey) {
e.preventDefault()
const currentDoc = useDocumentStore.getState().document
const next = useHistoryStore.getState().redo(currentDoc)
if (next) {
useDocumentStore.getState().applyHistoryState(next)
}
return
}
// Copy: Cmd/Ctrl+C
if (isMod && e.key === 'c' && !e.shiftKey) {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length > 0) {
e.preventDefault()
const nodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter((n): n is NonNullable<typeof n> => n != null)
useCanvasStore.getState().setClipboard(structuredClone(nodes))
}
return
}
// Cut: Cmd/Ctrl+X
if (isMod && e.key === 'x' && !e.shiftKey) {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length > 0) {
e.preventDefault()
const nodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter((n): n is NonNullable<typeof n> => n != null)
useCanvasStore.getState().setClipboard(structuredClone(nodes))
for (const id of selectedIds) {
useDocumentStore.getState().removeNode(id)
}
useCanvasStore.getState().clearSelection()
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
}
return
}
// Paste: Cmd/Ctrl+V
if (isMod && e.key === 'v' && !e.shiftKey) {
const { clipboard } = useCanvasStore.getState()
if (clipboard.length > 0) {
e.preventDefault()
const cloned = cloneNodesWithNewIds(clipboard, 10)
const newIds: string[] = []
for (const node of cloned) {
useDocumentStore.getState().addNode(null, node)
newIds.push(node.id)
}
useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null)
}
return
}
// Duplicate: Cmd/Ctrl+D
if (isMod && e.key === 'd') {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length > 0) {
e.preventDefault()
const nodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter((n): n is NonNullable<typeof n> => n != null)
const cloned = cloneNodesWithNewIds(nodes, 10)
const newIds: string[] = []
for (const node of cloned) {
useDocumentStore.getState().addNode(null, node)
newIds.push(node.id)
}
useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null)
}
return
}
// Save: Cmd/Ctrl+S
if (isMod && e.key === 's' && !e.shiftKey) {
e.preventDefault()
// Force-sync all Fabric object positions to the store before serializing
syncCanvasPositionsToStore()
const store = useDocumentStore.getState()
const { document: doc, fileName, fileHandle } = store
if (fileHandle) {
writeToFileHandle(fileHandle, doc).then(() => store.markClean())
} else if (supportsFileSystemAccess()) {
saveDocumentAs(doc, fileName ?? 'untitled.pen').then((result) => {
if (result) {
useDocumentStore.setState({
fileName: result.fileName,
fileHandle: result.handle,
isDirty: false,
})
}
})
} else if (fileName) {
downloadDocument(doc, fileName)
store.markClean()
} else {
store.setSaveDialogOpen(true)
}
return
}
// Open: Cmd/Ctrl+O
if (isMod && e.key === 'o' && !e.shiftKey) {
e.preventDefault()
if (supportsFileSystemAccess()) {
openDocumentFS().then((result) => {
if (result) {
useDocumentStore
.getState()
.loadDocument(result.doc, result.fileName, result.handle)
}
})
} else {
openDocument().then((result) => {
if (result) {
useDocumentStore
.getState()
.loadDocument(result.doc, result.fileName)
}
})
}
return
}
// Group: Cmd/Ctrl+G
if (isMod && e.key === 'g' && !e.shiftKey) {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length >= 2) {
e.preventDefault()
const groupId = useDocumentStore.getState().groupNodes(selectedIds)
if (groupId) {
useCanvasStore.getState().setSelection([groupId], groupId)
}
}
return
}
// Ungroup: Cmd/Ctrl+Shift+G
if (isMod && e.shiftKey && e.key.toLowerCase() === 'g') {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length === 1) {
e.preventDefault()
const node = useDocumentStore.getState().getNodeById(selectedIds[0])
if (node && node.type === 'group' && 'children' in node && node.children) {
const childIds = node.children.map((c) => c.id)
useDocumentStore.getState().ungroupNode(selectedIds[0])
useCanvasStore.getState().setSelection(childIds, childIds[0] ?? null)
}
}
return
}
// Tool shortcuts (single key, no modifier)
if (!isMod && !e.shiftKey && !e.altKey) {
const tool = TOOL_KEYS[e.key.toLowerCase()]
@ -58,9 +241,19 @@ export function useKeyboardShortcuts() {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length > 0) {
e.preventDefault()
if (selectedIds.length > 1) {
useHistoryStore
.getState()
.beginBatch(
useDocumentStore.getState().document.children,
)
}
for (const id of selectedIds) {
useDocumentStore.getState().removeNode(id)
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
}
useCanvasStore.getState().clearSelection()
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
@ -93,17 +286,37 @@ export function useKeyboardShortcuts() {
if (e.key === '[') {
e.preventDefault()
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length > 1) {
useHistoryStore
.getState()
.beginBatch(
useDocumentStore.getState().document.children,
)
}
for (const id of selectedIds) {
useDocumentStore.getState().reorderNode(id, 'down')
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
}
return
}
if (e.key === ']') {
e.preventDefault()
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length > 1) {
useHistoryStore
.getState()
.beginBatch(
useDocumentStore.getState().document.children,
)
}
for (const id of selectedIds) {
useDocumentStore.getState().reorderNode(id, 'up')
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
}
return
}
@ -113,6 +326,13 @@ export function useKeyboardShortcuts() {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length === 0) return
e.preventDefault()
if (selectedIds.length > 1) {
useHistoryStore
.getState()
.beginBatch(
useDocumentStore.getState().document.children,
)
}
const amount = e.shiftKey ? 10 : 1
for (const id of selectedIds) {
const node = useDocumentStore.getState().getNodeById(id)
@ -124,6 +344,9 @@ export function useKeyboardShortcuts() {
if (e.key === 'ArrowDown') updates.y = (node.y ?? 0) + amount
useDocumentStore.getState().updateNode(id, updates)
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
}
return
}
}

110
src/stores/history-store.ts Normal file
View file

@ -0,0 +1,110 @@
import { create } from 'zustand'
import type { PenDocument, PenNode } from '@/types/pen'
const MAX_HISTORY = 100
interface HistoryStoreState {
undoStack: PenDocument[]
redoStack: PenDocument[]
batchDepth: number
batchBaseState: PenDocument | null
pushState: (doc: PenDocument) => void
undo: (currentDoc: PenDocument) => PenDocument | null
redo: (currentDoc: PenDocument) => PenDocument | null
canUndo: () => boolean
canRedo: () => boolean
clear: () => void
startBatch: (doc: PenDocument) => void
endBatch: () => void
// Legacy API compatibility (used by some canvas event handlers)
beginBatch: (currentChildren: PenNode[]) => void
cancelBatch: () => void
}
export const useHistoryStore = create<HistoryStoreState>(
(set, get) => ({
undoStack: [],
redoStack: [],
batchDepth: 0,
batchBaseState: null,
pushState: (doc) => {
const { batchDepth } = get()
if (batchDepth > 0) return
const clone = structuredClone(doc)
set((s) => ({
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), clone],
redoStack: [],
}))
},
undo: (currentDoc) => {
const { undoStack } = get()
if (undoStack.length === 0) return null
const previous = undoStack[undoStack.length - 1]
const currentClone = structuredClone(currentDoc)
set((s) => ({
undoStack: s.undoStack.slice(0, -1),
redoStack: [...s.redoStack, currentClone],
}))
return structuredClone(previous)
},
redo: (currentDoc) => {
const { redoStack } = get()
if (redoStack.length === 0) return null
const next = redoStack[redoStack.length - 1]
const currentClone = structuredClone(currentDoc)
set((s) => ({
redoStack: s.redoStack.slice(0, -1),
undoStack: [...s.undoStack, currentClone],
}))
return structuredClone(next)
},
canUndo: () => get().undoStack.length > 0,
canRedo: () => get().redoStack.length > 0,
clear: () => set({ undoStack: [], redoStack: [], batchDepth: 0, batchBaseState: null }),
startBatch: (doc) => {
const { batchDepth } = get()
if (batchDepth === 0) {
set({ batchBaseState: structuredClone(doc), batchDepth: 1 })
} else {
set({ batchDepth: batchDepth + 1 })
}
},
endBatch: () => {
const { batchDepth, batchBaseState } = get()
if (batchDepth <= 0) return
if (batchDepth === 1 && batchBaseState) {
set((s) => ({
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), batchBaseState],
redoStack: [],
batchDepth: 0,
batchBaseState: null,
}))
} else {
set({ batchDepth: batchDepth - 1 })
}
},
// Legacy compatibility: beginBatch wraps startBatch by constructing a doc from children
beginBatch: (currentChildren) => {
const doc: PenDocument = { version: '1.0.0', children: currentChildren }
get().startBatch(doc)
},
cancelBatch: () => {
set({ batchDepth: 0, batchBaseState: null })
},
}),
)

65
src/utils/export.ts Normal file
View file

@ -0,0 +1,65 @@
import type { Canvas } from 'fabric'
function downloadFile(url: string, filename: string) {
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
export interface PNGExportOptions {
multiplier?: number
filename?: string
selectedOnly?: boolean
}
export interface SVGExportOptions {
filename?: string
selectedOnly?: boolean
}
export function exportToPNG(canvas: Canvas, options?: PNGExportOptions) {
const multiplier = options?.multiplier ?? 2
const filename = options?.filename ?? 'design.png'
if (options?.selectedOnly) {
const active = canvas.getActiveObject()
if (active) {
const dataURL = active.toDataURL({
format: 'png',
multiplier,
})
downloadFile(dataURL, filename)
return
}
}
const dataURL = canvas.toDataURL({
format: 'png',
multiplier,
})
downloadFile(dataURL, filename)
}
export function exportToSVG(canvas: Canvas, options?: SVGExportOptions) {
const filename = options?.filename ?? 'design.svg'
if (options?.selectedOnly) {
const active = canvas.getActiveObject()
if (active) {
const svg = active.toSVG()
const width = active.width ?? 100
const height = active.height ?? 100
const fullSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${svg}</svg>`
const blob = new Blob([fullSVG], { type: 'image/svg+xml' })
downloadFile(URL.createObjectURL(blob), filename)
return
}
}
const svg = canvas.toSVG()
const blob = new Blob([svg], { type: 'image/svg+xml' })
downloadFile(URL.createObjectURL(blob), filename)
}

View file

@ -0,0 +1,133 @@
import type { PenDocument } from '@/types/pen'
import { normalizePenDocument } from './normalize-pen-file'
// ---------------------------------------------------------------------------
// Feature detection
// ---------------------------------------------------------------------------
export function supportsFileSystemAccess(): boolean {
return 'showSaveFilePicker' in window
}
// ---------------------------------------------------------------------------
// File System Access API (Chrome / Edge)
// ---------------------------------------------------------------------------
/** Write document JSON to a FileSystemFileHandle. */
export async function writeToFileHandle(
handle: FileSystemFileHandle,
doc: PenDocument,
): Promise<void> {
const writable = await handle.createWritable()
await writable.write(JSON.stringify(doc, null, 2))
await writable.close()
}
/** Show native save-file picker, write, and return the handle + name. */
export async function saveDocumentAs(
doc: PenDocument,
suggestedName?: string,
): Promise<{ handle: FileSystemFileHandle; fileName: string } | null> {
try {
const handle: FileSystemFileHandle = await (
window as unknown as {
showSaveFilePicker: (opts: unknown) => Promise<FileSystemFileHandle>
}
).showSaveFilePicker({
suggestedName: suggestedName || 'untitled.pen',
types: [
{
description: 'Pen Design File',
accept: { 'application/json': ['.pen'] },
},
],
})
await writeToFileHandle(handle, doc)
return { handle, fileName: handle.name }
} catch {
// User cancelled or API error
return null
}
}
/** Open file via native picker, return doc + handle. */
export async function openDocumentFS(): Promise<{
doc: PenDocument
fileName: string
handle: FileSystemFileHandle
} | null> {
try {
const [handle]: FileSystemFileHandle[] = await (
window as unknown as {
showOpenFilePicker: (
opts: unknown,
) => Promise<FileSystemFileHandle[]>
}
).showOpenFilePicker({
types: [
{
description: 'Pen Design File',
accept: { 'application/json': ['.pen', '.json'] },
},
],
})
const file = await handle.getFile()
const text = await file.text()
const raw = JSON.parse(text) as PenDocument
if (!raw.version || !Array.isArray(raw.children)) {
throw new Error('Invalid PenDocument format')
}
const doc = normalizePenDocument(raw)
return { doc, fileName: file.name, handle }
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Fallback: download / file-input (Firefox, Safari)
// ---------------------------------------------------------------------------
/** Download document as a file (browser download). */
export function downloadDocument(doc: PenDocument, fileName: string): void {
const json = JSON.stringify(doc, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
/** Open file via <input type="file"> (fallback). */
export function openDocument(): Promise<{
doc: PenDocument
fileName: string
} | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.pen,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) {
resolve(null)
return
}
try {
const text = await file.text()
const raw = JSON.parse(text) as PenDocument
if (!raw.version || !Array.isArray(raw.children)) {
throw new Error('Invalid PenDocument format')
}
const doc = normalizePenDocument(raw)
resolve({ doc, fileName: file.name })
} catch {
resolve(null)
}
}
input.oncancel = () => resolve(null)
input.click()
})
}