From b425defdcaaffa8b69f32732ec2f769b24ab1bf4 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Wed, 18 Feb 2026 21:49:31 +0800 Subject: [PATCH] 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 --- src/components/shared/export-dialog.tsx | 132 ++++++++++++++ src/components/shared/save-dialog.tsx | 101 +++++++++++ src/hooks/use-keyboard-shortcuts.ts | 223 ++++++++++++++++++++++++ src/stores/history-store.ts | 110 ++++++++++++ src/utils/export.ts | 65 +++++++ src/utils/file-operations.ts | 133 ++++++++++++++ 6 files changed, 764 insertions(+) create mode 100644 src/components/shared/export-dialog.tsx create mode 100644 src/components/shared/save-dialog.tsx create mode 100644 src/stores/history-store.ts create mode 100644 src/utils/export.ts create mode 100644 src/utils/file-operations.ts diff --git a/src/components/shared/export-dialog.tsx b/src/components/shared/export-dialog.tsx new file mode 100644 index 000000000..c20775d7e --- /dev/null +++ b/src/components/shared/export-dialog.tsx @@ -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(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 ( +
+
+
+
+

Export

+ +
+ + {/* Format */} +
+ +
+ {(['png', 'svg'] as const).map((f) => ( + + ))} +
+
+ + {/* Scale (PNG only) */} + {format === 'png' && ( +
+ +
+ {[1, 2, 3].map((s) => ( + + ))} +
+
+ )} + + {/* Selected only */} + {hasSelection && ( + + )} + + {/* Export button */} + +
+
+ ) +} diff --git a/src/components/shared/save-dialog.tsx b/src/components/shared/save-dialog.tsx new file mode 100644 index 000000000..3f22c0252 --- /dev/null +++ b/src/components/shared/save-dialog.tsx @@ -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(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 ( +
+
+
+
+

Save As

+ +
+ + +
+ 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 + /> + .pen +
+ +
+ + +
+
+
+ ) +} diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index 5ef8bf314..497130e86 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -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 = { @@ -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 => 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 => 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 => 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 } } diff --git a/src/stores/history-store.ts b/src/stores/history-store.ts new file mode 100644 index 000000000..f87656af9 --- /dev/null +++ b/src/stores/history-store.ts @@ -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( + (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 }) + }, + }), +) diff --git a/src/utils/export.ts b/src/utils/export.ts new file mode 100644 index 000000000..fd33fa1e9 --- /dev/null +++ b/src/utils/export.ts @@ -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}` + 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) +} diff --git a/src/utils/file-operations.ts b/src/utils/file-operations.ts new file mode 100644 index 000000000..bc4463e5c --- /dev/null +++ b/src/utils/file-operations.ts @@ -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 { + 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 + } + ).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 + } + ).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 (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() + }) +}