fix(editor): place nested component instances in world space (#524)

- Derive default instance placement from transformed component bounds
- Convert the target point into the destination parent coordinate space
- Preserve explicit drag-and-drop coordinates and undo snapshots
This commit is contained in:
Danila Poyarkov 2026-08-14 21:24:20 +03:00 committed by GitHub
parent 29ed07bcc4
commit d8c968c04a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 170 additions and 3 deletions

View file

@ -48,6 +48,7 @@
### Fixed
- Place editor-created instances beside nested source components in world space, including transformed source and destination parents.
- Harden collaboration node synchronization against malformed remote source metadata and geometry while excluding derived text-renderer caches.
- Transfer native `.fig` exports over binary Tauri IPC instead of JSON byte arrays, preventing large desktop saves from being truncated or exhausting WebView memory. (#484)
- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior.

View file

@ -1,4 +1,6 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import type { SceneNode, Vector } from '@open-pencil/scene-graph'
import { getAxisAlignedWorldBounds, getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import type { EditorContext } from '#core/editor/types'
@ -9,6 +11,49 @@ function createInstanceSnapshot(instance: SceneNode): InstanceCreateSnapshot {
return snapshot
}
type DefaultInstancePlacement = {
local: Vector
world: Vector
}
function defaultInstancePlacement(
ctx: EditorContext,
component: SceneNode,
parentId: string
): DefaultInstancePlacement {
const bounds = getAxisAlignedWorldBounds(component, ctx.graph)
const world = { x: bounds.x + bounds.width + 40, y: bounds.y }
const parent = ctx.graph.getNode(parentId)
if (!parent) return { local: world, world }
const inverse = Matrix.invert(getWorldMatrix(parent, ctx.graph))
return { local: inverse ? Matrix.mapPoint(inverse, world) : world, world }
}
function alignInstanceWorldBounds(
ctx: EditorContext,
instance: SceneNode,
parentId: string,
target: Vector
): void {
const bounds = getAxisAlignedWorldBounds(instance, ctx.graph)
const worldDelta = { x: target.x - bounds.x, y: target.y - bounds.y }
const parent = ctx.graph.getNode(parentId)
const inverse = parent ? Matrix.invert(getWorldMatrix(parent, ctx.graph)) : null
if (!inverse) {
ctx.graph.updateNode(instance.id, {
x: instance.x + worldDelta.x,
y: instance.y + worldDelta.y
})
return
}
const origin = Matrix.mapPoint(inverse, { x: 0, y: 0 })
const delta = Matrix.mapPoint(inverse, worldDelta)
ctx.graph.updateNode(instance.id, {
x: instance.x + delta.x - origin.x,
y: instance.y + delta.y - origin.y
})
}
export function createComponentInstanceActions(ctx: EditorContext) {
function createInstanceFromComponent(
componentId: string,
@ -20,11 +65,15 @@ export function createComponentInstanceActions(ctx: EditorContext) {
if (component?.type !== 'COMPONENT') return null
const previousSelection = new Set(ctx.state.selectedIds)
const defaultPlacement = defaultInstancePlacement(ctx, component, parentId)
const instance = ctx.graph.createInstance(componentId, parentId, {
x: x ?? component.x + component.width + 40,
y: y ?? component.y
x: x ?? defaultPlacement.local.x,
y: y ?? defaultPlacement.local.y
})
if (!instance) return null
if (x === undefined && y === undefined) {
alignInstanceWorldBounds(ctx, instance, parentId, defaultPlacement.world)
}
const instanceId = instance.id
const snapshot = createInstanceSnapshot(instance)

View file

@ -1,10 +1,127 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { getAxisAlignedWorldBounds } from '@open-pencil/scene-graph/coordinate'
import { expectDefined } from '#tests/helpers/assert'
describe('create instance undo/redo', () => {
test('places a nested component beside its world bounds on the page', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const frame = editor.graph.createNode('FRAME', pageId, {
x: 500,
y: 700,
width: 300,
height: 200
})
const component = editor.graph.createNode('COMPONENT', frame.id, {
x: 40,
y: 60,
width: 100,
height: 40
})
const instanceId = expectDefined(editor.createInstanceFromComponent(component.id), 'instanceId')
const instance = expectDefined(editor.graph.getNode(instanceId), 'instance')
expect(instance.parentId).toBe(pageId)
expect({ x: instance.x, y: instance.y }).toEqual({ x: 680, y: 760 })
})
test('uses transformed world bounds for default placement', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const frame = editor.graph.createNode('FRAME', pageId, {
x: 300,
y: 200,
width: 240,
height: 180,
rotation: 30
})
const component = editor.graph.createNode('COMPONENT', frame.id, {
x: 25,
y: 35,
width: 90,
height: 50,
rotation: -15
})
const bounds = getAxisAlignedWorldBounds(component, editor.graph)
const instanceId = expectDefined(editor.createInstanceFromComponent(component.id), 'instanceId')
const instance = expectDefined(editor.graph.getNode(instanceId), 'instance')
expect(instance.x).toBeCloseTo(bounds.x + bounds.width + 40)
expect(instance.y).toBeCloseTo(bounds.y)
})
test('converts default world placement into a transformed destination parent', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const sourceFrame = editor.graph.createNode('FRAME', pageId, {
x: 100,
y: 80,
width: 300,
height: 200
})
const component = editor.graph.createNode('COMPONENT', sourceFrame.id, {
x: 30,
y: 40,
width: 80,
height: 30
})
const destination = editor.graph.createNode('FRAME', pageId, {
x: 500,
y: 300,
width: 240,
height: 180,
rotation: 90
})
const sourceBounds = getAxisAlignedWorldBounds(component, editor.graph)
const instanceId = expectDefined(
editor.createInstanceFromComponent(component.id, undefined, undefined, destination.id),
'instanceId'
)
const instance = expectDefined(editor.graph.getNode(instanceId), 'instance')
const instanceBounds = getAxisAlignedWorldBounds(instance, editor.graph)
expect(instance.parentId).toBe(destination.id)
expect(instanceBounds.x).toBeCloseTo(sourceBounds.x + sourceBounds.width + 40)
expect(instanceBounds.y).toBeCloseTo(sourceBounds.y)
editor.undo.undo()
editor.undo.redo()
const redone = expectDefined(editor.graph.getNode(instanceId), 'redone instance')
const redoneBounds = getAxisAlignedWorldBounds(redone, editor.graph)
expect(redoneBounds.x).toBeCloseTo(sourceBounds.x + sourceBounds.width + 40)
expect(redoneBounds.y).toBeCloseTo(sourceBounds.y)
})
test('preserves explicit placement coordinates for drag and drop callers', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const frame = editor.graph.createNode('FRAME', pageId, { x: 500, y: 700 })
const component = editor.graph.createNode('COMPONENT', frame.id, {
x: 40,
y: 60,
width: 100,
height: 40
})
const instanceId = expectDefined(
editor.createInstanceFromComponent(component.id, 25, 35, frame.id),
'instanceId'
)
const instance = expectDefined(editor.graph.getNode(instanceId), 'instance')
expect({ x: instance.x, y: instance.y, parentId: instance.parentId }).toEqual({
x: 25,
y: 35,
parentId: frame.id
})
})
test('undo restores the previous selection instead of selecting the source component', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId