* fix(canvas): show every top-level frame's name and select frames by it Since clicks follow Figma's depth, the empty part of a top-level frame that holds layers selects nothing, but a frame's name was drawn and hit-tested only while that frame was already selected. Once a top-level frame held layers it could not be selected from the canvas, as when one frame is dragged into another and the outer frame is then out of reach. Every frame on the page or in a section now shows its name, faded in the canvas's text color and in the selection color while selected or hovered, from the same viewport-culled label catalog as section and component labels. Its name is a hit target whether or not the frame is selected, so clicking it selects the frame, dragging it moves the frame, and hovering it highlights the frame; locked frames stay out of reach. SkiaRenderer.hitTestFrameTitle no longer takes the selected IDs. * fix(canvas): draw and hit labels whose node is just outside the view Label catalogs culled nodes by their own bounds, but frame, section, and component names sit outside the node, so a node just below the view hid a name that was on screen and could not be clicked. Label lookups now use the viewport widened by how far labels reach. Presses and hover also test component labels, then section titles, then frame names, the reverse of the order they are drawn, so overlapping labels pick the one on top.
166 lines
5.8 KiB
TypeScript
166 lines
5.8 KiB
TypeScript
import { expect, test } from 'bun:test'
|
|
|
|
import type { Font } from 'canvaskit-wasm'
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
import Matrix from '@open-pencil/scene-graph/matrix'
|
|
|
|
import { hitTestComponentLabel, hitTestFrameTitle } from '#core/canvas/labels/hit-test'
|
|
import { labelLayout } from '#core/canvas/labels/layout'
|
|
import {
|
|
frameLabelPlacement,
|
|
labelLocalPoint,
|
|
labelScreenMatrix
|
|
} from '#core/canvas/labels/transform'
|
|
import { createSceneGeometry } from '#core/geometry'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
import { nestedGeometryFixture } from '#tests/helpers/geometry'
|
|
|
|
const font = {
|
|
getGlyphIDs(text: string) {
|
|
return Uint16Array.from([...text].map((_, i) => i))
|
|
},
|
|
getGlyphWidths(ids: Uint16Array) {
|
|
return new Float32Array(ids.length).fill(8)
|
|
}
|
|
} as Font
|
|
|
|
test('layout bounds include clipped text, padding and the entire component icon', () => {
|
|
const section = expectDefined(
|
|
labelLayout('section', 60, false, { width: 100, height: 14 }),
|
|
'section layout'
|
|
)
|
|
expect(section.bounds).toEqual({ x: 0, y: -30, width: 60, height: 24 })
|
|
expect(section.text).toEqual({ x: 6, y: -25 })
|
|
expect(section.maxTextWidth).toBe(48)
|
|
const nested = expectDefined(
|
|
labelLayout('section', 60, true, { width: 100, height: 14 }),
|
|
'nested section layout'
|
|
)
|
|
expect(nested.bounds).toEqual({ x: 6, y: 6, width: 54, height: 24 })
|
|
expect(nested.text).toEqual({ x: 12, y: 11 })
|
|
expect(nested.maxTextWidth).toBe(42)
|
|
expect(nested.fontWeight).toBe(600)
|
|
const component = expectDefined(
|
|
labelLayout('component', 80, false, { width: 100, height: 11 }),
|
|
'component layout'
|
|
)
|
|
const icon = expectDefined(component.icon, 'icon')
|
|
expect(component.bounds.width).toBe(80)
|
|
expect(component.bounds.y + component.bounds.height).toBeGreaterThanOrEqual(icon.y + icon.height)
|
|
expect(labelLayout('component', 10)).toBeNull()
|
|
})
|
|
|
|
for (const flipX of [false, true]) {
|
|
for (const flipY of [false, true]) {
|
|
test(`label placement stays outside reflected bounds and shares its inverse (flipX=${flipX}, flipY=${flipY})`, () => {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
const { section, frame } = nestedGeometryFixture(graph, page.id, flipX, flipY)
|
|
const preview = { nodeId: section.id, angle: 70 }
|
|
const transform = frameLabelPlacement(frame, graph, preview)
|
|
const screen = Matrix.mapPoint(labelScreenMatrix(transform, { panX: 0, panY: 0, zoom: 2 }), {
|
|
x: 4,
|
|
y: -10
|
|
})
|
|
const world = { x: screen.x / 2, y: screen.y / 2 }
|
|
const local = expectDefined(labelLocalPoint(transform, 2, world), 'label-local point')
|
|
expect(local.x).toBeCloseTo(4, 9)
|
|
expect(local.y).toBeCloseTo(-10, 9)
|
|
const nodeLocal = expectDefined(
|
|
createSceneGeometry(graph, preview).toLocal(frame, world),
|
|
'node-local point'
|
|
)
|
|
expect(
|
|
nodeLocal.x < 0 ||
|
|
nodeLocal.x > frame.width ||
|
|
nodeLocal.y < 0 ||
|
|
nodeLocal.y > frame.height
|
|
).toBe(true)
|
|
expect(transform.rotation).toBeGreaterThanOrEqual(-45)
|
|
expect(transform.rotation).toBeLessThan(45)
|
|
expect(
|
|
hitTestFrameTitle(graph, world.x, world.y, 2, page.id, font, undefined, { preview })?.id
|
|
).toBe(frame.id)
|
|
})
|
|
}
|
|
}
|
|
|
|
test('hidden component labels and the clipped-away part of long titles are not hit targets', () => {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
const frame = graph.createNode('FRAME', page.id, {
|
|
name: 'A very long title that is clipped',
|
|
x: 100,
|
|
y: 100,
|
|
width: 50,
|
|
height: 100
|
|
})
|
|
expect(hitTestFrameTitle(graph, 140, 90, 1, page.id, font)?.id).toBe(frame.id)
|
|
expect(hitTestFrameTitle(graph, 170, 90, 1, page.id, font)).toBeNull()
|
|
graph.createNode('COMPONENT', frame.id, { name: 'Not drawn here', width: 80, height: 40 })
|
|
expect(hitTestComponentLabel(graph, 104, 90, 1, page.id, font)).toBeNull()
|
|
})
|
|
|
|
test('every frame on the page or in a section is hit by its name, selected or not', () => {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
const outer = graph.createNode('FRAME', page.id, {
|
|
name: 'Outer',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 200
|
|
})
|
|
const inner = graph.createNode('FRAME', outer.id, {
|
|
name: 'Inner',
|
|
x: 20,
|
|
y: 40,
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
const section = graph.createNode('SECTION', page.id, {
|
|
name: 'Flows',
|
|
x: 400,
|
|
y: 100,
|
|
width: 300,
|
|
height: 300
|
|
})
|
|
const inSection = graph.createNode('FRAME', section.id, {
|
|
name: 'Step',
|
|
x: 20,
|
|
y: 60,
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
|
|
// No selection: the name above a frame holding layers still selects it.
|
|
expect(hitTestFrameTitle(graph, 104, 90, 1, page.id, font)?.id).toBe(outer.id)
|
|
expect(hitTestFrameTitle(graph, 424, 150, 1, page.id, font)?.id).toBe(inSection.id)
|
|
// A frame inside another frame shows no name, so the spot above it is not a target.
|
|
expect(hitTestFrameTitle(graph, 124, 130, 1, page.id, font)).toBeNull()
|
|
expect(inner.parentId).toBe(outer.id)
|
|
|
|
// Locked frames cannot be picked on the canvas, by their names either.
|
|
graph.updateNode(outer.id, { locked: true })
|
|
expect(hitTestFrameTitle(graph, 104, 90, 1, page.id, font)).toBeNull()
|
|
})
|
|
|
|
test('a frame just outside the view whose name is inside it is still hit by its name', () => {
|
|
const graph = new SceneGraph()
|
|
const page = expectDefined(graph.getPages()[0], 'page')
|
|
const frame = graph.createNode('FRAME', page.id, {
|
|
name: 'Below',
|
|
x: 100,
|
|
y: 205,
|
|
width: 200,
|
|
height: 100
|
|
})
|
|
// The view ends at y = 200, above the frame; its name sits just above the frame, in view.
|
|
const viewport = { x: 0, y: 0, w: 400, h: 200 }
|
|
expect(hitTestFrameTitle(graph, 104, 197, 1, page.id, font, undefined, { viewport })?.id).toBe(
|
|
frame.id
|
|
)
|
|
})
|