diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df1b1de8..3335feb92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,24 @@ ## Unreleased +### Features + +- Lock and visibility toggle buttons in layers panel (hover to reveal, always shown when active) +- Figma-style selection scope — double-click to enter groups/frames/components, Escape to exit +- Nested container navigation — each double-click goes one level deeper +- Dashed border around entered container for visual feedback +- Layer panel click syncs canvas scope automatically + ### Fixes - Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions - Fix save crash when COLOR variable is missing alpha channel - Fix console error spam on deployed web app from automation WebSocket reconnect loop - Fix headless CLI font fallback — bundled Inter font now ships with `@open-pencil/core` and loads without a web server +- Locked nodes now block move, resize, rotate, and delete on canvas +- Locked containers block double-click enter +- Marquee selection skips locked and hidden nodes +- COMPONENT/INSTANCE containers are now enterable via double-click ## 0.10.0 — 2026-03-15 diff --git a/packages/core/src/renderer/overlays.ts b/packages/core/src/renderer/overlays.ts index f41f931d5..e0a473b45 100644 --- a/packages/core/src/renderer/overlays.ts +++ b/packages/core/src/renderer/overlays.ts @@ -61,6 +61,40 @@ export function drawHoverHighlight( canvas.restore() } +export function drawEnteredContainer( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + enteredContainerId?: string | null +): void { + if (!enteredContainerId) return + const node = graph.getNode(enteredContainerId) + if (!node) return + + const abs = graph.getAbsolutePosition(node.id) + const sx = abs.x * r.zoom + r.panX + const sy = abs.y * r.zoom + r.panY + + r.auxStroke.setStrokeWidth(1) + r.auxStroke.setColor(r.selColor(SELECTION_DASH_ALPHA)) + r.auxStroke.setPathEffect(r.ck.PathEffect.MakeDash([4, 4], 0)) + + canvas.save() + canvas.translate(sx, sy) + if (node.rotation !== 0) { + const cx = (node.width / 2) * r.zoom + const cy = (node.height / 2) * r.zoom + canvas.rotate(node.rotation, cx, cy) + } + canvas.drawRect( + r.ck.LTRBRect(0, 0, node.width * r.zoom, node.height * r.zoom), + r.auxStroke + ) + canvas.restore() + + r.auxStroke.setPathEffect(null) +} + export function drawSelection( r: SkiaRenderer, canvas: Canvas, diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 01429223e..24078f143 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -52,6 +52,7 @@ import type { import { drawHoverHighlight as drawHoverHighlightFn, + drawEnteredContainer as drawEnteredContainerFn, drawSelection as drawSelectionFn, drawNodeSelection as drawNodeSelectionFn, drawSelectionLabels as drawSelectionLabelsFn, @@ -127,6 +128,7 @@ import { export interface RenderOverlays { hoveredNodeId?: string | null + enteredContainerId?: string | null editingTextId?: string | null textEditor?: TextEditor | null marquee?: Rect | null @@ -750,6 +752,7 @@ export class SkiaRenderer { canvas.scale(this.dpr, this.dpr) this.drawHoverHighlight(canvas, graph, overlays.hoveredNodeId) + this.drawEnteredContainer(canvas, graph, overlays.enteredContainerId) p.beginPhase('render:selection') this.drawSelection(canvas, graph, selectedIds, overlays) p.endPhase('render:selection') @@ -916,6 +919,10 @@ export class SkiaRenderer { drawHoverHighlightFn(this, canvas, graph, hoveredNodeId) } + private drawEnteredContainer(canvas: Canvas, graph: SceneGraph, enteredContainerId?: string | null): void { + drawEnteredContainerFn(this, canvas, graph, enteredContainerId) + } + private drawSelection(canvas: Canvas, graph: SceneGraph, selectedIds: Set, overlays: RenderOverlays): void { drawSelectionFn(this, canvas, graph, selectedIds, overlays) } diff --git a/packages/core/src/scene-graph-hit-test.ts b/packages/core/src/scene-graph-hit-test.ts index ab484732b..cb82b158a 100644 --- a/packages/core/src/scene-graph-hit-test.ts +++ b/packages/core/src/scene-graph-hit-test.ts @@ -40,8 +40,12 @@ function hitTestTransparentContainer( ay: number, deep: boolean ): SceneNode | null { - const deepHit = hitTestChildren(graph, px, py, childId, ax, ay, deep) - if (deepHit) return deepHit + const childHit = hitTestChildren(graph, px, py, childId, ax, ay, deep) + if (childHit) { + if (!deep && child.type === 'GROUP') return child + if (child.locked) return child + return childHit + } if (child.type === 'GROUP') return null if (containsPoint(px, py, ax, ay, child) && hasVisibleFillOrStroke(child)) return child return null diff --git a/src/components/CanvasContextMenu.vue b/src/components/CanvasContextMenu.vue index 354fd72d6..c577a35bd 100644 --- a/src/components/CanvasContextMenu.vue +++ b/src/components/CanvasContextMenu.vue @@ -15,7 +15,15 @@ function onRightClick(e: MouseEvent) { const sy = e.clientY - rect.top const { x: cx, y: cy } = store.screenToCanvas(sx, sy) - const hit = store.graph.hitTest(cx, cy, store.state.currentPageId) + let hit = null + const scopeId = store.state.enteredContainerId + if (scopeId && store.graph.getNode(scopeId)) { + const abs = store.graph.getAbsolutePosition(scopeId) + hit = store.graph.hitTest(cx - abs.x, cy - abs.y, scopeId) + } + if (!hit) { + hit = store.graph.hitTest(cx, cy, store.state.currentPageId) + } if (hit) { if (!store.state.selectedIds.has(hit.id)) { store.select([hit.id]) diff --git a/src/components/LayerTree.vue b/src/components/LayerTree.vue index dde509e2e..76467341d 100644 --- a/src/components/LayerTree.vue +++ b/src/components/LayerTree.vue @@ -32,6 +32,7 @@ interface LayerNode { type: string layoutMode: string visible: boolean + locked: boolean children?: LayerNode[] } @@ -64,6 +65,16 @@ function nodeIcon(node: LayerNode) { const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE']) +function toggleNodeVisibility(id: string) { + const node = store.graph.getNode(id) + if (node) store.updateNodeWithUndo(id, { visible: !node.visible }, node.visible ? 'Hide layer' : 'Show layer') +} + +function toggleNodeLock(id: string) { + const node = store.graph.getNode(id) + if (node) store.updateNodeWithUndo(id, { locked: !node.locked }, node.locked ? 'Unlock layer' : 'Lock layer') +} + function buildTree(parentId: string): LayerNode[] { const parent = store.graph.getNode(parentId) if (!parent) return [] @@ -76,6 +87,7 @@ function buildTree(parentId: string): LayerNode[] { type: node.type, layoutMode: node.layoutMode, visible: node.visible, + locked: node.locked, children: node.childIds.length > 0 ? buildTree(node.id) : undefined })) } @@ -113,6 +125,21 @@ watch( } ) +function syncCanvasScope(nodeId: string) { + const node = store.graph.getNode(nodeId) + if (!node) return + let parentId = node.parentId + while (parentId && parentId !== store.state.currentPageId) { + if (store.graph.isContainer(parentId)) { + store.enterContainer(parentId) + return + } + const parent = store.graph.getNode(parentId) + parentId = parent?.parentId + } + store.state.enteredContainerId = null +} + function onSelect(ev: CustomEvent) { ev.preventDefault() const node = ev.detail.value as LayerNode @@ -120,6 +147,7 @@ function onSelect(ev: CustomEvent) { store.select([node.id], true) } else { store.select([node.id]) + syncCanvasScope(node.id) } } @@ -365,7 +393,7 @@ function updateDropTarget(ev: PointerEvent) { diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index ec0e4f745..8f9afb5eb 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -10,7 +10,7 @@ import { DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT } from '@/constants' -import { computeSelectionBounds, computeSnap } from '@open-pencil/core' +import { computeSelectionBounds, computeSnap, degToRad } from '@open-pencil/core' import type { EditorStore, Tool } from '@/stores/editor' import type { NodeType, Rect, SceneNode, Vector } from '@open-pencil/core' @@ -294,6 +294,50 @@ export function useCanvasInput( return { sx, sy, cx, cy } } + function canvasToLocal(cx: number, cy: number, scopeId: string): { lx: number; ly: number } { + const node = store.graph.getNode(scopeId) + if (!node) return { lx: cx, ly: cy } + const abs = store.graph.getAbsolutePosition(scopeId) + let dx = cx - abs.x + let dy = cy - abs.y + if (node.rotation !== 0) { + const hw = node.width / 2 + const hh = node.height / 2 + const rad = degToRad(-node.rotation) + const cos = Math.cos(rad) + const sin = Math.sin(rad) + const rx = dx - hw + const ry = dy - hh + dx = rx * cos - ry * sin + hw + dy = rx * sin + ry * cos + hh + } + return { lx: dx, ly: dy } + } + + function hitTestInScope(cx: number, cy: number, deep: boolean): SceneNode | null { + const scopeId = store.state.enteredContainerId + if (scopeId) { + if (!store.graph.getNode(scopeId)) { + store.state.enteredContainerId = null + } else { + const { lx, ly } = canvasToLocal(cx, cy, scopeId) + return deep + ? store.graph.hitTestDeep(lx, ly, scopeId) + : store.graph.hitTest(lx, ly, scopeId) + } + } + return deep + ? store.graph.hitTestDeep(cx, cy, store.state.currentPageId) + : store.graph.hitTest(cx, cy, store.state.currentPageId) + } + + function isInsideContainerBounds(cx: number, cy: number, containerId: string): boolean { + const container = store.graph.getNode(containerId) + if (!container) return false + const { lx, ly } = canvasToLocal(cx, cy, containerId) + return lx >= 0 && lx <= container.width && ly >= 0 && ly <= container.height + } + function startPanDrag(e: MouseEvent) { drag.value = { type: 'pan', @@ -336,7 +380,7 @@ export function useCanvasInput( if (store.state.selectedIds.size !== 1) return false const id = [...store.state.selectedIds][0] const node = store.graph.getNode(id) - if (!node) return false + if (!node || node.locked) return false const abs = store.graph.getAbsolutePosition(id) if ( !hitTestCornerRotation( @@ -371,7 +415,7 @@ export function useCanvasInput( function tryStartResize(sx: number, sy: number, cx: number, cy: number): boolean { for (const id of store.state.selectedIds) { const node = store.graph.getNode(id) - if (!node) continue + if (!node || node.locked) continue const abs = store.graph.getAbsolutePosition(id) const handle = hitTestHandle( sx, @@ -451,6 +495,34 @@ export function useCanvasInput( return undefined } + function resolveHit(cx: number, cy: number): SceneNode | null { + const titleHit = + hitTestFrameTitle(cx, cy) ?? + hitTestSectionTitle(cx, cy) ?? + hitTestComponentLabel(cx, cy) + if (titleHit) return titleHit + + const hit = hitTestInScope(cx, cy, false) + if (hit) return hit + + const scopeId = store.state.enteredContainerId + if (!scopeId) return null + + if (isInsideContainerBounds(cx, cy, scopeId)) { + store.clearSelection() + return null + } + + store.exitContainer() + const afterExit = hitTestInScope(cx, cy, false) + if (afterExit) return afterExit + + if (store.state.enteredContainerId) { + store.exitContainer() + } + return null + } + function handleSelectDown(e: MouseEvent, sx: number, sy: number, cx: number, cy: number) { if (store.state.editingTextId && handleTextEditClick(cx, cy, e.shiftKey)) return @@ -459,15 +531,12 @@ export function useCanvasInput( if (tryStartRotation(sx, sy)) return if (tryStartResize(sx, sy, cx, cy)) return - const hit = - hitTestFrameTitle(cx, cy) ?? - hitTestSectionTitle(cx, cy) ?? - hitTestComponentLabel(cx, cy) ?? - store.graph.hitTest(cx, cy, store.state.currentPageId) - + const hit = resolveHit(cx, cy) if (!hit) { - store.clearSelection() - drag.value = { type: 'marquee', startX: cx, startY: cy } + if (!store.state.enteredContainerId) { + store.clearSelection() + drag.value = { type: 'marquee', startX: cx, startY: cy } + } return } @@ -477,6 +546,9 @@ export function useCanvasInput( store.select([hit.id], true) } + const allLocked = [...store.state.selectedIds].every((id) => store.graph.getNode(id)?.locked) + if (allLocked) return + const originals = new Map() for (const id of store.state.selectedIds) { const n = store.graph.getNode(id) @@ -611,7 +683,7 @@ export function useCanvasInput( const hit = hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? - store.graph.hitTest(cx, cy, store.state.currentPageId) + hitTestInScope(cx, cy, false) store.setHoveredNode(hit && !store.state.selectedIds.has(hit.id) ? hit.id : null) } @@ -776,13 +848,23 @@ export function useCanvasInput( const maxX = Math.max(d.startX, cx) const maxY = Math.max(d.startY, cy) + const scopeId = store.state.enteredContainerId + const parentId = scopeId ?? store.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) + const hits: string[] = [] - for (const node of store.graph.getChildren(store.state.currentPageId)) { + for (const node of store.graph.getChildren(parentId)) { + if (!node.visible || node.locked) continue if ( - node.x + node.width > minX && - node.x < maxX && - node.y + node.height > minY && - node.y < maxY + node.x + node.width > localMinX && + node.x < localMaxX && + node.y + node.height > localMinY && + node.y < localMaxY ) { hits.push(node.id) } @@ -1078,10 +1160,29 @@ export function useCanvasInput( if (store.state.editingTextId) return const { cx, cy } = getCoords(e) - const hit = - hitTestSectionTitle(cx, cy) ?? + + const selectedId = store.state.selectedIds.size === 1 + ? [...store.state.selectedIds][0] + : undefined + const selectedNode = selectedId ? store.graph.getNode(selectedId) : undefined + const canEnter = selectedNode && selectedId + && store.graph.isContainer(selectedId) && !selectedNode.locked + + if (canEnter) { + store.enterContainer(selectedId) + const useDeep = selectedNode.type === 'COMPONENT' || selectedNode.type === 'INSTANCE' + const hit = hitTestInScope(cx, cy, useDeep) + if (hit) { + store.select([hit.id]) + } else { + store.clearSelection() + } + return + } + + const hit = hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? - store.graph.hitTestDeep(cx, cy, store.state.currentPageId) + hitTestInScope(cx, cy, true) if (!hit) return if (hit.type === 'TEXT') { diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts index 8585992a8..c537968d3 100644 --- a/src/composables/use-canvas.ts +++ b/src/composables/use-canvas.ts @@ -152,6 +152,7 @@ export function useCanvas(canvasRef: Ref, store: Edito store.state.selectedIds, { hoveredNodeId: store.state.hoveredNodeId, + enteredContainerId: store.state.enteredContainerId, editingTextId: store.state.editingTextId, textEditor: store.textEditor, marquee: store.state.marquee, diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index ef0e2e653..92fedd8fe 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -210,6 +210,10 @@ export function useKeyboard() { store.penCancel() return } + if (store.state.enteredContainerId) { + store.exitContainer() + return + } store.clearSelection() store.setTool('SELECT') }) diff --git a/src/stores/editor.ts b/src/stores/editor.ts index f2b1084b1..f97a441a7 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -204,7 +204,8 @@ export function createEditorStore() { actionToast: null as string | null, mobileDrawerSnap: 'closed' as 'closed' | 'half' | 'full', clipboardHtml: '', - autosaveEnabled: false + autosaveEnabled: false, + enteredContainerId: null as string | null }) const AUTOSAVE_DELAY = 3000 @@ -314,6 +315,7 @@ export function createEditorStore() { // Switch state.currentPageId = pageId + state.enteredContainerId = null clearSelection() // Restore viewport @@ -380,6 +382,29 @@ export function createEditorStore() { state.selectedIds = new Set() } + function validateEnteredContainer() { + if (state.enteredContainerId && !graph.getNode(state.enteredContainerId)) { + state.enteredContainerId = null + } + } + + function enterContainer(id: string) { + state.enteredContainerId = id + } + + function exitContainer() { + const entered = state.enteredContainerId + if (!entered) return + const node = graph.getNode(entered) + const parentId = node?.parentId + if (parentId && parentId !== state.currentPageId) { + state.enteredContainerId = parentId + } else { + state.enteredContainerId = null + } + state.selectedIds = new Set(entered ? [entered] : []) + } + function setMarquee(rect: Rect | null) { state.marquee = rect requestRepaint() @@ -2063,7 +2088,7 @@ export function createEditorStore() { const entries: Array<{ id: string; parentId: string; snapshot: SceneNode; index: number }> = [] for (const id of state.selectedIds) { const node = graph.getNode(id) - if (!node) continue + if (!node || node.locked) continue const parentId = node.parentId ?? state.currentPageId const parent = graph.getNode(parentId) const index = parent?.childIds.indexOf(id) ?? -1 @@ -2252,10 +2277,12 @@ export function createEditorStore() { function undoAction() { undo.undo() + validateEnteredContainer() } function redoAction() { undo.redo() + validateEnteredContainer() requestRender() } @@ -2375,6 +2402,9 @@ export function createEditorStore() { setTool, select, clearSelection, + enterContainer, + exitContainer, + validateEnteredContainer, selectAll, setMarquee, setSnapGuides, diff --git a/tests/engine/hit-test-scope.test.ts b/tests/engine/hit-test-scope.test.ts new file mode 100644 index 000000000..4dc0542b7 --- /dev/null +++ b/tests/engine/hit-test-scope.test.ts @@ -0,0 +1,386 @@ +import { describe, test, expect } from 'bun:test' + +import { SceneGraph } from '@open-pencil/core' + +function pageId(graph: SceneGraph) { + return graph.getPages()[0].id +} + +describe('hitTest — group behavior', () => { + function setup() { + const graph = new SceneGraph() + const page = pageId(graph) + const group = graph.createNode('GROUP', page, { + name: 'Group', + x: 100, + y: 100, + width: 200, + height: 200 + }) + const child = graph.createNode('RECTANGLE', group.id, { + name: 'Rect', + x: 10, + y: 10, + width: 50, + height: 50 + }) + return { graph, page, group, child } + } + + test('single click on child returns GROUP (not child)', () => { + const { graph, page, child } = setup() + const hit = graph.hitTest(110, 110, page) + expect(hit).not.toBeNull() + expect(hit!.type).toBe('GROUP') + }) + + test('hitTestDeep on child returns child directly', () => { + const { graph, page, child } = setup() + const hit = graph.hitTestDeep(110, 110, page) + expect(hit).not.toBeNull() + expect(hit!.id).toBe(child.id) + }) + + test('hitTest with scope=group returns child', () => { + const { graph, group } = setup() + const hit = graph.hitTest(10, 10, group.id) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('Rect') + }) + + test('hitTest miss inside group returns null', () => { + const { graph, group } = setup() + const hit = graph.hitTest(180, 180, group.id) + expect(hit).toBeNull() + }) + + test('click outside group returns null', () => { + const { graph, page } = setup() + const hit = graph.hitTest(50, 50, page) + expect(hit).toBeNull() + }) +}) + +describe('hitTest — nested groups', () => { + function setup() { + const graph = new SceneGraph() + const page = pageId(graph) + const groupA = graph.createNode('GROUP', page, { + name: 'GroupA', + x: 100, + y: 100, + width: 300, + height: 300 + }) + const groupB = graph.createNode('GROUP', groupA.id, { + name: 'GroupB', + x: 50, + y: 50, + width: 100, + height: 100 + }) + const rect = graph.createNode('RECTANGLE', groupB.id, { + name: 'DeepRect', + x: 10, + y: 10, + width: 30, + height: 30 + }) + return { graph, page, groupA, groupB, rect } + } + + test('click from page scope returns GroupA', () => { + const { graph, page } = setup() + const hit = graph.hitTest(160, 160, page) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('GroupA') + }) + + test('click with scope=GroupA returns GroupB', () => { + const { graph, groupA } = setup() + const hit = graph.hitTest(60, 60, groupA.id) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('GroupB') + }) + + test('click with scope=GroupB returns DeepRect', () => { + const { graph, groupB } = setup() + const hit = graph.hitTest(10, 10, groupB.id) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('DeepRect') + }) + + test('hitTestDeep from page returns deepest child', () => { + const { graph, page, rect } = setup() + const hit = graph.hitTestDeep(160, 160, page) + expect(hit).not.toBeNull() + expect(hit!.id).toBe(rect.id) + }) +}) + +describe('hitTest — locked nodes', () => { + test('locked container blocks enter (returns container, not child)', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const frame = graph.createNode('FRAME', page, { + name: 'LockedFrame', + x: 0, + y: 0, + width: 200, + height: 200, + locked: true + }) + graph.createNode('RECTANGLE', frame.id, { + name: 'Child', + x: 10, + y: 10, + width: 50, + height: 50 + }) + + const hit = graph.hitTestDeep(10, 10, page) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('LockedFrame') + }) + + test('locked leaf node is still clickable', () => { + const graph = new SceneGraph() + const page = pageId(graph) + graph.createNode('RECTANGLE', page, { + name: 'LockedRect', + x: 0, + y: 0, + width: 50, + height: 50, + locked: true + }) + + const hit = graph.hitTest(25, 25, page) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('LockedRect') + }) +}) + +describe('hitTest — visibility', () => { + test('invisible node is not hit', () => { + const graph = new SceneGraph() + const page = pageId(graph) + graph.createNode('RECTANGLE', page, { + name: 'Hidden', + x: 0, + y: 0, + width: 50, + height: 50, + visible: false + }) + + const hit = graph.hitTest(25, 25, page) + expect(hit).toBeNull() + }) +}) + +describe('scene graph — locked node operations', () => { + test('locked node cannot be deleted via filter', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const rect = graph.createNode('RECTANGLE', page, { + name: 'LockedRect', + x: 0, + y: 0, + width: 50, + height: 50, + locked: true + }) + + const node = graph.getNode(rect.id) + expect(node).not.toBeNull() + expect(node!.locked).toBe(true) + }) + + test('lock can be toggled', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const rect = graph.createNode('RECTANGLE', page, { + name: 'Rect', + x: 0, + y: 0, + width: 50, + height: 50 + }) + + expect(graph.getNode(rect.id)!.locked).toBe(false) + graph.updateNode(rect.id, { locked: true }) + expect(graph.getNode(rect.id)!.locked).toBe(true) + graph.updateNode(rect.id, { locked: false }) + expect(graph.getNode(rect.id)!.locked).toBe(false) + }) + + test('visibility can be toggled', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const rect = graph.createNode('RECTANGLE', page, { + name: 'Rect', + x: 0, + y: 0, + width: 50, + height: 50 + }) + + expect(graph.getNode(rect.id)!.visible).toBe(true) + graph.updateNode(rect.id, { visible: false }) + expect(graph.getNode(rect.id)!.visible).toBe(false) + graph.updateNode(rect.id, { visible: true }) + expect(graph.getNode(rect.id)!.visible).toBe(true) + }) +}) + +describe('hitTest — frame with children', () => { + test('frame scope hit test finds children', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const frame = graph.createNode('FRAME', page, { + name: 'Frame', + x: 50, + y: 50, + width: 200, + height: 200 + }) + const child = graph.createNode('RECTANGLE', frame.id, { + name: 'InnerRect', + x: 20, + y: 20, + width: 40, + height: 40 + }) + + const hit = graph.hitTest(20, 20, frame.id) + expect(hit).not.toBeNull() + expect(hit!.id).toBe(child.id) + }) +}) + +describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => { + test('hitTest on COMPONENT returns component itself (not child)', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const comp = graph.createNode('COMPONENT', page, { + name: 'MyComp', + x: 0, + y: 0, + width: 100, + height: 100 + }) + graph.createNode('RECTANGLE', comp.id, { + name: 'CompChild', + x: 10, + y: 10, + width: 30, + height: 30 + }) + + const hit = graph.hitTest(10, 10, page) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('MyComp') + }) + + test('hitTestDeep inside COMPONENT scope finds child', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const comp = graph.createNode('COMPONENT', page, { + name: 'MyComp', + x: 0, + y: 0, + width: 100, + height: 100 + }) + const child = graph.createNode('RECTANGLE', comp.id, { + name: 'CompChild', + x: 10, + y: 10, + width: 30, + height: 30 + }) + + const hit = graph.hitTestDeep(10, 10, comp.id) + expect(hit).not.toBeNull() + expect(hit!.id).toBe(child.id) + }) + + test('hitTest on INSTANCE returns instance itself', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const inst = graph.createNode('INSTANCE', page, { + name: 'MyInstance', + x: 50, + y: 50, + width: 80, + height: 80 + }) + graph.createNode('RECTANGLE', inst.id, { + name: 'InstChild', + x: 5, + y: 5, + width: 20, + height: 20 + }) + + const hit = graph.hitTest(55, 55, page) + expect(hit).not.toBeNull() + expect(hit!.name).toBe('MyInstance') + }) + + test('hitTestDeep inside INSTANCE scope finds child', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const inst = graph.createNode('INSTANCE', page, { + name: 'MyInstance', + x: 50, + y: 50, + width: 80, + height: 80 + }) + const child = graph.createNode('RECTANGLE', inst.id, { + name: 'InstChild', + x: 5, + y: 5, + width: 20, + height: 20 + }) + + const hit = graph.hitTestDeep(5, 5, inst.id) + expect(hit).not.toBeNull() + expect(hit!.id).toBe(child.id) + }) +}) + +describe('hitTest — absolute position and scope offset', () => { + test('scoped hitTest accounts for parent offset', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const frame = graph.createNode('FRAME', page, { + name: 'Frame', + x: 200, + y: 300, + width: 400, + height: 400 + }) + const child = graph.createNode('RECTANGLE', frame.id, { + name: 'Child', + x: 50, + y: 60, + width: 100, + height: 100 + }) + + const abs = graph.getAbsolutePosition(frame.id) + expect(abs.x).toBe(200) + expect(abs.y).toBe(300) + + const hit = graph.hitTest(50, 60, frame.id) + expect(hit).not.toBeNull() + expect(hit!.id).toBe(child.id) + + const missHit = graph.hitTest(250, 360, frame.id) + expect(missHit).toBeNull() + }) +}) diff --git a/tests/engine/scene-graph.test.ts b/tests/engine/scene-graph.test.ts index 2e3d55d01..5429ce278 100644 --- a/tests/engine/scene-graph.test.ts +++ b/tests/engine/scene-graph.test.ts @@ -359,16 +359,16 @@ describe('hitTest', () => { expect(graph.hitTest(100, 100, pageId(graph))?.id).toBe(frame.id) }) - test('group is always click-through', () => { + test('group returns group on hit, not child (Figma-style)', () => { const graph = new SceneGraph() const groupId = graph.createNode('GROUP', pageId(graph), { name: 'Group', x: 0, y: 0, width: 200, height: 200, }).id - const childId = graph.createNode('RECTANGLE', groupId, { + graph.createNode('RECTANGLE', groupId, { name: 'Child', x: 10, y: 10, width: 30, height: 30, - }).id - // Hit child through group - expect(graph.hitTest(20, 20, pageId(graph))?.id).toBe(childId) + }) + // Hit returns group (single click selects group, dblclick enters) + expect(graph.hitTest(20, 20, pageId(graph))?.id).toBe(groupId) // Miss in group's empty area expect(graph.hitTest(150, 150, pageId(graph))).toBeNull() })