* 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.
150 lines
5.4 KiB
TypeScript
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([])
|
|
})
|
|
}
|
|
})
|