feat(history): increase maximum undo/redo states and enhance batch handling

- Update history store to support up to 300 undo/redo states, improving user experience during complex editing sessions.
- Refactor batch handling in various components to ensure accurate history tracking and prevent unnecessary entries.
- Implement checks to avoid adding duplicate states to the undo stack, optimizing performance and memory usage.
- Enhance event handling in canvas and keyboard shortcuts to maintain consistent history state during user interactions.
This commit is contained in:
Kayshen-X 2026-02-20 15:41:45 +08:00
commit 1bcb2c5d10
8 changed files with 231 additions and 55 deletions

View file

@ -88,7 +88,7 @@ PenDocument (source of truth)
- **`src/stores/`** — Zustand stores (5 files):
- `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state, `variablesPanelOpen` toggle
- `document-store.ts` — PenDocument tree CRUD: `addNode`, `updateNode`, `removeNode`, `moveNode`, `reorderNode`, `duplicateNode`, `groupNodes`, `ungroupNode`, `toggleVisibility`, `toggleLock`, `scaleDescendantsInStore`, `rotateDescendantsInStore`, `getNodeById`, `getParentOf`, `getFlatNodes`, `isDescendantOf`; Variable CRUD: `setVariable`, `removeVariable`, `renameVariable`, `setThemes` (all with history support)
- `history-store.ts` — Undo/redo (max 100 states), batch mode for grouped operations
- `history-store.ts` — Undo/redo (max 300 states), batch mode for grouped operations
- `ai-store.ts` — Chat messages, streaming state, generated code, model selection
- `agent-settings-store.ts` — AI provider config (Anthropic/OpenAI), MCP CLI integrations, localStorage persistence
- **`src/types/`** — Type system:

View file

@ -59,7 +59,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
### History
- Undo/Redo with batched drag operations (Cmd+Z / Cmd+Shift+Z)
- Up to 100 history states
- Up to 300 history states
### Clipboard & Grouping

View file

@ -1,6 +1,6 @@
import { useDocumentStore } from '@/stores/document-store'
import { setFabricSyncLock } from './canvas-sync-lock'
import { rootFrameBounds } from './use-canvas-sync'
import { nodeRenderInfo, rootFrameBounds, layoutContainerBounds } from './use-canvas-sync'
import type { FabricObjectWithPenId } from './canvas-object-factory'
interface Bounds {
@ -31,11 +31,48 @@ function overlapArea(a: Bounds, b: Bounds): number {
return overlapX * overlapY
}
function toNumber(value: unknown): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const n = parseFloat(value)
return Number.isFinite(n) ? n : 0
}
return 0
}
function getParentBounds(parentId: string, parentNode: unknown): Bounds | null {
const root = rootFrameBounds.get(parentId)
if (root) return root
const layout = layoutContainerBounds.get(parentId)
if (layout) {
return { x: layout.x, y: layout.y, w: layout.w, h: layout.h }
}
if (
!parentNode ||
typeof parentNode !== 'object' ||
!('x' in parentNode) ||
!('y' in parentNode)
) {
return null
}
const parentInfo = nodeRenderInfo.get(parentId)
const x = toNumber((parentNode as { x?: unknown }).x) + (parentInfo?.parentOffsetX ?? 0)
const y = toNumber((parentNode as { y?: unknown }).y) + (parentInfo?.parentOffsetY ?? 0)
const w = toNumber((parentNode as { width?: unknown }).width)
const h = toNumber((parentNode as { height?: unknown }).height)
if (w <= 0 || h <= 0) return null
return { x, y, w, h }
}
/**
* Check if a Fabric object was dragged outside its parent root frame.
* If so, reparent it to the overlapping root frame or to the root level.
* Check if a Fabric object was dragged outside its current parent container.
* If so, reparent it to the overlapping root frame (if any), or to root level.
*
* Only triggers for direct children of root frames (MVP scope).
* Works for layout and non-layout containers.
* Returns true if reparenting occurred.
*/
export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
@ -46,9 +83,8 @@ export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
const parent = store.getParentOf(nodeId)
if (!parent) return false // Already root-level
// Only handle direct children of root frames
const parentBounds = rootFrameBounds.get(parent.id)
if (!parentBounds) return false // Parent is not a root frame
const parentBounds = getParentBounds(parent.id, parent)
if (!parentBounds) return false
// Compute object's absolute bounds from Fabric
const objBounds: Bounds = {
@ -66,7 +102,6 @@ export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
let bestOverlap = 0
for (const [frameId, frameBounds] of rootFrameBounds) {
if (frameId === parent.id) continue // Skip current parent
const area = overlapArea(objBounds, frameBounds)
if (area > bestOverlap) {
bestOverlap = area

View file

@ -3,6 +3,7 @@ import * as fabric from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, generateId } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import { useAIStore } from '@/stores/ai-store'
import type { PenDocument, PenNode } from '@/types/pen'
import type { ToolType } from '@/types/canvas'
import {
@ -163,6 +164,30 @@ export function useCanvasEvents() {
if (!upperEl) return
// --- Tool change: toggle selection ---
const applyInteractivityState = () => {
const tool = useCanvasStore.getState().activeTool
const isStreaming = useAIStore.getState().isStreaming
if (isStreaming) {
canvas.selection = false
canvas.skipTargetFind = true
canvas.discardActiveObject()
useCanvasStore.getState().clearSelection()
canvas.requestRenderAll()
return
}
if (isDrawingTool(tool)) {
canvas.selection = false
canvas.skipTargetFind = true
canvas.discardActiveObject()
canvas.requestRenderAll()
} else if (tool === 'select') {
canvas.selection = true
canvas.skipTargetFind = false
}
}
let prevTool = useCanvasStore.getState().activeTool
const unsubTool = useCanvasStore.subscribe((state) => {
if (state.activeTool === prevTool) return
@ -172,20 +197,19 @@ export function useCanvasEvents() {
}
prevTool = state.activeTool
if (!state.fabricCanvas) return
if (isDrawingTool(state.activeTool)) {
state.fabricCanvas.selection = false
state.fabricCanvas.skipTargetFind = true
state.fabricCanvas.discardActiveObject()
state.fabricCanvas.requestRenderAll()
} else if (state.activeTool === 'select') {
state.fabricCanvas.selection = true
state.fabricCanvas.skipTargetFind = false
}
applyInteractivityState()
})
const unsubStreaming = useAIStore.subscribe((state) => {
void state.isStreaming
applyInteractivityState()
})
applyInteractivityState()
// --- Drawing via native pointer events on the upper canvas ---
const onPointerDown = (e: PointerEvent) => {
if (useAIStore.getState().isStreaming) return
const tool = useCanvasStore.getState().activeTool
if (!isDrawingTool(tool)) return
const { isPanning } = useCanvasStore.getState().interaction
@ -265,6 +289,8 @@ export function useCanvasEvents() {
}
const onPointerMove = (e: PointerEvent) => {
if (useAIStore.getState().isStreaming) return
// Pen tool has its own move handling
if (isPenToolActive()) {
const pointer = toScene(canvas, e)
@ -310,6 +336,14 @@ export function useCanvasEvents() {
}
const onPointerUp = (_e: PointerEvent) => {
if (useAIStore.getState().isStreaming) {
if (tempObj) canvas.remove(tempObj)
tempObj = null
drawing = false
startPoint = null
return
}
// Pen tool: end handle drag
if (isPenToolActive()) {
penToolPointerUp(canvas)
@ -357,6 +391,8 @@ export function useCanvasEvents() {
}
const onDoubleClick = (e: MouseEvent) => {
if (useAIStore.getState().isStreaming) return
if (isPenToolActive()) {
e.preventDefault()
e.stopPropagation()
@ -416,7 +452,25 @@ export function useCanvasEvents() {
// modification never creates a no-op undo entry.
let preModificationDoc: PenDocument | null = null
// --- History batching for drag/resize/rotate ---
let transformBatchActive = false
let pendingBatchCloseRaf: number | null = null
const closeTransformBatch = () => {
if (!transformBatchActive) return
useHistoryStore
.getState()
.endBatch(useDocumentStore.getState().document)
transformBatchActive = false
}
canvas.on('mouse:down', (opt) => {
if (useAIStore.getState().isStreaming) return
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
pendingBatchCloseRaf = null
}
clipPathsCleared = false
preModificationDoc = null
const tool = useCanvasStore.getState().activeTool
@ -424,12 +478,31 @@ export function useCanvasEvents() {
const target = opt.target as FabricObjectWithPenId | null
if (!target?.penNodeId) return
// Snapshot the document BEFORE any drag/resize/rotate begins.
// structuredClone ensures we have a deep copy unaffected by later mutations.
preModificationDoc = structuredClone(useDocumentStore.getState().document)
useHistoryStore
.getState()
.startBatch(useDocumentStore.getState().document)
transformBatchActive = true
// Try to start layout reorder drag first
beginLayoutDrag(target.penNodeId)
// Only start layout reorder for actual move drags.
// Scale/rotate handles on layout children should follow normal transform sync.
const transform = (opt as unknown as {
transform?: { action?: string; corner?: string | null }
}).transform
const action = transform?.action
const corner = transform?.corner
const isHandleTransform = typeof corner === 'string' && corner.length > 0
const isMoveAction =
!isHandleTransform &&
(action === undefined || action === 'drag' || action === 'move')
if (isMoveAction) {
beginLayoutDrag(target.penNodeId)
} else {
cancelLayoutDrag()
}
// Start parent-child drag session (still needed for child propagation)
beginParentDrag(target.penNodeId, canvas)
@ -441,6 +514,17 @@ export function useCanvasEvents() {
// commit and cleanup. In Fabric.js v7 mouse:up can fire before
// object:modified, which would clear the session prematurely.
endParentDrag()
// Defer batch close one frame so object:modified can run first.
if (transformBatchActive) {
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
}
pendingBatchCloseRaf = requestAnimationFrame(() => {
pendingBatchCloseRaf = null
closeTransformBatch()
})
}
})
// --- Object modifications (drag, resize, rotate) via Fabric events ---
@ -593,6 +677,11 @@ export function useCanvasEvents() {
// entry. We use the pre-modification snapshot captured in mouse:down
// as the batch base to guarantee a correct undo point.
canvas.on('object:modified', (opt) => {
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
pendingBatchCloseRaf = null
}
clearGuides()
const target = opt.target
@ -623,10 +712,12 @@ export function useCanvasEvents() {
useDocumentStore.setState({
document: { ...doc, children: [...doc.children] },
})
closeTransformBatch()
return
}
endLayoutDrag(asPen, canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
@ -634,6 +725,7 @@ export function useCanvasEvents() {
if (isDragIntoActive()) {
commitDragInto(asPen, canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
@ -713,6 +805,8 @@ export function useCanvasEvents() {
useDocumentStore.setState({
document: { ...currentDoc, children: [...currentDoc.children] },
})
closeTransformBatch()
})
// --- Text editing: sync edited content back to document store ---
@ -732,7 +826,12 @@ export function useCanvasEvents() {
})
return () => {
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
}
closeTransformBatch()
unsubTool()
unsubStreaming()
upperEl.removeEventListener('pointerdown', onPointerDown)
upperEl.removeEventListener('pointermove', onPointerMove)
upperEl.removeEventListener('pointerup', onPointerUp)

View file

@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { Point } from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import { MIN_ZOOM, MAX_ZOOM } from './canvas-constants'
import type { ToolType } from '@/types/canvas'
@ -45,7 +46,7 @@ export function useCanvasViewport() {
newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom))
const rect = canvas.upperCanvasEl.getBoundingClientRect()
const point = { x: e.clientX - rect.left, y: e.clientY - rect.top }
const point = new Point(e.clientX - rect.left, e.clientY - rect.top)
canvas.zoomToPoint(point, newZoom)
const vpt = canvas.viewportTransform

View file

@ -297,7 +297,9 @@ export function useKeyboardShortcuts() {
useDocumentStore.getState().removeNode(id)
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
useHistoryStore
.getState()
.endBatch(useDocumentStore.getState().document)
}
useCanvasStore.getState().clearSelection()
const canvas = useCanvasStore.getState().fabricCanvas
@ -348,7 +350,9 @@ export function useKeyboardShortcuts() {
useDocumentStore.getState().reorderNode(id, 'down')
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
useHistoryStore
.getState()
.endBatch(useDocumentStore.getState().document)
}
return
}
@ -366,7 +370,9 @@ export function useKeyboardShortcuts() {
useDocumentStore.getState().reorderNode(id, 'up')
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
useHistoryStore
.getState()
.endBatch(useDocumentStore.getState().document)
}
return
}
@ -396,7 +402,9 @@ export function useKeyboardShortcuts() {
useDocumentStore.getState().updateNode(id, updates)
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
useHistoryStore
.getState()
.endBatch(useDocumentStore.getState().document)
}
return
}

View file

@ -226,7 +226,7 @@ export async function generateDesign(
}
} finally {
if (animated) {
useHistoryStore.getState().endBatch()
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
}
}
@ -413,7 +413,7 @@ export function animateNodesToCanvas(nodes: PenNode[]): void {
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
upsertPreparedNodes(prepared)
useHistoryStore.getState().endBatch()
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
}
function sanitizeNodesForInsert(
@ -538,7 +538,8 @@ function sanitizeScreenFrameBounds(node: PenNode): void {
function isScreenFrame(node: PenNode): boolean {
if (node.type !== 'frame') return false
if (typeof node.width !== 'number' || typeof node.height !== 'number') return false
if (!('width' in node) || typeof node.width !== 'number') return false
if (!('height' in node) || typeof node.height !== 'number') return false
const w = node.width
const h = node.height
const isMobileLike = w >= 320 && w <= 480 && h >= 640
@ -549,10 +550,13 @@ function isScreenFrame(node: PenNode): boolean {
function clampChildrenIntoScreen(frame: PenNode): void {
if (!('children' in frame) || !Array.isArray(frame.children)) return
if ('layout' in frame && frame.layout && frame.layout !== 'none') return
if (typeof frame.width !== 'number' || typeof frame.height !== 'number') return
if (!('width' in frame) || typeof frame.width !== 'number') return
if (!('height' in frame) || typeof frame.height !== 'number') return
const maxBleedX = frame.width * 0.1
const maxBleedY = frame.height * 0.1
const frameW = frame.width
const frameH = frame.height
const maxBleedX = frameW * 0.1
const maxBleedY = frameH * 0.1
for (const child of frame.children) {
const childWidth = 'width' in child && typeof child.width === 'number' ? child.width : null
@ -567,9 +571,9 @@ function clampChildrenIntoScreen(frame: PenNode): void {
}
const minX = -maxBleedX
const maxX = frame.width - childWidth + maxBleedX
const maxX = frameW - childWidth + maxBleedX
const minY = -maxBleedY
const maxY = frame.height - childHeight + maxBleedY
const maxY = frameH - childHeight + maxBleedY
child.x = clamp(child.x, minX, maxX)
child.y = clamp(child.y, minY, maxY)
@ -632,7 +636,12 @@ export function extractAndApplyDesign(responseText: string): number {
const nodes = extractJsonFromResponse(responseText)
if (!nodes || nodes.length === 0) return 0
applyNodesToCanvas(nodes)
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
try {
applyNodesToCanvas(nodes)
} finally {
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
}
return nodes.length
}
@ -647,19 +656,24 @@ export function extractAndApplyDesignModification(responseText: string): number
const { addNode, updateNode, getNodeById } = useDocumentStore.getState()
let count = 0
for (const node of nodes) {
const existing = getNodeById(node.id)
if (existing) {
// Update existing node
updateNode(node.id, node)
count++
} else {
// It's a new node implied by the modification (e.g. "add a button")
const rootFrame = getNodeById(DEFAULT_FRAME_ID)
const parentId = rootFrame ? DEFAULT_FRAME_ID : null
addNode(parentId, node)
count++
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
try {
for (const node of nodes) {
const existing = getNodeById(node.id)
if (existing) {
// Update existing node
updateNode(node.id, node)
count++
} else {
// It's a new node implied by the modification (e.g. "add a button")
const rootFrame = getNodeById(DEFAULT_FRAME_ID)
const parentId = rootFrame ? DEFAULT_FRAME_ID : null
addNode(parentId, node)
count++
}
}
} finally {
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
}
return count
}

View file

@ -1,7 +1,11 @@
import { create } from 'zustand'
import type { PenDocument, PenNode } from '@/types/pen'
const MAX_HISTORY = 100
const MAX_HISTORY = 300
function areDocumentsEqual(a: PenDocument, b: PenDocument): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
interface HistoryStoreState {
undoStack: PenDocument[]
@ -16,7 +20,7 @@ interface HistoryStoreState {
canRedo: () => boolean
clear: () => void
startBatch: (doc: PenDocument) => void
endBatch: () => void
endBatch: (currentDoc?: PenDocument) => void
// Legacy API compatibility (used by some canvas event handlers)
beginBatch: (currentChildren: PenNode[]) => void
@ -34,11 +38,17 @@ export const useHistoryStore = create<HistoryStoreState>(
const { batchDepth } = get()
if (batchDepth > 0) return
const clone = structuredClone(doc)
set((s) => ({
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), clone],
redoStack: [],
}))
set((s) => {
const last = s.undoStack[s.undoStack.length - 1]
if (last && areDocumentsEqual(last, doc)) {
return { redoStack: [] }
}
return {
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), structuredClone(doc)],
redoStack: [],
}
})
},
undo: (currentDoc) => {
@ -81,11 +91,20 @@ export const useHistoryStore = create<HistoryStoreState>(
}
},
endBatch: () => {
endBatch: (currentDoc) => {
const { batchDepth, batchBaseState } = get()
if (batchDepth <= 0) return
if (batchDepth === 1 && batchBaseState) {
const hasNoChanges = currentDoc
? areDocumentsEqual(batchBaseState, currentDoc)
: false
if (hasNoChanges) {
set({ batchDepth: 0, batchBaseState: null })
return
}
set((s) => ({
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), batchBaseState],
redoStack: [],