Entered container border, rotation support, INSTANCE/COMPONENT enterable, tests
This commit is contained in:
parent
6d3e2d20cd
commit
edd9a8e398
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue