mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
a56577ea85
commit
b425defdca
6 changed files with 764 additions and 0 deletions
132
src/components/shared/export-dialog.tsx
Normal file
132
src/components/shared/export-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
src/components/shared/save-dialog.tsx
Normal file
101
src/components/shared/save-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
110
src/stores/history-store.ts
Normal 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
65
src/utils/export.ts
Normal 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)
|
||||
}
|
||||
133
src/utils/file-operations.ts
Normal file
133
src/utils/file-operations.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue