2026-10-05 12:39:04 +00:00
|
|
|
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.
|
|
|
|
|
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
const FILL = [
|
|
|
|
|
{ type: 'SOLID' as const, color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }
|
|
|
|
|
]
|
2026-10-05 12:39:04 +00:00
|
|
|
|
|
|
|
|
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 })
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
const inSection = add('FRAME', 'In section', section.id, {
|
|
|
|
|
x: 50,
|
|
|
|
|
y: 50,
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 200
|
|
|
|
|
})
|
2026-10-05 12:39:04 +00:00
|
|
|
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 })
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
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 })
|
2026-10-05 12:39:04 +00:00
|
|
|
return {
|
|
|
|
|
graph,
|
|
|
|
|
page,
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
nodes: {
|
|
|
|
|
top,
|
|
|
|
|
nested,
|
|
|
|
|
leaf,
|
|
|
|
|
deeper,
|
|
|
|
|
deepLeaf,
|
|
|
|
|
direct,
|
|
|
|
|
group,
|
|
|
|
|
g1,
|
|
|
|
|
g2,
|
|
|
|
|
empty,
|
|
|
|
|
inSection,
|
|
|
|
|
sectionLeaf,
|
|
|
|
|
component,
|
|
|
|
|
card,
|
|
|
|
|
cardTitle,
|
|
|
|
|
cardBody
|
|
|
|
|
}
|
2026-10-05 12:39:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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')
|
|
|
|
|
})
|
|
|
|
|
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-05 12:39:04 +00:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-05 12:39:04 +00:00
|
|
|
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()
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
// 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()
|
2026-10-05 12:39:04 +00:00
|
|
|
expect(graph.isOpenContainer(nodes.top.id)).toBe(true)
|
|
|
|
|
expect(graph.isOpenContainer(nodes.empty.id)).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* 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.
2026-10-07 11:29:52 +00:00
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
})
|