Entered container border, rotation support, INSTANCE/COMPONENT enterable, tests

This commit is contained in:
Anton A S 2026-03-15 13:30:37 +03:00 committed by Danila Poyarkov
parent 6d3e2d20cd
commit edd9a8e398
5 changed files with 201 additions and 12 deletions

View file

@ -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,

View file

@ -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<string>, overlays: RenderOverlays): void {
drawSelectionFn(this, canvas, graph, selectedIds, overlays)
}

View file

@ -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 {

View file

@ -152,6 +152,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
store.state.selectedIds,
{
hoveredNodeId: store.state.hoveredNodeId,
enteredContainerId: store.state.enteredContainerId,
editingTextId: store.state.editingTextId,
textEditor: store.textEditor,
marquee: store.state.marquee,

View file

@ -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()
})
})