Allow node resize from any corner or edge (#1063)

This commit is contained in:
filtered
2025-05-26 16:36:03 +10:00
committed by GitHub
parent 942758e3a5
commit 71928af112
9 changed files with 571 additions and 59 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`.
*

View File

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

View File

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