Scope checklist: marquee in scope, layer panel sync, delete locked guard, undo/redo validation, tests
This commit is contained in:
parent
1b743bec94
commit
6d3e2d20cd
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
260
tests/engine/hit-test-scope.test.ts
Normal file
260
tests/engine/hit-test-scope.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue