mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14: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,
|
transparentCorners: false,
|
||||||
borderOpacityWhenMoving: 1,
|
borderOpacityWhenMoving: 1,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
hoverCursor: 'default',
|
||||||
})
|
})
|
||||||
fabricImg.setControlVisible('mtr', false)
|
fabricImg.setControlVisible('mtr', false)
|
||||||
applyRotationControls(fabricImg)
|
applyRotationControls(fabricImg)
|
||||||
|
|
@ -416,6 +417,7 @@ export function createFabricObject(
|
||||||
transparentCorners: false,
|
transparentCorners: false,
|
||||||
borderOpacityWhenMoving: 1,
|
borderOpacityWhenMoving: 1,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
hoverCursor: 'default',
|
||||||
})
|
})
|
||||||
obj.setControlVisible('mtr', false)
|
obj.setControlVisible('mtr', false)
|
||||||
applyRotationControls(obj)
|
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 { useCanvasSync } from './use-canvas-sync'
|
||||||
import { useDimensionLabel } from './use-dimension-label'
|
import { useDimensionLabel } from './use-dimension-label'
|
||||||
import { useFrameLabels } from './use-frame-labels'
|
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() {
|
export default function FabricCanvas() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
@ -18,8 +21,11 @@ export default function FabricCanvas() {
|
||||||
useCanvasViewport()
|
useCanvasViewport()
|
||||||
useCanvasSelection()
|
useCanvasSelection()
|
||||||
useCanvasSync()
|
useCanvasSync()
|
||||||
|
useCanvasHover()
|
||||||
|
useEnteredFrameOverlay()
|
||||||
useDimensionLabel(containerRef)
|
useDimensionLabel(containerRef)
|
||||||
useFrameLabels()
|
useFrameLabels()
|
||||||
|
useLayoutIndicator()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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,
|
penToolDoubleClick,
|
||||||
cancelPenTool,
|
cancelPenTool,
|
||||||
} from './pen-tool'
|
} 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(
|
function createNodeForTool(
|
||||||
tool: ToolType,
|
tool: ToolType,
|
||||||
|
|
@ -346,6 +355,44 @@ export function useCanvasEvents() {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
penToolDoubleClick(canvas)
|
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
|
if (tool !== 'select') return
|
||||||
const target = opt.target as FabricObjectWithPenId | null
|
const target = opt.target as FabricObjectWithPenId | null
|
||||||
if (!target?.penNodeId) return
|
if (!target?.penNodeId) return
|
||||||
const currentChildren =
|
useHistoryStore
|
||||||
useDocumentStore.getState().document.children
|
.getState()
|
||||||
useHistoryStore.getState().beginBatch(currentChildren)
|
.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)
|
beginParentDrag(target.penNodeId, canvas)
|
||||||
})
|
})
|
||||||
|
|
||||||
canvas.on('mouse:up', () => {
|
canvas.on('mouse:up', () => {
|
||||||
|
cancelLayoutDrag()
|
||||||
endParentDrag()
|
endParentDrag()
|
||||||
const { batchDepth } = useHistoryStore.getState()
|
useHistoryStore.getState().endBatch()
|
||||||
if (batchDepth > 0) {
|
|
||||||
useHistoryStore.getState().cancelBatch()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Object modifications (drag, resize, rotate) via Fabric events ---
|
// --- 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)
|
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
|
||||||
canvas.on('object:moving', (opt) => {
|
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
|
// Calculate guides + snap BEFORE syncing so the store gets the snapped position
|
||||||
calculateAndSnap(opt.target, canvas)
|
calculateAndSnap(opt.target, canvas)
|
||||||
|
|
||||||
|
|
@ -512,6 +569,23 @@ export function useCanvasEvents() {
|
||||||
// Single object -- bake scale and sync
|
// Single object -- bake scale and sync
|
||||||
const asPen = target as FabricObjectWithPenId
|
const asPen = target as FabricObjectWithPenId
|
||||||
if (asPen.penNodeId) {
|
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 scaleX = target.scaleX ?? 1
|
||||||
const scaleY = target.scaleY ?? 1
|
const scaleY = target.scaleY ?? 1
|
||||||
// Path/Polygon dimensions are derived from their data, so we can't
|
// Path/Polygon dimensions are derived from their data, so we can't
|
||||||
|
|
@ -538,6 +612,16 @@ export function useCanvasEvents() {
|
||||||
}
|
}
|
||||||
finalizeParentRotation(asPen)
|
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) {
|
} else if ('getObjects' in target) {
|
||||||
// ActiveSelection -- bake scale per child, then sync all
|
// ActiveSelection -- bake scale per child, then sync all
|
||||||
const group = target as fabric.ActiveSelection
|
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 { useEffect } from 'react'
|
||||||
|
import type { FabricObject } from 'fabric'
|
||||||
|
import { ActiveSelection } from 'fabric'
|
||||||
import { useCanvasStore } from '@/stores/canvas-store'
|
import { useCanvasStore } from '@/stores/canvas-store'
|
||||||
import type { FabricObjectWithPenId } from './canvas-object-factory'
|
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() {
|
export function useCanvasSelection() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -9,21 +44,46 @@ export function useCanvasSelection() {
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
|
|
||||||
canvas.on('selection:created', (e) => {
|
const handleSelection = (e: { selected?: FabricObject[] }) => {
|
||||||
const selected = e.selected ?? []
|
const selected = e.selected ?? []
|
||||||
const ids = selected
|
const ids = resolveIds(selected)
|
||||||
.map((obj) => (obj as FabricObjectWithPenId).penNodeId)
|
|
||||||
.filter(Boolean) as string[]
|
|
||||||
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
|
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
|
||||||
})
|
|
||||||
|
|
||||||
canvas.on('selection:updated', (e) => {
|
// Correct Fabric's active object to match the depth-resolved target.
|
||||||
const selected = e.selected ?? []
|
// Without this, Fabric keeps the deeply-nested child as active,
|
||||||
const ids = selected
|
// showing selection handles on the wrong element.
|
||||||
.map((obj) => (obj as FabricObjectWithPenId).penNodeId)
|
if (ids.length === 0) return
|
||||||
.filter(Boolean) as string[]
|
|
||||||
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
|
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', () => {
|
canvas.on('selection:cleared', () => {
|
||||||
useCanvasStore.getState().clearSelection()
|
useCanvasStore.getState().clearSelection()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from './canvas-object-factory'
|
} from './canvas-object-factory'
|
||||||
import { syncFabricObject } from './canvas-object-sync'
|
import { syncFabricObject } from './canvas-object-sync'
|
||||||
import { isFabricSyncLocked, setFabricSyncLock } from './canvas-sync-lock'
|
import { isFabricSyncLocked, setFabricSyncLock } from './canvas-sync-lock'
|
||||||
|
import { pendingAnimationNodes, getNextStaggerDelay } from '@/services/ai/design-animation'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Clip info — tracks parent frame bounds for child clipping
|
// 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. */
|
/** Rebuilt every sync cycle. Maps nodeId → parent offset + layout child status. */
|
||||||
export const nodeRenderInfo = new Map<string, NodeRenderInfo>()
|
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
|
// Layout engine — resolves vertical/horizontal auto-layout to absolute x/y
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -308,6 +312,7 @@ function flattenNodes(
|
||||||
clipCtx?: ClipInfo,
|
clipCtx?: ClipInfo,
|
||||||
clipMap?: Map<string, ClipInfo>,
|
clipMap?: Map<string, ClipInfo>,
|
||||||
isLayoutChild = false,
|
isLayoutChild = false,
|
||||||
|
depth = 0,
|
||||||
): PenNode[] {
|
): PenNode[] {
|
||||||
const result: PenNode[] = []
|
const result: PenNode[] = []
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
|
|
@ -384,18 +389,26 @@ function flattenNodes(
|
||||||
? computeLayoutPositions(resolved, children)
|
? computeLayoutPositions(resolved, children)
|
||||||
: 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
|
let childClip = clipCtx
|
||||||
const cr = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
|
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 }
|
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)
|
// Children inside layout containers are layout-controlled (position not manually editable)
|
||||||
const childIsLayoutChild = !!(layout && layout !== 'none')
|
const childIsLayoutChild = !!(layout && layout !== 'none')
|
||||||
|
|
||||||
result.push(
|
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() {
|
export function rebuildNodeRenderInfo() {
|
||||||
const state = useDocumentStore.getState()
|
const state = useDocumentStore.getState()
|
||||||
nodeRenderInfo.clear()
|
nodeRenderInfo.clear()
|
||||||
|
rootFrameBounds.clear()
|
||||||
flattenNodes(state.document.children, 0, 0, undefined, undefined, undefined, new Map())
|
flattenNodes(state.document.children, 0, 0, undefined, undefined, undefined, new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -491,6 +505,7 @@ export function useCanvasSync() {
|
||||||
|
|
||||||
const clipMap = new Map<string, ClipInfo>()
|
const clipMap = new Map<string, ClipInfo>()
|
||||||
nodeRenderInfo.clear()
|
nodeRenderInfo.clear()
|
||||||
|
rootFrameBounds.clear()
|
||||||
const flatNodes = flattenNodes(
|
const flatNodes = flattenNodes(
|
||||||
state.document.children, 0, 0, undefined, undefined, undefined, clipMap,
|
state.document.children, 0, 0, undefined, undefined, undefined, clipMap,
|
||||||
)
|
)
|
||||||
|
|
@ -521,7 +536,25 @@ export function useCanvasSync() {
|
||||||
} else {
|
} else {
|
||||||
const newObj = createFabricObject(node)
|
const newObj = createFabricObject(node)
|
||||||
if (newObj) {
|
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
|
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 {
|
import {
|
||||||
CHAT_SYSTEM_PROMPT,
|
CHAT_SYSTEM_PROMPT,
|
||||||
} from '@/services/ai/ai-prompts'
|
} from '@/services/ai/ai-prompts'
|
||||||
import {
|
import {
|
||||||
generateDesign,
|
generateDesign,
|
||||||
generateDesignModification,
|
generateDesignModification,
|
||||||
applyNodesToCanvas,
|
animateNodesToCanvas,
|
||||||
extractAndApplyDesign,
|
extractAndApplyDesign,
|
||||||
extractAndApplyDesignModification
|
extractAndApplyDesignModification
|
||||||
} from '@/services/ai/design-generator'
|
} from '@/services/ai/design-generator'
|
||||||
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
|
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
|
||||||
import type { AIProviderType } from '@/types/agent-settings'
|
import type { AIProviderType } from '@/types/agent-settings'
|
||||||
|
|
@ -179,26 +179,27 @@ function useChatHandlers() {
|
||||||
const count = extractAndApplyDesignModification(JSON.stringify(nodes))
|
const count = extractAndApplyDesignModification(JSON.stringify(nodes))
|
||||||
appliedCount += count
|
appliedCount += count
|
||||||
} else {
|
} else {
|
||||||
// --- GENERATION MODE ---
|
// --- GENERATION MODE (animated) ---
|
||||||
const { rawResponse, nodes } = await generateDesign({
|
const { rawResponse, nodes } = await generateDesign({
|
||||||
prompt: fullUserMessage,
|
prompt: fullUserMessage,
|
||||||
context: {
|
context: {
|
||||||
canvasSize: { width: 1200, height: 800 },
|
canvasSize: { width: 1200, height: 800 },
|
||||||
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
|
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
|
animated: true,
|
||||||
onApplyPartial: (partialCount: number) => {
|
onApplyPartial: (partialCount: number) => {
|
||||||
appliedCount += partialCount
|
appliedCount += partialCount
|
||||||
},
|
},
|
||||||
onTextUpdate: (text: string) => {
|
onTextUpdate: (text: string) => {
|
||||||
accumulated = text
|
accumulated = text
|
||||||
updateLastMessage(text)
|
updateLastMessage(text)
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
// Ensure final text is captured
|
// Ensure final text is captured
|
||||||
accumulated = rawResponse
|
accumulated = rawResponse
|
||||||
if (appliedCount === 0 && nodes.length > 0) {
|
if (appliedCount === 0 && nodes.length > 0) {
|
||||||
applyNodesToCanvas(nodes)
|
animateNodesToCanvas(nodes)
|
||||||
appliedCount += nodes.length
|
appliedCount += nodes.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -689,7 +690,7 @@ export default function AIChatPanel() {
|
||||||
|
|
||||||
<div className="flex items-center gap-1 justify-between w-full">
|
<div className="flex items-center gap-1 justify-between w-full">
|
||||||
{selectedIds.length > 0 && (
|
{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' : ''}
|
{selectedIds.length} object{selectedIds.length > 1 ? 's' : ''}
|
||||||
</span>
|
</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') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useCanvasStore.getState().clearSelection()
|
const { selectedIds, enteredFrameId } = useCanvasStore.getState().selection
|
||||||
useCanvasStore.getState().setActiveTool('select')
|
|
||||||
const canvas = useCanvasStore.getState().fabricCanvas
|
const canvas = useCanvasStore.getState().fabricCanvas
|
||||||
if (canvas) {
|
|
||||||
canvas.discardActiveObject()
|
if (selectedIds.length > 0) {
|
||||||
canvas.requestRenderAll()
|
// 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
|
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 { streamChat, generateCompletion } from './ai-service'
|
||||||
import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts'
|
import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts'
|
||||||
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
|
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 = {
|
const DESIGN_STREAM_TIMEOUTS = {
|
||||||
hardTimeoutMs: 300_000,
|
hardTimeoutMs: 300_000,
|
||||||
noTextTimeoutMs: 120_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
|
// Helper to find all complete JSON blocks in text
|
||||||
|
|
||||||
function extractJsonFromResponse(text: string): PenNode[] | null {
|
function extractJsonFromResponse(text: string): PenNode[] | null {
|
||||||
|
|
@ -136,13 +153,30 @@ export async function generateDesign(
|
||||||
callbacks?: {
|
callbacks?: {
|
||||||
onApplyPartial?: (count: number) => void
|
onApplyPartial?: (count: number) => void
|
||||||
onTextUpdate?: (text: string) => void
|
onTextUpdate?: (text: string) => void
|
||||||
|
/** When true, nodes are inserted with staggered fade-in animation. */
|
||||||
|
animated?: boolean
|
||||||
}
|
}
|
||||||
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
|
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
|
||||||
const userMessage = buildContextMessage(request)
|
const userMessage = buildContextMessage(request)
|
||||||
let fullResponse = ''
|
let fullResponse = ''
|
||||||
let processedBlockCount = 0
|
let processedBlockCount = 0
|
||||||
let streamError: string | null = null
|
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, [
|
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
|
||||||
{ role: 'user', content: userMessage },
|
{ role: 'user', content: userMessage },
|
||||||
], undefined, DESIGN_STREAM_TIMEOUTS)) {
|
], undefined, DESIGN_STREAM_TIMEOUTS)) {
|
||||||
|
|
@ -161,7 +195,18 @@ export async function generateDesign(
|
||||||
for (const blockJson of newBlocks) {
|
for (const blockJson of newBlocks) {
|
||||||
const blockNodes = tryParseNodes(blockJson)
|
const blockNodes = tryParseNodes(blockJson)
|
||||||
if (!blockNodes || blockNodes.length === 0) continue
|
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) {
|
if (applied > 0) {
|
||||||
|
|
@ -175,6 +220,11 @@ export async function generateDesign(
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (animated) {
|
||||||
|
useHistoryStore.getState().endBatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const streamedNodes = extractJsonFromResponse(fullResponse)
|
const streamedNodes = extractJsonFromResponse(fullResponse)
|
||||||
if (streamedNodes && streamedNodes.length > 0) {
|
if (streamedNodes && streamedNodes.length > 0) {
|
||||||
|
|
@ -280,6 +330,8 @@ function isCanvasOnlyEmptyFrame(): boolean {
|
||||||
*/
|
*/
|
||||||
function replaceEmptyFrame(generatedFrame: PenNode): void {
|
function replaceEmptyFrame(generatedFrame: PenNode): void {
|
||||||
const { updateNode } = useDocumentStore.getState()
|
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
|
// 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
|
const { id: _id, x: _x, y: _y, ...rest } = generatedFrame
|
||||||
updateNode(DEFAULT_FRAME_ID, rest)
|
updateNode(DEFAULT_FRAME_ID, rest)
|
||||||
|
|
@ -319,10 +371,13 @@ export function upsertNodesToCanvas(nodes: PenNode[]): number {
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
for (const node of preparedNodes) {
|
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) {
|
if (existing) {
|
||||||
const merged = mergeNodeForProgressiveUpsert(existing, node)
|
const remappedNode = resolvedId !== node.id ? { ...node, id: resolvedId } : node
|
||||||
updateNode(node.id, merged)
|
const merged = mergeNodeForProgressiveUpsert(existing, remappedNode)
|
||||||
|
updateNode(resolvedId, merged)
|
||||||
} else {
|
} else {
|
||||||
addNode(parentId, node)
|
addNode(parentId, node)
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +387,52 @@ export function upsertNodesToCanvas(nodes: PenNode[]): number {
|
||||||
return count
|
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(
|
function sanitizeNodesForInsert(
|
||||||
nodes: PenNode[],
|
nodes: PenNode[],
|
||||||
existingIds: Set<string>,
|
existingIds: Set<string>,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,10 @@ interface AIState {
|
||||||
panelCorner: PanelCorner
|
panelCorner: PanelCorner
|
||||||
isMinimized: boolean
|
isMinimized: boolean
|
||||||
chatTitle: string
|
chatTitle: string
|
||||||
|
generationProgress: { current: number; total: number } | null
|
||||||
|
|
||||||
setChatTitle: (title: string) => void
|
setChatTitle: (title: string) => void
|
||||||
|
setGenerationProgress: (progress: { current: number; total: number } | null) => void
|
||||||
|
|
||||||
setModel: (model: string) => void
|
setModel: (model: string) => void
|
||||||
setAvailableModels: (models: AIModelInfo[]) => void
|
setAvailableModels: (models: AIModelInfo[]) => void
|
||||||
|
|
@ -58,8 +60,10 @@ export const useAIStore = create<AIState>((set) => ({
|
||||||
panelCorner: 'bottom-left',
|
panelCorner: 'bottom-left',
|
||||||
isMinimized: false,
|
isMinimized: false,
|
||||||
chatTitle: 'New Chat',
|
chatTitle: 'New Chat',
|
||||||
|
generationProgress: null,
|
||||||
|
|
||||||
setChatTitle: (chatTitle) => set({ chatTitle }),
|
setChatTitle: (chatTitle) => set({ chatTitle }),
|
||||||
|
setGenerationProgress: (generationProgress) => set({ generationProgress }),
|
||||||
|
|
||||||
addMessage: (msg) =>
|
addMessage: (msg) =>
|
||||||
set((s) => ({ messages: [...s.messages, msg] })),
|
set((s) => ({ messages: [...s.messages, msg] })),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ interface CanvasStoreState {
|
||||||
setPan: (x: number, y: number) => void
|
setPan: (x: number, y: number) => void
|
||||||
setSelection: (ids: string[], activeId: string | null) => void
|
setSelection: (ids: string[], activeId: string | null) => void
|
||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
|
setHoveredId: (id: string | null) => void
|
||||||
|
enterFrame: (frameId: string) => void
|
||||||
|
exitFrame: () => void
|
||||||
|
exitAllFrames: () => void
|
||||||
setInteraction: (partial: Partial<CanvasInteraction>) => void
|
setInteraction: (partial: Partial<CanvasInteraction>) => void
|
||||||
setFabricCanvas: (canvas: Canvas | null) => void
|
setFabricCanvas: (canvas: Canvas | null) => void
|
||||||
setClipboard: (nodes: PenNode[]) => void
|
setClipboard: (nodes: PenNode[]) => void
|
||||||
|
|
@ -31,7 +35,7 @@ interface CanvasStoreState {
|
||||||
export const useCanvasStore = create<CanvasStoreState>((set) => ({
|
export const useCanvasStore = create<CanvasStoreState>((set) => ({
|
||||||
activeTool: 'select',
|
activeTool: 'select',
|
||||||
viewport: { zoom: 1, panX: 0, panY: 0 },
|
viewport: { zoom: 1, panX: 0, panY: 0 },
|
||||||
selection: { selectedIds: [], activeId: null },
|
selection: { selectedIds: [], activeId: null, hoveredId: null, enteredFrameId: null, enteredFrameStack: [] },
|
||||||
interaction: {
|
interaction: {
|
||||||
isDrawing: false,
|
isDrawing: false,
|
||||||
isPanning: false,
|
isPanning: false,
|
||||||
|
|
@ -51,10 +55,52 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
|
||||||
set((s) => ({ viewport: { ...s.viewport, panX, panY } })),
|
set((s) => ({ viewport: { ...s.viewport, panX, panY } })),
|
||||||
|
|
||||||
setSelection: (selectedIds, activeId) =>
|
setSelection: (selectedIds, activeId) =>
|
||||||
set({ selection: { selectedIds, activeId } }),
|
set((s) => ({ selection: { ...s.selection, selectedIds, activeId } })),
|
||||||
|
|
||||||
clearSelection: () =>
|
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) =>
|
setInteraction: (partial) =>
|
||||||
set((s) => ({ interaction: { ...s.interaction, ...partial } })),
|
set((s) => ({ interaction: { ...s.interaction, ...partial } })),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ export interface ViewportState {
|
||||||
export interface SelectionState {
|
export interface SelectionState {
|
||||||
selectedIds: string[]
|
selectedIds: string[]
|
||||||
activeId: string | null
|
activeId: string | null
|
||||||
|
hoveredId: string | null
|
||||||
|
enteredFrameId: string | null
|
||||||
|
enteredFrameStack: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasInteraction {
|
export interface CanvasInteraction {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue