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:
parent
29ed07bcc4
commit
d8c968c04a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue