fix(scene-graph): keep a reparented node where it is drawn (#760)

A reparented node keeps its drawn position, rotation, and flips: translation-only parent chains shift x/y by the parents' origin difference, and any other chain decomposes the node's world matrix against the new parent through localTransformFromWorld() in coordinate.ts. Fixes #737.
This commit is contained in:
mrhard9090 2026-09-30 03:08:35 +03:00 committed by GitHub
parent 802091b051
commit 1b8db8985b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 161 additions and 9 deletions

View file

@ -22,6 +22,7 @@
### Fixed
- Keep a layer where it is drawn when it moves into or out of a rotated or flipped parent, instead of shifting it and leaving it at its old angle (#737).
- Size auto-width text from `.pen` files to its content in CLI exports, instead of a 10000px placeholder that stretched hugging frames in HTML and Storybook output, and keep narrow widths a `.pen` file sets explicitly instead of widening multi-character text.
- Keep grid layouts, rotation, inner shadows, every shadow of a layer, layer and background blur, flex grow, right-to-left direction, and sections in HTML export, which previously turned grids into columns and dropped the rest.
- Show what to update instead of a blank window when the browser or system WebView is too old, naming the detected macOS, Safari, Chrome, Edge, Firefox, WebKitGTK, or WebView2 version and linking a prefilled bug report, and explain a failed start the same way (#744).

View file

@ -224,3 +224,31 @@ export function getWorldHandles(
w: { x: pts[14], y: pts[15] }
}
}
type LocalTransform = Pick<SceneNode, 'x' | 'y' | 'rotation' | 'flipX' | 'flipY'>
/**
* Local transform that draws `node` with the given world matrix once it sits under a parent
* whose world matrix is `parentWorld`. Keeps the node's own `flipX` when the matrix allows it.
*/
export function localTransformFromWorld(
node: SceneNode,
world: Mat3,
parentWorld: Mat3
): LocalTransform | null {
const parentInverse = Matrix.invert(parentWorld)
if (!parentInverse) return null
const local = Matrix.multiply(parentInverse, world)
const [a, b, , c, d] = local
const sx = node.flipX ? -1 : 1
const sy = a * d - b * c < 0 ? -sx : sx
let rotation = (Math.atan2(sy * c, sx * a) * 180) / Math.PI
if (Math.abs(rotation) < 1e-9) rotation = 0
const transform = { rotation, flipX: sx < 0, flipY: sy < 0 }
const origin = getNodeLocalMatrix({ ...node, ...transform }, { x: 0, y: 0 })
return { ...transform, x: local[2] - origin[2], y: local[5] - origin[5] }
}
export function isTranslationOnly(matrix: Mat3): boolean {
return matrix[0] === 1 && matrix[1] === 0 && matrix[3] === 0 && matrix[4] === 1
}

View file

@ -41,6 +41,7 @@ import { cloneNodeProps } from './copy'
import { bindNodeEvents } from './events'
import * as HitTest from './hit-test'
import * as Instances from './instances'
import Matrix, { type Mat3 } from './matrix'
import { CONTAINER_TYPES, createDefaultNode } from './node-defaults'
import { updateNodePreview, type NodePreviewObserver } from './preview'
import { styleDetachmentChanges } from './shared-styles'
@ -54,7 +55,13 @@ export * from './types'
import type { Emitter } from 'nanoevents'
import { getAbsolutePosition } from './coordinate'
import {
getAbsolutePosition,
getNodeLocalMatrix,
getWorldMatrix,
isTranslationOnly,
localTransformFromWorld
} from './coordinate'
import type { Color, Rect, Vector } from './primitives'
import type {
DocumentColorSpace,
@ -507,12 +514,8 @@ export class SceneGraph {
const oldParentId = node.parentId
this.absPosCache.clear()
const absPos = this.getAbsolutePosition(nodeId)
const newParentNode = this.nodes.get(newParentId)
const newParentAbs =
newParentId === this.rootId || newParentNode?.type === 'CANVAS'
? { x: 0, y: 0 }
: this.getAbsolutePosition(newParentId)
const oldParentWorld = this.parentWorldMatrix(oldParent)
const newParentWorld = this.parentWorldMatrix(newParent)
if (oldParent) {
oldParent.childIds = oldParent.childIds.filter((cid) => cid !== nodeId)
@ -521,12 +524,23 @@ export class SceneGraph {
node.parentId = newParentId
newParent.childIds.push(nodeId)
node.x = absPos.x - newParentAbs.x
node.y = absPos.y - newParentAbs.y
if (isTranslationOnly(oldParentWorld) && isTranslationOnly(newParentWorld)) {
node.x += oldParentWorld[2] - newParentWorld[2]
node.y += oldParentWorld[5] - newParentWorld[5]
} else {
const world = Matrix.multiply(oldParentWorld, getNodeLocalMatrix(node))
const local = localTransformFromWorld(node, world, newParentWorld)
if (local) Object.assign(node, local)
}
this.emitter.emit('node:reparented', nodeId, oldParentId, newParentId)
}
private parentWorldMatrix(parent: SceneNode | undefined): Mat3 {
if (!parent || parent.id === this.rootId) return Matrix.identity()
return getWorldMatrix(parent, this)
}
reorderChild(nodeId: string, parentId: string, insertIndex: number): void {
const node = this.nodes.get(nodeId)
if (!node) return

View file

@ -0,0 +1,109 @@
import { describe, expect, test } from 'bun:test'
import { getWorldMatrix, SceneGraph, TransformMatrix } from '@open-pencil/scene-graph'
function setup() {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
return { graph, pageId }
}
function worldCorners(graph: SceneGraph, id: string) {
const node = graph.getNode(id)
if (!node) throw new Error(`missing node ${id}`)
const w = node.width
const h = node.height
return TransformMatrix.mapPoints(getWorldMatrix(node, graph), [0, 0, w, 0, w, h, 0, h])
}
function expectSameCorners(actual: number[], expected: number[]) {
expect(actual).toHaveLength(expected.length)
actual.forEach((value, index) => expect(value).toBeCloseTo(expected[index], 6))
}
describe('reparentNode keeps the node where it is drawn', () => {
test('moving into a rotated frame keeps the corners and counter-rotates the node', () => {
const { graph, pageId } = setup()
const frame = graph.createNode('FRAME', pageId, {
x: 100,
y: 50,
width: 240,
height: 240,
rotation: 30
})
const rect = graph.createNode('RECTANGLE', pageId, { x: 500, y: 400, width: 40, height: 20 })
const before = worldCorners(graph, rect.id)
graph.reparentNode(rect.id, frame.id)
expectSameCorners(worldCorners(graph, rect.id), before)
expect(graph.getNode(rect.id)?.rotation).toBeCloseTo(-30, 6)
})
test('moving into a rotated frame and back out restores the original transform', () => {
const { graph, pageId } = setup()
const frame = graph.createNode('FRAME', pageId, {
x: 100,
y: 50,
width: 240,
height: 240,
rotation: 30
})
const rect = graph.createNode('RECTANGLE', pageId, { x: 500, y: 400, width: 40, height: 20 })
graph.reparentNode(rect.id, frame.id)
graph.reparentNode(rect.id, pageId)
const node = graph.getNode(rect.id)
expect(node?.x).toBeCloseTo(500, 6)
expect(node?.y).toBeCloseTo(400, 6)
expect(node?.rotation).toBeCloseTo(0, 6)
})
test('moving into a flipped frame keeps the corners', () => {
const { graph, pageId } = setup()
const frame = graph.createNode('FRAME', pageId, {
x: 100,
y: 100,
width: 300,
height: 200,
flipX: true
})
const rect = graph.createNode('RECTANGLE', pageId, {
x: 150,
y: 120,
width: 40,
height: 20,
rotation: 15
})
const before = worldCorners(graph, rect.id)
graph.reparentNode(rect.id, frame.id)
expectSameCorners(worldCorners(graph, rect.id), before)
const node = graph.getNode(rect.id)
expect(node?.flipX).not.toBe(node?.flipY)
})
test('a rotated node moved between unrotated frames keeps its corners and rotation', () => {
const { graph, pageId } = setup()
const left = graph.createNode('FRAME', pageId, { x: 0, y: 0, width: 200, height: 200 })
const right = graph.createNode('FRAME', pageId, { x: 300, y: 40, width: 200, height: 200 })
const rect = graph.createNode('RECTANGLE', left.id, {
x: 20,
y: 30,
width: 60,
height: 20,
rotation: 45
})
const before = worldCorners(graph, rect.id)
graph.reparentNode(rect.id, right.id)
expectSameCorners(worldCorners(graph, rect.id), before)
const node = graph.getNode(rect.id)
expect(node?.x).toBe(-280)
expect(node?.y).toBe(-10)
expect(node?.rotation).toBe(45)
})
})