diff --git a/CHANGELOG.md b/CHANGELOG.md index bdfb82899..6f52cc13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/editor/components/instances.ts b/packages/core/src/editor/components/instances.ts index fba9f583c..1156aa788 100644 --- a/packages/core/src/editor/components/instances.ts +++ b/packages/core/src/editor/components/instances.ts @@ -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) diff --git a/tests/engine/editor/undo/create-instance.test.ts b/tests/engine/editor/undo/create-instance.test.ts index 14358d569..1da3d7397 100644 --- a/tests/engine/editor/undo/create-instance.test.ts +++ b/tests/engine/editor/undo/create-instance.test.ts @@ -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