feat(canvas): add hover highlighting, deep selection, root frame clipping and drag-out reparenting

Implement Figma-style canvas interactions:
- Hover outlines: solid blue on hovered element, dashed on direct children
- Depth-aware selection: single click selects at current depth, double-click
  enters containers progressively, Escape exits frame context
- Root frame clipping: depth-0 frames always clip overflowing children
- Drag-out reparenting: dragging a child outside its root frame detaches it
  to the overlapping frame or promotes to root level, with undo support
- Layout reorder drag with insertion indicator
- AI design generation with staggered fade-in animation
- Use default cursor on hover instead of move/crosshair
This commit is contained in:
Fini 2026-02-20 02:16:55 +08:00
parent 1664631729
commit dd3744fc8d
18 changed files with 1207 additions and 47 deletions

View file

@ -368,6 +368,7 @@ export function createFabricObject(
transparentCorners: false,
borderOpacityWhenMoving: 1,
padding: 0,
hoverCursor: 'default',
})
fabricImg.setControlVisible('mtr', false)
applyRotationControls(fabricImg)
@ -416,6 +417,7 @@ export function createFabricObject(
transparentCorners: false,
borderOpacityWhenMoving: 1,
padding: 0,
hoverCursor: 'default',
})
obj.setControlVisible('mtr', false)
applyRotationControls(obj)

106
src/canvas/drag-reparent.ts Normal file
View file

@ -0,0 +1,106 @@
import { useDocumentStore } from '@/stores/document-store'
import { setFabricSyncLock } from './canvas-sync-lock'
import { rootFrameBounds } from './use-canvas-sync'
import type { FabricObjectWithPenId } from './canvas-object-factory'
interface Bounds {
x: number
y: number
w: number
h: number
}
function isCompletelyOutside(obj: Bounds, parent: Bounds): boolean {
return (
obj.x + obj.w <= parent.x ||
obj.x >= parent.x + parent.w ||
obj.y + obj.h <= parent.y ||
obj.y >= parent.y + parent.h
)
}
function overlapArea(a: Bounds, b: Bounds): number {
const overlapX = Math.max(
0,
Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x),
)
const overlapY = Math.max(
0,
Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y),
)
return overlapX * overlapY
}
/**
* 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.
*
* Only triggers for direct children of root frames (MVP scope).
* Returns true if reparenting occurred.
*/
export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
const nodeId = obj.penNodeId
if (!nodeId) return false
const store = useDocumentStore.getState()
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
// Compute object's absolute bounds from Fabric
const objBounds: Bounds = {
x: obj.left ?? 0,
y: obj.top ?? 0,
w: (obj.width ?? 0) * (obj.scaleX ?? 1),
h: (obj.height ?? 0) * (obj.scaleY ?? 1),
}
// Only trigger if completely outside parent
if (!isCompletelyOutside(objBounds, parentBounds)) return false
// Find the root frame with the most overlap
let bestFrameId: string | null = null
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
bestFrameId = frameId
}
}
setFabricSyncLock(true)
try {
if (bestFrameId) {
// Reparent into the overlapping frame — convert absolute to relative position
const targetBounds = rootFrameBounds.get(bestFrameId)!
store.updateNode(nodeId, {
x: objBounds.x - targetBounds.x,
y: objBounds.y - targetBounds.y,
})
const targetChildren = store.getNodeById(bestFrameId)
const childCount =
targetChildren && 'children' in targetChildren && targetChildren.children
? targetChildren.children.length
: 0
store.moveNode(nodeId, bestFrameId, childCount)
} else {
// No overlapping frame — make it a root-level node
store.updateNode(nodeId, {
x: objBounds.x,
y: objBounds.y,
})
const rootCount = store.document.children.length
store.moveNode(nodeId, null, rootCount)
}
} finally {
setFabricSyncLock(false)
}
return true
}

View file

@ -7,6 +7,9 @@ import { useCanvasSelection } from './use-canvas-selection'
import { useCanvasSync } from './use-canvas-sync'
import { useDimensionLabel } from './use-dimension-label'
import { useFrameLabels } from './use-frame-labels'
import { useLayoutIndicator } from './use-layout-indicator'
import { useCanvasHover } from './use-canvas-hover'
import { useEnteredFrameOverlay } from './use-entered-frame-overlay'
export default function FabricCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
@ -18,8 +21,11 @@ export default function FabricCanvas() {
useCanvasViewport()
useCanvasSelection()
useCanvasSync()
useCanvasHover()
useEnteredFrameOverlay()
useDimensionLabel(containerRef)
useFrameLabels()
useLayoutIndicator()
return (
<div

View file

@ -0,0 +1,299 @@
import type * as fabric from 'fabric'
import { useDocumentStore } from '@/stores/document-store'
import type { PenNode, ContainerProps } from '@/types/pen'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { setFabricSyncLock } from './canvas-sync-lock'
import { nodeRenderInfo } from './use-canvas-sync'
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
interface LayoutDragSession {
nodeId: string
parentId: string
parentLayout: 'vertical' | 'horizontal'
siblingIds: string[]
originalIndex: number
}
let activeSession: LayoutDragSession | null = null
/** Insertion indicator state — read by use-layout-indicator.ts */
export let activeInsertionIndicator: {
x: number
y: number
length: number
orientation: 'vertical' | 'horizontal'
} | null = null
// ---------------------------------------------------------------------------
// Padding helper (duplicated from use-canvas-sync to avoid circular deps)
// ---------------------------------------------------------------------------
interface Padding {
top: number
right: number
bottom: number
left: number
}
function resolvePadding(
padding:
| number
| [number, number]
| [number, number, number, number]
| string
| undefined,
): Padding {
if (!padding || typeof padding === 'string')
return { top: 0, right: 0, bottom: 0, left: 0 }
if (typeof padding === 'number')
return { top: padding, right: padding, bottom: padding, left: padding }
if (padding.length === 2)
return {
top: padding[0],
right: padding[1],
bottom: padding[0],
left: padding[1],
}
return {
top: padding[0],
right: padding[1],
bottom: padding[2],
left: padding[3],
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildFabObjectMap(
canvas: fabric.Canvas,
): Map<string, FabricObjectWithPenId> {
const map = new Map<string, FabricObjectWithPenId>()
for (const obj of canvas.getObjects() as FabricObjectWithPenId[]) {
if (obj.penNodeId) map.set(obj.penNodeId, obj)
}
return map
}
/**
* Calculate insertion index based on the dragged object's main-axis center
* relative to each sibling's midpoint. The returned index is in "after
* removal" space (siblingIds already excludes the dragged node) and maps
* directly to the `index` parameter of `moveNode`.
*/
function calcInsertionIndex(
obj: FabricObjectWithPenId,
fabObjectMap: Map<string, FabricObjectWithPenId>,
): number {
if (!activeSession) return 0
const { siblingIds, parentLayout } = activeSession
const isVertical = parentLayout === 'vertical'
const objMainCenter = isVertical
? (obj.top ?? 0) + ((obj.height ?? 0) * (obj.scaleY ?? 1)) / 2
: (obj.left ?? 0) + ((obj.width ?? 0) * (obj.scaleX ?? 1)) / 2
let insertIndex = siblingIds.length
for (let i = 0; i < siblingIds.length; i++) {
const sibObj = fabObjectMap.get(siblingIds[i])
if (!sibObj) continue
const sibMid = isVertical
? (sibObj.top ?? 0) + ((sibObj.height ?? 0) * (sibObj.scaleY ?? 1)) / 2
: (sibObj.left ?? 0) +
((sibObj.width ?? 0) * (sibObj.scaleX ?? 1)) / 2
if (objMainCenter < sibMid) {
insertIndex = i
break
}
}
return insertIndex
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
/**
* Try to begin a layout reorder drag.
* Returns true if the node is a layout child and the session was started.
*/
export function beginLayoutDrag(nodeId: string): boolean {
const info = nodeRenderInfo.get(nodeId)
if (!info?.isLayoutChild) return false
const { getParentOf } = useDocumentStore.getState()
const parent = getParentOf(nodeId)
if (!parent) return false
const container = parent as PenNode & ContainerProps
const layout = container.layout
if (!layout || layout === 'none') return false
const children = 'children' in parent ? parent.children ?? [] : []
const originalIndex = children.findIndex((c) => c.id === nodeId)
if (originalIndex === -1) return false
activeSession = {
nodeId,
parentId: parent.id,
parentLayout: layout as 'vertical' | 'horizontal',
siblingIds: children.filter((c) => c.id !== nodeId).map((c) => c.id),
originalIndex,
}
return true
}
/**
* Update insertion indicator position during drag.
*/
export function updateLayoutDrag(
obj: FabricObjectWithPenId,
canvas: fabric.Canvas,
): void {
if (!activeSession) return
const { siblingIds, parentId, parentLayout } = activeSession
const isVertical = parentLayout === 'vertical'
const fabObjectMap = buildFabObjectMap(canvas)
const insertIndex = calcInsertionIndex(obj, fabObjectMap)
const { getNodeById } = useDocumentStore.getState()
const parent = getNodeById(parentId) as
| (PenNode & ContainerProps)
| undefined
if (!parent) return
const pad = resolvePadding(parent.padding)
const gap = typeof parent.gap === 'number' ? parent.gap : 0
const parentInfo = nodeRenderInfo.get(parentId)
const parentAbsX = (parentInfo?.parentOffsetX ?? 0) + (parent.x ?? 0)
const parentAbsY = (parentInfo?.parentOffsetY ?? 0) + (parent.y ?? 0)
const parentW = typeof parent.width === 'number' ? parent.width : 0
const parentH = typeof parent.height === 'number' ? parent.height : 0
if (isVertical) {
let indicatorY: number
if (siblingIds.length === 0) {
indicatorY = parentAbsY + pad.top
} else if (insertIndex === 0) {
const firstSib = fabObjectMap.get(siblingIds[0])
indicatorY = firstSib
? (firstSib.top ?? 0) - gap / 2
: parentAbsY + pad.top
} else if (insertIndex >= siblingIds.length) {
const lastSib = fabObjectMap.get(siblingIds[siblingIds.length - 1])
indicatorY = lastSib
? (lastSib.top ?? 0) +
(lastSib.height ?? 0) * (lastSib.scaleY ?? 1) +
gap / 2
: parentAbsY + parentH - pad.bottom
} else {
const prev = fabObjectMap.get(siblingIds[insertIndex - 1])
const next = fabObjectMap.get(siblingIds[insertIndex])
const prevBottom = prev
? (prev.top ?? 0) + (prev.height ?? 0) * (prev.scaleY ?? 1)
: 0
const nextTop = next ? (next.top ?? 0) : 0
indicatorY = (prevBottom + nextTop) / 2
}
activeInsertionIndicator = {
x: parentAbsX + pad.left,
y: indicatorY,
length: parentW - pad.left - pad.right,
orientation: 'horizontal',
}
} else {
let indicatorX: number
if (siblingIds.length === 0) {
indicatorX = parentAbsX + pad.left
} else if (insertIndex === 0) {
const firstSib = fabObjectMap.get(siblingIds[0])
indicatorX = firstSib
? (firstSib.left ?? 0) - gap / 2
: parentAbsX + pad.left
} else if (insertIndex >= siblingIds.length) {
const lastSib = fabObjectMap.get(siblingIds[siblingIds.length - 1])
indicatorX = lastSib
? (lastSib.left ?? 0) +
(lastSib.width ?? 0) * (lastSib.scaleX ?? 1) +
gap / 2
: parentAbsX + parentW - pad.right
} else {
const prev = fabObjectMap.get(siblingIds[insertIndex - 1])
const next = fabObjectMap.get(siblingIds[insertIndex])
const prevRight = prev
? (prev.left ?? 0) + (prev.width ?? 0) * (prev.scaleX ?? 1)
: 0
const nextLeft = next ? (next.left ?? 0) : 0
indicatorX = (prevRight + nextLeft) / 2
}
activeInsertionIndicator = {
x: indicatorX,
y: parentAbsY + pad.top,
length: parentH - pad.top - pad.bottom,
orientation: 'vertical',
}
}
canvas.requestRenderAll()
}
/**
* End the layout drag: clear manual x/y, reorder the node, force re-sync.
*/
export function endLayoutDrag(
obj: FabricObjectWithPenId,
canvas: fabric.Canvas,
): void {
if (!activeSession) return
const { nodeId, parentId, originalIndex } = activeSession
const fabObjectMap = buildFabObjectMap(canvas)
const newIndex = calcInsertionIndex(obj, fabObjectMap)
setFabricSyncLock(true)
// Clear manual position so layout engine takes over
useDocumentStore.getState().updateNode(nodeId, {
x: undefined,
y: undefined,
} as Partial<PenNode>)
// Reorder if the position actually changed
if (newIndex !== originalIndex) {
useDocumentStore.getState().moveNode(nodeId, parentId, newIndex)
}
setFabricSyncLock(false)
// Force re-sync: create new children reference so the subscription fires
const doc = useDocumentStore.getState().document
useDocumentStore.setState({
document: { ...doc, children: [...doc.children] },
})
activeSession = null
activeInsertionIndicator = null
canvas.requestRenderAll()
}
/** Cancel the layout drag session (safety cleanup). */
export function cancelLayoutDrag(): void {
activeSession = null
activeInsertionIndicator = null
}
/** Check if a layout drag session is currently active. */
export function isLayoutDragActive(): boolean {
return activeSession !== null
}

View file

@ -0,0 +1,67 @@
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import type { PenNode } from '@/types/pen'
/**
* Pure utility module for depth-aware selection.
* Determines which nodes are selectable at the current entered-frame depth.
*/
/** Returns the set of node IDs that are selectable at the current depth. */
export function getSelectableNodeIds(): Set<string> {
const { enteredFrameId } = useCanvasStore.getState().selection
const doc = useDocumentStore.getState().document
if (!enteredFrameId) {
// Root level: only top-level children are selectable
return new Set(doc.children.map((n) => n.id))
}
const frame = useDocumentStore.getState().getNodeById(enteredFrameId)
if (!frame || !('children' in frame) || !frame.children) {
return new Set()
}
return new Set(frame.children.map((n) => n.id))
}
/**
* Given a Fabric target's penNodeId, resolve it to the selectable node
* at the current depth. Walks up the parent chain until it finds a node
* in the selectable set.
*
* Returns null if the target is entirely outside the current context
* (e.g. belongs to a different root frame when inside an entered frame).
*/
export function resolveTargetAtDepth(nodeId: string): string | null {
const selectableIds = getSelectableNodeIds()
// Direct match
if (selectableIds.has(nodeId)) return nodeId
// Walk up parent chain
let currentId: string | undefined = nodeId
while (currentId) {
const parent = useDocumentStore.getState().getParentOf(currentId)
if (!parent) break
if (selectableIds.has(parent.id)) return parent.id
currentId = parent.id
}
return null
}
/** Check whether a node is a container that can be "entered" via double-click. */
export function isEnterableContainer(nodeId: string): boolean {
const node = useDocumentStore.getState().getNodeById(nodeId)
if (!node) return false
if (node.type !== 'frame' && node.type !== 'group') return false
if (!('children' in node) || !node.children || node.children.length === 0) return false
return true
}
/** Return the direct children IDs of a container node. */
export function getChildIds(nodeId: string): Set<string> {
const node = useDocumentStore.getState().getNodeById(nodeId)
if (!node || !('children' in node) || !node.children) return new Set()
return new Set(node.children.map((n: PenNode) => n.id))
}

View file

@ -32,6 +32,15 @@ import {
penToolDoubleClick,
cancelPenTool,
} from './pen-tool'
import {
beginLayoutDrag,
updateLayoutDrag,
endLayoutDrag,
cancelLayoutDrag,
isLayoutDragActive,
} from './layout-reorder'
import { isEnterableContainer, resolveTargetAtDepth } from './selection-context'
import { checkDragReparent } from './drag-reparent'
function createNodeForTool(
tool: ToolType,
@ -346,6 +355,44 @@ export function useCanvasEvents() {
e.preventDefault()
e.stopPropagation()
penToolDoubleClick(canvas)
return
}
const tool = useCanvasStore.getState().activeTool
if (tool !== 'select') return
const { activeId } = useCanvasStore.getState().selection
if (!activeId) return
if (isEnterableContainer(activeId)) {
canvas.discardActiveObject()
useCanvasStore.getState().enterFrame(activeId)
// Find and select the child under the cursor (Figma-style)
canvas.calcOffset()
const pointer = canvas.getScenePoint(e as unknown as PointerEvent)
const objects = canvas.getObjects() as FabricObjectWithPenId[]
// Iterate topmost-first to find the child under the cursor
for (let i = objects.length - 1; i >= 0; i--) {
const obj = objects[i]
if (!obj.penNodeId) continue
if (!obj.containsPoint(pointer)) continue
// Resolve to a selectable node at the new (entered) depth
const resolved = resolveTargetAtDepth(obj.penNodeId)
if (!resolved) continue
// Find the Fabric object for the resolved target
const resolvedObj = objects.find((o) => o.penNodeId === resolved)
if (resolvedObj) {
canvas.setActiveObject(resolvedObj)
useCanvasStore.getState().setSelection([resolved], resolved)
}
break
}
canvas.requestRenderAll()
}
}
@ -362,20 +409,21 @@ export function useCanvasEvents() {
if (tool !== 'select') return
const target = opt.target as FabricObjectWithPenId | null
if (!target?.penNodeId) return
const currentChildren =
useDocumentStore.getState().document.children
useHistoryStore.getState().beginBatch(currentChildren)
useHistoryStore
.getState()
.startBatch(useDocumentStore.getState().document)
// Start parent-child drag session if this is a container
// Try to start layout reorder drag first
beginLayoutDrag(target.penNodeId)
// Start parent-child drag session (still needed for child propagation)
beginParentDrag(target.penNodeId, canvas)
})
canvas.on('mouse:up', () => {
cancelLayoutDrag()
endParentDrag()
const { batchDepth } = useHistoryStore.getState()
if (batchDepth > 0) {
useHistoryStore.getState().cancelBatch()
}
useHistoryStore.getState().endBatch()
})
// --- Object modifications (drag, resize, rotate) via Fabric events ---
@ -483,6 +531,15 @@ export function useCanvasEvents() {
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
canvas.on('object:moving', (opt) => {
if (isLayoutDragActive()) {
// Layout reorder mode: update insertion indicator, still propagate children
updateLayoutDrag(opt.target as FabricObjectWithPenId, canvas)
if (getActiveDragSession()) {
moveDescendants(opt.target as FabricObjectWithPenId, canvas)
}
return
}
// Calculate guides + snap BEFORE syncing so the store gets the snapped position
calculateAndSnap(opt.target, canvas)
@ -512,6 +569,23 @@ export function useCanvasEvents() {
// Single object -- bake scale and sync
const asPen = target as FabricObjectWithPenId
if (asPen.penNodeId) {
// Layout reorder: skip normal sync, reorder instead
// BUT first check if the node was dragged outside its root frame
if (isLayoutDragActive()) {
if (checkDragReparent(asPen)) {
// Dragged outside parent — cancel layout reorder and detach
cancelLayoutDrag()
rebuildNodeRenderInfo()
const doc = useDocumentStore.getState().document
useDocumentStore.setState({
document: { ...doc, children: [...doc.children] },
})
return
}
endLayoutDrag(asPen, canvas)
rebuildNodeRenderInfo()
return
}
const scaleX = target.scaleX ?? 1
const scaleY = target.scaleY ?? 1
// Path/Polygon dimensions are derived from their data, so we can't
@ -538,6 +612,16 @@ export function useCanvasEvents() {
}
finalizeParentRotation(asPen)
}
// Check if the node was dragged out of / into a root frame
if (checkDragReparent(asPen)) {
// Force re-sync since tree structure changed
rebuildNodeRenderInfo()
const doc = useDocumentStore.getState().document
useDocumentStore.setState({
document: { ...doc, children: [...doc.children] },
})
}
} else if ('getObjects' in target) {
// ActiveSelection -- bake scale per child, then sync all
const group = target as fabric.ActiveSelection

View file

@ -0,0 +1,132 @@
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { resolveTargetAtDepth, getChildIds } from './selection-context'
const HOVER_COLOR = '#3b82f6'
const HOVER_LINE_WIDTH = 1.5
const CHILD_DASH = [4, 4]
export function useCanvasHover() {
useEffect(() => {
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
// --- Hover tracking via mouse:move ---
// Always track hoveredId regardless of selection state,
// so dashed child outlines appear on hover even for selected elements.
canvas.on('mouse:move', (opt) => {
const tool = useCanvasStore.getState().activeTool
if (tool !== 'select') return
const target = opt.target as FabricObjectWithPenId | null
const { hoveredId } = useCanvasStore.getState().selection
if (!target?.penNodeId) {
if (hoveredId !== null) {
useCanvasStore.getState().setHoveredId(null)
canvas.requestRenderAll()
}
return
}
const resolved = resolveTargetAtDepth(target.penNodeId)
if (resolved !== hoveredId) {
useCanvasStore.getState().setHoveredId(resolved)
canvas.requestRenderAll()
}
})
// Clear hover when mouse leaves the canvas
canvas.on('mouse:out', () => {
const { hoveredId } = useCanvasStore.getState().selection
if (hoveredId !== null) {
useCanvasStore.getState().setHoveredId(null)
canvas.requestRenderAll()
}
})
// --- Outline rendering on the lower canvas ---
canvas.on('after:render', () => {
const { hoveredId, selectedIds } = useCanvasStore.getState().selection
if (!hoveredId) return
const isSelected = selectedIds.includes(hoveredId)
const el = canvas.lowerCanvasEl
if (!el) return
const ctx = el.getContext('2d')
if (!ctx) return
const vpt = canvas.viewportTransform
if (!vpt) return
const zoom = vpt[0]
const dpr = el.width / el.offsetWidth
ctx.save()
ctx.setTransform(
vpt[0] * dpr, vpt[1] * dpr,
vpt[2] * dpr, vpt[3] * dpr,
vpt[4] * dpr, vpt[5] * dpr,
)
// Solid outline on hovered target — skip if already selected
// (Fabric draws its own selection handles)
if (!isSelected) {
drawNodeOutline(ctx, hoveredId, false, zoom)
}
// Dashed outlines on direct children — always draw on hover
const childIds = getChildIds(hoveredId)
for (const childId of childIds) {
drawNodeOutline(ctx, childId, true, zoom)
}
ctx.restore()
})
function drawNodeOutline(
ctx: CanvasRenderingContext2D,
nodeId: string,
dashed: boolean,
zoom: number,
) {
const objects = canvas!.getObjects() as FabricObjectWithPenId[]
const obj = objects.find((o) => o.penNodeId === nodeId)
if (!obj) return
const left = obj.left ?? 0
const top = obj.top ?? 0
const w = (obj.width ?? 0) * (obj.scaleX ?? 1)
const h = (obj.height ?? 0) * (obj.scaleY ?? 1)
const angle = obj.angle ?? 0
ctx.save()
if (angle !== 0) {
const cx = left + w / 2
const cy = top + h / 2
ctx.translate(cx, cy)
ctx.rotate((angle * Math.PI) / 180)
ctx.translate(-cx, -cy)
}
ctx.strokeStyle = HOVER_COLOR
ctx.lineWidth = HOVER_LINE_WIDTH / zoom
if (dashed) {
ctx.setLineDash(CHILD_DASH.map((d) => d / zoom))
} else {
ctx.setLineDash([])
}
ctx.strokeRect(left, top, w, h)
ctx.restore()
}
}, 100)
return () => clearInterval(interval)
}, [])
}

View file

@ -1,6 +1,41 @@
import { useEffect } from 'react'
import type { FabricObject } from 'fabric'
import { ActiveSelection } from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { resolveTargetAtDepth } from './selection-context'
/**
* Resolve a list of Fabric selected objects to node IDs at the current
* entered-frame depth. If any target falls outside the current context,
* exits all frames and retries at root level.
*/
function resolveIds(selected: FabricObject[]): string[] {
const resolved = new Set<string>()
let hasUnresolved = false
for (const obj of selected) {
const penId = (obj as FabricObjectWithPenId).penNodeId
if (!penId) continue
const target = resolveTargetAtDepth(penId)
if (target) resolved.add(target)
else hasUnresolved = true
}
// If any target is outside current context, exit all frames and retry
if (hasUnresolved) {
useCanvasStore.getState().exitAllFrames()
resolved.clear()
for (const obj of selected) {
const penId = (obj as FabricObjectWithPenId).penNodeId
if (!penId) continue
const target = resolveTargetAtDepth(penId)
if (target) resolved.add(target)
}
}
return [...resolved]
}
export function useCanvasSelection() {
useEffect(() => {
@ -9,21 +44,46 @@ export function useCanvasSelection() {
if (!canvas) return
clearInterval(interval)
canvas.on('selection:created', (e) => {
const handleSelection = (e: { selected?: FabricObject[] }) => {
const selected = e.selected ?? []
const ids = selected
.map((obj) => (obj as FabricObjectWithPenId).penNodeId)
.filter(Boolean) as string[]
const ids = resolveIds(selected)
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
})
canvas.on('selection:updated', (e) => {
const selected = e.selected ?? []
const ids = selected
.map((obj) => (obj as FabricObjectWithPenId).penNodeId)
.filter(Boolean) as string[]
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
})
// Correct Fabric's active object to match the depth-resolved target.
// Without this, Fabric keeps the deeply-nested child as active,
// showing selection handles on the wrong element.
if (ids.length === 0) return
const objects = canvas.getObjects() as FabricObjectWithPenId[]
if (ids.length === 1) {
const currentActive = selected[0] as FabricObjectWithPenId
if (currentActive?.penNodeId !== ids[0]) {
const correctObj = objects.find((o) => o.penNodeId === ids[0])
if (correctObj) {
canvas.setActiveObject(correctObj)
canvas.requestRenderAll()
}
}
} else {
// Multi-select: build an ActiveSelection from the resolved objects
const resolvedSet = new Set(ids)
const resolvedObjs = objects.filter(
(o) => o.penNodeId && resolvedSet.has(o.penNodeId),
)
if (resolvedObjs.length > 1) {
const sel = new ActiveSelection(resolvedObjs, { canvas })
canvas.setActiveObject(sel)
canvas.requestRenderAll()
} else if (resolvedObjs.length === 1) {
canvas.setActiveObject(resolvedObjs[0])
canvas.requestRenderAll()
}
}
}
canvas.on('selection:created', handleSelection)
canvas.on('selection:updated', handleSelection)
canvas.on('selection:cleared', () => {
useCanvasStore.getState().clearSelection()

View file

@ -9,6 +9,7 @@ import {
} from './canvas-object-factory'
import { syncFabricObject } from './canvas-object-sync'
import { isFabricSyncLocked, setFabricSyncLock } from './canvas-sync-lock'
import { pendingAnimationNodes, getNextStaggerDelay } from '@/services/ai/design-animation'
// ---------------------------------------------------------------------------
// Clip info — tracks parent frame bounds for child clipping
@ -36,6 +37,9 @@ export interface NodeRenderInfo {
/** Rebuilt every sync cycle. Maps nodeId → parent offset + layout child status. */
export const nodeRenderInfo = new Map<string, NodeRenderInfo>()
/** Maps root-frame IDs to their absolute bounds. Rebuilt every sync cycle. */
export const rootFrameBounds = new Map<string, { x: number; y: number; w: number; h: number }>()
// ---------------------------------------------------------------------------
// Layout engine — resolves vertical/horizontal auto-layout to absolute x/y
// ---------------------------------------------------------------------------
@ -308,6 +312,7 @@ function flattenNodes(
clipCtx?: ClipInfo,
clipMap?: Map<string, ClipInfo>,
isLayoutChild = false,
depth = 0,
): PenNode[] {
const result: PenNode[] = []
for (const node of nodes) {
@ -384,18 +389,26 @@ function flattenNodes(
? computeLayoutPositions(resolved, children)
: children
// Compute clip context for children: if this frame has cornerRadius, clip children to it
// Compute clip context for children:
// - Root frames (depth 0, type frame) always clip their children
// - Non-root frames clip only when they have cornerRadius
let childClip = clipCtx
const cr = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
if (cr > 0) {
const isRootFrame = node.type === 'frame' && depth === 0
if (isRootFrame || cr > 0) {
childClip = { x: parentAbsX, y: parentAbsY, w: nodeW, h: nodeH, rx: cr }
}
// Track root frame bounds for drag-out reparenting
if (isRootFrame) {
rootFrameBounds.set(node.id, { x: parentAbsX, y: parentAbsY, w: nodeW, h: nodeH })
}
// Children inside layout containers are layout-controlled (position not manually editable)
const childIsLayoutChild = !!(layout && layout !== 'none')
result.push(
...flattenNodes(positioned, parentAbsX, parentAbsY, childAvailW, childAvailH, childClip, clipMap, childIsLayoutChild),
...flattenNodes(positioned, parentAbsX, parentAbsY, childAvailW, childAvailH, childClip, clipMap, childIsLayoutChild, depth + 1),
)
}
}
@ -410,6 +423,7 @@ function flattenNodes(
export function rebuildNodeRenderInfo() {
const state = useDocumentStore.getState()
nodeRenderInfo.clear()
rootFrameBounds.clear()
flattenNodes(state.document.children, 0, 0, undefined, undefined, undefined, new Map())
}
@ -491,6 +505,7 @@ export function useCanvasSync() {
const clipMap = new Map<string, ClipInfo>()
nodeRenderInfo.clear()
rootFrameBounds.clear()
const flatNodes = flattenNodes(
state.document.children, 0, 0, undefined, undefined, undefined, clipMap,
)
@ -521,7 +536,25 @@ export function useCanvasSync() {
} else {
const newObj = createFabricObject(node)
if (newObj) {
canvas.add(newObj)
const shouldAnimate = pendingAnimationNodes.has(node.id)
if (shouldAnimate) {
const targetOpacity = newObj.opacity ?? 1
const delay = getNextStaggerDelay()
newObj.set({ opacity: 0 })
canvas.add(newObj)
// Fire-and-forget: the setTimeout yields to the macrotask queue,
// so it runs between SSE stream chunks without blocking the stream.
setTimeout(() => {
newObj.animate({ opacity: targetOpacity }, {
duration: 250,
easing: fabric.util.ease.easeOutCubic,
onChange: () => canvas.requestRenderAll(),
onComplete: () => pendingAnimationNodes.delete(node.id),
})
}, delay)
} else {
canvas.add(newObj)
}
obj = newObj
}
}

View file

@ -0,0 +1,71 @@
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
const FRAME_OVERLAY_COLOR = '#3b82f6'
const FRAME_OVERLAY_DASH = [6, 4]
const FRAME_OVERLAY_WIDTH = 2
/**
* Renders a dashed border around the currently entered frame
* to indicate the active selection context.
* Draws on the lower canvas (same pattern as guides / frame labels).
*/
export function useEnteredFrameOverlay() {
useEffect(() => {
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
canvas.on('after:render', () => {
const { enteredFrameId } = useCanvasStore.getState().selection
if (!enteredFrameId) return
const objects = canvas.getObjects() as FabricObjectWithPenId[]
const frameObj = objects.find((o) => o.penNodeId === enteredFrameId)
if (!frameObj) return
const el = canvas.lowerCanvasEl
if (!el) return
const ctx = el.getContext('2d')
if (!ctx) return
const vpt = canvas.viewportTransform
if (!vpt) return
const zoom = vpt[0]
const dpr = el.width / el.offsetWidth
const left = frameObj.left ?? 0
const top = frameObj.top ?? 0
const w = (frameObj.width ?? 0) * (frameObj.scaleX ?? 1)
const h = (frameObj.height ?? 0) * (frameObj.scaleY ?? 1)
const angle = frameObj.angle ?? 0
ctx.save()
ctx.setTransform(
vpt[0] * dpr, vpt[1] * dpr,
vpt[2] * dpr, vpt[3] * dpr,
vpt[4] * dpr, vpt[5] * dpr,
)
if (angle !== 0) {
const cx = left + w / 2
const cy = top + h / 2
ctx.translate(cx, cy)
ctx.rotate((angle * Math.PI) / 180)
ctx.translate(-cx, -cy)
}
ctx.strokeStyle = FRAME_OVERLAY_COLOR
ctx.lineWidth = FRAME_OVERLAY_WIDTH / zoom
ctx.setLineDash(FRAME_OVERLAY_DASH.map((d) => d / zoom))
ctx.strokeRect(left, top, w, h)
ctx.restore()
})
}, 100)
return () => clearInterval(interval)
}, [])
}

View file

@ -0,0 +1,69 @@
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { activeInsertionIndicator } from './layout-reorder'
/**
* Renders the layout reorder insertion indicator on the canvas overlay
* using the `after:render` hook same pattern as use-canvas-guides.ts.
*/
export function useLayoutIndicator() {
useEffect(() => {
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
const onAfterRender = () => {
if (!activeInsertionIndicator) return
const el = canvas.lowerCanvasEl
const ctx = el?.getContext('2d')
if (!ctx) return
const vpt = canvas.viewportTransform
if (!vpt) return
const zoom = vpt[0]
ctx.save()
ctx.transform(vpt[0], vpt[1], vpt[2], vpt[3], vpt[4], vpt[5])
const { x, y, length, orientation } = activeInsertionIndicator
// Draw indicator line
ctx.strokeStyle = '#3B82F6'
ctx.lineWidth = 2 / zoom
ctx.setLineDash([])
ctx.beginPath()
if (orientation === 'horizontal') {
ctx.moveTo(x, y)
ctx.lineTo(x + length, y)
} else {
ctx.moveTo(x, y)
ctx.lineTo(x, y + length)
}
ctx.stroke()
// Small circles at endpoints
ctx.fillStyle = '#3B82F6'
const r = 3 / zoom
ctx.beginPath()
if (orientation === 'horizontal') {
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.moveTo(x + length + r, y)
ctx.arc(x + length, y, r, 0, Math.PI * 2)
} else {
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.moveTo(x + r, y + length)
ctx.arc(x, y + length, r, 0, Math.PI * 2)
}
ctx.fill()
ctx.restore()
}
canvas.on('after:render', onAfterRender)
}, 100)
return () => clearInterval(interval)
}, [])
}

View file

@ -12,12 +12,12 @@ import { streamChat, fetchAvailableModels } from '@/services/ai/ai-service'
import {
CHAT_SYSTEM_PROMPT,
} from '@/services/ai/ai-prompts'
import {
generateDesign,
import {
generateDesign,
generateDesignModification,
applyNodesToCanvas,
extractAndApplyDesign,
extractAndApplyDesignModification
animateNodesToCanvas,
extractAndApplyDesign,
extractAndApplyDesignModification
} from '@/services/ai/design-generator'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import type { AIProviderType } from '@/types/agent-settings'
@ -179,26 +179,27 @@ function useChatHandlers() {
const count = extractAndApplyDesignModification(JSON.stringify(nodes))
appliedCount += count
} else {
// --- GENERATION MODE ---
// --- GENERATION MODE (animated) ---
const { rawResponse, nodes } = await generateDesign({
prompt: fullUserMessage,
context: {
canvasSize: { width: 1200, height: 800 },
canvasSize: { width: 1200, height: 800 },
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
},
}, {
animated: true,
onApplyPartial: (partialCount: number) => {
appliedCount += partialCount
},
onTextUpdate: (text: string) => {
accumulated = text
updateLastMessage(text)
}
},
})
// Ensure final text is captured
accumulated = rawResponse
if (appliedCount === 0 && nodes.length > 0) {
applyNodesToCanvas(nodes)
animateNodesToCanvas(nodes)
appliedCount += nodes.length
}
}
@ -689,7 +690,7 @@ export default function AIChatPanel() {
<div className="flex items-center gap-1 justify-between w-full">
{selectedIds.length > 0 && (
<span className="text-[10px] text-muted-foreground ml-2 select-none">
<span className="flex text-[10px] text-muted-foreground ml-2 select-none overflow-hidden text-ellipsis ">
{selectedIds.length} object{selectedIds.length > 1 ? 's' : ''}
</span>
)}

View file

@ -237,15 +237,33 @@ export function useKeyboardShortcuts() {
}
}
// Escape: deselect all
// Escape: 1) clear selection, 2) exit frame, 3) switch to select tool
if (e.key === 'Escape') {
e.preventDefault()
useCanvasStore.getState().clearSelection()
useCanvasStore.getState().setActiveTool('select')
const { selectedIds, enteredFrameId } = useCanvasStore.getState().selection
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
if (selectedIds.length > 0) {
// Step 1: clear current selection
useCanvasStore.getState().clearSelection()
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
} else if (enteredFrameId) {
// Step 2: exit entered frame
useCanvasStore.getState().exitFrame()
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
} else {
// Step 3: switch to select tool
useCanvasStore.getState().setActiveTool('select')
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
}
return
}

View file

@ -0,0 +1,58 @@
import type { PenNode } from '@/types/pen'
// ---------------------------------------------------------------------------
// Shared coordination set — checked by canvas-sync to trigger fade-in
// ---------------------------------------------------------------------------
/** IDs of nodes that should fade in when their Fabric object is created. */
export const pendingAnimationNodes = new Set<string>()
// ---------------------------------------------------------------------------
// Stagger counter — determines delay per node within a batch
// ---------------------------------------------------------------------------
/** Index of the next animated object across all batches in a generation. */
let currentIndex = 0
/** Index where the current batch started (reset per JSON block). */
let batchStartIndex = 0
/**
* Mark all node IDs in the tree for fade-in animation.
* When canvas-sync creates Fabric objects for these IDs, it will set
* opacity to 0 and schedule a delayed fade-in.
*/
export function markNodesForAnimation(nodes: PenNode[]): void {
for (const node of nodes) {
pendingAnimationNodes.add(node.id)
if ('children' in node && Array.isArray(node.children)) {
markNodesForAnimation(node.children)
}
}
}
/**
* Start a new animation batch. Resets the relative stagger so that the
* first node in this batch starts fading in immediately (delay 0).
* Call this before each JSON block's upsert.
*/
export function startNewAnimationBatch(): void {
batchStartIndex = currentIndex
}
/**
* Get the stagger delay (ms) for the next animated object.
* Called by canvas-sync each time it creates a new Fabric object
* whose ID is in `pendingAnimationNodes`.
*/
export function getNextStaggerDelay(): number {
const relativeIndex = currentIndex - batchStartIndex
currentIndex++
return relativeIndex * 60
}
/** Reset all animation state. Call once at the start of a generation. */
export function resetAnimationState(): void {
pendingAnimationNodes.clear()
batchStartIndex = 0
currentIndex = 0
}

View file

@ -3,12 +3,29 @@ import type { AIDesignRequest } from './ai-types'
import { streamChat, generateCompletion } from './ai-service'
import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts'
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import {
markNodesForAnimation,
startNewAnimationBatch,
resetAnimationState,
} from './design-animation'
const DESIGN_STREAM_TIMEOUTS = {
hardTimeoutMs: 300_000,
noTextTimeoutMs: 120_000,
}
// ---------------------------------------------------------------------------
// Cross-phase ID remapping — tracks replaceEmptyFrame mappings so that
// later phases recognise the root frame ID has been remapped to DEFAULT_FRAME_ID.
// ---------------------------------------------------------------------------
const generationRemappedIds = new Map<string, string>()
function resetGenerationRemapping(): void {
generationRemappedIds.clear()
}
// Helper to find all complete JSON blocks in text
function extractJsonFromResponse(text: string): PenNode[] | null {
@ -136,13 +153,30 @@ export async function generateDesign(
callbacks?: {
onApplyPartial?: (count: number) => void
onTextUpdate?: (text: string) => void
/** When true, nodes are inserted with staggered fade-in animation. */
animated?: boolean
}
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
const userMessage = buildContextMessage(request)
let fullResponse = ''
let processedBlockCount = 0
let streamError: string | null = null
const animated = callbacks?.animated ?? false
// Reset cross-phase ID remapping so that replaceEmptyFrame mappings
// from a previous generation don't leak into this one.
resetGenerationRemapping()
// Animation setup: single history batch + stagger state.
// Nodes are inserted immediately (sync) via upsertNodesToCanvas.
// Canvas-sync creates Fabric objects at opacity 0 and schedules
// staggered fade-in via fire-and-forget setTimeout — no stream blocking.
if (animated) {
resetAnimationState()
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
}
try {
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
{ role: 'user', content: userMessage },
], undefined, DESIGN_STREAM_TIMEOUTS)) {
@ -161,7 +195,18 @@ export async function generateDesign(
for (const blockJson of newBlocks) {
const blockNodes = tryParseNodes(blockJson)
if (!blockNodes || blockNodes.length === 0) continue
applied += upsertNodesToCanvas(blockNodes)
if (animated) {
// Mark sanitized IDs for animation, then upsert (sync).
// Canvas-sync will create objects at opacity 0 and schedule
// staggered fade-in via setTimeout — does NOT block the stream.
const prepared = sanitizeNodesForUpsert(blockNodes)
startNewAnimationBatch()
markNodesForAnimation(prepared)
applied += upsertPreparedNodes(prepared)
} else {
applied += upsertNodesToCanvas(blockNodes)
}
}
if (applied > 0) {
@ -175,6 +220,11 @@ export async function generateDesign(
break
}
}
} finally {
if (animated) {
useHistoryStore.getState().endBatch()
}
}
const streamedNodes = extractJsonFromResponse(fullResponse)
if (streamedNodes && streamedNodes.length > 0) {
@ -280,6 +330,8 @@ function isCanvasOnlyEmptyFrame(): boolean {
*/
function replaceEmptyFrame(generatedFrame: PenNode): void {
const { updateNode } = useDocumentStore.getState()
// Record the remapping so subsequent phases can find this node by its original ID
generationRemappedIds.set(generatedFrame.id, DEFAULT_FRAME_ID)
// Keep root frame ID and position (x=0, y=0), take everything else from generated frame
const { id: _id, x: _x, y: _y, ...rest } = generatedFrame
updateNode(DEFAULT_FRAME_ID, rest)
@ -319,10 +371,13 @@ export function upsertNodesToCanvas(nodes: PenNode[]): number {
let count = 0
for (const node of preparedNodes) {
const existing = getNodeById(node.id)
// Resolve remapped IDs (e.g., root frame that was mapped to DEFAULT_FRAME_ID in Phase 1)
const resolvedId = generationRemappedIds.get(node.id) ?? node.id
const existing = getNodeById(resolvedId)
if (existing) {
const merged = mergeNodeForProgressiveUpsert(existing, node)
updateNode(node.id, merged)
const remappedNode = resolvedId !== node.id ? { ...node, id: resolvedId } : node
const merged = mergeNodeForProgressiveUpsert(existing, remappedNode)
updateNode(resolvedId, merged)
} else {
addNode(parentId, node)
}
@ -332,6 +387,52 @@ export function upsertNodesToCanvas(nodes: PenNode[]): number {
return count
}
/** Same as upsertNodesToCanvas but skips sanitization (caller already did it). */
function upsertPreparedNodes(preparedNodes: PenNode[]): number {
if (isCanvasOnlyEmptyFrame() && preparedNodes.length === 1 && preparedNodes[0].type === 'frame') {
replaceEmptyFrame(preparedNodes[0])
return 1
}
const { addNode, updateNode, getNodeById } = useDocumentStore.getState()
const rootFrame = getNodeById(DEFAULT_FRAME_ID)
const parentId = rootFrame ? DEFAULT_FRAME_ID : null
let count = 0
for (const node of preparedNodes) {
// Resolve remapped IDs (e.g., root frame that was mapped to DEFAULT_FRAME_ID in Phase 1)
const resolvedId = generationRemappedIds.get(node.id) ?? node.id
const existing = getNodeById(resolvedId)
if (existing) {
const remappedNode = resolvedId !== node.id ? { ...node, id: resolvedId } : node
const merged = mergeNodeForProgressiveUpsert(existing, remappedNode)
updateNode(resolvedId, merged)
} else {
addNode(parentId, node)
}
count++
}
return count
}
/**
* Animate nodes onto the canvas with a staggered fade-in effect.
* Synchronous nodes are inserted immediately, and canvas-sync
* schedules fire-and-forget staggered opacity animations.
*/
export function animateNodesToCanvas(nodes: PenNode[]): void {
resetGenerationRemapping()
resetAnimationState()
const prepared = sanitizeNodesForUpsert(nodes)
startNewAnimationBatch()
markNodesForAnimation(prepared)
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
upsertPreparedNodes(prepared)
useHistoryStore.getState().endBatch()
}
function sanitizeNodesForInsert(
nodes: PenNode[],
existingIds: Set<string>,

View file

@ -24,8 +24,10 @@ interface AIState {
panelCorner: PanelCorner
isMinimized: boolean
chatTitle: string
generationProgress: { current: number; total: number } | null
setChatTitle: (title: string) => void
setGenerationProgress: (progress: { current: number; total: number } | null) => void
setModel: (model: string) => void
setAvailableModels: (models: AIModelInfo[]) => void
@ -58,8 +60,10 @@ export const useAIStore = create<AIState>((set) => ({
panelCorner: 'bottom-left',
isMinimized: false,
chatTitle: 'New Chat',
generationProgress: null,
setChatTitle: (chatTitle) => set({ chatTitle }),
setGenerationProgress: (generationProgress) => set({ generationProgress }),
addMessage: (msg) =>
set((s) => ({ messages: [...s.messages, msg] })),

View file

@ -22,6 +22,10 @@ interface CanvasStoreState {
setPan: (x: number, y: number) => void
setSelection: (ids: string[], activeId: string | null) => void
clearSelection: () => void
setHoveredId: (id: string | null) => void
enterFrame: (frameId: string) => void
exitFrame: () => void
exitAllFrames: () => void
setInteraction: (partial: Partial<CanvasInteraction>) => void
setFabricCanvas: (canvas: Canvas | null) => void
setClipboard: (nodes: PenNode[]) => void
@ -31,7 +35,7 @@ interface CanvasStoreState {
export const useCanvasStore = create<CanvasStoreState>((set) => ({
activeTool: 'select',
viewport: { zoom: 1, panX: 0, panY: 0 },
selection: { selectedIds: [], activeId: null },
selection: { selectedIds: [], activeId: null, hoveredId: null, enteredFrameId: null, enteredFrameStack: [] },
interaction: {
isDrawing: false,
isPanning: false,
@ -51,10 +55,52 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
set((s) => ({ viewport: { ...s.viewport, panX, panY } })),
setSelection: (selectedIds, activeId) =>
set({ selection: { selectedIds, activeId } }),
set((s) => ({ selection: { ...s.selection, selectedIds, activeId } })),
clearSelection: () =>
set({ selection: { selectedIds: [], activeId: null } }),
set((s) => ({ selection: { ...s.selection, selectedIds: [], activeId: null } })),
setHoveredId: (hoveredId) =>
set((s) => ({ selection: { ...s.selection, hoveredId } })),
enterFrame: (frameId) =>
set((s) => ({
selection: {
...s.selection,
enteredFrameId: frameId,
enteredFrameStack: [...s.selection.enteredFrameStack, frameId],
hoveredId: null,
selectedIds: [],
activeId: null,
},
})),
exitFrame: () =>
set((s) => {
const stack = s.selection.enteredFrameStack.slice(0, -1)
return {
selection: {
...s.selection,
enteredFrameId: stack[stack.length - 1] ?? null,
enteredFrameStack: stack,
hoveredId: null,
selectedIds: [],
activeId: null,
},
}
}),
exitAllFrames: () =>
set((s) => ({
selection: {
...s.selection,
enteredFrameId: null,
enteredFrameStack: [],
hoveredId: null,
selectedIds: [],
activeId: null,
},
})),
setInteraction: (partial) =>
set((s) => ({ interaction: { ...s.interaction, ...partial } })),

View file

@ -18,6 +18,9 @@ export interface ViewportState {
export interface SelectionState {
selectedIds: string[]
activeId: string | null
hoveredId: string | null
enteredFrameId: string | null
enteredFrameStack: string[]
}
export interface CanvasInteraction {