Fix inline rename and rotated hit testing

This commit is contained in:
Danila Poyarkov 2026-03-31 16:41:32 +03:00
parent 69784953fb
commit b826202b09
6 changed files with 80 additions and 5 deletions

View file

@ -1,10 +1,14 @@
import { computeAbsoluteBounds } from '../geometry'
import { computeLayout } from '../layout'
import type { LayoutMode, SceneNode } from '../scene-graph'
import type { LayoutMode, NodeType, SceneNode } from '../scene-graph'
import type { EditorContext } from './types'
export function createStructureActions(ctx: EditorContext) {
function defaultNodeName(type: NodeType): string {
return type.charAt(0) + type.slice(1).toLowerCase()
}
function isTopLevel(parentId: string | null): boolean {
return !parentId || parentId === ctx.graph.rootId || parentId === ctx.state.currentPageId
}
@ -376,7 +380,10 @@ export function createStructureActions(ctx: EditorContext) {
}
function renameNode(id: string, name: string) {
ctx.graph.updateNode(id, { name })
const node = ctx.graph.getNode(id)
if (!node) return
const trimmedName = name.trim()
ctx.graph.updateNode(id, { name: trimmedName || defaultNodeName(node.type) })
}
return {

View file

@ -1,3 +1,5 @@
import { degToRad } from './geometry'
import type { SceneGraph, SceneNode, NodeType } from './scene-graph'
const CONTAINER_TYPES = new Set<NodeType>([
@ -16,7 +18,21 @@ function hasVisibleFillOrStroke(node: SceneNode): boolean {
}
function containsPoint(px: number, py: number, ax: number, ay: number, node: SceneNode): boolean {
return px >= ax && px <= ax + node.width && py >= ay && py <= ay + node.height
if (node.rotation === 0) {
return px >= ax && px <= ax + node.width && py >= ay && py <= ay + node.height
}
const cx = ax + node.width / 2
const cy = ay + node.height / 2
const dx = px - cx
const dy = py - cy
const rad = degToRad(-node.rotation)
const cos = Math.cos(rad)
const sin = Math.sin(rad)
const localX = dx * cos - dy * sin + node.width / 2
const localY = dx * sin + dy * cos + node.height / 2
return localX >= 0 && localX <= node.width && localY >= 0 && localY <= node.height
}
function hitTestOpaqueContainer(

View file

@ -102,7 +102,7 @@ function onLayerRightClick(e: MouseEvent) {
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="node.name"
@blur="rename.commit(node.id, $event.target as HTMLInputElement)"
@keydown="rename.onKeydown"
@keydown.stop="rename.onKeydown"
/>
</div>

View file

@ -72,7 +72,7 @@ function handlePageDblClick(
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="pg.name"
@blur="rename.commit(pg.id, $event.target as HTMLInputElement)"
@keydown="rename.onKeydown"
@keydown.stop="rename.onKeydown"
/>
</div>
<div

View file

@ -194,6 +194,31 @@ test('clicking outside rename input commits', async () => {
canvas.assertNoErrors()
})
test('clearing a layer name falls back to the default node name', async () => {
await canvas.drawRect(980, 600, 50, 50)
await canvas.waitForRender()
const row = layerRows().filter({ hasText: 'Rectangle' }).last()
const countBefore = await layerRows().count()
await row.dblclick()
const input = page.locator('[data-test-id="layers-item-input"]')
await expect(input).toBeVisible()
await input.clear()
await input.press('Enter')
await canvas.waitForRender()
const countAfter = await layerRows().count()
expect(countAfter).toBe(countBefore)
const names = await getLayerNames()
expect(names.filter((name) => name === 'Rectangle').length).toBeGreaterThan(0)
canvas.assertNoErrors()
})
test('double-click does not toggle tree expand', async () => {
const rowCountBefore = await layerRows().count()

View file

@ -257,6 +257,33 @@ describe('hitTest — frame with children', () => {
expect(hit).not.toBeNull()
expect(hit!.id).toBe(child.id)
})
test('rotated frame scope hit test finds children using rotated local bounds', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const frame = graph.createNode('FRAME', page, {
name: 'RotatedFrame',
x: 100,
y: 100,
width: 200,
height: 120,
rotation: 45
})
const child = graph.createNode('RECTANGLE', frame.id, {
name: 'InnerRect',
x: 60,
y: 30,
width: 80,
height: 40
})
const hitInside = graph.hitTest(100, 60, frame.id)
expect(hitInside).not.toBeNull()
expect(hitInside!.id).toBe(child.id)
const hitOutside = graph.hitTest(10, 10, frame.id)
expect(hitOutside).toBeNull()
})
})
describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => {