feat(store,canvas): add reusable component and instance system

add makeReusable/detachComponent store methods, RefNode resolution in
canvas sync with component/instance selection borders, virtual child ID
handling in selection context, purple/instance-colored frame labels,
and collision avoidance when duplicating nodes
This commit is contained in:
Fini 2026-02-21 04:46:56 +08:00
parent 5e53b85efa
commit a4d6e11aad
6 changed files with 410 additions and 9 deletions

View file

@ -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'

View file

@ -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) {

View file

@ -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<string, Partial<PenNode>>,
): 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<string>(),
): 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<string, Partial<PenNode>> }
// 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<string, unknown> = { ...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<string>()
const instanceIds = new Set<string>()
;(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({

View file

@ -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<string>) {
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<string>) {
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<string>()
collectReusableIds(store.document.children, reusableIds)
// Instance (RefNode) IDs
const instanceIds = new Set<string>()
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)
}

View file

@ -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<DocumentStoreState>(
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<DocumentStoreState>(
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<DocumentStoreState>(
: 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<DocumentStoreState>(
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<PenNode>),
},
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<PenNode>),
},
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<string, unknown>
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) => {

View file

@ -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<string, unknown>).reusable
}
if (offset !== 0) {
withNewId.x = (withNewId.x ?? 0) + offset
withNewId.y = (withNewId.y ?? 0) + offset