openpencil/tests/e2e/perf/large-document.spec.ts
Danila Poyarkov 24db186ac8
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.
2026-10-05 12:39:04 +00:00

150 lines
5.4 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { seedLargeDocument } from '#tests/helpers/large-document'
import { measureHitTesting } from '#tests/helpers/performance/hit-testing'
type TimingSummary = {
hitTestMissMs: number
hitTestHitMs: number
cachedFrameMs: number
overlayFrameMs: number
volatileFrameMs: number
volatileMode: string
}
type PerformanceProfile = {
timings: TimingSummary
pointer: { calls: number; totalMs: number }
}
const SCALES = [500, 2000]
const profiles = new Map<number, PerformanceProfile>()
test.describe.serial('large-document performance', () => {
for (const nodeCount of SCALES) {
test(`profiles representative ${nodeCount}-node interactions`, async ({ page }, testInfo) => {
test.setTimeout(120_000)
await page.goto('/?test&no-chrome&no-rulers')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
await canvas.clearCanvas()
const fixture = await seedLargeDocument(page, nodeCount)
await canvas.waitForRender()
const bounds = await canvas.canvas.boundingBox()
if (!bounds) throw new Error('Canvas bounds unavailable')
const pointerProfile = await measureHitTesting(page, async () => {
await page.mouse.move(bounds.x + 10, bounds.y + 10)
await page.mouse.move(bounds.x + bounds.width - 10, bounds.y + bounds.height - 10, {
steps: 40
})
})
const result = await page.evaluate((profile): Promise<TimingSummary> => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const renderer = store.renderer
if (!renderer) throw new Error('OpenPencil renderer not initialized')
const graph = store.graph
const iterations = 50
function average(run: () => void) {
const startedAt = performance.now()
for (let index = 0; index < iterations; index++) run()
return (performance.now() - startedAt) / iterations
}
renderer.dpr = window.devicePixelRatio || 1
renderer.panX = store.state.panX
renderer.panY = store.state.panY
renderer.zoom = store.state.zoom
renderer.viewportWidth = 1280
renderer.viewportHeight = 800
renderer.showRulers = false
renderer.pageColor = store.state.pageColor
renderer.pageId = store.state.currentPageId
renderer.render(graph, store.state.selectedIds, {}, store.state.sceneVersion)
const lastId = profile.leafIds.at(-1)
const lastNode = lastId ? graph.getNode(lastId) : null
if (!lastNode) throw new Error('Large-document target not found')
const lastPosition = graph.getAbsolutePosition(lastNode.id)
const hitTestMissMs = average(() => {
graph.hitTestSelectable(
profile.worldWidth + 100,
profile.worldHeight + 100,
store.state.currentPageId,
store.state.selectedIds
)
})
const hitTestHitMs = average(() => {
graph.hitTestSelectable(
lastPosition.x + lastNode.width / 2,
lastPosition.y + lastNode.height / 2,
store.state.currentPageId,
store.state.selectedIds
)
})
const cachedFrameMs = average(() => {
renderer.render(graph, store.state.selectedIds, {}, store.state.sceneVersion)
})
const overlayFrameMs = average(() => {
renderer.render(
graph,
store.state.selectedIds,
{ hoveredNodeId: lastNode.id },
store.state.sceneVersion
)
})
const volatileFrameMs = average(() => {
renderer.render(
graph,
store.state.selectedIds,
{ rotationPreview: { nodeId: lastNode.id, angle: 1 } },
store.state.sceneVersion
)
})
return {
hitTestMissMs,
hitTestHitMs,
cachedFrameMs,
overlayFrameMs,
volatileFrameMs,
volatileMode: renderer.profiler.stats.scenePictureMode
}
}, fixture)
const profile = { timings: result, pointer: pointerProfile }
profiles.set(nodeCount, profile)
await testInfo.attach(`large-document-${nodeCount}.json`, {
body: JSON.stringify(profile, null, 2),
contentType: 'application/json'
})
expect(fixture.nodeCount).toBe(nodeCount)
expect(pointerProfile.calls).toBeGreaterThan(0)
expect(result.volatileMode).toBe('volatile')
expect(result.hitTestMissMs).toBeGreaterThanOrEqual(0)
expect(result.hitTestHitMs).toBeGreaterThanOrEqual(0)
expect(result.cachedFrameMs).toBeGreaterThanOrEqual(0)
expect(result.overlayFrameMs).toBeGreaterThanOrEqual(0)
expect(result.volatileFrameMs).toBeGreaterThanOrEqual(0)
const smallerProfile = profiles.get(SCALES[0])
if (nodeCount !== SCALES[0] && smallerProfile) {
const scaleRatio = nodeCount / SCALES[0]
expect(result.hitTestMissMs).toBeLessThanOrEqual(
Math.max(smallerProfile.timings.hitTestMissMs * scaleRatio * 2, 1)
)
expect(pointerProfile.totalMs).toBeLessThanOrEqual(
Math.max(smallerProfile.pointer.totalMs * scaleRatio * 2, 10)
)
}
expect(canvas.errors.filter((error) => !error.includes('127.0.0.1:7600'))).toEqual([])
})
}
})