diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a92a7f8f..c1c68c05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/editor/selection/hit-test.ts b/packages/core/src/editor/selection/hit-test.ts index 8013e9c68..4681f30fc 100644 --- a/packages/core/src/editor/selection/hit-test.ts +++ b/packages/core/src/editor/selection/hit-test.ts @@ -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) { diff --git a/packages/docs/user-guide/selection-and-manipulation.md b/packages/docs/user-guide/selection-and-manipulation.md index d18b54c62..5c7a5fea4 100644 --- a/packages/docs/user-guide/selection-and-manipulation.md +++ b/packages/docs/user-guide/selection-and-manipulation.md @@ -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 +- ⌘ + click (Ctrl + click on Windows and Linux) selects the deepest layer under the cursor - Shift + 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 - ⌘A — select all nodes on the current page - **Click empty canvas** — deselect all diff --git a/packages/scene-graph/src/hit-test.ts b/packages/scene-graph/src/hit-test.ts index 2033c1ea0..e43418019 100644 --- a/packages/scene-graph/src/hit-test.ts +++ b/packages/scene-graph/src/hit-test.ts @@ -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 +): 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() + 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) diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 3c00a28d9..085660878 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -701,6 +701,23 @@ export class SceneGraph { return HitTest.hitTestDeep(this, px, py, scopeId) } + hitTestSelectable( + px: number, + py: number, + scopeId: string, + selectedIds: ReadonlySet + ): 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) } diff --git a/packages/scene-graph/tests/basic/selectable-hit.test.ts b/packages/scene-graph/tests/basic/selectable-hit.test.ts new file mode 100644 index 000000000..68784257f --- /dev/null +++ b/packages/scene-graph/tests/basic/selectable-hit.test.ts @@ -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) => + 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) + }) +}) diff --git a/packages/vue/src/canvas/text-edit/input.ts b/packages/vue/src/canvas/text-edit/input.ts index 31cd5b15f..10ed18580 100644 --- a/packages/vue/src/canvas/text-edit/input.ts +++ b/packages/vue/src/canvas/text-edit/input.ts @@ -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 { diff --git a/packages/vue/src/canvas/transform/actions.ts b/packages/vue/src/canvas/transform/actions.ts index 0900d9687..225aa76ab 100644 --- a/packages/vue/src/canvas/transform/actions.ts +++ b/packages/vue/src/canvas/transform/actions.ts @@ -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 { diff --git a/packages/vue/src/canvas/transform/marquee.ts b/packages/vue/src/canvas/transform/marquee.ts index 1c815e3f6..0418af227 100644 --- a/packages/vue/src/canvas/transform/marquee.ts +++ b/packages/vue/src/canvas/transform/marquee.ts @@ -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) } diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index fb65b085a..64dc85972 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -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 diff --git a/packages/vue/src/shared/input/geometry.ts b/packages/vue/src/shared/input/geometry.ts index cc2274bc8..b05b65ac7 100644 --- a/packages/vue/src/shared/input/geometry.ts +++ b/packages/vue/src/shared/input/geometry.ts @@ -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( diff --git a/packages/vue/src/shared/input/select.ts b/packages/vue/src/shared/input/select.ts index 9c686f215..f2af663be 100644 --- a/packages/vue/src/shared/input/select.ts +++ b/packages/vue/src/shared/input/select.ts @@ -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 } diff --git a/packages/vue/src/shared/input/select/hit.ts b/packages/vue/src/shared/input/select/hit.ts index fa2b80ea3..818c20de3 100644 --- a/packages/vue/src/shared/input/select/hit.ts +++ b/packages/vue/src/shared/input/select/hit.ts @@ -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) { diff --git a/packages/vue/src/shared/input/types.ts b/packages/vue/src/shared/input/types.ts index f97e95d01..508f3ce98 100644 --- a/packages/vue/src/shared/input/types.ts +++ b/packages/vue/src/shared/input/types.ts @@ -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 { diff --git a/packages/vue/tests/canvas/transform/marquee.test.ts b/packages/vue/tests/canvas/transform/marquee.test.ts new file mode 100644 index 000000000..9e3e4b802 --- /dev/null +++ b/packages/vue/tests/canvas/transform/marquee.test.ts @@ -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([]) + }) +}) diff --git a/tests/e2e/perf/large-document.spec.ts b/tests/e2e/perf/large-document.spec.ts index 80edd2d38..74539bb59 100644 --- a/tests/e2e/perf/large-document.spec.ts +++ b/tests/e2e/perf/large-document.spec.ts @@ -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(() => { diff --git a/tests/helpers/performance/hit-testing.ts b/tests/helpers/performance/hit-testing.ts index 2b80e40e2..e26d7afb6 100644 --- a/tests/helpers/performance/hit-testing.ts +++ b/tests/helpers/performance/hit-testing.ts @@ -4,10 +4,10 @@ export async function measureHitTesting(page: Page, run: () => Promise) { 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) { return { read: () => ({ calls, totalMs }), dispose: () => { - graph.hitTest = original + graph.hitTestSelectable = original } } })