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:
parent
69dbc36a7e
commit
24db186ac8
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
92
packages/scene-graph/tests/basic/selectable-hit.test.ts
Normal file
92
packages/scene-graph/tests/basic/selectable-hit.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
42
packages/vue/tests/canvas/transform/marquee.test.ts
Normal file
42
packages/vue/tests/canvas/transform/marquee.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue