diff --git a/src/canvas/canvas-constants.ts b/src/canvas/canvas-constants.ts index 665de70c0..910178e47 100644 --- a/src/canvas/canvas-constants.ts +++ b/src/canvas/canvas-constants.ts @@ -15,6 +15,8 @@ export function getCanvasBackground(): string { : CANVAS_BACKGROUND_DARK } export const SELECTION_BLUE = '#0d99ff' +export const COMPONENT_COLOR = '#a855f7' +export const INSTANCE_COLOR = '#9281f7' // Smart guides export const GUIDE_COLOR = '#FF6B35' diff --git a/src/canvas/selection-context.ts b/src/canvas/selection-context.ts index e42e9257d..8f09f4687 100644 --- a/src/canvas/selection-context.ts +++ b/src/canvas/selection-context.ts @@ -38,6 +38,20 @@ export function resolveTargetAtDepth(nodeId: string): string | null { // Direct match if (selectableIds.has(nodeId)) return nodeId + // Handle virtual instance child IDs (refId__childId) + if (nodeId.includes('__')) { + const refId = nodeId.substring(0, nodeId.indexOf('__')) + if (selectableIds.has(refId)) return refId + // Walk up from the RefNode + let cur: string | undefined = refId + while (cur) { + const parent = useDocumentStore.getState().getParentOf(cur) + if (!parent) break + if (selectableIds.has(parent.id)) return parent.id + cur = parent.id + } + } + // Walk up parent chain let currentId: string | undefined = nodeId while (currentId) { diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index ba7c76250..fcd164300 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -11,6 +11,8 @@ import { syncFabricObject } from './canvas-object-sync' import { isFabricSyncLocked, setFabricSyncLock } from './canvas-sync-lock' import { pendingAnimationNodes, getNextStaggerDelay } from '@/services/ai/design-animation' import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables' +import { findNodeInTree } from '@/stores/document-store' +import { COMPONENT_COLOR, INSTANCE_COLOR, SELECTION_BLUE } from './canvas-constants' // --------------------------------------------------------------------------- // Clip info — tracks parent frame bounds for child clipping @@ -317,6 +319,92 @@ function computeLayoutPositions( }) } +// --------------------------------------------------------------------------- +// Resolve RefNodes — expand instances by looking up their referenced component +// --------------------------------------------------------------------------- + +/** Give children unique IDs scoped to the instance, apply overrides from descendants. */ +function remapInstanceChildIds( + children: PenNode[], + refId: string, + overrides?: Record>, +): PenNode[] { + return children.map((child) => { + const virtualId = `${refId}__${child.id}` + const ov = overrides?.[child.id] ?? {} + const mapped = { ...child, ...ov, id: virtualId } as PenNode + if ('children' in mapped && mapped.children) { + ;(mapped as PenNode & { children: PenNode[] }).children = + remapInstanceChildIds(mapped.children, refId, overrides) + } + return mapped + }) +} + +/** + * Recursively resolve all RefNodes in the tree by expanding them + * with their referenced component's structure. + */ +function resolveRefs( + nodes: PenNode[], + rootNodes: PenNode[], + visited = new Set(), +): PenNode[] { + return nodes.flatMap((node) => { + if (node.type !== 'ref') { + if ('children' in node && node.children) { + return [ + { + ...node, + children: resolveRefs(node.children, rootNodes, visited), + } as PenNode, + ] + } + return [node] + } + + // Resolve RefNode + if (visited.has(node.ref)) return [] // circular reference guard + const component = findNodeInTree(rootNodes, node.ref) + if (!component) return [] + + visited.add(node.ref) + + const refNode = node as PenNode & { descendants?: Record> } + // Apply top-level visual overrides from descendants[componentId] + const topOverrides = refNode.descendants?.[node.ref] ?? {} + + // Build resolved node: component base → overrides → RefNode's own properties + const resolved: Record = { ...component, ...topOverrides } + // Apply all explicitly-defined RefNode properties (position, size, opacity, etc.) + for (const [key, val] of Object.entries(node)) { + if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue + if (val !== undefined) { + resolved[key] = val + } + } + // Use component's type (not 'ref') and ensure name fallback + resolved.type = component.type + if (!resolved.name) resolved.name = component.name + // Clear the reusable flag — this is an instance, not the component + delete resolved.reusable + const resolvedNode = resolved as unknown as PenNode + + // Remap children IDs to avoid clashes with the original component + if ('children' in resolvedNode && resolvedNode.children) { + ;(resolvedNode as PenNode & { children: PenNode[] }).children = + remapInstanceChildIds( + resolvedNode.children, + node.id, + refNode.descendants, + ) + } + + visited.delete(node.ref) + return [resolvedNode] + }) +} + // --------------------------------------------------------------------------- // Flatten document tree → absolute-positioned list for Fabric.js // --------------------------------------------------------------------------- @@ -461,7 +549,8 @@ export function rebuildNodeRenderInfo() { nodeRenderInfo.clear() rootFrameBounds.clear() layoutContainerBounds.clear() - flattenNodes(state.document.children, 0, 0, undefined, undefined, undefined, new Map()) + const resolvedTree = resolveRefs(state.document.children, state.document.children) + flattenNodes(resolvedTree, 0, 0, undefined, undefined, undefined, new Map()) } /** @@ -554,8 +643,10 @@ export function useCanvasSync() { nodeRenderInfo.clear() rootFrameBounds.clear() layoutContainerBounds.clear() + // Resolve RefNodes before flattening so instances render as their component + const resolvedTree = resolveRefs(state.document.children, state.document.children) const flatNodes = flattenNodes( - state.document.children, 0, 0, undefined, undefined, undefined, clipMap, + resolvedTree, 0, 0, undefined, undefined, undefined, clipMap, ).map((node) => resolveNodeForCanvas(node, variables, activeTheme)) const nodeMap = new Map(flatNodes.map((n) => [n.id, n])) const objects = canvas.getObjects() as FabricObjectWithPenId[] @@ -565,6 +656,17 @@ export function useCanvasSync() { .map((o) => [o.penNodeId!, o]), ) + // Collect component and instance IDs for selection styling + const reusableIds = new Set() + const instanceIds = new Set() + ;(function collectComponentIds(nodes: PenNode[]) { + for (const n of nodes) { + if ('reusable' in n && n.reusable === true) reusableIds.add(n.id) + if (n.type === 'ref') instanceIds.add(n.id) + if ('children' in n && n.children) collectComponentIds(n.children) + } + })(state.document.children) + // Remove objects that no longer exist in the document for (const obj of objects) { if (obj.penNodeId && !nodeMap.has(obj.penNodeId)) { @@ -613,8 +715,23 @@ export function useCanvasSync() { } } - // Apply clip path from parent frame with cornerRadius if (obj) { + // Component/instance selection border styling + if (reusableIds.has(node.id)) { + obj.borderColor = COMPONENT_COLOR + obj.cornerColor = COMPONENT_COLOR + obj.borderDashArray = [] + } else if (instanceIds.has(node.id)) { + obj.borderColor = INSTANCE_COLOR + obj.cornerColor = INSTANCE_COLOR + obj.borderDashArray = [4, 4] + } else if (obj.borderColor === COMPONENT_COLOR || obj.borderColor === INSTANCE_COLOR) { + obj.borderColor = SELECTION_BLUE + obj.cornerColor = SELECTION_BLUE + obj.borderDashArray = [] + } + + // Apply clip path from parent frame with cornerRadius const clip = clipMap.get(node.id) if (clip) { obj.clipPath = new fabric.Rect({ diff --git a/src/canvas/use-frame-labels.ts b/src/canvas/use-frame-labels.ts index 80c025c26..5e4fca70e 100644 --- a/src/canvas/use-frame-labels.ts +++ b/src/canvas/use-frame-labels.ts @@ -1,12 +1,38 @@ import { useEffect } from 'react' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore } from '@/stores/document-store' +import { COMPONENT_COLOR, INSTANCE_COLOR } from './canvas-constants' import type { FabricObjectWithPenId } from './canvas-object-factory' +import type { PenNode } from '@/types/pen' const LABEL_FONT_SIZE = 12 const LABEL_OFFSET_Y = 6 const LABEL_COLOR = '#999999' +/** Collect IDs of all nodes with reusable: true in the tree. */ +function collectReusableIds(nodes: PenNode[], result: Set) { + for (const node of nodes) { + if ('reusable' in node && node.reusable === true) { + result.add(node.id) + } + if ('children' in node && node.children) { + collectReusableIds(node.children, result) + } + } +} + +/** Collect IDs of all RefNodes (instances) in the tree. */ +function collectInstanceIds(nodes: PenNode[], result: Set) { + for (const node of nodes) { + if (node.type === 'ref') { + result.add(node.id) + } + if ('children' in node && node.children) { + collectInstanceIds(node.children, result) + } + } +} + export function useFrameLabels() { useEffect(() => { const interval = setInterval(() => { @@ -26,22 +52,33 @@ export function useFrameLabels() { const dpr = el.width / el.offsetWidth const store = useDocumentStore.getState() - // Only top-level frame nodes show labels + // Top-level frame nodes show labels const topFrameIds = new Set( store.document.children .filter((c) => c.type === 'frame') .map((c) => c.id), ) + // Reusable component IDs (at any depth) + const reusableIds = new Set() + collectReusableIds(store.document.children, reusableIds) + // Instance (RefNode) IDs + const instanceIds = new Set() + collectInstanceIds(store.document.children, instanceIds) + const objects = canvas.getObjects() as FabricObjectWithPenId[] ctx.save() const fontSize = LABEL_FONT_SIZE * dpr ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` - ctx.fillStyle = LABEL_COLOR for (const obj of objects) { if (!obj.penNodeId) continue - if (!topFrameIds.has(obj.penNodeId)) continue + + const isTopFrame = topFrameIds.has(obj.penNodeId) + const isReusable = reusableIds.has(obj.penNodeId) + const isInstance = instanceIds.has(obj.penNodeId) + + if (!isTopFrame && !isReusable && !isInstance) continue const node = store.getNodeById(obj.penNodeId) if (!node) continue @@ -50,6 +87,11 @@ export function useFrameLabels() { const x = ((obj.left ?? 0) * zoom + vpt[4]) * dpr const y = ((obj.top ?? 0) * zoom + vpt[5]) * dpr + ctx.fillStyle = isReusable + ? COMPONENT_COLOR + : isInstance + ? INSTANCE_COLOR + : LABEL_COLOR ctx.fillText(name, x, y - LABEL_OFFSET_Y * dpr) } diff --git a/src/stores/document-store.ts b/src/stores/document-store.ts index 476fc60a7..fb8d22035 100644 --- a/src/stores/document-store.ts +++ b/src/stores/document-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { nanoid } from 'nanoid' -import type { PenDocument, PenNode, GroupNode } from '@/types/pen' +import type { PenDocument, PenNode, GroupNode, RefNode } from '@/types/pen' import type { VariableDefinition } from '@/types/variables' import { useHistoryStore } from '@/stores/history-store' import { getDefaultTheme } from '@/variables/resolve-variables' @@ -98,6 +98,71 @@ function flattenNodes(nodes: PenNode[]): PenNode[] { return result } +/** Resolve the bounding box of a node, falling back to its referenced component for RefNodes. */ +function getNodeBounds( + node: PenNode, + allNodes: PenNode[], +): { x: number; y: number; w: number; h: number } { + const x = node.x ?? 0 + const y = node.y ?? 0 + let w = ('width' in node && typeof node.width === 'number') ? node.width : 0 + let h = ('height' in node && typeof node.height === 'number') ? node.height : 0 + if (node.type === 'ref' && !w) { + const refComp = findNodeInTree(allNodes, (node as RefNode).ref) + if (refComp) { + w = ('width' in refComp && typeof refComp.width === 'number') ? refComp.width : 100 + h = ('height' in refComp && typeof refComp.height === 'number') ? refComp.height : 100 + } + } + return { x, y, w: w || 100, h: h || 100 } +} + +/** + * Find a clear X position to the right of `sourceX + sourceW` that doesn't + * overlap any sibling (excluding `excludeId`) on the same vertical band. + */ +function findClearX( + sourceX: number, + sourceW: number, + proposedY: number, + proposedH: number, + siblings: PenNode[], + excludeId: string, + allNodes: PenNode[], + gap = 20, +): number { + const proposedW = sourceW + let proposedX = sourceX + sourceW + gap + + const siblingBounds: { x: number; y: number; w: number; h: number }[] = [] + for (const sib of siblings) { + if (sib.id === excludeId) continue + const b = getNodeBounds(sib, allNodes) + if (b.w > 0 && b.h > 0) siblingBounds.push(b) + } + + let maxAttempts = 100 + while (maxAttempts-- > 0) { + const hasOverlap = siblingBounds.some((b) => { + const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x + const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y + return overlapX && overlapY + }) + if (!hasOverlap) break + let maxRight = proposedX + for (const b of siblingBounds) { + const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x + const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y + if (overlapX && overlapY && b.x + b.w > maxRight) { + maxRight = b.x + b.w + } + } + proposedX = maxRight + gap + } + + return proposedX +} + function isDescendantOf( nodes: PenNode[], nodeId: string, @@ -234,6 +299,10 @@ interface DocumentStoreState { getFlatNodes: () => PenNode[] isDescendantOf: (nodeId: string, ancestorId: string) => boolean + // Component management + makeReusable: (nodeId: string) => void + detachComponent: (nodeId: string) => string | undefined + // Variable management setVariable: (name: string, definition: VariableDefinition) => void removeVariable: (name: string) => void @@ -398,6 +467,47 @@ export const useDocumentStore = create( const node = findNodeInTree(state.document.children, id) if (!node) return null + // Duplicating a reusable component creates an instance (RefNode) + if ('reusable' in node && node.reusable === true) { + const bounds = getNodeBounds(node, state.document.children) + const parent = findParentInTree(state.document.children, id) + const parentId = parent ? parent.id : null + const siblings = parent + ? ('children' in parent ? parent.children ?? [] : []) + : state.document.children + const idx = siblings.findIndex((n) => n.id === id) + + const clearX = findClearX( + bounds.x, bounds.w, bounds.y, bounds.h, + siblings, id, state.document.children, + ) + + const refNode: RefNode = { + id: nanoid(), + type: 'ref', + ref: node.id, + name: node.name ?? node.type, + x: clearX, + y: bounds.y, + } + + useHistoryStore.getState().pushState(state.document) + set((s) => ({ + document: { + ...s.document, + children: insertNodeInTree( + s.document.children, + parentId, + refNode as PenNode, + idx + 1, + ), + }, + isDirty: true, + })) + return refNode.id + } + + // Regular duplication for non-reusable nodes const cloneWithNewIds = (n: PenNode): PenNode => { const cloned = { ...n, id: nanoid() } as PenNode if ('children' in cloned && cloned.children) { @@ -408,8 +518,6 @@ export const useDocumentStore = create( const clone = cloneWithNewIds(node) clone.name = (clone.name ?? clone.type) + ' copy' - if (clone.x !== undefined) clone.x += 10 - if (clone.y !== undefined) clone.y += 10 const parent = findParentInTree(state.document.children, id) const parentId = parent ? parent.id : null @@ -418,6 +526,13 @@ export const useDocumentStore = create( : state.document.children const idx = siblings.findIndex((n) => n.id === id) + const bounds = getNodeBounds(node, state.document.children) + clone.x = findClearX( + bounds.x, bounds.w, bounds.y, bounds.h, + siblings, id, state.document.children, + ) + clone.y = bounds.y + useHistoryStore.getState().pushState(state.document) set((s) => ({ document: { @@ -596,6 +711,113 @@ export const useDocumentStore = create( isDescendantOf: (nodeId, ancestorId) => isDescendantOf(get().document.children, nodeId, ancestorId), + // --- Component management --- + + makeReusable: (nodeId) => { + const state = get() + const node = findNodeInTree(state.document.children, nodeId) + if (!node) return + // Only container types (frame, group, rectangle) can be made reusable + if (node.type !== 'frame' && node.type !== 'group' && node.type !== 'rectangle') return + if ('reusable' in node && node.reusable) return + useHistoryStore.getState().pushState(state.document) + set((s) => ({ + document: { + ...s.document, + children: updateNodeInTree(s.document.children, nodeId, { + reusable: true, + } as Partial), + }, + isDirty: true, + })) + }, + + detachComponent: (nodeId) => { + const state = get() + const node = findNodeInTree(state.document.children, nodeId) + if (!node) return + + // Case 1: Detach a reusable component (remove reusable flag) + if ('reusable' in node && node.reusable) { + useHistoryStore.getState().pushState(state.document) + set((s) => ({ + document: { + ...s.document, + children: updateNodeInTree(s.document.children, nodeId, { + reusable: undefined, + } as Partial), + }, + isDirty: true, + })) + return nodeId + } + + // Case 2: Detach an instance (RefNode → independent node tree) + if (node.type === 'ref') { + const component = findNodeInTree(state.document.children, node.ref) + if (!component) return + + useHistoryStore.getState().pushState(state.document) + + // Apply overrides to a copy of the component before cloning IDs + const source = structuredClone(component) + // Apply top-level visual overrides (fill, stroke, etc.) + const topOverrides = node.descendants?.[node.ref] + if (topOverrides) { + Object.assign(source, topOverrides) + } + // Apply child-level overrides + if (node.descendants && 'children' in source && source.children) { + source.children = source.children.map((child: PenNode) => { + const override = node.descendants?.[child.id] + return override ? ({ ...child, ...override } as PenNode) : child + }) + } + + // Clone with new IDs + const cloneWithNewIds = (n: PenNode): PenNode => { + const cloned = { ...n, id: nanoid() } as PenNode + if ('children' in cloned && cloned.children) { + cloned.children = cloned.children.map(cloneWithNewIds) + } + return cloned + } + const detached = cloneWithNewIds(source) + // Apply all direct instance properties (position, size, meta) + const detachedRecord = detached as unknown as Record + for (const [key, val] of Object.entries(node)) { + if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children' || key === 'id') continue + if (val !== undefined) { + detachedRecord[key] = val + } + } + if (!detached.name) detached.name = source.name + delete detachedRecord.reusable + + // Replace the RefNode with the detached tree + const parent = findParentInTree(state.document.children, nodeId) + const parentId = parent ? parent.id : null + const siblings = parent + ? ('children' in parent ? parent.children ?? [] : []) + : state.document.children + const idx = siblings.findIndex((n) => n.id === nodeId) + + let newChildren = removeNodeFromTree(state.document.children, nodeId) + newChildren = insertNodeInTree( + newChildren, + parentId, + detached, + idx >= 0 ? idx : undefined, + ) + + set({ + document: { ...state.document, children: newChildren }, + isDirty: true, + }) + return detached.id + } + }, + // --- Variable management --- setVariable: (name, definition) => { diff --git a/src/utils/node-clone.ts b/src/utils/node-clone.ts index 2f71169f5..b5792d16d 100644 --- a/src/utils/node-clone.ts +++ b/src/utils/node-clone.ts @@ -14,6 +14,10 @@ export function cloneNodesWithNewIds( ): PenNode[] { return structuredClone(nodes).map((node) => { const withNewId = reassignIds(node) + // Cloned nodes should not retain reusable component status + if ('reusable' in withNewId) { + delete (withNewId as unknown as Record).reusable + } if (offset !== 0) { withNewId.x = (withNewId.x ?? 0) + offset withNewId.y = (withNewId.y ?? 0) + offset