mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 22:09:55 +00:00
Allow node resize from any corner or edge (#1063)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Rectangle>,
|
||||
| "setHeightBottomAnchored"
|
||||
| "setWidthRightAnchored"
|
||||
| "resizeTopLeft"
|
||||
| "resizeBottomLeft"
|
||||
| "resizeTopRight"
|
||||
| "resizeBottomRight"
|
||||
| "resizeBottomRight"
|
||||
| "updateTo"
|
||||
>
|
||||
|
||||
@@ -238,8 +238,8 @@ type TypedArrays =
|
||||
| Float64Array
|
||||
|
||||
type TypedBigIntArrays = BigInt64Array | BigUint64Array
|
||||
type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> =
|
||||
Omit<T, "fill" | "copyWithin" | "reverse" | "set" | "sort" | "subarray">
|
||||
export type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> =
|
||||
Omit<Readonly<T>, "fill" | "copyWithin" | "reverse" | "set" | "sort" | "subarray">
|
||||
|
||||
/** Union of property names that are of type Match */
|
||||
export type KeysOfType<T, Match> = 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`.
|
||||
*
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
|
||||
title_color?: string
|
||||
title_text_color?: string
|
||||
keepAllLinksOnBypass: boolean
|
||||
resizeHandleSize?: number
|
||||
resizeEdgeSize?: number
|
||||
}
|
||||
|
||||
// End backwards compat
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
164
test/LGraphNode.resize.test.ts
Normal file
164
test/LGraphNode.resize.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
144
test/infrastructure/Rectangle.resize.test.ts
Normal file
144
test/infrastructure/Rectangle.resize.test.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user