fix(canvas,store): history dedup, AI streaming guards, and drag reparent for nested containers

- Increase undo history to 300 states, deduplicate identical snapshots
- endBatch accepts optional currentDoc to skip no-op batches
- Add AI streaming guards: disable canvas interaction during generation
- Defer transform batch close via rAF so object:modified fires before endBatch
- Restrict layout reorder to move drags only (not scale/rotate handles)
- Extend checkDragReparent to work for any parent container, not just root frames
- Wrap design apply/modify in history batches for proper undo support
This commit is contained in:
Fini 2026-02-20 15:27:05 +08:00
parent 4a51f4742d
commit 9894dbcd0b
7 changed files with 213 additions and 60 deletions

View file

@ -63,7 +63,7 @@ React Components (Toolbar, LayerPanel, PropertyPanel)
- **`src/stores/`** — Zustand stores (5 files):
- `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state
- `document-store.ts` — PenDocument tree CRUD: `addNode`, `updateNode`, `removeNode`, `moveNode`, `reorderNode`, `duplicateNode`, `groupNodes`, `ungroupNode`, `toggleVisibility`, `toggleLock`, `scaleDescendantsInStore`, `rotateDescendantsInStore`, `getNodeById`, `getParentOf`, `getFlatNodes`, `isDescendantOf`
- `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

@ -48,7 +48,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 { 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()
@ -410,7 +446,24 @@ export function useCanvasEvents() {
upperEl.addEventListener('dblclick', onDoubleClick)
// --- 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
const tool = useCanvasStore.getState().activeTool
if (tool !== 'select') return
@ -419,9 +472,24 @@ export function useCanvasEvents() {
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)
@ -433,7 +501,16 @@ export function useCanvasEvents() {
// commit and cleanup. In Fabric.js v7 mouse:up can fire before
// object:modified, which would clear the session prematurely.
endParentDrag()
useHistoryStore.getState().endBatch()
// 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 ---
@ -528,17 +605,6 @@ export function useCanvasEvents() {
setFabricSyncLock(false)
}
// History batching: group all intermediate drag/resize/rotate updates
// into a single undo entry instead of one per mouse-move event.
canvas.on('mouse:down', () => {
useHistoryStore
.getState()
.startBatch(useDocumentStore.getState().document)
})
canvas.on('mouse:up', () => {
useHistoryStore.getState().endBatch()
})
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
let clipPathsCleared = false
@ -593,6 +659,11 @@ export function useCanvasEvents() {
// Final sync: reset scale to 1 and bake into width/height
canvas.on('object:modified', (opt) => {
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
pendingBatchCloseRaf = null
}
clearGuides()
const target = opt.target
@ -610,10 +681,12 @@ export function useCanvasEvents() {
useDocumentStore.setState({
document: { ...doc, children: [...doc.children] },
})
closeTransformBatch()
return
}
endLayoutDrag(asPen, canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
@ -621,6 +694,7 @@ export function useCanvasEvents() {
if (isDragIntoActive()) {
commitDragInto(asPen, canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
@ -694,6 +768,8 @@ export function useCanvasEvents() {
useDocumentStore.setState({
document: { ...currentDoc, children: [...currentDoc.children] },
})
closeTransformBatch()
})
// --- Text editing: sync edited content back to document store ---
@ -713,7 +789,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

@ -284,7 +284,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
@ -335,7 +337,9 @@ export function useKeyboardShortcuts() {
useDocumentStore.getState().reorderNode(id, 'down')
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
useHistoryStore
.getState()
.endBatch(useDocumentStore.getState().document)
}
return
}
@ -353,7 +357,9 @@ export function useKeyboardShortcuts() {
useDocumentStore.getState().reorderNode(id, 'up')
}
if (selectedIds.length > 1) {
useHistoryStore.getState().endBatch()
useHistoryStore
.getState()
.endBatch(useDocumentStore.getState().document)
}
return
}
@ -383,7 +389,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

@ -217,7 +217,7 @@ export async function generateDesign(
}
} finally {
if (animated) {
useHistoryStore.getState().endBatch()
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
}
}
@ -402,7 +402,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(
@ -621,7 +621,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
}
@ -636,19 +641,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: [],