mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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:
parent
1664631729
commit
dd3744fc8d
18 changed files with 1207 additions and 47 deletions
|
|
@ -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
106
src/canvas/drag-reparent.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
299
src/canvas/layout-reorder.ts
Normal file
299
src/canvas/layout-reorder.ts
Normal 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
|
||||
}
|
||||
67
src/canvas/selection-context.ts
Normal file
67
src/canvas/selection-context.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
132
src/canvas/use-canvas-hover.ts
Normal file
132
src/canvas/use-canvas-hover.ts
Normal 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)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
src/canvas/use-entered-frame-overlay.ts
Normal file
71
src/canvas/use-entered-frame-overlay.ts
Normal 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)
|
||||
}, [])
|
||||
}
|
||||
69
src/canvas/use-layout-indicator.ts
Normal file
69
src/canvas/use-layout-indicator.ts
Normal 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)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
58
src/services/ai/design-animation.ts
Normal file
58
src/services/ai/design-animation.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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] })),
|
||||
|
|
|
|||
|
|
@ -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 } })),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue