diff --git a/src/CanvasPointer.ts b/src/CanvasPointer.ts index 2969f8a49..fffc2cc96 100644 --- a/src/CanvasPointer.ts +++ b/src/CanvasPointer.ts @@ -1,3 +1,4 @@ +import type { CompassDirection } from "./interfaces" import type { CanvasPointerEvent } from "./types/events" import { dist2 } from "./measure" @@ -59,6 +60,9 @@ export class CanvasPointer { /** Used downstream for touch event support. */ isDown: boolean = false + /** The resize handle currently being hovered or dragged */ + resizeDirection?: CompassDirection + /** * If `true`, {@link eDown}, {@link eMove}, and {@link eUp} will be set to * `undefined` when {@link reset} is called. @@ -269,6 +273,7 @@ export class CanvasPointer { this.isDown = false this.isDouble = false this.dragStarted = false + this.resizeDirection = undefined if (this.clearEventsOnReset) { this.eDown = undefined diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 25c189884..16dec2bc4 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -44,7 +44,7 @@ import { strokeShape } from "./draw" import { NullGraphError } from "./infrastructure/NullGraphError" import { LGraphGroup } from "./LGraphGroup" import { LGraphNode, type NodeId, type NodeProperty } from "./LGraphNode" -import { LiteGraph, type Rectangle } from "./litegraph" +import { LiteGraph, Rectangle } from "./litegraph" import { type LinkId, LLink } from "./LLink" import { containsRect, @@ -202,6 +202,17 @@ interface ICreatePanelOptions { height?: any } +const cursors = { + N: "ns-resize", + NE: "nesw-resize", + E: "ew-resize", + SE: "nwse-resize", + S: "ns-resize", + SW: "nesw-resize", + W: "ew-resize", + NW: "nwse-resize", +} as const + /** * This class is in charge of rendering one graph inside a canvas. And provides all the interaction required. * Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked @@ -276,8 +287,8 @@ export class LGraphCanvas { cursor = "grabbing" } else if (this.state.readOnly) { cursor = "grab" - } else if (this.state.hoveringOver & CanvasItem.ResizeSe) { - cursor = "se-resize" + } else if (this.pointer.resizeDirection) { + cursor = cursors[this.pointer.resizeDirection] ?? cursors.SE } else if (this.state.hoveringOver & CanvasItem.Node) { cursor = "crosshair" } else if (this.state.hoveringOver & CanvasItem.Reroute) { @@ -1864,6 +1875,7 @@ export class LGraphCanvas { for (const otherNode of nodes) { if (otherNode.mouseOver && node != otherNode) { // mouse leave + this.pointer.resizeDirection = undefined otherNode.mouseOver = undefined this._highlight_input = undefined this._highlight_pos = undefined @@ -2240,45 +2252,6 @@ export class LGraphCanvas { this.setDirty(true, true) } } else if (!node.flags.collapsed) { - // Resize node - if (node.resizable !== false && node.inResizeCorner(x, y)) { - const b = node.boundingRect - const offsetX = x - (b[0] + b[2]) - const offsetY = y - (b[1] + b[3]) - - pointer.onDragStart = () => { - graph.beforeChange() - this.resizing_node = node - } - - pointer.onDrag = (eMove) => { - if (this.read_only) return - - // Resize only by the exact pointer movement - const pos: Point = [ - eMove.canvasX - node.pos[0] - offsetX, - eMove.canvasY - node.pos[1] - offsetY, - ] - // Unless snapping. - if (this.#snapToGrid) snapPoint(pos, this.#snapToGrid) - - const min = node.computeSize() - pos[0] = Math.max(min[0], pos[0]) - pos[1] = Math.max(min[1], pos[1]) - node.setSize(pos) - - this.#dirty() - } - - pointer.onDragEnd = () => { - this.#dirty() - graph.afterChange(this.resizing_node) - } - pointer.finally = () => this.resizing_node = null - this.canvas.style.cursor = "se-resize" - return - } - const { inputs, outputs } = node // Outputs @@ -2355,7 +2328,7 @@ export class LGraphCanvas { } } - // Click was inside the node, but not on input/output, or the resize corner + // Click was inside the node, but not on input/output, or resize area const pos: Point = [x - node.pos[0], y - node.pos[1]] // Widget @@ -2385,6 +2358,124 @@ export class LGraphCanvas { if (node.onMouseDown?.(e, pos, this) || !this.allow_dragnodes) return + // Check for resize AFTER checking all other interaction areas + if (!node.flags.collapsed) { + const resizeDirection = node.findResizeDirection(x, y) + if (resizeDirection) { + pointer.resizeDirection = resizeDirection + const startBounds = new Rectangle(node.pos[0], node.pos[1], node.size[0], node.size[1]) + + pointer.onDragStart = () => { + graph.beforeChange() + this.resizing_node = node + } + + pointer.onDrag = (eMove) => { + if (this.read_only) return + + const deltaX = eMove.canvasX - x + const deltaY = eMove.canvasY - y + + const newBounds = new Rectangle(startBounds.x, startBounds.y, startBounds.width, startBounds.height) + + // Handle resize based on the direction + switch (resizeDirection) { + case "N": // North (top) + newBounds.y = startBounds.y + deltaY + newBounds.height = startBounds.height - deltaY + break + case "NE": // North-East (top-right) + newBounds.y = startBounds.y + deltaY + newBounds.width = startBounds.width + deltaX + newBounds.height = startBounds.height - deltaY + break + case "E": // East (right) + newBounds.width = startBounds.width + deltaX + break + case "SE": // South-East (bottom-right) + newBounds.width = startBounds.width + deltaX + newBounds.height = startBounds.height + deltaY + break + case "S": // South (bottom) + newBounds.height = startBounds.height + deltaY + break + case "SW": // South-West (bottom-left) + newBounds.x = startBounds.x + deltaX + newBounds.width = startBounds.width - deltaX + newBounds.height = startBounds.height + deltaY + break + case "W": // West (left) + newBounds.x = startBounds.x + deltaX + newBounds.width = startBounds.width - deltaX + break + case "NW": // North-West (top-left) + newBounds.x = startBounds.x + deltaX + newBounds.y = startBounds.y + deltaY + newBounds.width = startBounds.width - deltaX + newBounds.height = startBounds.height - deltaY + break + } + + // Apply snapping to position changes + if (this.#snapToGrid) { + if (resizeDirection.includes("N") || resizeDirection.includes("W")) { + const originalX = newBounds.x + const originalY = newBounds.y + + snapPoint(newBounds.pos, this.#snapToGrid) + + // Adjust size to compensate for snapped position + if (resizeDirection.includes("N")) { + newBounds.height += originalY - newBounds.y + } + if (resizeDirection.includes("W")) { + newBounds.width += originalX - newBounds.x + } + } + + snapPoint(newBounds.size, this.#snapToGrid) + } + + // Apply snapping to size changes + + // Enforce minimum size + const min = node.computeSize() + if (newBounds.width < min[0]) { + // If resizing from left, adjust position to maintain right edge + if (resizeDirection.includes("W")) { + newBounds.x = startBounds.x + startBounds.width - min[0] + } + newBounds.width = min[0] + } + if (newBounds.height < min[1]) { + // If resizing from top, adjust position to maintain bottom edge + if (resizeDirection.includes("N")) { + newBounds.y = startBounds.y + startBounds.height - min[1] + } + newBounds.height = min[1] + } + + node.pos = newBounds.pos + node.setSize(newBounds.size) + + this.#dirty() + } + + pointer.onDragEnd = () => { + this.#dirty() + graph.afterChange(node) + } + pointer.finally = () => { + this.resizing_node = null + pointer.resizeDirection = undefined + } + + // Set appropriate cursor for resize direction + this.canvas.style.cursor = cursors[resizeDirection] + return + } + } + // Drag node pointer.onDragStart = pointer => this.#startDraggingItems(node, pointer, true) pointer.onDragEnd = e => this.#processDraggedItems(e) @@ -2616,7 +2707,8 @@ export class LGraphCanvas { this.dirty_canvas = true } else if (resizingGroup) { // Resizing a group - underPointer |= CanvasItem.ResizeSe | CanvasItem.Group + underPointer |= CanvasItem.Group + this.pointer.resizeDirection = "SE" } else if (this.dragging_canvas) { this.ds.offset[0] += delta[0] / this.ds.scale this.ds.offset[1] += delta[1] / this.ds.scale @@ -2744,9 +2836,12 @@ export class LGraphCanvas { this.dirty_canvas = true } - // Resize corner - if (node.inResizeCorner(e.canvasX, e.canvasY)) { - underPointer |= CanvasItem.ResizeSe + // Resize direction - only show resize cursor if not over inputs/outputs/widgets + if (inputId === -1 && outputId === -1 && !overWidget) { + this.pointer.resizeDirection = node.findResizeDirection(e.canvasX, e.canvasY) + } else { + // Clear resize direction when over inputs/outputs/widgets + this.pointer.resizeDirection &&= undefined } } else { // Reroutes @@ -2768,7 +2863,10 @@ export class LGraphCanvas { !this.read_only && group.isInResize(e.canvasX, e.canvasY) ) { - underPointer |= CanvasItem.ResizeSe + this.pointer.resizeDirection = "SE" + } else { + // Clear resize direction when not over any resize handle + this.pointer.resizeDirection = undefined } } } @@ -2798,8 +2896,6 @@ export class LGraphCanvas { this.#dirty() } - - if (this.resizing_node) underPointer |= CanvasItem.ResizeSe } this.hoveringOver = underPointer diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 53080c6e0..0d1fd845e 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -1,7 +1,9 @@ import type { DragAndScale } from "./DragAndScale" import type { IDrawBoundingOptions } from "./draw" +import type { ReadOnlyRectangle } from "./infrastructure/Rectangle" import type { ColorOption, + CompassDirection, DefaultConnectionColors, Dictionary, IColorable, @@ -30,7 +32,7 @@ import { getNodeInputOnPos, getNodeOutputOnPos } from "./canvas/measureSlots" import { NullGraphError } from "./infrastructure/NullGraphError" import { BadgePosition, LGraphBadge } from "./LGraphBadge" import { LGraphCanvas } from "./LGraphCanvas" -import { type LGraphNodeConstructor, LiteGraph } from "./litegraph" +import { type LGraphNodeConstructor, LiteGraph, Rectangle } from "./litegraph" import { LLink } from "./LLink" import { createBounds, isInRect, isInRectangle, isPointInRect, snapPoint } from "./measure" import { NodeInputSlot } from "./node/NodeInputSlot" @@ -190,6 +192,9 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { static filter?: string static skip_list?: boolean + static resizeHandleSize = 15 + static resizeEdgeSize = 5 + /** Default setting for {@link LGraphNode.connectInputToOutput}. @see {@link INodeFlags.keepAllLinksOnBypass} */ static keepAllLinksOnBypass: boolean = false @@ -320,7 +325,10 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { * Updated by {@link LGraphCanvas.drawNode} */ _collapsed_width?: number - /** Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}. */ + /** + * Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}. + * WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour. + */ onBounding?(this: LGraphNode, out: Rect): void console?: string[] _level?: number @@ -349,13 +357,13 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { } /** @inheritdoc {@link boundingRect} */ - #boundingRect: Float32Array = new Float32Array(4) + #boundingRect: Rectangle = new Rectangle() /** * Cached node position & area as `x, y, width, height`. Includes changes made by {@link onBounding}, if present. * * Determines the node hitbox and other rendering effects. Calculated once at the start of every frame. */ - get boundingRect(): ReadOnlyRect { + get boundingRect(): ReadOnlyRectangle { return this.#boundingRect } @@ -1634,6 +1642,30 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { ) } + /** + * Returns which resize handle the point is over, or null if none + * @param canvasX X position in canvas coordinates + * @param canvasY Y position in canvas coordinates + * @returns Resize handle type or null + */ + findResizeDirection(canvasX: number, canvasY: number): CompassDirection | undefined { + if (this.resizable === false) return + + const { boundingRect } = this + if (!boundingRect.containsXy(canvasX, canvasY)) return + + // Check corners first (they take priority over edges) + const cnr = boundingRect.findContainingCorner(canvasX, canvasY, LGraphNode.resizeHandleSize) + if (cnr) return cnr + + // Edges - only need to check one axis because we already know the point is inside the node + const edgeSize = LGraphNode.resizeEdgeSize + if (canvasX - boundingRect.left < edgeSize) return "W" + if (boundingRect.right - canvasX < edgeSize) return "E" + if (canvasY - boundingRect.top < edgeSize) return "N" + if (boundingRect.bottom - canvasY < edgeSize) return "S" + } + /** * returns all the info available about a property of this node. * @param property name of the property diff --git a/src/infrastructure/Rectangle.ts b/src/infrastructure/Rectangle.ts index 9a40b57d3..7810ebb83 100644 --- a/src/infrastructure/Rectangle.ts +++ b/src/infrastructure/Rectangle.ts @@ -1,4 +1,6 @@ -import type { Point, ReadOnlyPoint, ReadOnlyRect, ReadOnlySize, Size } from "@/interfaces" +import type { CompassDirection, Point, ReadOnlyPoint, ReadOnlyRect, ReadOnlySize, ReadOnlyTypedArray, Size } from "@/interfaces" + +import { isInRectangle } from "@/measure" /** * A rectangle, represented as a float64 array of 4 numbers: [x, y, width, height]. @@ -204,6 +206,60 @@ export class Rectangle extends Float64Array { this.y + this.height > rect[1] } + /** + * Finds the corner (if any) of this rectangle that contains the point [{@link x}, {@link y}]. + * @param x The x-coordinate to check + * @param y The y-coordinate to check + * @param cornerSize Each corner is treated as an inset square with this width and height. + * @returns The compass direction of the corner that contains the point, or `undefined` if the point is not in any corner. + */ + findContainingCorner(x: number, y: number, cornerSize: number): CompassDirection | undefined { + if (this.isInTopLeftCorner(x, y, cornerSize)) return "NW" + if (this.isInTopRightCorner(x, y, cornerSize)) return "NE" + if (this.isInBottomLeftCorner(x, y, cornerSize)) return "SW" + if (this.isInBottomRightCorner(x, y, cornerSize)) return "SE" + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the top-left corner of this rectangle, otherwise `false`. */ + isInTopLeftCorner(x: number, y: number, cornerSize: number): boolean { + return isInRectangle(x, y, this.x, this.y, cornerSize, cornerSize) + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the top-right corner of this rectangle, otherwise `false`. */ + isInTopRightCorner(x: number, y: number, cornerSize: number): boolean { + return isInRectangle(x, y, this.right - cornerSize, this.y, cornerSize, cornerSize) + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the bottom-left corner of this rectangle, otherwise `false`. */ + isInBottomLeftCorner(x: number, y: number, cornerSize: number): boolean { + return isInRectangle(x, y, this.x, this.bottom - cornerSize, cornerSize, cornerSize) + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the bottom-right corner of this rectangle, otherwise `false`. */ + isInBottomRightCorner(x: number, y: number, cornerSize: number): boolean { + return isInRectangle(x, y, this.right - cornerSize, this.bottom - cornerSize, cornerSize, cornerSize) + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the top edge of this rectangle, otherwise `false`. */ + isInTopEdge(x: number, y: number, edgeSize: number): boolean { + return isInRectangle(x, y, this.x, this.y, this.width, edgeSize) + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the bottom edge of this rectangle, otherwise `false`. */ + isInBottomEdge(x: number, y: number, edgeSize: number): boolean { + return isInRectangle(x, y, this.x, this.bottom - edgeSize, this.width, edgeSize) + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the left edge of this rectangle, otherwise `false`. */ + isInLeftEdge(x: number, y: number, edgeSize: number): boolean { + return isInRectangle(x, y, this.x, this.y, edgeSize, this.height) + } + + /** @returns `true` if the point [{@link x}, {@link y}] is in the right edge of this rectangle, otherwise `false`. */ + isInRightEdge(x: number, y: number, edgeSize: number): boolean { + return isInRectangle(x, y, this.right - edgeSize, this.y, edgeSize, this.height) + } + /** @returns The centre point of this rectangle, as a new {@link Point}. */ getCentre(): Point { return [this.centreX, this.centreY] @@ -311,3 +367,15 @@ export class Rectangle extends Float64Array { } } } + +export type ReadOnlyRectangle = Omit< + ReadOnlyTypedArray, + | "setHeightBottomAnchored" + | "setWidthRightAnchored" + | "resizeTopLeft" + | "resizeBottomLeft" + | "resizeTopRight" + | "resizeBottomRight" + | "resizeBottomRight" + | "updateTo" +> diff --git a/src/interfaces.ts b/src/interfaces.ts index 145a994d4..e0e0998e3 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -238,8 +238,8 @@ type TypedArrays = | Float64Array type TypedBigIntArrays = BigInt64Array | BigUint64Array -type ReadOnlyTypedArray = - Omit +export type ReadOnlyTypedArray = + Omit, "fill" | "copyWithin" | "reverse" | "set" | "sort" | "subarray"> /** Union of property names that are of type Match */ export type KeysOfType = Exclude<{ [P in keyof T]: T[P] extends Match ? P : never }[keyof T], undefined> @@ -259,6 +259,9 @@ export interface IBoundaryNodes { export type Direction = "top" | "bottom" | "left" | "right" +/** Resize handle positions (compass points) */ +export type CompassDirection = "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW" + /** * A string that represents a specific data / slot type, e.g. `STRING`. * diff --git a/src/litegraph.ts b/src/litegraph.ts index 1d2d56bfb..6e5e7c2f8 100644 --- a/src/litegraph.ts +++ b/src/litegraph.ts @@ -82,6 +82,8 @@ export interface LGraphNodeConstructor { title_color?: string title_text_color?: string keepAllLinksOnBypass: boolean + resizeHandleSize?: number + resizeEdgeSize?: number } // End backwards compat diff --git a/src/types/globalEnums.ts b/src/types/globalEnums.ts index 6d95d1aa2..911c41dd9 100644 --- a/src/types/globalEnums.ts +++ b/src/types/globalEnums.ts @@ -34,8 +34,6 @@ export enum CanvasItem { Reroute = 1 << 2, /** The path of a link */ Link = 1 << 3, - /** A resize in the bottom-right corner */ - ResizeSe = 1 << 4, /** A reroute slot */ RerouteSlot = 1 << 5, } diff --git a/test/LGraphNode.resize.test.ts b/test/LGraphNode.resize.test.ts new file mode 100644 index 000000000..0b1ba595e --- /dev/null +++ b/test/LGraphNode.resize.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect } from "vitest" + +import { LGraphNode } from "@/LGraphNode" +import { LiteGraph } from "@/litegraph" + +import { test } from "./testExtensions" + +describe("LGraphNode resize functionality", () => { + let node: LGraphNode + + beforeEach(() => { + // Set up LiteGraph constants needed for measure + LiteGraph.NODE_TITLE_HEIGHT = 20 + + node = new LGraphNode("Test Node") + node.pos = [100, 100] + node.size = [200, 150] + + // Create a mock canvas context for updateArea + const mockCtx = {} as CanvasRenderingContext2D + + // Call updateArea to populate boundingRect + node.updateArea(mockCtx) + }) + + describe("findResizeDirection", () => { + describe("corners", () => { + test("should detect NW (top-left) corner", () => { + // With title bar, top is at y=80 (100 - 20) + // Corner is from (100, 80) to (100 + 15, 80 + 15) + expect(node.findResizeDirection(100, 80)).toBe("NW") + expect(node.findResizeDirection(110, 90)).toBe("NW") + expect(node.findResizeDirection(114, 94)).toBe("NW") + }) + + test("should detect NE (top-right) corner", () => { + // Corner is from (300 - 15, 80) to (300, 80 + 15) + expect(node.findResizeDirection(285, 80)).toBe("NE") + expect(node.findResizeDirection(290, 90)).toBe("NE") + expect(node.findResizeDirection(299, 94)).toBe("NE") + }) + + test("should detect SW (bottom-left) corner", () => { + // Bottom is at y=250 (100 + 150) + // Corner is from (100, 250 - 15) to (100 + 15, 250) + expect(node.findResizeDirection(100, 235)).toBe("SW") + expect(node.findResizeDirection(110, 240)).toBe("SW") + expect(node.findResizeDirection(114, 249)).toBe("SW") + }) + + test("should detect SE (bottom-right) corner", () => { + // Corner is from (300 - 15, 250 - 15) to (300, 250) + expect(node.findResizeDirection(285, 235)).toBe("SE") + expect(node.findResizeDirection(290, 240)).toBe("SE") + expect(node.findResizeDirection(299, 249)).toBe("SE") + }) + }) + + describe("edges", () => { + test("should detect N (top) edge", () => { + // Top edge at y=80, but not in corners + expect(node.findResizeDirection(150, 80)).toBe("N") + expect(node.findResizeDirection(150, 84)).toBe("N") + expect(node.findResizeDirection(200, 80)).toBe("N") + }) + + test("should detect S (bottom) edge", () => { + // Bottom edge at y=250, but need to check within the 5px threshold + expect(node.findResizeDirection(150, 249)).toBe("S") + expect(node.findResizeDirection(150, 246)).toBe("S") + expect(node.findResizeDirection(200, 247)).toBe("S") + }) + + test("should detect W (left) edge", () => { + // Left edge at x=100, but not in corners + expect(node.findResizeDirection(100, 150)).toBe("W") + expect(node.findResizeDirection(104, 150)).toBe("W") + expect(node.findResizeDirection(100, 200)).toBe("W") + }) + + test("should detect E (right) edge", () => { + // Right edge at x=300, but need to check within the 5px threshold + expect(node.findResizeDirection(299, 150)).toBe("E") + expect(node.findResizeDirection(296, 150)).toBe("E") + expect(node.findResizeDirection(298, 200)).toBe("E") + }) + }) + + describe("priority", () => { + test("corners should have priority over edges", () => { + // These points are technically on both corner and edge + // Corner should win + expect(node.findResizeDirection(100, 84)).toBe("NW") // Not "W" + expect(node.findResizeDirection(104, 80)).toBe("NW") // Not "N" + }) + }) + + describe("negative cases", () => { + test("should return undefined when outside node bounds", () => { + expect(node.findResizeDirection(50, 50)).toBeUndefined() + expect(node.findResizeDirection(350, 300)).toBeUndefined() + expect(node.findResizeDirection(99, 150)).toBeUndefined() + expect(node.findResizeDirection(301, 150)).toBeUndefined() + }) + + test("should return undefined when inside node but not on resize areas", () => { + // Center of node (accounting for title bar offset) + expect(node.findResizeDirection(200, 165)).toBeUndefined() + // Just inside the edge threshold + expect(node.findResizeDirection(106, 150)).toBeUndefined() + expect(node.findResizeDirection(294, 150)).toBeUndefined() + expect(node.findResizeDirection(150, 86)).toBeUndefined() // 80 + 6 + expect(node.findResizeDirection(150, 244)).toBeUndefined() + }) + + test("should return undefined when node is not resizable", () => { + node.resizable = false + expect(node.findResizeDirection(100, 100)).toBeUndefined() + expect(node.findResizeDirection(300, 250)).toBeUndefined() + expect(node.findResizeDirection(150, 100)).toBeUndefined() + }) + }) + + describe("edge cases", () => { + test("should handle nodes at origin", () => { + node.pos = [0, 0] + node.size = [100, 100] + + // Update boundingRect with new position/size + const mockCtx = {} as CanvasRenderingContext2D + node.updateArea(mockCtx) + + expect(node.findResizeDirection(0, -20)).toBe("NW") // Account for title bar + expect(node.findResizeDirection(99, 99)).toBe("SE") // Bottom-right corner (100-1, 100-1) + expect(node.findResizeDirection(50, -20)).toBe("N") + expect(node.findResizeDirection(0, 50)).toBe("W") + }) + + test("should handle very small nodes", () => { + node.size = [20, 20] // Smaller than corner size + + // Update boundingRect with new size + const mockCtx = {} as CanvasRenderingContext2D + node.updateArea(mockCtx) + + // Corners still work (accounting for title bar offset) + expect(node.findResizeDirection(100, 80)).toBe("NW") + expect(node.findResizeDirection(119, 119)).toBe("SE") + }) + }) + }) + + describe("resizeEdgeSize static property", () => { + test("should have default value of 5", () => { + expect(LGraphNode.resizeEdgeSize).toBe(5) + }) + }) + + describe("resizeHandleSize static property", () => { + test("should have default value of 15", () => { + expect(LGraphNode.resizeHandleSize).toBe(15) + }) + }) +}) diff --git a/test/infrastructure/Rectangle.resize.test.ts b/test/infrastructure/Rectangle.resize.test.ts new file mode 100644 index 000000000..47fb4cb4b --- /dev/null +++ b/test/infrastructure/Rectangle.resize.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, test } from "vitest" + +import { Rectangle } from "@/infrastructure/Rectangle" + +describe("Rectangle resize functionality", () => { + let rect: Rectangle + + beforeEach(() => { + rect = new Rectangle(100, 200, 300, 400) // x, y, width, height + // So: left=100, top=200, right=400, bottom=600 + }) + + describe("findContainingCorner", () => { + const cornerSize = 15 + + test("should detect NW (top-left) corner", () => { + expect(rect.findContainingCorner(100, 200, cornerSize)).toBe("NW") + expect(rect.findContainingCorner(110, 210, cornerSize)).toBe("NW") + expect(rect.findContainingCorner(114, 214, cornerSize)).toBe("NW") + }) + + test("should detect NE (top-right) corner", () => { + // Top-right corner starts at (right - cornerSize, top) = (385, 200) + expect(rect.findContainingCorner(385, 200, cornerSize)).toBe("NE") + expect(rect.findContainingCorner(390, 210, cornerSize)).toBe("NE") + expect(rect.findContainingCorner(399, 214, cornerSize)).toBe("NE") + }) + + test("should detect SW (bottom-left) corner", () => { + // Bottom-left corner starts at (left, bottom - cornerSize) = (100, 585) + expect(rect.findContainingCorner(100, 585, cornerSize)).toBe("SW") + expect(rect.findContainingCorner(110, 590, cornerSize)).toBe("SW") + expect(rect.findContainingCorner(114, 599, cornerSize)).toBe("SW") + }) + + test("should detect SE (bottom-right) corner", () => { + // Bottom-right corner starts at (right - cornerSize, bottom - cornerSize) = (385, 585) + expect(rect.findContainingCorner(385, 585, cornerSize)).toBe("SE") + expect(rect.findContainingCorner(390, 590, cornerSize)).toBe("SE") + expect(rect.findContainingCorner(399, 599, cornerSize)).toBe("SE") + }) + + test("should return undefined when not in any corner", () => { + // Middle of rectangle + expect(rect.findContainingCorner(250, 400, cornerSize)).toBeUndefined() + // On edge but not in corner + expect(rect.findContainingCorner(200, 200, cornerSize)).toBeUndefined() + expect(rect.findContainingCorner(100, 400, cornerSize)).toBeUndefined() + // Outside rectangle + expect(rect.findContainingCorner(50, 150, cornerSize)).toBeUndefined() + }) + }) + + describe("corner detection methods", () => { + const cornerSize = 20 + + describe("isInTopLeftCorner", () => { + test("should return true when point is in top-left corner", () => { + expect(rect.isInTopLeftCorner(100, 200, cornerSize)).toBe(true) + expect(rect.isInTopLeftCorner(110, 210, cornerSize)).toBe(true) + expect(rect.isInTopLeftCorner(119, 219, cornerSize)).toBe(true) + }) + + test("should return false when point is outside top-left corner", () => { + expect(rect.isInTopLeftCorner(120, 200, cornerSize)).toBe(false) + expect(rect.isInTopLeftCorner(100, 220, cornerSize)).toBe(false) + expect(rect.isInTopLeftCorner(99, 200, cornerSize)).toBe(false) + expect(rect.isInTopLeftCorner(100, 199, cornerSize)).toBe(false) + }) + }) + + describe("isInTopRightCorner", () => { + test("should return true when point is in top-right corner", () => { + // Top-right corner area is from (right - cornerSize, top) to (right, top + cornerSize) + // That's (380, 200) to (400, 220) + expect(rect.isInTopRightCorner(380, 200, cornerSize)).toBe(true) + expect(rect.isInTopRightCorner(390, 210, cornerSize)).toBe(true) + expect(rect.isInTopRightCorner(399, 219, cornerSize)).toBe(true) + }) + + test("should return false when point is outside top-right corner", () => { + expect(rect.isInTopRightCorner(379, 200, cornerSize)).toBe(false) + expect(rect.isInTopRightCorner(400, 220, cornerSize)).toBe(false) + expect(rect.isInTopRightCorner(401, 200, cornerSize)).toBe(false) + expect(rect.isInTopRightCorner(400, 199, cornerSize)).toBe(false) + }) + }) + + describe("isInBottomLeftCorner", () => { + test("should return true when point is in bottom-left corner", () => { + // Bottom-left corner area is from (left, bottom - cornerSize) to (left + cornerSize, bottom) + // That's (100, 580) to (120, 600) + expect(rect.isInBottomLeftCorner(100, 580, cornerSize)).toBe(true) + expect(rect.isInBottomLeftCorner(110, 590, cornerSize)).toBe(true) + expect(rect.isInBottomLeftCorner(119, 599, cornerSize)).toBe(true) + }) + + test("should return false when point is outside bottom-left corner", () => { + expect(rect.isInBottomLeftCorner(120, 600, cornerSize)).toBe(false) + expect(rect.isInBottomLeftCorner(100, 579, cornerSize)).toBe(false) + expect(rect.isInBottomLeftCorner(99, 600, cornerSize)).toBe(false) + expect(rect.isInBottomLeftCorner(100, 601, cornerSize)).toBe(false) + }) + }) + + describe("isInBottomRightCorner", () => { + test("should return true when point is in bottom-right corner", () => { + // Bottom-right corner area is from (right - cornerSize, bottom - cornerSize) to (right, bottom) + // That's (380, 580) to (400, 600) + expect(rect.isInBottomRightCorner(380, 580, cornerSize)).toBe(true) + expect(rect.isInBottomRightCorner(390, 590, cornerSize)).toBe(true) + expect(rect.isInBottomRightCorner(399, 599, cornerSize)).toBe(true) + }) + + test("should return false when point is outside bottom-right corner", () => { + expect(rect.isInBottomRightCorner(379, 600, cornerSize)).toBe(false) + expect(rect.isInBottomRightCorner(400, 579, cornerSize)).toBe(false) + expect(rect.isInBottomRightCorner(401, 600, cornerSize)).toBe(false) + expect(rect.isInBottomRightCorner(400, 601, cornerSize)).toBe(false) + }) + }) + }) + + describe("edge cases", () => { + test("should handle zero-sized corner areas", () => { + expect(rect.findContainingCorner(100, 200, 0)).toBeUndefined() + expect(rect.isInTopLeftCorner(100, 200, 0)).toBe(false) + }) + + test("should handle rectangles at origin", () => { + const originRect = new Rectangle(0, 0, 100, 100) + expect(originRect.findContainingCorner(0, 0, 10)).toBe("NW") + // Bottom-right corner is at (90, 90) to (100, 100) + expect(originRect.findContainingCorner(90, 90, 10)).toBe("SE") + }) + + test("should handle negative coordinates", () => { + const negRect = new Rectangle(-50, -50, 100, 100) + expect(negRect.findContainingCorner(-50, -50, 10)).toBe("NW") + // Bottom-right corner is at (40, 40) to (50, 50) + expect(negRect.findContainingCorner(40, 40, 10)).toBe("SE") + }) + }) +})