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.
This commit is contained in:
Danila Poyarkov 2026-10-07 11:29:52 +00:00 committed by GitHub
parent 5b9533a230
commit 4faccd047d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 757 additions and 50 deletions

View file

@ -73,6 +73,7 @@
- Create and inspect slots from scripts run through `eval` and from AI and MCP tools, as in Figma: `component.createSlot()` adds a slot frame and its `SLOT` property, slot frames read `type: 'SLOT'` with `resetSlot()` and `limitViolations`, `appendChild`, `insertChild`, and `remove()` work inside slots and refuse the rest of an instance, and `addComponentProperty` and `editComponentProperty` take a `description` and `slotSettings`.
- Control the running app from the CLI and MCP: `openpencil documents` opens, creates, saves, closes, and brings documents to the front, `openpencil undo` and `redo` step back through changes made through the CLI and MCP, refusing to revert edits made in the editor, and `openpencil settings get` and `set` read and change theme, language, animations, snapping, canvas rendering, recovery, chat, and design check settings. MCP clients get the same through `activate_document`, `undo`, `redo`, `get_settings`, and `update_settings`; credentials, models, MCP connections, storage, and tool access stay out of reach. `openpencil tool list`, `describe`, and `call` run any MCP tool from the shell, against the running app or headlessly on a file.
- Be in several shared rooms at once, each in its own tab and syncing in the background. Join a room from Home with **Join room…**, and from a browser on a computer open it in the desktop app with **Open in desktop app** (`openpencil://join?room=<id>`).
- See how auto layout arranges layers, as in Figma: hovering a horizontal or vertical auto layout frame outlines its visible direct children with dotted lines, and selecting a single layer dots the border of its auto layout parent, in purple for components and instances. The outlines, and the padding and gap markers of a selected frame, hide while layers move, resize, or rotate.
- Join a shared room right away under a generated name such as *Teal Fox*, and set the one name every room shows in Settings or the share panel.
### Changed
@ -96,7 +97,7 @@
- `createInstance()` and `detachInstance()` in the plugin API return the node type they built, so reading `componentProperties`, `setProperties`, or `isExposedInstance` from a new instance no longer needs a cast; `FigmaInstanceNode` is exported alongside the other node types.
- Name the tool and list every invalid argument with where it is when AI chat, the CLI, or WebMCP calls a tool wrongly, as MCP clients already saw, as in `Invalid arguments for create_shape:` followed by `× Invalid type: Expected ("FRAME" | …) but received "CIRCLE"` and `→ at type`. Design JSX component properties and gradient stops report their problems the same way. Previously only the first problem was named, without the tool or the argument.
- HTML and Tailwind JSX export write variable-bound colors, spacing, radii, borders, sizes, type sizes, and opacity as the tokens they come from, such as `var(--color-primary)` or `bg-primary`, and put layers set to another mode in it with an attribute such as `data-theme="dark"`. Values CSS would not resolve as the canvas draws them stay literal, and standalone HTML includes the stylesheet for the tokens it uses.
- Clicking selects layers as in Figma: inside a top-level frame or section, a click selects the frame's direct child rather than the deepest layer, the empty part of a top-level frame or section that holds layers selects nothing, so every top-level frame shows its name above it, in the selection color while selected or hovered, and clicking or dragging the name selects or moves the frame; once a layer is selected, clicks reach its siblings and cousins. Double-click goes one level deeper and ⌘-click (Ctrl-click on Windows and Linux) selects the deepest layer. A marquee started inside a top-level frame or section selects that container's layers, and from the page it selects a frame or section that holds layers only when fully enclosed.
- Clicking selects layers as in Figma: inside a top-level frame or section, a click selects the frame's direct child rather than the deepest layer, the empty part of a top-level frame or section that holds layers selects nothing, while the gaps and padding of a top-level auto layout frame select, highlight, and drag it, and a frame inside a top-level frame, or an empty frame, is selected by its empty area even without a fill, which ⌘-click looks through, so every top-level frame shows its name above it, in the selection color while selected or hovered, and clicking or dragging the name selects or moves the frame; once a layer is selected, clicks reach its siblings and cousins. Double-click goes one level deeper, as does another click inside a selected frame, component, or instance, so repeated clicks at one point reach the layer under it; ⌘-click (Ctrl-click on Windows and Linux) selects the deepest layer. A marquee started inside a top-level frame or section selects that container's layers, and from the page it selects a frame or section that holds layers only when fully enclosed.
- Drag, draw, duplicate, and paste follow Figma. A dragged layer lands in the frame under the cursor and leaves its frame as soon as the cursor does; groups and locked frames never take a drop, a component set takes back only its own variants, and a layer inside a group stays in it unless dropped on another frame. Groups and booleans fit their children after a move or nudge, and a group whose last layer leaves is removed. Pressing inside a selected frame, group, or component set drags it rather than the layer under the cursor, and an auto layout child dragged out lands where you drop it. Hold Space while dragging to keep layers in their parents, Shift to move along one axis, or Control to drop into auto layout as an absolute-positioned layer. Locked layers stay put when the rest of the selection moves. Shapes drawn inside a frame, auto layout, or slot go into it, a frame drawn over layers takes in the ones it fully covers, and a section does so when moved or resized too. Wrapped auto layout inserts on the line under the cursor. Duplicates keep their names, and duplicating a main component with ⌘D or Alt-drag creates an instance. ⌘D duplicates in place, placing a lone top-level frame's copy to its right, and ⌘V keeps the copied position, centering an axis that doesn't fit the selected frame; **Paste here** still pastes at the cursor.
- Give popovers, menus, dropdowns, and pickers one look and motion: the same rounded panel with a thin outline that reads in light and dark themes, a short fade and grow from the side they open on, and an immediate close; with reduced motion they appear at once.
- Add and remove items the same way across the properties panel: a section's + adds an item and a row's − removes it, now including grid columns and rows and variant properties. The + of a component set adds Property 1 ready to rename instead of showing a form, and a variant is removed with Delete like any layer.

View file

@ -3,11 +3,11 @@ import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { strokeLayoutChildren } from '#core/canvas/overlays/layout-outlines'
import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
import {
AUTO_LAYOUT_HOVER_BLUE,
AUTO_LAYOUT_HOVER_BLUE_FILL,
AUTO_LAYOUT_HOVER_CHILD_DASH,
AUTO_LAYOUT_HOVER_MAGENTA,
AUTO_LAYOUT_HOVER_MAGENTA_FILL,
AUTO_LAYOUT_HOVER_STRIPE_GAP,
@ -253,27 +253,6 @@ function drawPaddingHover(
)
}
function drawChildrenHover(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph, node: SceneNode) {
r.auxStroke.setStrokeWidth(1)
r.auxStroke.setColor(r.selColor())
r.auxStroke.setPathEffect(
r.ck.PathEffect.MakeDash([AUTO_LAYOUT_HOVER_CHILD_DASH, AUTO_LAYOUT_HOVER_CHILD_DASH], 0)
)
for (const child of visibleLayoutChildren(node, graph)) {
const abs = graph.getAbsolutePosition(child.id)
canvas.drawRect(
r.ck.LTRBRect(
abs.x * r.zoom + r.panX,
abs.y * r.zoom + r.panY,
(abs.x + child.width) * r.zoom + r.panX,
(abs.y + child.height) * r.zoom + r.panY
),
r.auxStroke
)
}
r.auxStroke.setPathEffect(null)
}
export function drawAutoLayoutHover(
r: SkiaRenderer,
canvas: Canvas,
@ -284,7 +263,7 @@ export function drawAutoLayoutHover(
const node = graph.getNode(hover.nodeId)
if (!node || (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL')) return
if (hover.kind === 'children') drawChildrenHover(r, canvas, graph, node)
if (hover.kind === 'children') strokeLayoutChildren(r, canvas, graph, node)
if (hover.kind === 'spacing' || hover.kind === 'spacing-value') {
drawSpacingHover(r, canvas, graph, node, hover.kind === 'spacing-value')
}

View file

@ -0,0 +1,91 @@
import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
import { LAYOUT_OUTLINE_DASH, LAYOUT_OUTLINE_GAP } from '#core/constants'
import { createSceneGeometry, type RotationPreview } from '#core/geometry'
function isFlowLayout(node: SceneNode | undefined): node is SceneNode {
return node?.layoutMode === 'HORIZONTAL' || node?.layoutMode === 'VERTICAL'
}
function visibleChildren(graph: SceneGraph, container: SceneNode) {
return container.childIds
.map((id) => graph.getNode(id))
.filter((child): child is SceneNode => !!child && child.visible)
}
/** Strokes each node's box with the dotted auto layout outline, in the container's color. */
export function strokeLayoutOutlines(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
container: SceneNode,
nodes: readonly SceneNode[],
preview?: RotationPreview | null
): void {
if (nodes.length === 0) return
const geometry = createSceneGeometry(graph, preview)
// MakeDash allocates a WASM PathEffect the JS GC won't reclaim; free it after drawing.
const dash = r.ck.PathEffect.MakeDash(
[LAYOUT_OUTLINE_DASH / r.zoom, LAYOUT_OUTLINE_GAP / r.zoom],
0
)
r.auxStroke.setStrokeWidth(1 / r.zoom)
r.auxStroke.setColor(r.outlineColor(container))
r.auxStroke.setPathEffect(dash)
try {
for (const node of nodes) {
canvas.save()
canvas.concat(geometry.screenMatrix(node, r))
canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.auxStroke)
canvas.restore()
}
} finally {
r.auxStroke.setPathEffect(null)
dash.delete()
}
}
/** Dotted outlines around the visible direct children of an auto layout frame. */
export function strokeLayoutChildren(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
container: SceneNode,
preview?: RotationPreview | null
): void {
strokeLayoutOutlines(r, canvas, graph, container, visibleChildren(graph, container), preview)
}
/**
* Dotted outlines that show how an auto layout frame arranges its children, as in Figma: around
* the visible direct children of a hovered flow layout frame, and around the flow layout parent of
* a single selected layer. Grid and plain frames show none, and none show while layers transform.
*/
export function drawLayoutOutlines(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: ReadonlySet<string>,
hoveredNodeId: string | null | undefined,
overlays: RenderOverlays
): void {
if (overlays.transforming || overlays.marquee) return
const preview = overlays.rotationPreview
if (selectedIds.size === 1) {
const [selectedId] = selectedIds
const parentId = graph.getNode(selectedId)?.parentId
const parent = parentId ? graph.getNode(parentId) : undefined
if (isFlowLayout(parent) && parent.id !== hoveredNodeId) {
strokeLayoutOutlines(r, canvas, graph, parent, [parent], preview)
}
}
const hovered = hoveredNodeId ? graph.getNode(hoveredNodeId) : undefined
if (isFlowLayout(hovered) && !selectedIds.has(hovered.id) && selectedIds.size <= 1) {
strokeLayoutChildren(r, canvas, graph, hovered, preview)
}
}

View file

@ -5,6 +5,7 @@ import type { SceneGraph } from '@open-pencil/scene-graph'
import { drawGuides } from '#core/canvas/guides/draw'
import { drawIssueHighlight, drawIssueMarkers } from '#core/canvas/issues/draw'
import { layoutIssueMarkers } from '#core/canvas/issues/layout'
import { drawLayoutOutlines } from '#core/canvas/overlays/layout-outlines'
import { drawMeasurementSegment } from '#core/canvas/overlays/measurement'
import { drawCodeFocus } from '#core/canvas/overlays/selection'
import { drawSlotOutlines } from '#core/canvas/overlays/slots'
@ -98,6 +99,7 @@ export function drawOverlayPass(
drawCodeFocus(r, canvas, graph, overlays.codeFocusNodeId, overlays.rotationPreview)
if (!measuring)
drawSlotOutlines(r, canvas, graph, selectedIds, hoveredNodeId, overlays.rotationPreview)
if (!measuring) drawLayoutOutlines(r, canvas, graph, selectedIds, hoveredNodeId, overlays)
r.drawHoverHighlight(canvas, graph, hoveredNodeId, overlays.rotationPreview)
drawIssueHighlight(r, canvas, graph, overlays.designIssues?.highlight, overlays.rotationPreview)
r.drawEnteredContainer(canvas, graph, overlays.enteredContainerId, overlays.rotationPreview)
@ -112,7 +114,9 @@ export function drawOverlayPass(
r.drawSnapGuides(canvas, overlays.snapGuides)
r.drawMarquee(canvas, overlays.marquee)
r.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator)
if (!measuring) r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover)
if (!measuring && !overlays.transforming) {
r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover)
}
r.drawNodeEditOverlay(canvas, graph, overlays.nodeEditState)
r.drawPenOverlay(canvas, overlays.penState)
updateIssueMarkers(r, graph, overlays)

View file

@ -65,6 +65,7 @@ export function renderFromEditorState(
playing: previewing,
playIslands: previewing ? new Set(playIslandRoots(graph, state.currentPageId)) : undefined,
hoveredNodeId: previewing ? null : state.hoveredNodeId,
transforming: state.transforming,
measurementMode: state.measurementMode,
enteredContainerId: state.enteredContainerId,
editingTextId: state.editingTextId,

View file

@ -38,6 +38,7 @@ export interface RenderOverlays {
/** Layers a previewing canvas leaves to its live islands. */
playIslands?: ReadonlySet<string>
hoveredNodeId?: string | null
transforming?: boolean
measurementMode?: MeasurementMode
enteredContainerId?: string | null
editingTextId?: string | null

View file

@ -68,7 +68,9 @@ export const AUTO_LAYOUT_HOVER_TICK_LENGTH = 12
export const AUTO_LAYOUT_HOVER_STRIPE_GAP = 8
export const AUTO_LAYOUT_HOVER_STRIPE_WIDTH = 1
export const AUTO_LAYOUT_HOVER_STROKE_WIDTH = 1.5
export const AUTO_LAYOUT_HOVER_CHILD_DASH = 4
/** Dash and gap, in screen pixels, of the dotted outlines around auto layout children (Figma 126). */
export const LAYOUT_OUTLINE_DASH = 1.75
export const LAYOUT_OUTLINE_GAP = 1.25
export const AUTO_LAYOUT_HOVER_VALUE_OFFSET = 18
export const AUTO_LAYOUT_HOVER_VALUE_PILL_HEIGHT = 22
export const AUTO_LAYOUT_HOVER_VALUE_PILL_RADIUS = 5

View file

@ -282,6 +282,7 @@ export function createEditor(options?: EditorOptions) {
state.currentPageId = _graph.getPages()[0]?.id ?? _graph.rootId
setSelectedIds(new Set())
state.hoveredNodeId = null
state.transforming = false
state.measurementMode = 'off'
state.snapGuides = []
state.guides = { preview: null, hovered: null, selected: null, redline: null }

View file

@ -51,6 +51,12 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
ctx.requestRepaint()
}
function setTransforming(transforming: boolean) {
if (ctx.state.transforming === transforming) return
ctx.state.transforming = transforming
ctx.requestRepaint()
}
/** Marks the layer of the code element around the cursor, separately from canvas hover. */
function setCodeFocusNode(id: string | null) {
if (ctx.state.codeFocusNodeId === id) return
@ -99,6 +105,7 @@ export function createSelectionOverlayActions(ctx: EditorContext) {
setSelectedGuide,
setRotationPreview,
setHoveredNode,
setTransforming,
setCodeFocusNode,
setMeasurementMode,
setDropTarget,

View file

@ -13,6 +13,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState {
dropTargetId: null,
layoutInsertIndicator: null,
hoveredNodeId: null,
transforming: false,
measurementMode: 'off',
editingTextId: null,
penState: null,

View file

@ -69,6 +69,8 @@ export interface EditorViewState {
direction: 'HORIZONTAL' | 'VERTICAL'
} | null
hoveredNodeId: string | null
/** A pointer move, resize, or rotation is in progress. */
transforming: boolean
measurementMode: MeasurementMode
editingTextId: string | null
penState: {

View file

@ -8,11 +8,12 @@ description: Selecting, moving, resizing, rotating, duplicating, and organizing
Select objects to move, resize, rotate, duplicate, and organize them on the canvas.
## Selecting
- **Click** a node to select it (deselects everything else). As in Figma, a click inside a top-level frame selects that frame's direct child, such as a nested frame or a group, rather than the deepest layer; click a top-level frame by its title, or anywhere when it is empty
- **Double-click** to go one level deeper; once a layer is selected, clicks reach its siblings and cousins directly
- **Click** a node to select it (deselects everything else). As in Figma, a click inside a top-level frame selects that frame's direct child, such as a nested frame or a group, rather than the deepest layer; click a top-level frame by its title, anywhere when it is empty, or by its gaps and padding when it has auto layout
- **Hover** an auto layout frame to see its direct children outlined with dotted lines; while a single layer is selected, the border of its auto layout parent is dotted
- **Double-click** to go one level deeper, or click again inside a selected frame, component, or instance; once a layer is selected, clicks reach its siblings and cousins directly
- <kbd>⌘</kbd> + click (<kbd>Ctrl</kbd> + click on Windows and Linux) selects the deepest layer under the cursor
- <kbd>Shift</kbd> + click to add or remove a node from the current selection
- **Marquee drag** — drag on empty canvas to draw a selection rectangle; intersecting nodes are selected on release. Started inside a top-level frame or section, it selects that container's layers; from the page, a frame or section that holds layers is selected only when fully enclosed
- **Marquee drag** — drag on empty canvas to draw a selection rectangle; intersecting nodes are selected on release. Started inside a top-level frame without auto layout or a section, it selects that container's layers; from the page, a frame or section that holds layers is selected only when fully enclosed
- <kbd>⌘</kbd><kbd>A</kbd> — select all nodes on the current page
- **Click empty canvas** — deselect all

View file

@ -17,6 +17,21 @@ function hasVisibleFillOrStroke(node: SceneNode): boolean {
return node.fills.some((f) => f.visible) || node.strokes.some((s) => s.visible)
}
function isTopLevel(graph: SceneGraph, node: SceneNode): boolean {
const parent = node.parentId ? graph.nodes.get(node.parentId) : undefined
return parent?.type === 'CANVAS' || parent?.type === 'SECTION'
}
/**
* An open container whose empty area still belongs to it, as in Figma: a top-level frame with auto
* layout is selected, hovered, and dragged by its gaps and padding, while its children stay one
* click away, with or without a fill. A plain top-level frame's empty area is background, and a
* nested frame's empty area belongs to it only as a click target (see `frameChildAt`).
*/
function ownsItsEmptyArea(graph: SceneGraph, node: SceneNode): boolean {
return node.type === 'FRAME' && node.layoutMode !== 'NONE' && isTopLevel(graph, node)
}
function hasTransformedAncestor(
node: SceneNode,
graph: SceneGraph,
@ -98,7 +113,10 @@ function hitTestTransparentContainer(
return childHit
}
if (containsPoint(px, py, child, graph, transformCache) && hasVisibleFillOrStroke(child))
if (
containsPoint(px, py, child, graph, transformCache) &&
(hasVisibleFillOrStroke(child) || ownsItsEmptyArea(graph, child))
)
return child
return null
}
@ -168,8 +186,58 @@ function opensByItself(graph: SceneGraph, node: SceneNode): boolean {
if (node.type === 'COMPONENT_SET') return true
if (node.childIds.length === 0) return false
if (node.type === 'SECTION') return true
const parent = node.parentId ? graph.nodes.get(node.parentId) : undefined
return node.type === 'FRAME' && (parent?.type === 'CANVAS' || parent?.type === 'SECTION')
return node.type === 'FRAME' && isTopLevel(graph, node)
}
/**
* The topmost frame among a container's children whose bounds hold the point, filled or not: in
* Figma a click on the empty area of a frame inside an open container, or of an empty frame on the
* page or in a section, selects that frame, while a deep (⌘) click looks through it.
*/
function frameChildAt(
graph: SceneGraph,
container: SceneNode,
px: number,
py: number
): SceneNode | null {
const transformCache = new Map<string, boolean>()
for (let i = container.childIds.length - 1; i >= 0; i--) {
const child = graph.nodes.get(container.childIds[i])
if (!child || child.internalOnly || !child.visible || child.type !== 'FRAME') continue
// A board, such as a top-level frame in a section, keeps its empty area as background.
if (opensByItself(graph, child) && !ownsItsEmptyArea(graph, child)) continue
if (containsPoint(px, py, child, graph, transformCache)) return child
}
return null
}
/**
* The containers the selection opens: every ancestor of a selected layer inside the scope, and a
* selected container itself, so a click inside it selects the layer under the point, as in Figma.
* A selected group or boolean stays closed: clicking inside it keeps it selected.
*/
function openedBySelection(
graph: SceneGraph,
selectedIds: ReadonlySet<string>,
scopeId: string
): Set<string> {
const ancestors = new Set<string>()
for (const id of selectedIds) {
const node = graph.nodes.get(id)
if (
node &&
node.childIds.length > 0 &&
node.type !== 'GROUP' &&
node.type !== 'BOOLEAN_OPERATION'
)
ancestors.add(id)
let parentId = node?.parentId
while (parentId && parentId !== scopeId && !ancestors.has(parentId)) {
ancestors.add(parentId)
parentId = graph.nodes.get(parentId)?.parentId
}
}
return ancestors
}
/**
@ -177,7 +245,8 @@ function opensByItself(graph: SceneGraph, node: SceneNode): boolean {
* the point and stops at the first layer that is not open. Top-level frames and sections with
* layers and component sets are open, and so is every ancestor of the selection, so clicks reach
* the siblings of selected layers. Where every layer under the point is open, a container opened
* by the selection is selected, and a top-level frame or section is not.
* by the selection is selected, so is a top-level frame with auto layout, and a plain top-level
* frame or a section is not.
*/
export function hitTestSelectable(
graph: SceneGraph,
@ -187,7 +256,10 @@ export function hitTestSelectable(
selectedIds: ReadonlySet<string>
): SceneNode | null {
const deepest = hitTestChildren(graph, px, py, scopeId, true)
if (!deepest) return null
if (!deepest) {
const scope = graph.nodes.get(scopeId)
return scope ? frameChildAt(graph, scope, px, py) : null
}
const chain: SceneNode[] = []
for (let node: SceneNode | undefined = deepest; node && node.id !== scopeId;) {
@ -195,22 +267,19 @@ export function hitTestSelectable(
node = node.parentId ? graph.nodes.get(node.parentId) : undefined
}
const openedBySelection = new Set<string>()
for (const id of selectedIds) {
let parentId = graph.nodes.get(id)?.parentId
while (parentId && parentId !== scopeId && !openedBySelection.has(parentId)) {
openedBySelection.add(parentId)
parentId = graph.nodes.get(parentId)?.parentId
}
}
const opened = openedBySelection(graph, selectedIds, scopeId)
for (const node of chain) {
// A locked layer stands in for everything inside it.
if (node.locked) return node
if (!openedBySelection.has(node.id) && !opensByItself(graph, node)) return node
if (!opened.has(node.id) && !opensByItself(graph, node)) return node
}
const last = chain.at(-1)
return last && openedBySelection.has(last.id) && !opensByItself(graph, last) ? last : null
if (!last) return null
const frame = frameChildAt(graph, last, px, py)
if (frame) return frame
if (ownsItsEmptyArea(graph, last)) return last
return opened.has(last.id) && !opensByItself(graph, last) ? last : null
}
/** Whether a click looks into this container rather than selecting it; see `hitTestSelectable`. */
@ -231,7 +300,7 @@ export function hitTestOpenContainer(
): SceneNode | null {
const deepest = hitTestChildren(graph, px, py, scopeId, true)
for (let node: SceneNode | undefined = deepest ?? undefined; node && node.id !== scopeId;) {
if (!opensByItself(graph, node)) return null
if (!opensByItself(graph, node) || ownsItsEmptyArea(graph, node)) return null
node = node.parentId ? graph.nodes.get(node.parentId) : undefined
}
return deepest

View file

@ -6,7 +6,9 @@ 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 }]
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()
@ -24,14 +26,66 @@ function scene() {
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 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 }
nodes: {
top,
nested,
leaf,
deeper,
deepLeaf,
direct,
group,
g1,
g2,
empty,
inSection,
sectionLeaf,
component,
card,
cardTitle,
cardBody
}
}
}
@ -65,6 +119,24 @@ describe('hitTestSelectable', () => {
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')
@ -75,18 +147,117 @@ describe('hitTestSelectable', () => {
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('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')
})
})

View file

@ -1,5 +1,5 @@
import { useEventListener } from '@vueuse/core'
import { onScopeDispose, ref, type Ref } from 'vue'
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
@ -45,6 +45,12 @@ export function useCanvasInput(
isEnabled: () => boolean = () => true
) {
const drag = ref<DragState | null>(null)
// Canvas chrome that explains layout, such as auto layout child outlines, steps aside while
// layers move, resize, or rotate.
watch(
() => drag.value?.type,
(type) => editor.setTransforming(type === 'move' || type === 'resize' || type === 'rotate')
)
const canvasLabelEdit = createCanvasLabelEdit(editor)
const cursorOverride = ref<string | null>(null)
/** Whether the primary button is held on a preview control, such as a slider thumb. */

View file

@ -29,6 +29,7 @@ export function cloneCanvasPaneState(id: string, source: CanvasPaneState): Canva
id,
selectedIds: new Set<string>(),
hoveredNodeId: null,
transforming: false,
measurementMode: 'off',
editingTextId: null,
marquee: null,

View file

@ -0,0 +1,175 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
// Matches Figma desktop 126: hovering a flow auto layout frame dots its direct children, a single
// selected layer dots its flow auto layout parent, and neither shows on plain frames or while
// layers move.
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
async function expectCanvas(name: string) {
editor.canvas.assertNoErrors()
const buffer = await editor.canvas.screenshotCanvasRegion(700, 460)
expect(buffer).toMatchSnapshot(`${name}.png`, { maxDiffPixelRatio: 0, threshold: 0 })
}
/**
* A vertical auto layout card holding a title, a horizontal row of two chips, and a button,
* next to a plain frame with one layer.
*/
async function createCards() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const graph = store.graph
const pageId = store.state.currentPageId
const fill = (r: number, g: number, b: number) => [
{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }
]
const padding = (value: number) => ({
paddingTop: value,
paddingRight: value,
paddingBottom: value,
paddingLeft: value
})
const card = graph.createNode('FRAME', pageId, {
name: 'Card',
x: 60,
y: 60,
width: 240,
height: 300,
fills: fill(1, 1, 1),
layoutMode: 'VERTICAL',
itemSpacing: 16,
...padding(24)
})
const title = graph.createNode('RECTANGLE', card.id, {
name: 'Title',
x: 24,
y: 24,
width: 192,
height: 40,
fills: fill(0.85, 0.85, 0.9)
})
const row = graph.createNode('FRAME', card.id, {
name: 'Row',
x: 24,
y: 80,
width: 192,
height: 60,
fills: fill(0.95, 0.95, 0.97),
layoutMode: 'HORIZONTAL',
itemSpacing: 10,
...padding(10)
})
for (const x of [10, 70]) {
graph.createNode('RECTANGLE', row.id, {
name: 'Chip',
x,
y: 10,
width: 50,
height: 40,
fills: fill(0.9, 0.6, 0.3)
})
}
graph.createNode('RECTANGLE', card.id, {
name: 'Button',
x: 24,
y: 156,
width: 192,
height: 40,
fills: fill(0.2, 0.4, 1)
})
const plain = graph.createNode('FRAME', pageId, {
name: 'Plain',
x: 360,
y: 60,
width: 240,
height: 300,
fills: fill(1, 1, 1)
})
graph.createNode('RECTANGLE', plain.id, {
name: 'Layer',
x: 24,
y: 24,
width: 192,
height: 40,
fills: fill(0.85, 0.85, 0.9)
})
store.clearSelection()
store.requestRender()
return { card: card.id, title: title.id, row: row.id, plain: plain.id }
})
}
async function show(selected: string[], hovered: string | null) {
await editor.page.evaluate(
({ selected, hovered }) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.select(selected)
store.setHoveredNode(hovered)
store.requestRender()
},
{ selected, hovered }
)
await editor.canvas.waitForRender()
}
async function at(id: string, dx: number, dy: number) {
return editor.page.evaluate(
({ id, dx, dy }) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const abs = store.graph.getAbsolutePosition(id)
const { zoom, panX, panY } = store.state
return { x: (abs.x + dx) * zoom + panX, y: (abs.y + dy) * zoom + panY }
},
{ id, dx, dy }
)
}
test('a hovered auto layout frame dots its direct children', async () => {
const { card } = await createCards()
await show([], card)
await expectCanvas('layout-outlines-hovered-card')
})
test('a hovered nested auto layout frame dots only its own children', async () => {
const { row } = await createCards()
await show([], row)
await expectCanvas('layout-outlines-hovered-row')
})
test('a selected layer dots its auto layout parent', async () => {
const { title } = await createCards()
await show([title], null)
await expectCanvas('layout-outlines-selected-child')
})
test('a hovered plain frame shows no outlines', async () => {
const { plain } = await createCards()
await show([], plain)
await expectCanvas('layout-outlines-hovered-plain')
})
test('outlines step aside while a layer moves', async () => {
const { card, title } = await createCards()
await show([title], null)
const from = await at(card, 120, 44)
const box = await editor.canvas.canvas.boundingBox()
if (!box) throw new Error('Canvas has no bounding box')
await editor.page.mouse.move(box.x + from.x, box.y + from.y)
await editor.page.mouse.down()
await editor.page.mouse.move(box.x + from.x + 4, box.y + from.y + 4, { steps: 4 })
await expect
.poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.transforming))
.toBe(true)
await editor.canvas.waitForRender()
await expectCanvas('layout-outlines-moving-child')
await editor.page.mouse.up()
await expect
.poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.transforming))
.toBe(false)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,194 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
// Matches Figma desktop 126 with the same pointer input: the gaps and padding of a top-level auto
// layout frame select, highlight, and drag it, while those of a plain top-level frame are background.
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
/** A top-level card with auto layout and one without, each holding a title and a button. */
async function createCards() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const fill = (r: number, g: number, b: number) => [
{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }
]
const card = (name: string, x: number, layoutMode: 'VERTICAL' | 'NONE') => {
const frame = store.graph.createNode('FRAME', pageId, {
name,
x,
y: 100,
width: 200,
height: 240,
fills: fill(1, 1, 1),
layoutMode,
itemSpacing: 24,
paddingTop: 24,
paddingRight: 24,
paddingBottom: 24,
paddingLeft: 24
})
store.graph.createNode('RECTANGLE', frame.id, {
name: `${name} title`,
x: 24,
y: 24,
width: 152,
height: 40,
fills: fill(0.85, 0.85, 0.9)
})
store.graph.createNode('RECTANGLE', frame.id, {
name: `${name} button`,
x: 24,
y: 88,
width: 152,
height: 40,
fills: fill(0.2, 0.4, 1)
})
return frame.id
}
const ids = { auto: card('Auto card', 100, 'VERTICAL'), plain: card('Plain card', 400, 'NONE') }
store.clearSelection()
store.requestRender()
return ids
})
}
/** Canvas coordinates of a point given relative to a node's top-left corner. */
async function at(id: string, dx: number, dy: number) {
return editor.page.evaluate(
({ id, dx, dy }) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const abs = store.graph.getAbsolutePosition(id)
const { zoom, panX, panY } = store.state
return { x: (abs.x + dx) * zoom + panX, y: (abs.y + dy) * zoom + panY }
},
{ id, dx, dy }
)
}
async function state() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return { selected: [...store.state.selectedIds], hovered: store.state.hoveredNodeId }
})
}
async function position(id: string) {
return editor.page.evaluate((nodeId) => {
const node = window.openPencil?.getStore?.().graph.getNode(nodeId)
return node ? { x: node.x, y: node.y } : null
}, id)
}
test('hovering and clicking the empty area of a top-level auto layout frame selects it', async () => {
const { auto } = await createCards()
await editor.canvas.waitForRender()
const gap = await at(auto, 100, 200)
await editor.canvas.hover(gap.x, gap.y)
await expect.poll(async () => (await state()).hovered).toBe(auto)
await editor.canvas.click(gap.x, gap.y)
await expect.poll(async () => (await state()).selected).toEqual([auto])
editor.canvas.assertNoErrors()
})
test('a click on a layer in a top-level auto layout frame still selects the layer', async () => {
const { auto } = await createCards()
await editor.canvas.waitForRender()
const button = await at(auto, 100, 108)
const buttonId = await editor.page.evaluate(
(frameId) => window.openPencil?.getStore?.().graph.getNode(frameId)?.childIds[1],
auto
)
await editor.canvas.click(button.x, button.y)
await expect.poll(async () => (await state()).selected).toEqual([buttonId])
editor.canvas.assertNoErrors()
})
test('dragging the empty area of a top-level auto layout frame moves it', async () => {
const { auto } = await createCards()
await editor.canvas.waitForRender()
const gap = await at(auto, 100, 200)
await editor.canvas.drag(gap.x, gap.y, gap.x + 60, gap.y + 40)
await expect.poll(() => position(auto)).toEqual({ x: 160, y: 140 })
expect((await state()).selected).toEqual([auto])
editor.canvas.assertNoErrors()
})
test('the empty area of a plain top-level frame stays background', async () => {
const { plain } = await createCards()
await editor.canvas.waitForRender()
const gap = await at(plain, 100, 200)
await editor.canvas.hover(gap.x, gap.y)
expect((await state()).hovered).toBeNull()
await editor.canvas.click(gap.x, gap.y)
expect((await state()).selected).toEqual([])
await editor.canvas.drag(gap.x, gap.y, gap.x + 60, gap.y + 40)
expect(await position(plain)).toEqual({ x: 400, y: 100 })
editor.canvas.assertNoErrors()
})
test('repeated clicks at one point reach deeper layers, one level at a time', async () => {
const ids = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const fill = (r: number, g: number, b: number) => [
{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }
]
const page = store.state.currentPageId
const board = store.graph.createNode('FRAME', page, {
name: 'Board',
x: 100,
y: 100,
width: 300,
height: 300,
fills: fill(1, 1, 1)
})
const grid = store.graph.createNode('FRAME', board.id, {
name: 'Grid',
x: 20,
y: 20,
width: 260,
height: 260,
fills: fill(0.95, 0.95, 0.95)
})
const cell = store.graph.createNode('FRAME', grid.id, {
name: 'Cell',
x: 40,
y: 40,
width: 80,
height: 80,
fills: fill(0.1, 0.1, 0.1)
})
const label = store.graph.createNode('RECTANGLE', cell.id, {
name: 'Label',
x: 20,
y: 20,
width: 40,
height: 40,
fills: fill(1, 1, 1)
})
store.clearSelection()
store.requestRender()
return { grid: grid.id, cell: cell.id, label: label.id }
})
await editor.canvas.waitForRender()
const point = await at(ids.label, 20, 20)
for (const expected of [ids.grid, ids.cell, ids.label]) {
await editor.canvas.click(point.x, point.y)
await expect.poll(async () => (await state()).selected).toEqual([expected])
// Apart enough not to count as a double-click, which goes one level deeper by itself.
await editor.page.waitForTimeout(600)
}
editor.canvas.assertNoErrors()
})