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:
parent
5e53b85efa
commit
a4d6e11aad
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue