From 6d3e2d20cd67a4fbdbee6ee5de4072c2b16193a8 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Sun, 15 Mar 2026 13:24:27 +0300 Subject: [PATCH] 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) + }) +})