mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
4a51f4742d
commit
9894dbcd0b
7 changed files with 213 additions and 60 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue