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