feat: select layers at Figma's click depth (#900)

* feat: select layers at Figma's click depth

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

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

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

A marquee scoped to a rotated frame mapped only two corners into its space, and page-level enclosure used a frame's unrotated rectangle. Both now compare each layer's axis-aligned canvas bounds with the marquee, so rotated frames and their children are selected by what is drawn.
This commit is contained in:
Danila Poyarkov 2026-10-05 12:39:04 +00:00 committed by GitHub
parent 69dbc36a7e
commit 24db186ac8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 301 additions and 63 deletions

View file

@ -59,6 +59,7 @@
### Changed
- 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, and 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.
- 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

@ -16,12 +16,14 @@ export function createSelectionHitTestActions(
if (!scopeNode) {
ctx.state.enteredContainerId = null
} else {
return deep ? ctx.graph.hitTestDeep(cx, cy, scopeId) : ctx.graph.hitTest(cx, cy, scopeId)
return deep
? ctx.graph.hitTestDeep(cx, cy, scopeId)
: ctx.graph.hitTestSelectable(cx, cy, scopeId, ctx.state.selectedIds)
}
}
return deep
? ctx.graph.hitTestDeep(cx, cy, ctx.state.currentPageId)
: ctx.graph.hitTest(cx, cy, ctx.state.currentPageId)
: ctx.graph.hitTestSelectable(cx, cy, ctx.state.currentPageId, ctx.state.selectedIds)
}
function selectAtPoint(cx: number, cy: number) {

View file

@ -8,9 +8,11 @@ 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)
- **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
- <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; all intersecting nodes are selected on release
- **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
- <kbd>⌘</kbd><kbd>A</kbd> — select all nodes on the current page
- **Click empty canvas** — deselect all

View file

@ -160,6 +160,83 @@ export function hitTestDeep(
return hitTestChildren(graph, px, py, scope, true)
}
/**
* A container a click looks into by itself, as in Figma: a top-level frame (on the page or in a
* section) or a section that holds layers, and a component set.
*/
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')
}
/**
* The layer a click selects, as in Figma. It walks from the scope down to the deepest layer under
* 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.
*/
export function hitTestSelectable(
graph: SceneGraph,
px: number,
py: number,
scopeId: string,
selectedIds: ReadonlySet<string>
): SceneNode | null {
const deepest = hitTestChildren(graph, px, py, scopeId, true)
if (!deepest) return null
const chain: SceneNode[] = []
for (let node: SceneNode | undefined = deepest; node && node.id !== scopeId;) {
chain.unshift(node)
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
}
}
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
}
const last = chain.at(-1)
return last && openedBySelection.has(last.id) && !opensByItself(graph, last) ? last : null
}
/** Whether a click looks into this container rather than selecting it; see `hitTestSelectable`. */
export function isOpenContainer(graph: SceneGraph, nodeId: string): boolean {
const node = graph.nodes.get(nodeId)
return node !== undefined && opensByItself(graph, node)
}
/**
* The innermost open container under the point when every layer there is open: a marquee starting
* here selects that container's layers, as in Figma.
*/
export function hitTestOpenContainer(
graph: SceneGraph,
px: number,
py: number,
scopeId: string
): 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
node = node.parentId ? graph.nodes.get(node.parentId) : undefined
}
return deepest
}
/** Whether the point lies inside the node's rotated and flipped bounds. */
export function isPointInNode(graph: SceneGraph, nodeId: string, px: number, py: number): boolean {
const node = graph.nodes.get(nodeId)

View file

@ -701,6 +701,23 @@ export class SceneGraph {
return HitTest.hitTestDeep(this, px, py, scopeId)
}
hitTestSelectable(
px: number,
py: number,
scopeId: string,
selectedIds: ReadonlySet<string>
): SceneNode | null {
return HitTest.hitTestSelectable(this, px, py, scopeId, selectedIds)
}
hitTestOpenContainer(px: number, py: number, scopeId: string): SceneNode | null {
return HitTest.hitTestOpenContainer(this, px, py, scopeId)
}
isOpenContainer(nodeId: string): boolean {
return HitTest.isOpenContainer(this, nodeId)
}
isPointInNode(nodeId: string, px: number, py: number): boolean {
return HitTest.isPointInNode(this, nodeId, px, py)
}

View file

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

View file

@ -83,10 +83,12 @@ export function createTextEditInput(options: TextEditInputOptions) {
cx: number,
cy: number
): SceneNode | null {
const hit = editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId)
if (!hit) return null
if (hit.id === containerId || editor.graph.isDescendant(hit.id, containerId)) return hit
return null
// Double-clicking goes one level into the container, as in Figma.
const hit = editor.graph.hitTestSelectable(cx, cy, containerId, new Set())
if (hit) return hit
return editor.graph.isPointInNode(containerId, cx, cy)
? (editor.graph.getNode(containerId) ?? null)
: null
}
function startSectionTitleRename(cx: number, cy: number): boolean {

View file

@ -9,14 +9,9 @@ import {
import { handleTextSelectMove as handleTextSelectMoveAction } from '#vue/canvas/transform/text-selection'
import type { DragMarquee, DragPan, DragRotate, DragState } from '#vue/shared/input/types'
type CanvasToLocal = (cx: number, cy: number, scopeId: string) => { lx: number; ly: number }
type SetDrag = (drag: DragState) => void
export function createTransformInputActions(
editor: Editor,
canvasToLocal: CanvasToLocal,
setDrag: SetDrag
) {
export function createTransformInputActions(editor: Editor, setDrag: SetDrag) {
function tryStartRotation(cx: number, cy: number): boolean {
return tryStartRotationAction(editor, setDrag, cx, cy)
}
@ -34,7 +29,7 @@ export function createTransformInputActions(
}
function handleMarqueeMove(d: DragMarquee, cx: number, cy: number) {
handleMarqueeMoveAction(editor, canvasToLocal, d, cx, cy)
handleMarqueeMoveAction(editor, d, cx, cy)
}
return {

View file

@ -1,43 +1,41 @@
import type { Editor } from '@open-pencil/core/editor'
import { getAxisAlignedWorldBounds } from '@open-pencil/scene-graph/coordinate'
import type { Rect } from '@open-pencil/scene-graph/primitives'
import type { DragMarquee } from '#vue/shared/input/types'
type CanvasToLocal = (cx: number, cy: number, scopeId: string) => { lx: number; ly: number }
function intersects(a: Rect, b: Rect) {
return a.x + a.width > b.x && a.x < b.x + b.width && a.y + a.height > b.y && a.y < b.y + b.height
}
export function handleMarqueeMove(
editor: Editor,
canvasToLocal: CanvasToLocal,
d: DragMarquee,
cx: number,
cy: number
) {
const minX = Math.min(d.startX, cx)
const minY = Math.min(d.startY, cy)
const maxX = Math.max(d.startX, cx)
const maxY = Math.max(d.startY, cy)
function encloses(outer: Rect, inner: Rect) {
return (
inner.x >= outer.x &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y >= outer.y &&
inner.y + inner.height <= outer.y + outer.height
)
}
const scopeId = editor.state.enteredContainerId
const parentId = scopeId ?? editor.state.currentPageId
const localMin = scopeId ? canvasToLocal(minX, minY, scopeId) : { lx: minX, ly: minY }
const localMax = scopeId ? canvasToLocal(maxX, maxY, scopeId) : { lx: maxX, ly: maxY }
const localMinX = Math.min(localMin.lx, localMax.lx)
const localMinY = Math.min(localMin.ly, localMax.ly)
const localMaxX = Math.max(localMin.lx, localMax.lx)
const localMaxY = Math.max(localMin.ly, localMax.ly)
export function handleMarqueeMove(editor: Editor, d: DragMarquee, cx: number, cy: number) {
const marquee = {
x: Math.min(d.startX, cx),
y: Math.min(d.startY, cy),
width: Math.abs(cx - d.startX),
height: Math.abs(cy - d.startY)
}
// Layers are compared by their canvas bounds, so rotated layers and containers match what is drawn.
const scopeId = d.containerId ?? editor.state.enteredContainerId
const hits: string[] = []
for (const node of editor.graph.getChildren(parentId)) {
for (const node of editor.graph.getChildren(scopeId ?? editor.state.currentPageId)) {
if (!node.visible || node.locked) continue
if (
node.x + node.width > localMinX &&
node.x < localMaxX &&
node.y + node.height > localMinY &&
node.y < localMaxY
) {
hits.push(node.id)
}
const bounds = getAxisAlignedWorldBounds(node, editor.graph)
// As in Figma, on the page a frame or section holding layers needs to be fully enclosed.
const enclose = !scopeId && editor.graph.isOpenContainer(node.id)
if (enclose ? encloses(marquee, bounds) : intersects(marquee, bounds)) hits.push(node.id)
}
editor.select(hits)
editor.setMarquee({ x: minX, y: minY, width: maxX - minX, height: maxY - minY })
editor.setMarquee(marquee)
}

View file

@ -150,7 +150,7 @@ export function useCanvasInput(
handleRotateMove,
handleTextSelectMove,
handleMarqueeMove
} = createCanvasTransformInput(editor, canvasToLocal, setDrag)
} = createCanvasTransformInput(editor, setDrag)
function paddingValue(node: SceneNode, side: 'top' | 'right' | 'bottom' | 'left') {
if (side === 'top') return node.paddingTop

View file

@ -55,12 +55,12 @@ export function hitTestInEditorScope(
} else {
return deep
? editor.graph.hitTestDeep(cx, cy, scopeId)
: editor.graph.hitTest(cx, cy, scopeId)
: editor.graph.hitTestSelectable(cx, cy, scopeId, editor.state.selectedIds)
}
}
return deep
? editor.graph.hitTestDeep(cx, cy, editor.state.currentPageId)
: editor.graph.hitTest(cx, cy, editor.state.currentPageId)
: editor.graph.hitTestSelectable(cx, cy, editor.state.currentPageId, editor.state.selectedIds)
}
export function isInsideEditorContainerBounds(

View file

@ -22,6 +22,13 @@ export interface HitTestFns {
hitTestFrameTitle: (cx: number, cy: number) => SceneNode | null
}
/** Clears the selection and starts a marquee in the open frame or section under the press. */
function startMarquee(cx: number, cy: number, editor: Editor, setDrag: (d: DragState) => void) {
editor.clearSelection()
const container = editor.graph.hitTestOpenContainer(cx, cy, editor.state.currentPageId)
setDrag({ type: 'marquee', startX: cx, startY: cy, containerId: container?.id })
}
export function handleSelectDown(
e: MouseEvent,
cx: number,
@ -52,12 +59,9 @@ export function handleSelectDown(
return
}
const hit = resolveHit(cx, cy, editor, fns)
const hit = resolveHit(cx, cy, editor, fns, e.metaKey || e.ctrlKey)
if (!hit) {
if (!editor.state.enteredContainerId) {
editor.clearSelection()
setDrag({ type: 'marquee', startX: cx, startY: cy })
}
if (!editor.state.enteredContainerId) startMarquee(cx, cy, editor, setDrag)
return
}

View file

@ -3,11 +3,13 @@ import type { SceneNode } from '@open-pencil/scene-graph'
import type { HitTestFns } from '#vue/shared/input/select'
/** The layer a press selects; `deep` (Cmd or Ctrl held) reaches the deepest layer, as in Figma. */
export function resolveHit(
cx: number,
cy: number,
editor: Editor,
fns: HitTestFns
fns: HitTestFns,
deep = false
): SceneNode | null {
const titleHit =
fns.hitTestFrameTitle(cx, cy) ??
@ -15,7 +17,7 @@ export function resolveHit(
fns.hitTestComponentLabel(cx, cy)
if (titleHit) return titleHit
const hit = fns.hitTestInScope(cx, cy, false)
const hit = fns.hitTestInScope(cx, cy, deep)
if (hit) return hit
const scopeId = editor.state.enteredContainerId
@ -27,7 +29,7 @@ export function resolveHit(
}
editor.exitContainer()
const afterExit = fns.hitTestInScope(cx, cy, false)
const afterExit = fns.hitTestInScope(cx, cy, deep)
if (afterExit) return afterExit
if (editor.state.enteredContainerId) {

View file

@ -85,6 +85,8 @@ export interface DragMarquee {
type: 'marquee'
startX: number
startY: number
/** The open frame or section the marquee started in, whose layers it selects. */
containerId?: string
}
export interface DragRotate {

View file

@ -0,0 +1,42 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { createEditor, type Editor } from '@open-pencil/core/editor'
import { handleMarqueeMove } from '#vue/canvas/transform/marquee'
let editor: Editor
afterEach(() => editor.dispose())
function marquee(from: [number, number], to: [number, number], containerId?: string) {
editor.clearSelection()
handleMarqueeMove(editor, { type: 'marquee', startX: from[0], startY: from[1], containerId }, ...to)
return [...editor.state.selectedIds]
}
describe('marquee selection', () => {
test('on the page, encloses a rotated frame by its drawn bounds', () => {
editor = createEditor()
const page = editor.state.currentPageId
// 200×100 turned a quarter about its center: drawn at x 50…150, y −50…150.
const frame = editor.graph.createNode('FRAME', page, { width: 200, height: 100, rotation: 90 })
editor.graph.createNode('RECTANGLE', frame.id, { width: 20, height: 20 })
expect(marquee([-10, -10], [210, 110])).toEqual([])
expect(marquee([40, -60], [160, 160])).toEqual([frame.id])
})
test('inside a rotated frame, selects the children the marquee touches on the canvas', () => {
editor = createEditor()
const page = editor.state.currentPageId
const frame = editor.graph.createNode('FRAME', page, { width: 200, height: 100, rotation: 90 })
// Local (150…180, 40…60) is drawn near the bottom of the turned frame, around y 100…130.
const child = editor.graph.createNode('RECTANGLE', frame.id, {
x: 150,
y: 40,
width: 30,
height: 20
})
expect(marquee([60, 90], [140, 140], frame.id)).toEqual([child.id])
expect(marquee([60, -40], [140, 0], frame.id)).toEqual([])
})
})

View file

@ -72,17 +72,19 @@ test.describe.serial('large-document performance', () => {
const lastPosition = graph.getAbsolutePosition(lastNode.id)
const hitTestMissMs = average(() => {
graph.hitTest(
graph.hitTestSelectable(
profile.worldWidth + 100,
profile.worldHeight + 100,
store.state.currentPageId
store.state.currentPageId,
store.state.selectedIds
)
})
const hitTestHitMs = average(() => {
graph.hitTest(
graph.hitTestSelectable(
lastPosition.x + lastNode.width / 2,
lastPosition.y + lastNode.height / 2,
store.state.currentPageId
store.state.currentPageId,
store.state.selectedIds
)
})
const cachedFrameMs = average(() => {

View file

@ -4,10 +4,10 @@ export async function measureHitTesting(page: Page, run: () => Promise<void>) {
const probe = await page.evaluateHandle(() => {
const graph = window.openPencil?.getStore?.().graph
if (!graph) throw new Error('OpenPencil graph not initialized')
const original = graph.hitTest
const original = graph.hitTestSelectable
let calls = 0
let totalMs = 0
graph.hitTest = (...args) => {
graph.hitTestSelectable = (...args) => {
const start = performance.now()
const result = original.apply(graph, args)
totalMs += performance.now() - start
@ -17,7 +17,7 @@ export async function measureHitTesting(page: Page, run: () => Promise<void>) {
return {
read: () => ({ calls, totalMs }),
dispose: () => {
graph.hitTest = original
graph.hitTestSelectable = original
}
}
})