openpencil/packages/scene-graph/tests/basic/selectable-hit.test.ts
Danila Poyarkov 24db186ac8
feat: select layers at Figma's click depth (#900)
* feat: select layers at Figma's click depth

Checked against Figma desktop 126 with the same pointer input in both
editors. A click walks from the page down to the deepest layer under the
cursor and stops at the first layer that is not open: top-level frames
(on the page or in a section) and sections that hold layers, component
sets, and every ancestor of the selection are open. So a click inside a
top-level frame selects its direct child, the empty part of a top-level
frame or section selects nothing, and a selected layer's siblings and
cousins are one click away. Double-click goes one level deeper and
Cmd/Ctrl-click reaches the deepest layer.

A marquee started inside a top-level frame or section selects that
container's layers; from the page, a frame or section holding layers is
selected only when fully enclosed.

* fix(vue): compare marquee hits by canvas bounds

A marquee scoped to a rotated frame mapped only two corners into its space, and page-level enclosure used a frame's unrotated rectangle. Both now compare each layer's axis-aligned canvas bounds with the marquee, so rotated frames and their children are selected by what is drawn.
2026-10-05 12:39:04 +00:00

93 lines
4.2 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { SceneGraph, type NodeType, type SceneNode } from '@open-pencil/scene-graph'
import { pageId } from './helpers'
// Each expectation matches a click observed in Figma desktop 126 with real pointer input.
const FILL = [{ type: 'SOLID' as const, color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
function scene() {
const graph = new SceneGraph()
const page = pageId(graph)
const add = (type: NodeType, name: string, parent: string, props: Partial<SceneNode>) =>
graph.createNode(type, parent, { name, fills: FILL, ...props })
const top = add('FRAME', 'Top', page, { width: 500, height: 500 })
const nested = add('FRAME', 'Nested', top.id, { x: 50, y: 50, width: 300, height: 300 })
const leaf = add('RECTANGLE', 'Leaf', nested.id, { x: 50, y: 50, width: 60, height: 60 })
const deeper = add('FRAME', 'Deeper', nested.id, { x: 150, y: 150, width: 120, height: 120 })
const deepLeaf = add('RECTANGLE', 'Deep leaf', deeper.id, { x: 20, y: 20, width: 40, height: 40 })
const direct = add('RECTANGLE', 'Direct', top.id, { x: 400, y: 400, width: 60, height: 60 })
const group = add('GROUP', 'Group', top.id, { x: 400, y: 50, width: 80, height: 110, fills: [] })
const g1 = add('RECTANGLE', 'g1', group.id, { width: 40, height: 40 })
const g2 = add('RECTANGLE', 'g2', group.id, { x: 40, y: 70, width: 40, height: 40 })
const empty = add('FRAME', 'Empty', page, { x: 600, width: 100, height: 100 })
const section = add('SECTION', 'Section', page, { y: 600, width: 500, height: 300 })
const inSection = add('FRAME', 'In section', section.id, { x: 50, y: 50, width: 200, height: 200 })
const sectionLeaf = add('RECTANGLE', 'sl', inSection.id, { x: 20, y: 20, width: 60, height: 60 })
const component = add('COMPONENT', 'Component', page, { x: 800, width: 200, height: 200 })
add('RECTANGLE', 'cr', component.id, { x: 20, y: 20, width: 60, height: 60 })
return {
graph,
page,
nodes: { top, nested, leaf, deeper, deepLeaf, direct, group, g1, g2, empty, inSection, sectionLeaf, component }
}
}
/** A scene and a click on it, reporting the selected layer's name. */
function setup() {
const built = scene()
const click = (x: number, y: number, selected: string[] = []) =>
built.graph.hitTestSelectable(x, y, built.page, new Set(selected))?.name ?? null
return { ...built, click }
}
describe('hitTestSelectable', () => {
test('selects the direct child of a top-level frame, however deep the click', () => {
const { click } = setup()
expect(click(130, 130)).toBe('Nested')
expect(click(250, 250)).toBe('Nested')
expect(click(430, 430)).toBe('Direct')
expect(click(420, 70)).toBe('Group')
})
test('selects nothing in the empty part of a top-level frame or section with layers', () => {
const { click } = setup()
expect(click(20, 450)).toBeNull()
expect(click(450, 850)).toBeNull()
expect(click(200, 700)).toBeNull()
})
test('selects empty top-level frames and components whole', () => {
const { click } = setup()
expect(click(650, 50)).toBe('Empty')
expect(click(850, 50)).toBe('Component')
})
test('treats a frame in a section as top-level', () => {
const { click } = setup()
expect(click(100, 700)).toBe('sl')
})
test('opens the ancestors of the selection', () => {
const { click, nodes } = setup()
expect(click(250, 250, [nodes.leaf.id])).toBe('Deeper')
expect(click(130, 130, [nodes.deepLeaf.id])).toBe('Leaf')
expect(click(460, 140, [nodes.g1.id])).toBe('g2')
expect(click(230, 230, [nodes.deeper.id])).toBe('Deeper')
// A container opened by the selection is selected where nothing inside it is hit.
expect(click(330, 100, [nodes.leaf.id])).toBe('Nested')
expect(click(20, 450, [nodes.nested.id])).toBeNull()
})
test('finds the open container a marquee starts in', () => {
const { graph, page, nodes } = setup()
expect(graph.hitTestOpenContainer(20, 450, page)?.name).toBe('Top')
expect(graph.hitTestOpenContainer(200, 700, page)?.name).toBe('In section')
expect(graph.hitTestOpenContainer(130, 130, page)).toBeNull()
expect(graph.isOpenContainer(nodes.top.id)).toBe(true)
expect(graph.isOpenContainer(nodes.empty.id)).toBe(false)
})
})