From bc85d73cc8c6cfe963c58498dcf74c629d2a0a08 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 12:34:53 +0300 Subject: [PATCH 01/11] Add lock and visibility toggles to layers panel --- src/components/LayerTree.vue | 51 ++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/components/LayerTree.vue b/src/components/LayerTree.vue index dde509e2e..fb91d8818 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.updateNode(id, { visible: !node.visible }) +} + +function toggleNodeLock(id: string) { + const node = store.graph.getNode(id) + if (node) store.updateNode(id, { locked: !node.locked }) +} + 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 })) } @@ -365,7 +377,7 @@ function updateDropTarget(ev: PointerEvent) { From 6596702389c8d0509596b3ec9e9f7b56ec69de79 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 12:41:26 +0300 Subject: [PATCH 02/11] Fix lock: block move/resize/rotate for locked nodes, skip locked in marquee, improve toggle icon visibility --- src/components/LayerTree.vue | 34 +++++++++++++++++++++++++---- src/composables/use-canvas-input.ts | 8 +++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/components/LayerTree.vue b/src/components/LayerTree.vue index fb91d8818..cfe6d49dc 100644 --- a/src/components/LayerTree.vue +++ b/src/components/LayerTree.vue @@ -423,10 +423,23 @@ function updateDropTarget(ev: PointerEvent) { @pointerdown.stop @click.stop="toggleNodeLock(item.value.id)" > - + - + diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index ec0e4f745..04832ec37 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -336,7 +336,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 +371,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, @@ -477,6 +477,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) @@ -778,6 +781,7 @@ export function useCanvasInput( const hits: string[] = [] for (const node of store.graph.getChildren(store.state.currentPageId)) { + if (!node.visible || node.locked) continue if ( node.x + node.width > minX && node.x < maxX && From 116acb5cfaddc0309dd5d0bb631ce05b6cb23b28 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 12:44:01 +0300 Subject: [PATCH 03/11] Make lock/visibility icons more visible --- src/components/LayerTree.vue | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/LayerTree.vue b/src/components/LayerTree.vue index cfe6d49dc..76e1d1dc6 100644 --- a/src/components/LayerTree.vue +++ b/src/components/LayerTree.vue @@ -428,17 +428,17 @@ function updateDropTarget(ev: PointerEvent) { class="size-3" :class=" store.state.selectedIds.has(item.value.id) - ? 'text-white/80' - : 'text-surface/70' + ? 'text-white' + : 'text-surface' " /> @@ -453,17 +453,17 @@ function updateDropTarget(ev: PointerEvent) { class="size-3" :class=" store.state.selectedIds.has(item.value.id) - ? 'text-white/80' - : 'text-surface/70' + ? 'text-white' + : 'text-surface' " /> From 01235c0d849d28bb297f04a02fc6318355b22cd8 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 12:48:07 +0300 Subject: [PATCH 04/11] Fix group selection: single click selects group, double click enters, locked groups block enter --- packages/core/src/scene-graph-hit-test.ts | 8 ++++++-- src/composables/use-canvas-input.ts | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) 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/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index 04832ec37..0648b8be6 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -1082,10 +1082,18 @@ export function useCanvasInput( if (store.state.editingTextId) return const { cx, cy } = getCoords(e) - const hit = - hitTestSectionTitle(cx, cy) ?? - hitTestComponentLabel(cx, cy) ?? - store.graph.hitTestDeep(cx, cy, store.state.currentPageId) + + 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 + + const hit = canEnter + ? store.graph.hitTestDeep(cx, cy, selectedId) + : (hitTestSectionTitle(cx, cy) ?? + hitTestComponentLabel(cx, cy) ?? + store.graph.hitTestDeep(cx, cy, store.state.currentPageId)) if (!hit) return if (hit.type === 'TEXT') { From 19d6e019602e999425f2a3a1efab62c991416267 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 12:52:28 +0300 Subject: [PATCH 05/11] Fix double-click enter: offset coords to group-local space --- src/composables/use-canvas-input.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index 0648b8be6..25bb9dc64 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -1089,11 +1089,15 @@ export function useCanvasInput( const selectedNode = selectedId ? store.graph.getNode(selectedId) : undefined const canEnter = selectedNode && selectedId && store.graph.isContainer(selectedId) && !selectedNode.locked - const hit = canEnter - ? store.graph.hitTestDeep(cx, cy, selectedId) - : (hitTestSectionTitle(cx, cy) ?? + let hit: SceneNode | null = null + if (canEnter) { + const abs = store.graph.getAbsolutePosition(selectedId) + hit = store.graph.hitTestDeep(cx - abs.x, cy - abs.y, selectedId) + } else { + hit = hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? - store.graph.hitTestDeep(cx, cy, store.state.currentPageId)) + store.graph.hitTestDeep(cx, cy, store.state.currentPageId) + } if (!hit) return if (hit.type === 'TEXT') { From 1b743bec948ccf89b3b41ce03ff40a0c074047e7 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 13:07:00 +0300 Subject: [PATCH 06/11] Add selection scope: enter containers with dblclick, exit with Escape or click-outside --- src/components/CanvasContextMenu.vue | 10 +++- src/composables/use-canvas-input.ts | 90 ++++++++++++++++++++++------ src/composables/use-keyboard.ts | 4 ++ src/stores/editor.ts | 23 ++++++- 4 files changed, 108 insertions(+), 19 deletions(-) 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/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index 25bb9dc64..c260f662b 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -294,6 +294,31 @@ export function useCanvasInput( return { sx, sy, cx, cy } } + 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 abs = store.graph.getAbsolutePosition(scopeId) + return deep + ? store.graph.hitTestDeep(cx - abs.x, cy - abs.y, scopeId) + : store.graph.hitTest(cx - abs.x, cy - abs.y, 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 abs = store.graph.getAbsolutePosition(containerId) + return cx >= abs.x && cx <= abs.x + container.width + && cy >= abs.y && cy <= abs.y + container.height + } + function startPanDrag(e: MouseEvent) { drag.value = { type: 'pan', @@ -451,6 +476,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 +512,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 } @@ -614,7 +664,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) } @@ -1087,17 +1137,23 @@ export function useCanvasInput( ? [...store.state.selectedIds][0] : undefined const selectedNode = selectedId ? store.graph.getNode(selectedId) : undefined - const canEnter = selectedNode && selectedId && store.graph.isContainer(selectedId) && !selectedNode.locked + const canEnter = selectedNode && selectedId + && store.graph.isContainer(selectedId) && !selectedNode.locked - let hit: SceneNode | null = null if (canEnter) { - const abs = store.graph.getAbsolutePosition(selectedId) - hit = store.graph.hitTestDeep(cx - abs.x, cy - abs.y, selectedId) - } else { - hit = hitTestSectionTitle(cx, cy) ?? - hitTestComponentLabel(cx, cy) ?? - store.graph.hitTestDeep(cx, cy, store.state.currentPageId) + store.enterContainer(selectedId) + const hit = hitTestInScope(cx, cy, false) + if (hit) { + store.select([hit.id]) + } else { + store.clearSelection() + } + return } + + const hit = hitTestSectionTitle(cx, cy) ?? + hitTestComponentLabel(cx, cy) ?? + hitTestInScope(cx, cy, true) if (!hit) return if (hit.type === 'TEXT') { 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..09577c811 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,23 @@ export function createEditorStore() { state.selectedIds = new Set() } + 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() @@ -2375,6 +2394,8 @@ export function createEditorStore() { setTool, select, clearSelection, + enterContainer, + exitContainer, selectAll, setMarquee, setSnapGuides, From 6d3e2d20cd67a4fbdbee6ee5de4072c2b16193a8 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 13:24:27 +0300 Subject: [PATCH 07/11] Scope checklist: marquee in scope, layer panel sync, delete locked guard, undo/redo validation, tests --- src/components/LayerTree.vue | 16 ++ src/composables/use-canvas-input.ts | 18 +- src/stores/editor.ts | 11 +- tests/engine/hit-test-scope.test.ts | 260 ++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 tests/engine/hit-test-scope.test.ts diff --git a/src/components/LayerTree.vue b/src/components/LayerTree.vue index 76e1d1dc6..e5dd9335d 100644 --- a/src/components/LayerTree.vue +++ b/src/components/LayerTree.vue @@ -125,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 @@ -132,6 +147,7 @@ function onSelect(ev: CustomEvent) { store.select([node.id], true) } else { store.select([node.id]) + syncCanvasScope(node.id) } } diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index c260f662b..c5facbe08 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -829,14 +829,22 @@ 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 abs = scopeId ? store.graph.getAbsolutePosition(scopeId) : { x: 0, y: 0 } + const localMinX = minX - abs.x + const localMinY = minY - abs.y + const localMaxX = maxX - abs.x + const localMaxY = maxY - abs.y + 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) } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 09577c811..f97a441a7 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -382,6 +382,12 @@ 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 } @@ -2082,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 @@ -2271,10 +2277,12 @@ export function createEditorStore() { function undoAction() { undo.undo() + validateEnteredContainer() } function redoAction() { undo.redo() + validateEnteredContainer() requestRender() } @@ -2396,6 +2404,7 @@ export function createEditorStore() { 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..82f471070 --- /dev/null +++ b/tests/engine/hit-test-scope.test.ts @@ -0,0 +1,260 @@ +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) + }) +}) From edd9a8e3988696cf6e6b3fb4bcf008ee9edef438 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 13:30:37 +0300 Subject: [PATCH 08/11] Entered container border, rotation support, INSTANCE/COMPONENT enterable, tests --- packages/core/src/renderer/overlays.ts | 34 +++++++ packages/core/src/renderer/renderer.ts | 7 ++ src/composables/use-canvas-input.ts | 45 ++++++--- src/composables/use-canvas.ts | 1 + tests/engine/hit-test-scope.test.ts | 126 +++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 12 deletions(-) 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/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index c5facbe08..7252fe394 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -294,16 +294,36 @@ 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 = (-node.rotation * Math.PI) / 180 + 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 abs = store.graph.getAbsolutePosition(scopeId) + const { lx, ly } = canvasToLocal(cx, cy, scopeId) return deep - ? store.graph.hitTestDeep(cx - abs.x, cy - abs.y, scopeId) - : store.graph.hitTest(cx - abs.x, cy - abs.y, scopeId) + ? store.graph.hitTestDeep(lx, ly, scopeId) + : store.graph.hitTest(lx, ly, scopeId) } } return deep @@ -314,9 +334,8 @@ export function useCanvasInput( function isInsideContainerBounds(cx: number, cy: number, containerId: string): boolean { const container = store.graph.getNode(containerId) if (!container) return false - const abs = store.graph.getAbsolutePosition(containerId) - return cx >= abs.x && cx <= abs.x + container.width - && cy >= abs.y && cy <= abs.y + container.height + const { lx, ly } = canvasToLocal(cx, cy, containerId) + return lx >= 0 && lx <= container.width && ly >= 0 && ly <= container.height } function startPanDrag(e: MouseEvent) { @@ -831,11 +850,12 @@ export function useCanvasInput( const scopeId = store.state.enteredContainerId const parentId = scopeId ?? store.state.currentPageId - const abs = scopeId ? store.graph.getAbsolutePosition(scopeId) : { x: 0, y: 0 } - const localMinX = minX - abs.x - const localMinY = minY - abs.y - const localMaxX = maxX - abs.x - const localMaxY = maxY - abs.y + 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(parentId)) { @@ -1150,7 +1170,8 @@ export function useCanvasInput( if (canEnter) { store.enterContainer(selectedId) - const hit = hitTestInScope(cx, cy, false) + const useDeep = selectedNode.type === 'COMPONENT' || selectedNode.type === 'INSTANCE' + const hit = hitTestInScope(cx, cy, useDeep) if (hit) { store.select([hit.id]) } else { 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/tests/engine/hit-test-scope.test.ts b/tests/engine/hit-test-scope.test.ts index 82f471070..4dc0542b7 100644 --- a/tests/engine/hit-test-scope.test.ts +++ b/tests/engine/hit-test-scope.test.ts @@ -258,3 +258,129 @@ describe('hitTest — frame with children', () => { 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() + }) +}) From 0a50326bb7d354706a2076283a1d0324c51a909a Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 13:37:51 +0300 Subject: [PATCH 09/11] Update changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From da97f13040c0ac09fab99aa7d6ce3114e08fd569 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 13:55:06 +0300 Subject: [PATCH 10/11] Update group hit-test assertion to match Figma-style behavior --- tests/engine/scene-graph.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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() }) From f14f0a4259d1f1244ef8219a4cef1b9cf285f064 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 16 Mar 2026 14:29:26 +0300 Subject: [PATCH 11/11] Fix review issues: undoable lock/visibility toggles, use degToRad --- src/components/LayerTree.vue | 4 ++-- src/composables/use-canvas-input.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/LayerTree.vue b/src/components/LayerTree.vue index e5dd9335d..76467341d 100644 --- a/src/components/LayerTree.vue +++ b/src/components/LayerTree.vue @@ -67,12 +67,12 @@ const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE']) function toggleNodeVisibility(id: string) { const node = store.graph.getNode(id) - if (node) store.updateNode(id, { visible: !node.visible }) + 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.updateNode(id, { locked: !node.locked }) + if (node) store.updateNodeWithUndo(id, { locked: !node.locked }, node.locked ? 'Unlock layer' : 'Lock layer') } function buildTree(parentId: string): LayerNode[] { diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index 7252fe394..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' @@ -303,7 +303,7 @@ export function useCanvasInput( if (node.rotation !== 0) { const hw = node.width / 2 const hh = node.height / 2 - const rad = (-node.rotation * Math.PI) / 180 + const rad = degToRad(-node.rotation) const cos = Math.cos(rad) const sin = Math.sin(rad) const rx = dx - hw