From 95af20c1c4eb104f3fba657fb8d23ec41558c34d Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Thu, 31 Oct 2024 03:02:41 +1100 Subject: [PATCH] =?UTF-8?q?Snaps=20for=20Comfy=20=F0=9F=AB=B0=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Snaps for Comfy * Add snap visual effects * Update node measure to work everywhere * Fix findConnectByTypeSlot does not work for "*" * Fix regression in fast widget conversion --- src/LGraphCanvas.ts | 275 +++++++++++++++++++++++++++-------------- src/LGraphNode.ts | 66 +++++----- src/LiteGraphGlobal.ts | 2 + 3 files changed, 217 insertions(+), 126 deletions(-) diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 84b27f8c2..a4e29d094 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -294,9 +294,7 @@ export class LGraphCanvas { last_mouse_dragging: boolean onMouseDown: (arg0: CanvasMouseEvent) => void _highlight_pos?: Point - _highlight_input?: Point - _highlight_input_slot?: INodeInputSlot - _highlight_output?: Point + _highlight_input?: INodeInputSlot // TODO: Check if panels are used node_panel options_panel @@ -1762,6 +1760,7 @@ export class LGraphCanvas { nodes[i].mouseOver = null this._highlight_input = null this._highlight_pos = null + this.link_over_widget = null // Hover transitions // TODO: Implement single lerp ease factor for current progress on hover in/out. In drawNode, multiply by ease factor and differential value (e.g. bg alpha +0.5). @@ -2328,8 +2327,6 @@ export class LGraphCanvas { * Called when a mouse move event has to be processed **/ processMouseMove(e: CanvasMouseEvent): boolean { - this.link_over_widget = null - if (this.autoresize) this.resize() if (this.set_canvas_dirty_on_mouse_event) @@ -2405,15 +2402,18 @@ export class LGraphCanvas { this.dirty_canvas = true // For input/output hovering - const pos: Point = [0, 0] //to store the output of isOverNodeInput + //to store the output of isOverNodeInput + const pos: Point = [0, 0] const inputId = this.isOverNodeInput(node, e.canvasX, e.canvasY, pos) const outputId = this.isOverNodeOutput(node, e.canvasX, e.canvasY, pos) + const overWidget = this.getWidgetAtCursor(node) if (!node.mouseOver) { //mouse enter node.mouseOver = { - inputId: inputId, - outputId: outputId + inputId: null, + outputId: null, + overWidget: null, } this.node_over = node this.dirty_canvas = true @@ -2421,64 +2421,84 @@ export class LGraphCanvas { node.onMouseEnter?.(e) } - // The input the mouse is over has changed - if (node.mouseOver.inputId !== inputId || node.mouseOver.outputId !== outputId) { - node.mouseOver.inputId = inputId - node.mouseOver.outputId = outputId - this.dirty_canvas = true - } - //in case the node wants to do something node.onMouseMove?.(e, [e.canvasX - node.pos[0], e.canvasY - node.pos[1]], this) - //if dragging a link - if (this.connecting_links) { - const firstLink = this.connecting_links[0] + // The input the mouse is over has changed + if (node.mouseOver.inputId !== inputId || node.mouseOver.outputId !== outputId || node.mouseOver.overWidget !== overWidget) { + node.mouseOver.inputId = inputId + node.mouseOver.outputId = outputId + node.mouseOver.overWidget = overWidget - if (firstLink.output) { + // Check if link is over anything it could connect to - record position of valid target for snap / highlight + if (this.connecting_links) { + const firstLink = this.connecting_links[0] - //on top of input - if (this.isOverNodeBox(node, e.canvasX, e.canvasY)) { - //mouse on top of the corner box, don't know what to do - } else { - //check if I have a slot below de mouse - if (inputId != -1 && node.inputs[inputId] && LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type)) { - this._highlight_input = pos - this._highlight_input_slot = node.inputs[inputId] // XXX CHECK THIS - } else { + // Default: nothing highlighted + let highlightPos: Point = null + let highlightInput: INodeInputSlot = null + let linkOverWidget: IWidget = null + + if (firstLink.node === node) { + // Cannot connect link from a node to itself + } else if (firstLink.output) { + + // Connecting from an output to an input + + if (inputId === -1 && outputId === -1) { // Allow support for linking to widgets, handled externally to LiteGraph - if (this.getWidgetLinkType) { - const overWidget = this.getWidgetAtCursor(node) - - if (overWidget) { - const widgetLinkType = this.getWidgetLinkType(overWidget, node) - if (widgetLinkType && LiteGraph.isValidConnection(firstLink.output.type, widgetLinkType)) { - if (firstLink.node.isValidWidgetLink?.(firstLink.output.slot_index, node, overWidget) !== false) { - this.link_over_widget = overWidget - this.link_over_widget_type = widgetLinkType - } + if (this.getWidgetLinkType && overWidget) { + const widgetLinkType = this.getWidgetLinkType(overWidget, node) + if (widgetLinkType && LiteGraph.isValidConnection(firstLink.output.type, widgetLinkType)) { + if (firstLink.node.isValidWidgetLink?.(firstLink.output.slot_index, node, overWidget) !== false) { + linkOverWidget = overWidget + this.link_over_widget_type = widgetLinkType } } } - - this._highlight_input = null - this._highlight_input_slot = null // XXX CHECK THIS - } - } - - } else if (firstLink.input) { - //on top of output - if (this.isOverNodeBox(node, e.canvasX, e.canvasY)) { - //mouse on top of the corner box, don't know what to do - } else { - //check if I have a slot below de mouse - if (outputId != -1 && node.outputs[outputId] && LiteGraph.isValidConnection(firstLink.input.type, node.outputs[outputId].type)) { - this._highlight_output = pos + // Node background / title under the pointer + if (!linkOverWidget) { + const targetSlotId = firstLink.node.findConnectByTypeSlot(true, node, firstLink.output.type) + if (targetSlotId !== null && targetSlotId >= 0) { + node.getConnectionPos(true, targetSlotId, pos) + highlightPos = pos + highlightInput = node.inputs[targetSlotId] + } + } + } else if (this.isOverNodeBox(node, e.canvasX, e.canvasY)) { + //mouse on top of the corner box, don't know what to do } else { - this._highlight_output = null + //check if I have a slot below de mouse + if (inputId != -1 && node.inputs[inputId] && LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type)) { + highlightPos = pos + highlightInput = node.inputs[inputId] // XXX CHECK THIS + } + } + + } else if (firstLink.input) { + + // Connecting from an input to an output + if (inputId === -1 && outputId === -1) { + const targetSlotId = firstLink.node.findConnectByTypeSlot(false, node, firstLink.input.type) + if (targetSlotId !== null && targetSlotId >= 0) { + node.getConnectionPos(false, targetSlotId, pos) + highlightPos = pos + } + } else if (this.isOverNodeBox(node, e.canvasX, e.canvasY)) { + //mouse on top of the corner box, don't know what to do + } else { + //check if I have a slot below de mouse + if (outputId != -1 && node.outputs[outputId] && LiteGraph.isValidConnection(firstLink.input.type, node.outputs[outputId].type)) { + highlightPos = pos + } } } + this._highlight_pos = highlightPos + this._highlight_input = highlightInput + this.link_over_widget = linkOverWidget } + + this.dirty_canvas = true } //Search for corner @@ -3769,11 +3789,12 @@ export class LGraphCanvas { link_color = LiteGraph.CONNECTING_LINK_COLOR } + const highlightPos: Point = this.#getHighlightPosition() //the connection being dragged by the mouse this.renderLink( ctx, link.pos, - [this.graph_mouse[0], this.graph_mouse[1]], + highlightPos, null, false, null, @@ -3825,43 +3846,8 @@ export class LGraphCanvas { } ctx.fill() - ctx.fillStyle = "#ffcc00" - if (this._highlight_input) { - ctx.beginPath() - if (this._highlight_input_slot?.shape === LiteGraph.ARROW_SHAPE) { - ctx.moveTo(this._highlight_input[0] + 8, this._highlight_input[1] + 0.5) - ctx.lineTo(this._highlight_input[0] - 4, this._highlight_input[1] + 6 + 0.5) - ctx.lineTo(this._highlight_input[0] - 4, this._highlight_input[1] - 6 + 0.5) - ctx.closePath() - } else { - ctx.arc( - this._highlight_input[0], - this._highlight_input[1], - 6, - 0, - Math.PI * 2 - ) - } - ctx.fill() - } - if (this._highlight_output) { - ctx.beginPath() - if (this._highlight_input_slot?.shape === LiteGraph.ARROW_SHAPE) { - ctx.moveTo(this._highlight_output[0] + 8, this._highlight_output[1] + 0.5) - ctx.lineTo(this._highlight_output[0] - 4, this._highlight_output[1] + 6 + 0.5) - ctx.lineTo(this._highlight_output[0] - 4, this._highlight_output[1] - 6 + 0.5) - ctx.closePath() - } else { - ctx.arc( - this._highlight_output[0], - this._highlight_output[1], - 6, - 0, - Math.PI * 2 - ) - } - ctx.fill() - } + // Gradient half-border over target node + this.#renderSnapHighlight(ctx, highlightPos) } } @@ -3905,6 +3891,104 @@ export class LGraphCanvas { // @ts-expect-error if (ctx.finish2D) ctx.finish2D() } + + /** Get the target snap / highlight point in graph space */ + #getHighlightPosition(): Point { + return LiteGraph.snaps_for_comfy + ? this._highlight_pos ?? this.graph_mouse + : this.graph_mouse + } + + /** + * Renders indicators showing where a link will connect if released. + * Partial border over target node and a highlight over the slot itself. + * @param ctx Canvas 2D context + */ + #renderSnapHighlight(ctx: CanvasRenderingContext2D, highlightPos: Point): void { + if (!this._highlight_pos) return + + ctx.fillStyle = "#ffcc00" + ctx.beginPath() + const shape = this._highlight_input?.shape + + if (shape === RenderShape.ARROW) { + ctx.moveTo(highlightPos[0] + 8, highlightPos[1] + 0.5) + ctx.lineTo(highlightPos[0] - 4, highlightPos[1] + 6 + 0.5) + ctx.lineTo(highlightPos[0] - 4, highlightPos[1] - 6 + 0.5) + ctx.closePath() + } else { + ctx.arc( + highlightPos[0], + highlightPos[1], + 6, + 0, + Math.PI * 2 + ) + } + ctx.fill() + + if (!LiteGraph.snap_highlights_node) return + + // Ensure we're mousing over a node and connecting a link + const node = this.node_over + if (!(node && this.connecting_links?.[0])) return + + const { strokeStyle, lineWidth } = ctx + + const area = LGraphCanvas.#tmp_area + node.measure(area) + node.onBounding?.(area) + const gap = 3 + const radius = this.round_radius + gap + + const x = area[0] - gap + const y = area[1] - gap + const width = area[2] + (gap * 2) + const height = area[3] + (gap * 2) + + ctx.beginPath() + ctx.roundRect(x, y, width, height, radius) + + // TODO: Currently works on LTR slots only. Add support for other directions. + const start = this.connecting_links[0].output === null ? 0 : 1 + const inverter = start ? -1 : 1 + + // Radial highlight centred on highlight pos + const hx = highlightPos[0] + const hy = highlightPos[1] + const gRadius = width < height + ? width + : width * Math.max(height / width, 0.5) + + const gradient = ctx.createRadialGradient(hx, hy, 0, hx, hy, gRadius) + gradient.addColorStop(1, "#00000000") + gradient.addColorStop(0, "#ffcc00aa") + + // Linear gradient over half the node. + const linearGradient = ctx.createLinearGradient(x, y, x + width, y) + linearGradient.addColorStop(0.5, "#00000000") + linearGradient.addColorStop(start + (0.67 * inverter), "#ddeeff33") + linearGradient.addColorStop(start + inverter, "#ffcc0055") + + /** + * Workaround for a canvas render issue. + * In Chromium 129 (2024-10-15), rounded corners can be rendered with the wrong part of a gradient colour. + * Occurs only at certain thicknesses / arc sizes. + */ + ctx.setLineDash([radius, radius * 0.001]) + + ctx.lineWidth = 1 + ctx.strokeStyle = linearGradient + ctx.stroke() + + ctx.strokeStyle = gradient + ctx.stroke() + + ctx.setLineDash([]) + ctx.lineWidth = lineWidth + ctx.strokeStyle = strokeStyle + } + /** * draws the panel in the corner that shows subgraph properties **/ @@ -4683,11 +4767,12 @@ export class LGraphCanvas { ? false : true + // Normalised node dimensions const area = LGraphCanvas.#tmp_area - area[0] = 0 //x - area[1] = render_title ? -title_height : 0 //y - area[2] = size[0] + 1 //w - area[3] = render_title ? size[1] + title_height : size[1] //h + node.measure(area) + area[0] -= node.pos[0] + area[1] -= node.pos[1] + area[2]++ const old_alpha = ctx.globalAlpha diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 9b9f08675..d224a28fd 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -24,8 +24,9 @@ export type INodeProperties = Dictionary & { } interface IMouseOverData { - inputId: number - outputId: number + inputId: number | null + outputId: number | null + overWidget: IWidget | null } interface ConnectByTypeOptions { @@ -1421,6 +1422,27 @@ export class LGraphNode { return custom_widget } + /** + * Measures the node for rendering, populating {@link out} with the results in graph space. + * @param out Results (x, y, width, height) are inserted into this array. + * @param pad Expands the area by this amount on each side. Default: 0 + */ + measure(out: Rect, pad = 0): void { + const titleMode = this.constructor.title_mode + const renderTitle = titleMode != LiteGraph.TRANSPARENT_TITLE && titleMode != LiteGraph.NO_TITLE + const titleHeight = renderTitle ? LiteGraph.NODE_TITLE_HEIGHT : 0 + + out[0] = this.pos[0] - pad + out[1] = this.pos[1] + -titleHeight - pad + if (!this.flags?.collapsed) { + out[2] = this.size[0] + (2 * pad) + out[3] = this.size[1] + titleHeight + (2 * pad) + } else { + out[2] = (this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH) + (2 * pad) + out[3] = LiteGraph.NODE_TITLE_HEIGHT + (2 * pad) + } + } + /** * returns the bounding of the object, used for rendering purposes * @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage @@ -1429,36 +1451,16 @@ export class LGraphNode { */ getBounding(out?: Float32Array, compute_outer?: boolean): Float32Array { out = out || new Float32Array(4) - const nodePos = this.pos - const isCollapsed = this.flags.collapsed - const nodeSize = this.size - - let left_offset = 0 - // 1 offset due to how nodes are rendered - let right_offset = 1 - let top_offset = 0 - let bottom_offset = 0 - + this.measure(out) if (compute_outer) { // 4 offset for collapsed node connection points - left_offset = 4 - // 6 offset for right shadow and collapsed node connection points - right_offset = 6 + left_offset - // 4 offset for collapsed nodes top connection points - top_offset = 4 - // 5 offset for bottom shadow and collapsed node connection points - bottom_offset = 5 + top_offset + out[0] -= 4 + out[1] -= 4 + // Add shadow & left offset + out[2] += 6 + 4 + // Add shadow & top offsets + out[3] += 5 + 4 } - - out[0] = nodePos[0] - left_offset - out[1] = nodePos[1] - LiteGraph.NODE_TITLE_HEIGHT - top_offset - out[2] = isCollapsed - ? (this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH) + right_offset - : nodeSize[0] + right_offset - out[3] = isCollapsed - ? LiteGraph.NODE_TITLE_HEIGHT + bottom_offset - : nodeSize[1] + LiteGraph.NODE_TITLE_HEIGHT + bottom_offset - this.onBounding?.(out) return out } @@ -1746,8 +1748,10 @@ export class LGraphNode { } // connect to the first free input slot if not found a specific type and this output is general if (opts.wildcardToTyped && (slotType == 0 || slotType == "*" || slotType == "")) { - const find = findInputs ? node.findInputSlotFree : node.findOutputSlotFree - const nonEventSlot = find({ typesNotAccepted: [LiteGraph.EVENT] }) + const opt = { typesNotAccepted: [LiteGraph.EVENT] } + const nonEventSlot = findInputs + ? node.findInputSlotFree(opt) + : node.findOutputSlotFree(opt) if (nonEventSlot >= 0) return nonEventSlot } return null diff --git a/src/LiteGraphGlobal.ts b/src/LiteGraphGlobal.ts index b9bedbd37..dbec66c02 100644 --- a/src/LiteGraphGlobal.ts +++ b/src/LiteGraphGlobal.ts @@ -130,6 +130,8 @@ export class LiteGraphGlobal { shift_click_do_break_link_from = false // [false!] prefer false if results too easy to break links - implement with ALT or TODO custom keys click_do_break_link_to = false // [false!]prefer false, way too easy to break links ctrl_alt_click_do_break_link = true // [true!] who accidentally ctrl-alt-clicks on an in/output? nobody! that's who! + snaps_for_comfy = true // [true!] snaps links when dragging connections over valid targets + snap_highlights_node = true // [true!] renders a partial border to highlight when a dragged link is snapped to a node search_hide_on_mouse_leave = true // [false on mobile] better true if not touch device, TODO add an helper/listener to close if false search_filter_enabled = false // [true!] enable filtering slots type in the search widget, !requires auto_load_slot_types or manual set registered_slot_[in/out]_types and slot_types_[in/out]