* fix: select top-level auto layout frames by their empty area Checked against Figma desktop 126 with real pointer input. The gaps and padding of a top-level frame with auto layout (any direction or grid, filled or not, on the page or in a section) select, highlight, and drag the frame, while clicks on its layers still select those layers. A plain top-level frame's empty area stays background, as #900 recorded; its oracle scenarios only used frames without auto layout. Auto layout frames are hit by their bounds even without a fill, and a press on such a frame's empty area no longer starts a marquee. * feat(canvas): outline auto layout children with dotted lines, as in Figma Hovering a horizontal or vertical auto layout frame outlines its visible direct children, and a single selected layer dots the border of its auto layout parent, in the container's outline color. Grid and plain frames, grandchildren, and multi-selections show none. Line weight and dash were measured from Figma 126 screenshots: 1 px with 1.75 px dashes and 1.25 px gaps in screen space. The editor gains a transforming flag that canvas input sets while layers move, resize, or rotate; the outlines and the padding and gap markers of a selected auto layout frame hide while it is set, as Figma's do. The children outline a selected auto layout frame drew under the pointer now uses the same stroke, and the dash effect is freed after each draw. * fix(canvas): select unfilled frames by their empty area only on a click Live Figma 126, with real pointer input on a top-level card holding an unfilled auto layout row and an unfilled plain frame, with and without auto layout on the card: a click on either frame's empty area selects that frame, while a ⌘-click looks through it to the card. Only top-level auto layout frames now own their empty area in hit testing, so a deep hit no longer stops at a nested unfilled auto layout frame. A click that ends on an open container selects the frame child under the point by its bounds, filled or not, which plain frames did not get before; boards that open by themselves keep their empty area as background. * fix(canvas): select an empty unfilled frame on the page by its bounds Live Figma 126: a click inside an empty frame without a fill selects it, on the page and in a section. The section case already did after the previous commit; on the page the click found nothing, since an unfilled frame without children is not hit. A click that hits nothing now looks for a frame under the point among the scope's children, which skips boards that hold layers, as their empty area stays background. * refactor(scene-graph): read the containers the selection opens in their own helper * fix(canvas): open a selected container, so repeated clicks reach deeper layers In Figma, clicking again at the same point inside a selected frame, component, or instance selects the layer under the pointer, so repeated clicks walk down to a text in a nested component such as shadcn's calendar. The click hit test opened only the ancestors of the selection, so a selected nested container stopped the walk at itself and only a double-click went deeper. A selected container with layers now opens like its ancestors; groups and booleans stay closed, as in Figma. Pressing inside the selection still drags it, and its empty area keeps it selected.
264 lines
9.4 KiB
TypeScript
264 lines
9.4 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 })
|
|
const card = add('FRAME', 'Card', page, {
|
|
x: 1100,
|
|
width: 300,
|
|
height: 400,
|
|
layoutMode: 'VERTICAL'
|
|
})
|
|
const cardTitle = add('RECTANGLE', 'Card title', card.id, {
|
|
x: 20,
|
|
y: 20,
|
|
width: 260,
|
|
height: 40
|
|
})
|
|
const cardBody = add('FRAME', 'Card body', card.id, { x: 20, y: 80, width: 260, height: 200 })
|
|
add('RECTANGLE', 'Card body leaf', cardBody.id, { x: 20, y: 20, width: 60, height: 60 })
|
|
const grid = add('FRAME', 'Grid', page, {
|
|
x: 1500,
|
|
width: 200,
|
|
height: 200,
|
|
layoutMode: 'GRID',
|
|
fills: []
|
|
})
|
|
add('RECTANGLE', 'Cell', grid.id, { x: 10, y: 10, width: 60, height: 60 })
|
|
const sectionCard = add('FRAME', 'Section card', section.id, {
|
|
x: 300,
|
|
y: 20,
|
|
width: 150,
|
|
height: 150,
|
|
layoutMode: 'HORIZONTAL'
|
|
})
|
|
add('RECTANGLE', 'sc', sectionCard.id, { x: 20, y: 20, width: 40, height: 40 })
|
|
return {
|
|
graph,
|
|
page,
|
|
nodes: {
|
|
top,
|
|
nested,
|
|
leaf,
|
|
deeper,
|
|
deepLeaf,
|
|
direct,
|
|
group,
|
|
g1,
|
|
g2,
|
|
empty,
|
|
inSection,
|
|
sectionLeaf,
|
|
component,
|
|
card,
|
|
cardTitle,
|
|
cardBody
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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('selects a top-level auto-layout frame by its empty area', () => {
|
|
const { click, nodes } = setup()
|
|
expect(click(1120, 350)).toBe('Card')
|
|
expect(click(1110, 70)).toBe('Card')
|
|
expect(click(1550, 150)).toBe('Grid')
|
|
expect(click(1150, 30)).toBe('Card title')
|
|
expect(click(1130, 110)).toBe('Card body')
|
|
expect(click(1110, 70, [nodes.card.id])).toBe('Card')
|
|
expect(click(1150, 30, [nodes.card.id])).toBe('Card title')
|
|
expect(click(1120, 350, [nodes.cardTitle.id])).toBe('Card')
|
|
})
|
|
|
|
test('selects an auto-layout frame in a section by its empty area', () => {
|
|
const { click } = setup()
|
|
expect(click(420, 750)).toBe('Section card')
|
|
expect(click(330, 650)).toBe('sc')
|
|
})
|
|
|
|
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')
|
|
// 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('opens a selected container, so repeated clicks reach deeper layers', () => {
|
|
const { click, nodes } = setup()
|
|
expect(click(230, 230)).toBe('Nested')
|
|
expect(click(230, 230, [nodes.nested.id])).toBe('Deeper')
|
|
expect(click(230, 230, [nodes.deeper.id])).toBe('Deep leaf')
|
|
// Its empty area keeps it selected.
|
|
expect(click(300, 300, [nodes.deeper.id])).toBe('Deeper')
|
|
// A selected group stays closed.
|
|
expect(click(420, 70, [nodes.group.id])).toBe('Group')
|
|
})
|
|
|
|
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()
|
|
// Dragging the empty area of an auto-layout frame moves it instead of starting a marquee.
|
|
expect(graph.hitTestOpenContainer(1120, 350, page)).toBeNull()
|
|
expect(graph.hitTestOpenContainer(420, 750, page)).toBeNull()
|
|
expect(graph.isOpenContainer(nodes.top.id)).toBe(true)
|
|
expect(graph.isOpenContainer(nodes.empty.id)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('unfilled frames inside a top-level frame', () => {
|
|
/** A card, with or without auto layout, holding an unfilled auto layout row and plain box. */
|
|
function cards() {
|
|
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 })
|
|
for (const [x, layoutMode] of [
|
|
[0, 'VERTICAL'],
|
|
[400, 'NONE']
|
|
] as const) {
|
|
const card = add('FRAME', `${layoutMode} card`, page, {
|
|
x,
|
|
width: 320,
|
|
height: 360,
|
|
layoutMode
|
|
})
|
|
const row = add('FRAME', `${layoutMode} row`, card.id, {
|
|
x: 24,
|
|
y: 24,
|
|
width: 272,
|
|
height: 100,
|
|
fills: [],
|
|
layoutMode: 'HORIZONTAL'
|
|
})
|
|
add('RECTANGLE', 'Chip', row.id, { x: 30, y: 30, width: 40, height: 40 })
|
|
const box = add('FRAME', `${layoutMode} box`, card.id, {
|
|
x: 24,
|
|
y: 148,
|
|
width: 272,
|
|
height: 100,
|
|
fills: []
|
|
})
|
|
add('RECTANGLE', 'Dot', box.id, { x: 30, y: 30, width: 40, height: 40 })
|
|
}
|
|
const section = add('SECTION', 'Section', page, { y: 600, width: 500, height: 400 })
|
|
const board = add('FRAME', 'Board', section.id, {
|
|
x: 50,
|
|
y: 50,
|
|
width: 300,
|
|
height: 300,
|
|
fills: []
|
|
})
|
|
add('RECTANGLE', 'Board leaf', board.id, { x: 20, y: 20, width: 60, height: 60 })
|
|
add('FRAME', 'Empty in section', section.id, {
|
|
x: 380,
|
|
y: 50,
|
|
width: 100,
|
|
height: 100,
|
|
fills: []
|
|
})
|
|
add('FRAME', 'Empty on page', page, { x: 800, width: 100, height: 100, fills: [] })
|
|
const click = (x: number, y: number) =>
|
|
graph.hitTestSelectable(x, y, page, new Set())?.name ?? null
|
|
const deep = (x: number, y: number) => graph.hitTestDeep(x, y)?.name ?? null
|
|
return { click, deep }
|
|
}
|
|
|
|
test('a click selects them by their empty area, with or without auto layout', () => {
|
|
const { click } = cards()
|
|
expect(click(224, 74)).toBe('VERTICAL row')
|
|
expect(click(224, 198)).toBe('VERTICAL box')
|
|
expect(click(624, 74)).toBe('NONE row')
|
|
expect(click(624, 198)).toBe('NONE box')
|
|
})
|
|
|
|
test('a deep click looks through their empty area to the card', () => {
|
|
const { deep } = cards()
|
|
expect(deep(224, 74)).toBe('VERTICAL card')
|
|
expect(deep(224, 198)).toBe('VERTICAL card')
|
|
expect(deep(624, 74)).toBe('NONE card')
|
|
expect(deep(624, 198)).toBe('NONE card')
|
|
})
|
|
|
|
test('the empty area of an unfilled board in a section stays background', () => {
|
|
const { click } = cards()
|
|
expect(click(250, 900)).toBeNull()
|
|
})
|
|
|
|
test('a click selects an empty unfilled frame on the page or in a section', () => {
|
|
const { click } = cards()
|
|
expect(click(850, 50)).toBe('Empty on page')
|
|
expect(click(430, 700)).toBe('Empty in section')
|
|
})
|
|
})
|