From 4faccd047d51976d4ef8b151c073438f05e7a206 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 7 Oct 2026 11:29:52 +0000 Subject: [PATCH] feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- CHANGELOG.md | 3 +- .../src/canvas/overlays/auto-layout-hover.ts | 25 +-- .../src/canvas/overlays/layout-outlines.ts | 91 ++++++++ .../core/src/canvas/renderer/overlay-pass.ts | 6 +- packages/core/src/canvas/renderer/pipeline.ts | 1 + packages/core/src/canvas/renderer/types.ts | 1 + packages/core/src/constants.ts | 4 +- packages/core/src/editor/create.ts | 1 + .../core/src/editor/selection/overlays.ts | 7 + packages/core/src/editor/state/view.ts | 1 + packages/core/src/editor/types.ts | 2 + .../user-guide/selection-and-manipulation.md | 7 +- packages/scene-graph/src/hit-test.ts | 101 +++++++-- .../tests/basic/selectable-hit.test.ts | 179 +++++++++++++++- packages/vue/src/canvas/useCanvasInput.ts | 8 +- src/app/editor/panes/state.ts | 1 + tests/e2e/canvas/layout-outlines.spec.ts | 175 ++++++++++++++++ ...utlines-hovered-card-openpencil-darwin.png | Bin 0 -> 9911 bytes ...tlines-hovered-plain-openpencil-darwin.png | Bin 0 -> 8214 bytes ...outlines-hovered-row-openpencil-darwin.png | Bin 0 -> 8945 bytes ...utlines-moving-child-openpencil-darwin.png | Bin 0 -> 10733 bytes ...lines-selected-child-openpencil-darwin.png | Bin 0 -> 13800 bytes .../slot-frame-selected-openpencil-darwin.png | Bin 14737 -> 16888 bytes .../e2e/canvas/top-level-frame-click.spec.ts | 194 ++++++++++++++++++ 24 files changed, 757 insertions(+), 50 deletions(-) create mode 100644 packages/core/src/canvas/overlays/layout-outlines.ts create mode 100644 tests/e2e/canvas/layout-outlines.spec.ts create mode 100644 tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-hovered-card-openpencil-darwin.png create mode 100644 tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-hovered-plain-openpencil-darwin.png create mode 100644 tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-hovered-row-openpencil-darwin.png create mode 100644 tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-moving-child-openpencil-darwin.png create mode 100644 tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-selected-child-openpencil-darwin.png create mode 100644 tests/e2e/canvas/top-level-frame-click.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 821ebd172..7820ddb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=`). +- 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. diff --git a/packages/core/src/canvas/overlays/auto-layout-hover.ts b/packages/core/src/canvas/overlays/auto-layout-hover.ts index 8aad135df..6a9859c10 100644 --- a/packages/core/src/canvas/overlays/auto-layout-hover.ts +++ b/packages/core/src/canvas/overlays/auto-layout-hover.ts @@ -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') } diff --git a/packages/core/src/canvas/overlays/layout-outlines.ts b/packages/core/src/canvas/overlays/layout-outlines.ts new file mode 100644 index 000000000..bb216004a --- /dev/null +++ b/packages/core/src/canvas/overlays/layout-outlines.ts @@ -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, + 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) + } +} diff --git a/packages/core/src/canvas/renderer/overlay-pass.ts b/packages/core/src/canvas/renderer/overlay-pass.ts index cc9f454c9..0bbee8258 100644 --- a/packages/core/src/canvas/renderer/overlay-pass.ts +++ b/packages/core/src/canvas/renderer/overlay-pass.ts @@ -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) diff --git a/packages/core/src/canvas/renderer/pipeline.ts b/packages/core/src/canvas/renderer/pipeline.ts index 942dd4804..a92d641ba 100644 --- a/packages/core/src/canvas/renderer/pipeline.ts +++ b/packages/core/src/canvas/renderer/pipeline.ts @@ -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, diff --git a/packages/core/src/canvas/renderer/types.ts b/packages/core/src/canvas/renderer/types.ts index 17bffbddd..0041680d4 100644 --- a/packages/core/src/canvas/renderer/types.ts +++ b/packages/core/src/canvas/renderer/types.ts @@ -38,6 +38,7 @@ export interface RenderOverlays { /** Layers a previewing canvas leaves to its live islands. */ playIslands?: ReadonlySet hoveredNodeId?: string | null + transforming?: boolean measurementMode?: MeasurementMode enteredContainerId?: string | null editingTextId?: string | null diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 56605543b..70f1fd904 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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 diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 296cbf95e..070f194f3 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -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 } diff --git a/packages/core/src/editor/selection/overlays.ts b/packages/core/src/editor/selection/overlays.ts index 88a1c2de1..1418c0196 100644 --- a/packages/core/src/editor/selection/overlays.ts +++ b/packages/core/src/editor/selection/overlays.ts @@ -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, diff --git a/packages/core/src/editor/state/view.ts b/packages/core/src/editor/state/view.ts index 1565517d2..8d9f873ff 100644 --- a/packages/core/src/editor/state/view.ts +++ b/packages/core/src/editor/state/view.ts @@ -13,6 +13,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState { dropTargetId: null, layoutInsertIndicator: null, hoveredNodeId: null, + transforming: false, measurementMode: 'off', editingTextId: null, penState: null, diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 9abb0bc0c..0b734c3ee 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -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: { diff --git a/packages/docs/user-guide/selection-and-manipulation.md b/packages/docs/user-guide/selection-and-manipulation.md index 5c7a5fea4..e8235a15d 100644 --- a/packages/docs/user-guide/selection-and-manipulation.md +++ b/packages/docs/user-guide/selection-and-manipulation.md @@ -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 - ⌘ + click (Ctrl + click on Windows and Linux) selects the deepest layer under the cursor - Shift + 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 - ⌘A — select all nodes on the current page - **Click empty canvas** — deselect all diff --git a/packages/scene-graph/src/hit-test.ts b/packages/scene-graph/src/hit-test.ts index e43418019..82eaac84e 100644 --- a/packages/scene-graph/src/hit-test.ts +++ b/packages/scene-graph/src/hit-test.ts @@ -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() + 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, + scopeId: string +): Set { + const ancestors = new Set() + 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 ): 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() - 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 diff --git a/packages/scene-graph/tests/basic/selectable-hit.test.ts b/packages/scene-graph/tests/basic/selectable-hit.test.ts index 68784257f..c85b053c6 100644 --- a/packages/scene-graph/tests/basic/selectable-hit.test.ts +++ b/packages/scene-graph/tests/basic/selectable-hit.test.ts @@ -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) => + 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') + }) +}) diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index d8b7f6053..6d6d46165 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -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(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(null) /** Whether the primary button is held on a preview control, such as a slider thumb. */ diff --git a/src/app/editor/panes/state.ts b/src/app/editor/panes/state.ts index 3ddcfe9b5..0042f54bb 100644 --- a/src/app/editor/panes/state.ts +++ b/src/app/editor/panes/state.ts @@ -29,6 +29,7 @@ export function cloneCanvasPaneState(id: string, source: CanvasPaneState): Canva id, selectedIds: new Set(), hoveredNodeId: null, + transforming: false, measurementMode: 'off', editingTextId: null, marquee: null, diff --git a/tests/e2e/canvas/layout-outlines.spec.ts b/tests/e2e/canvas/layout-outlines.spec.ts new file mode 100644 index 000000000..ab5de69c3 --- /dev/null +++ b/tests/e2e/canvas/layout-outlines.spec.ts @@ -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) +}) diff --git a/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-hovered-card-openpencil-darwin.png b/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-hovered-card-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..1ff930e29dee5b8bd81d2b86ee16b9c50843c29d GIT binary patch literal 9911 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 E2k;lyIsgCw literal 0 HcmV?d00001 diff --git a/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-hovered-plain-openpencil-darwin.png b/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-hovered-plain-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..7a180d3ae7b1a269b06d1b60df2c5ed07edc8f9f GIT binary patch literal 8214 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 literal 0 HcmV?d00001 diff --git a/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-moving-child-openpencil-darwin.png b/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-moving-child-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..4172b9ab1b278019b10c78e6ea0d7362d3f4624f GIT binary patch literal 10733 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 literal 0 HcmV?d00001 diff --git a/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-selected-child-openpencil-darwin.png b/tests/e2e/canvas/layout-outlines.spec.ts-snapshots/layout-outlines-selected-child-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..ff372c373f883478888b85cbbf03c9357de743bb GIT binary patch literal 13800 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 ZF#UEEaq-}2I7GJ28JnFh`N8?d{{T9et2O`t diff --git a/tests/e2e/canvas/top-level-frame-click.spec.ts b/tests/e2e/canvas/top-level-frame-click.spec.ts new file mode 100644 index 000000000..208ef0872 --- /dev/null +++ b/tests/e2e/canvas/top-level-frame-click.spec.ts @@ -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() +})